From 76ff677a84723abc2de1b91b08d4984f75e93881 Mon Sep 17 00:00:00 2001 From: Nicholas Roscino Date: Fri, 27 Feb 2026 13:35:05 +0100 Subject: [PATCH 1/2] chore: allow script evaluation on service worker --- src/McpContext.ts | 7 +- src/tools/ToolDefinition.ts | 13 +- src/tools/script.ts | 215 +++++++++++++++++++++++---------- src/tools/tools.ts | 2 +- src/types.ts | 8 +- tests/tools/extensions.test.ts | 2 +- tests/tools/script.test.ts | 136 ++++++++++++++++++--- 7 files changed, 295 insertions(+), 88 deletions(-) diff --git a/src/McpContext.ts b/src/McpContext.ts index 5975ed660..5763ebb3a 100644 --- a/src/McpContext.ts +++ b/src/McpContext.ts @@ -45,6 +45,7 @@ import type { GeolocationOptions, TextSnapshot, TextSnapshotNode, + ExtensionServiceWorker, } from './types.js'; import { ExtensionRegistry, @@ -59,12 +60,6 @@ export type { TextSnapshotNode, } from './types.js'; -export interface ExtensionServiceWorker { - url: string; - target: Target; - id: string; -} - interface McpContextOptions { // Whether the DevTools windows are exposed as pages for debugging of DevTools. experimentalDevToolsDebugging: boolean; diff --git a/src/tools/ToolDefinition.ts b/src/tools/ToolDefinition.ts index 5fe2a9f3d..812629da1 100644 --- a/src/tools/ToolDefinition.ts +++ b/src/tools/ToolDefinition.ts @@ -5,6 +5,7 @@ */ import type {ParsedArguments} from '../cli.js'; +import type {McpPage} from '../McpPage.js'; import {zod} from '../third_party/index.js'; import type { Dialog, @@ -14,7 +15,11 @@ import type { Viewport, } from '../third_party/index.js'; import type {InsightName, TraceResult} from '../trace-processing/parse.js'; -import type {TextSnapshotNode, GeolocationOptions} from '../types.js'; +import type { + TextSnapshotNode, + GeolocationOptions, + ExtensionServiceWorker, +} from '../types.js'; import type {InstalledExtension} from '../utils/ExtensionRegistry.js'; import type {PaginationOptions} from '../utils/types.js'; @@ -136,6 +141,7 @@ export type Context = Readonly<{ recordedTraces(): TraceResult[]; storeTraceRecording(result: TraceResult): void; getPageById(pageId: number): ContextPage; + resolvePageById(pageId?: number): ContextPage; newPage( background?: boolean, isolatedContextName?: string, @@ -188,6 +194,11 @@ export type Context = Readonly<{ uninstallExtension(id: string): Promise; listExtensions(): InstalledExtension[]; getExtension(id: string): InstalledExtension | undefined; + getSelectedMcpPage(): McpPage; + getExtensionServiceWorkers(): ExtensionServiceWorker[]; + getExtensionServiceWorkerId( + extensionServiceWorker: ExtensionServiceWorker, + ): string | undefined; }>; export type ContextPage = Readonly<{ diff --git a/src/tools/script.ts b/src/tools/script.ts index d7352b790..33b0eaf88 100644 --- a/src/tools/script.ts +++ b/src/tools/script.ts @@ -5,22 +5,27 @@ */ import {zod} from '../third_party/index.js'; -import type {Frame, JSHandle, Page} from '../third_party/index.js'; +import type {Frame, JSHandle, Page, WebWorker} from '../third_party/index.js'; +import type {ExtensionServiceWorker} from '../types.js'; import {ToolCategory} from './categories.js'; -import {definePageTool} from './ToolDefinition.js'; +import type {Context, Response} from './ToolDefinition.js'; +import {defineTool, pageIdSchema} from './ToolDefinition.js'; -export const evaluateScript = definePageTool({ - name: 'evaluate_script', - description: `Evaluate a JavaScript function inside the currently selected page. Returns the response as JSON, +export type Evaluatable = Page | Frame | WebWorker; + +export const evaluateScript = defineTool(cliArgs => { + return { + name: 'evaluate_script', + description: `Evaluate a JavaScript function inside the currently selected page. Returns the response as JSON, so returned values have to be JSON-serializable.`, - annotations: { - category: ToolCategory.DEBUGGING, - readOnlyHint: false, - }, - schema: { - function: zod.string().describe( - `A JavaScript function declaration to be executed by the tool in the currently selected page. + annotations: { + category: ToolCategory.DEBUGGING, + readOnlyHint: false, + }, + schema: { + function: zod.string().describe( + `A JavaScript function declaration to be executed by the tool in the currently selected page. Example without arguments: \`() => { return document.title }\` or \`async () => { @@ -30,57 +35,143 @@ Example with arguments: \`(el) => { return el.innerText; }\` `, - ), - args: zod - .array( - zod.object({ - uid: zod - .string() - .describe( - 'The uid of an element on the page from the page content snapshot', - ), - }), - ) - .optional() - .describe(`An optional list of arguments to pass to the function.`), - }, - handler: async (request, response, context) => { - const args: Array> = []; - try { - const frames = new Set(); - for (const el of request.params.args ?? []) { - const handle = await request.page.getElementByUid(el.uid); - frames.add(handle.frame); - args.push(handle); + ), + args: zod + .array( + zod.object({ + uid: zod + .string() + .describe( + 'The uid of an element on the page from the page content snapshot', + ), + }), + ) + .optional() + .describe(`An optional list of arguments to pass to the function.`), + ...(cliArgs?.experimentalPageIdRouting ? pageIdSchema : {}), + ...(cliArgs?.categoryExtensions + ? { + serviceWorkerId: zod + .string() + .optional() + .describe( + `An optional service worker id to evaluate the script in.`, + ), + } + : {}), + }, + handler: async (request, response, context) => { + const { + serviceWorkerId, + args: uidArgs, + function: fnString, + pageId, + } = request.params; + + if (cliArgs?.categoryExtensions && serviceWorkerId) { + if (uidArgs && uidArgs.length > 0) { + throw new Error( + 'args (element uids) cannot be used when evaluating in a service worker.', + ); + } + if (pageId) { + throw new Error('specify either a pageId or a serviceWorkerId.'); + } + + const worker = await getWebWorker(context, serviceWorkerId); + await performEvaluation(worker, fnString, [], response, context); + return; } - let pageOrFrame: Page | Frame; - // We can't evaluate the element handle across frames - if (frames.size > 1) { - throw new Error( - "Elements from different frames can't be evaluated together.", - ); - } else { - pageOrFrame = [...frames.values()][0] ?? request.page.pptrPage; + + const mcpPage = cliArgs?.experimentalPageIdRouting + ? context.resolvePageById(request.params.pageId) + : context.getSelectedMcpPage(); + const page: Page = mcpPage.pptrPage; + + const args: Array> = []; + try { + const frames = new Set(); + for (const el of uidArgs ?? []) { + const handle = await context.getElementByUid(el.uid, page); + frames.add(handle.frame); + args.push(handle); + } + + const evaluatable = await getPageOrFrame(page, frames); + + await performEvaluation(evaluatable, fnString, args, response, context); + } finally { + void Promise.allSettled(args.map(arg => arg.dispose())); } - const fn = await pageOrFrame.evaluateHandle( - `(${request.params.function})`, + }, + }; +}); + +const performEvaluation = async ( + evaluatable: Evaluatable, + fnString: string, + args: Array>, + response: Response, + context: Context, +) => { + const fn = await evaluatable.evaluateHandle(`(${fnString})`); + try { + await context.waitForEventsAfterAction(async () => { + const result = await evaluatable.evaluate( + async (fn, ...args) => { + // @ts-expect-error no types for function fn + return JSON.stringify(await fn(...args)); + }, + fn, + ...args, ); - args.unshift(fn); - await context.waitForEventsAfterAction(async () => { - const result = await pageOrFrame.evaluate( - async (fn, ...args) => { - // @ts-expect-error no types. - return JSON.stringify(await fn(...args)); - }, - ...args, - ); - response.appendResponseLine('Script ran on page and returned:'); - response.appendResponseLine('```json'); - response.appendResponseLine(`${result}`); - response.appendResponseLine('```'); - }); - } finally { - void Promise.allSettled(args.map(arg => arg.dispose())); + response.appendResponseLine('Script ran on page and returned:'); + response.appendResponseLine('```json'); + response.appendResponseLine(`${result}`); + response.appendResponseLine('```'); + }); + } finally { + void fn.dispose(); + } +}; + +const getPageOrFrame = async ( + page: Page, + frames: Set, +): Promise => { + let pageOrFrame: Page | Frame; + // We can't evaluate the element handle across frames + if (frames.size > 1) { + throw new Error( + "Elements from different frames can't be evaluated together.", + ); + } else { + pageOrFrame = [...frames.values()][0] ?? page; + } + + return pageOrFrame; +}; + +const getWebWorker = async ( + context: Context, + serviceWorkerId: string, +): Promise => { + const serviceWorkers = context.getExtensionServiceWorkers(); + + const serviceWorker = serviceWorkers.find( + (sw: ExtensionServiceWorker) => + context.getExtensionServiceWorkerId(sw) === serviceWorkerId, + ); + + if (serviceWorker && serviceWorker.target) { + const worker = await serviceWorker.target.worker(); + + if (!worker) { + throw new Error('Service worker target not found.'); } - }, -}); + + return worker; + } else { + throw new Error('Service worker not found.'); + } +}; diff --git a/src/tools/tools.ts b/src/tools/tools.ts index 27d20cedc..4672516b1 100644 --- a/src/tools/tools.ts +++ b/src/tools/tools.ts @@ -44,7 +44,7 @@ export const createTools = (args: ParsedArguments) => { const tools = []; for (const tool of rawTools) { if (typeof tool === 'function') { - tools.push(tool(args)); + tools.push(tool(args) as unknown as ToolDefinition); } else { tools.push(tool as ToolDefinition); } diff --git a/src/types.ts b/src/types.ts index 69dddd2a9..a85796edf 100644 --- a/src/types.ts +++ b/src/types.ts @@ -4,7 +4,13 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type {SerializedAXNode, Viewport} from './third_party/index.js'; +import type {SerializedAXNode, Viewport, Target} from './third_party/index.js'; + +export interface ExtensionServiceWorker { + url: string; + target: Target; + id: string; +} export interface TextSnapshotNode extends SerializedAXNode { id: string; diff --git a/tests/tools/extensions.test.ts b/tests/tools/extensions.test.ts index 2fbf62a0a..58fa0a40b 100644 --- a/tests/tools/extensions.test.ts +++ b/tests/tools/extensions.test.ts @@ -24,7 +24,7 @@ const EXTENSION_PATH = path.join( '../../../tests/tools/fixtures/extension', ); -function extractId(response: McpResponse) { +export function extractId(response: McpResponse) { const responseLine = response.responseLines[0]; assert.ok(responseLine, 'Response should not be empty'); const match = responseLine.match(/Extension installed\. Id: (.+)/); diff --git a/tests/tools/script.test.ts b/tests/tools/script.test.ts index 1ddaea4c7..787367269 100644 --- a/tests/tools/script.test.ts +++ b/tests/tools/script.test.ts @@ -5,22 +5,31 @@ */ import assert from 'node:assert'; +import path from 'node:path'; import {describe, it} from 'node:test'; +import type {ParsedArguments} from '../../src/cli.js'; +import {installExtension} from '../../src/tools/extensions.js'; import {evaluateScript} from '../../src/tools/script.js'; import {serverHooks} from '../server.js'; import {html, withMcpContext} from '../utils.js'; +import {extractId} from './extensions.test.js'; + +const EXTENSION_PATH = path.join( + import.meta.dirname, + '../../../tests/tools/fixtures/extension-sw', +); + describe('script', () => { const server = serverHooks(); describe('browser_evaluate_script', () => { it('evaluates', async () => { await withMcpContext(async (response, context) => { - await evaluateScript.handler( + await evaluateScript().handler( { params: {function: String(() => 2 * 5)}, - page: context.getSelectedMcpPage(), }, response, context, @@ -31,10 +40,9 @@ describe('script', () => { }); it('runs in selected page', async () => { await withMcpContext(async (response, context) => { - await evaluateScript.handler( + await evaluateScript().handler( { params: {function: String(() => document.title)}, - page: context.getSelectedMcpPage(), }, response, context, @@ -51,10 +59,9 @@ describe('script', () => { `); response.resetResponseLineForTesting(); - await evaluateScript.handler( + await evaluateScript().handler( { params: {function: String(() => document.title)}, - page: context.getSelectedMcpPage(), }, response, context, @@ -71,7 +78,7 @@ describe('script', () => { await page.setContent(html` `); - await evaluateScript.handler( + await evaluateScript().handler( { params: { function: String(() => { @@ -82,7 +89,6 @@ describe('script', () => { return {scripts}; }), }, - page: context.getSelectedMcpPage(), }, response, context, @@ -100,7 +106,7 @@ describe('script', () => { await page.setContent(html` `); - await evaluateScript.handler( + await evaluateScript().handler( { params: { function: String(async () => { @@ -108,7 +114,6 @@ describe('script', () => { return 'Works'; }), }, - page: context.getSelectedMcpPage(), }, response, context, @@ -126,7 +131,7 @@ describe('script', () => { await context.createTextSnapshot(context.getSelectedMcpPage()); - await evaluateScript.handler( + await evaluateScript().handler( { params: { function: String(async (el: Element) => { @@ -134,7 +139,6 @@ describe('script', () => { }), args: [{uid: '1_1'}], }, - page: context.getSelectedMcpPage(), }, response, context, @@ -152,7 +156,7 @@ describe('script', () => { await context.createTextSnapshot(context.getSelectedMcpPage()); - await evaluateScript.handler( + await evaluateScript().handler( { params: { function: String((container: Element, child: Element) => { @@ -160,7 +164,6 @@ describe('script', () => { }), args: [{uid: '1_0'}, {uid: '1_1'}], }, - page: context.getSelectedMcpPage(), }, response, context, @@ -181,7 +184,7 @@ describe('script', () => { const page = context.getSelectedPptrPage(); await page.goto(server.getRoute('/main')); await context.createTextSnapshot(context.getSelectedMcpPage()); - await evaluateScript.handler( + await evaluateScript().handler( { params: { function: String((element: Element) => { @@ -189,7 +192,6 @@ describe('script', () => { }), args: [{uid: '1_3'}], }, - page: context.getSelectedMcpPage(), }, response, context, @@ -198,5 +200,107 @@ describe('script', () => { assert.strictEqual(JSON.parse(lineEvaluation), 'I am iframe button'); }); }); + it('evaluates inside extension service worker', async () => { + await withMcpContext( + async (response, context) => { + await installExtension.handler( + {params: {path: EXTENSION_PATH}}, + response, + context, + ); + + const extensionId = extractId(response); + const swTarget = await context.browser.waitForTarget( + t => t.type() === 'service_worker' && t.url().includes(extensionId), + ); + + await context.createExtensionServiceWorkersSnapshot(); + const swList = context.getExtensionServiceWorkers(); + const sw = swList.find(s => s.target === swTarget); + + if (!sw) { + assert.fail('Service worker not found in context list'); + } + + const swId = context.getExtensionServiceWorkerId(sw); + + response.resetResponseLineForTesting(); + await evaluateScript({ + categoryExtensions: true, + } as ParsedArguments).handler( + { + params: { + function: String(() => { + return 'chrome' in globalThis ? 'has-chrome' : 'no-chrome'; + }), + serviceWorkerId: swId, + }, + }, + response, + context, + ); + + const lineEvaluation = response.responseLines.at(2)!; + assert.strictEqual(JSON.parse(lineEvaluation), 'has-chrome'); + }, + {}, + {categoryExtensions: true} as ParsedArguments, + ); + }); + + it('throws error when both pageId and serviceWorkerId are provided', async () => { + await withMcpContext( + async (response, context) => { + await assert.rejects( + evaluateScript({ + categoryExtensions: true, + } as ParsedArguments).handler( + { + params: { + function: String(() => 'test'), + serviceWorkerId: 'example_service_worker', + pageId: '1', + }, + }, + response, + context, + ), + { + message: 'specify either a pageId or a serviceWorkerId.', + }, + ); + }, + {}, + {categoryExtensions: true} as ParsedArguments, + ); + }); + + it('throws error when args are provided with serviceWorkerId', async () => { + await withMcpContext( + async (response, context) => { + await assert.rejects( + evaluateScript({ + categoryExtensions: true, + } as ParsedArguments).handler( + { + params: { + function: String(() => 'test'), + serviceWorkerId: 'example_service_worker', + args: [{uid: '1_1'}], + }, + }, + response, + context, + ), + { + message: + 'args (element uids) cannot be used when evaluating in a service worker.', + }, + ); + }, + {}, + {categoryExtensions: true} as ParsedArguments, + ); + }); }); }); From 3ffacb420458fda8aa4d0425fb41f3f30792e272 Mon Sep 17 00:00:00 2001 From: Nicholas Roscino Date: Fri, 27 Feb 2026 14:17:02 +0100 Subject: [PATCH 2/2] chore: rebase conflicts --- src/tools/ToolDefinition.ts | 1 - src/tools/script.ts | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/tools/ToolDefinition.ts b/src/tools/ToolDefinition.ts index 812629da1..a9e432336 100644 --- a/src/tools/ToolDefinition.ts +++ b/src/tools/ToolDefinition.ts @@ -141,7 +141,6 @@ export type Context = Readonly<{ recordedTraces(): TraceResult[]; storeTraceRecording(result: TraceResult): void; getPageById(pageId: number): ContextPage; - resolvePageById(pageId?: number): ContextPage; newPage( background?: boolean, isolatedContextName?: string, diff --git a/src/tools/script.ts b/src/tools/script.ts index 33b0eaf88..fa2d18c17 100644 --- a/src/tools/script.ts +++ b/src/tools/script.ts @@ -84,7 +84,7 @@ Example with arguments: \`(el) => { } const mcpPage = cliArgs?.experimentalPageIdRouting - ? context.resolvePageById(request.params.pageId) + ? context.getPageById(request.params.pageId) : context.getSelectedMcpPage(); const page: Page = mcpPage.pptrPage; @@ -92,7 +92,7 @@ Example with arguments: \`(el) => { try { const frames = new Set(); for (const el of uidArgs ?? []) { - const handle = await context.getElementByUid(el.uid, page); + const handle = await mcpPage.getElementByUid(el.uid); frames.add(handle.frame); args.push(handle); }