diff --git a/README.md b/README.md index 3bb64ae6c..cceb29f1c 100644 --- a/README.md +++ b/README.md @@ -515,13 +515,14 @@ If you run into any issues, checkout our [troubleshooting guide](./docs/troubles - **Network** (2 tools) - [`get_network_request`](docs/tool-reference.md#get_network_request) - [`list_network_requests`](docs/tool-reference.md#list_network_requests) -- **Debugging** (8 tools) +- **Debugging** (9 tools) - [`evaluate_script`](docs/tool-reference.md#evaluate_script) - [`get_console_message`](docs/tool-reference.md#get_console_message) - [`lighthouse_audit`](docs/tool-reference.md#lighthouse_audit) - [`list_console_messages`](docs/tool-reference.md#list_console_messages) - [`take_screenshot`](docs/tool-reference.md#take_screenshot) - [`take_snapshot`](docs/tool-reference.md#take_snapshot) + - [`list_dedicated_workers`](docs/tool-reference.md#list_dedicated_workers) - [`screencast_start`](docs/tool-reference.md#screencast_start) - [`screencast_stop`](docs/tool-reference.md#screencast_stop) - **Memory** (12 tools) @@ -654,6 +655,11 @@ The Chrome DevTools MCP server supports the following configuration option: - **Type:** boolean - **Default:** `false` +- **`--experimentalWorkers`/ `--experimental-workers`** + Whether to expose tools for enumerating dedicated Web Workers of the selected page and evaluating scripts inside them. Off by default so that worker execution contexts do not add to the baseline token usage. + - **Type:** boolean + - **Default:** `false` + - **`--experimentalScreencast`/ `--experimental-screencast`** Exposes experimental screencast tools (requires ffmpeg). Install ffmpeg https://www.ffmpeg.org/download.html and ensure it is available in the MCP server PATH. - **Type:** boolean diff --git a/docs/tool-reference.md b/docs/tool-reference.md index bb8bc6b7d..24daa3d5c 100644 --- a/docs/tool-reference.md +++ b/docs/tool-reference.md @@ -30,13 +30,14 @@ - **[Network](#network)** (2 tools) - [`get_network_request`](#get_network_request) - [`list_network_requests`](#list_network_requests) -- **[Debugging](#debugging)** (8 tools) +- **[Debugging](#debugging)** (9 tools) - [`evaluate_script`](#evaluate_script) - [`get_console_message`](#get_console_message) - [`lighthouse_audit`](#lighthouse_audit) - [`list_console_messages`](#list_console_messages) - [`take_screenshot`](#take_screenshot) - [`take_snapshot`](#take_snapshot) + - [`list_dedicated_workers`](#list_dedicated_workers) - [`screencast_start`](#screencast_start) - [`screencast_stop`](#screencast_stop) - **[Memory](#memory)** (12 tools) @@ -425,6 +426,14 @@ in the DevTools Elements panel (if any). --- +### `list_dedicated_workers` + +**Description:** List the dedicated Web Workers running in the currently selected page. Returns a worker id for each one that can be passed as the 'workerId' argument to [`evaluate_script`](#evaluate_script) to run a script inside that worker's execution context. (requires flag: --experimentalWorkers=true) + +**Parameters:** None + +--- + ### `screencast_start` **Description:** Starts recording a screencast (video) of the selected page in specified format. (requires flag: --experimentalScreencast=true) diff --git a/src/McpContext.ts b/src/McpContext.ts index 2601883a2..f0f24ca45 100644 --- a/src/McpContext.ts +++ b/src/McpContext.ts @@ -32,13 +32,14 @@ import { type Extension, type Root, type DevTools, + type WebWorker, } from './third_party/index.js'; import {listPages} from './tools/pages.js'; import {CLOSE_PAGE_ERROR} from './tools/ToolDefinition.js'; import type {Context, SupportedExtensions} from './tools/ToolDefinition.js'; import type {TraceResult} from './trace-processing/parse.js'; import type {Logger} from './types.js'; -import type {ExtensionServiceWorker} from './types.js'; +import type {DedicatedWorker, ExtensionServiceWorker} from './types.js'; import {getTempFilePath, resolveCanonicalPath} from './utils/files.js'; interface McpContextOptions { // Whether the DevTools windows are exposed as pages for debugging of DevTools. @@ -93,6 +94,10 @@ export class McpContext implements Context { #extensionServiceWorkerMap = new WeakMap(); #nextExtensionServiceWorkerId = 1; + #dedicatedWorkers: DedicatedWorker[] = []; + #dedicatedWorkerMap = new WeakMap(); + #nextDedicatedWorkerId = 1; + #traceResults: TraceResult[] = []; #locatorClass: typeof Locator; @@ -572,6 +577,43 @@ export class McpContext implements Context { return this.#extensionServiceWorkerMap.get(extensionServiceWorker.target); } + /** + * Creates a snapshot of the dedicated Web Workers of the currently selected + * page. This is intentionally not part of the default page snapshot: worker + * execution contexts are only enumerated when a tool explicitly asks for + * them, so they don't add to the baseline token usage. + */ + createDedicatedWorkersSnapshot(): DedicatedWorker[] { + const workers = this.getSelectedMcpPage().pptrPage.workers(); + + for (const worker of workers) { + if (!this.#dedicatedWorkerMap.has(worker)) { + this.#dedicatedWorkerMap.set( + worker, + 'worker-' + this.#nextDedicatedWorkerId++, + ); + } + } + + this.#dedicatedWorkers = workers.map(worker => { + return { + worker, + id: this.#dedicatedWorkerMap.get(worker)!, + url: worker.url(), + }; + }); + + return this.#dedicatedWorkers; + } + + getDedicatedWorkers(): DedicatedWorker[] { + return this.#dedicatedWorkers; + } + + getDedicatedWorkerId(dedicatedWorker: DedicatedWorker): string | undefined { + return this.#dedicatedWorkerMap.get(dedicatedWorker.worker); + } + async saveTemporaryFile( data: Uint8Array, filename: string, diff --git a/src/bin/chrome-devtools-cli-options.ts b/src/bin/chrome-devtools-cli-options.ts index 191d1fb37..757ab1ce8 100644 --- a/src/bin/chrome-devtools-cli-options.ts +++ b/src/bin/chrome-devtools-cli-options.ts @@ -767,6 +767,12 @@ export const commands: Commands = { }, }, }, + list_dedicated_workers: { + description: + "List the dedicated Web Workers running in the currently selected page. Returns a worker id for each one that can be passed as the 'workerId' argument to evaluate_script to run a script inside that worker's execution context. (requires flag: --experimentalWorkers=true)", + category: 'Debugging', + args: {}, + }, list_extensions: { description: 'Lists all the Chrome extensions installed in the browser. This includes their name, ID, version, and enabled status. (requires flag: --categoryExtensions=true)', diff --git a/src/bin/chrome-devtools-mcp-cli-options.ts b/src/bin/chrome-devtools-mcp-cli-options.ts index c943b7c38..799168856 100644 --- a/src/bin/chrome-devtools-mcp-cli-options.ts +++ b/src/bin/chrome-devtools-mcp-cli-options.ts @@ -193,6 +193,11 @@ export const cliOptions = { describe: 'Whether to enable interoperability tools', hidden: true, }, + experimentalWorkers: { + type: 'boolean', + describe: + 'Whether to expose tools for enumerating dedicated Web Workers of the selected page and evaluating scripts inside them. Off by default so that worker execution contexts do not add to the baseline token usage.', + }, experimentalScreencast: { type: 'boolean', describe: diff --git a/src/telemetry/flag_usage_metrics.json b/src/telemetry/flag_usage_metrics.json index da297928a..aed5cb149 100644 --- a/src/telemetry/flag_usage_metrics.json +++ b/src/telemetry/flag_usage_metrics.json @@ -371,5 +371,13 @@ { "name": "allow_unrestricted_paths", "flagType": "boolean" + }, + { + "name": "experimental_workers_present", + "flagType": "boolean" + }, + { + "name": "experimental_workers", + "flagType": "boolean" } ] diff --git a/src/telemetry/tool_call_metrics.json b/src/telemetry/tool_call_metrics.json index 194ac0ddd..70270f2a8 100644 --- a/src/telemetry/tool_call_metrics.json +++ b/src/telemetry/tool_call_metrics.json @@ -893,6 +893,10 @@ } ] }, + { + "name": "list_dedicated_workers", + "args": [] + }, { "name": "get_heapsnapshot_object_details", "args": [ diff --git a/src/tools/ToolDefinition.ts b/src/tools/ToolDefinition.ts index 7689e5a0d..378183a87 100644 --- a/src/tools/ToolDefinition.ts +++ b/src/tools/ToolDefinition.ts @@ -28,6 +28,7 @@ import type { TextSnapshotNode, GeolocationOptions, ExtensionServiceWorker, + DedicatedWorker, } from '../types.js'; import type {PaginationOptions} from '../types.js'; import type {WaitForEventsResult, DialogAction} from '../WaitForHelper.js'; @@ -234,6 +235,9 @@ export type Context = Readonly<{ getExtensionServiceWorkerId( extensionServiceWorker: ExtensionServiceWorker, ): string | undefined; + createDedicatedWorkersSnapshot(): DedicatedWorker[]; + getDedicatedWorkers(): DedicatedWorker[]; + getDedicatedWorkerId(dedicatedWorker: DedicatedWorker): string | undefined; getHeapSnapshotAggregates( filePath: string, filterName?: string, diff --git a/src/tools/script.ts b/src/tools/script.ts index 022409e9c..dcc38eafe 100644 --- a/src/tools/script.ts +++ b/src/tools/script.ts @@ -17,7 +17,7 @@ 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${cliArgs?.categoryExtensions ? ' or service worker' : ''}. Returns the response as JSON, so returned values have to be JSON-serializable.`, + description: `Evaluate a JavaScript function inside the currently selected page${cliArgs?.categoryExtensions ? ' or service worker' : ''}${cliArgs?.experimentalWorkers ? ' or dedicated worker' : ''}. Returns the response as JSON, so returned values have to be JSON-serializable.`, annotations: { category: ToolCategory.DEBUGGING, readOnlyHint: false, @@ -62,12 +62,23 @@ Example with arguments: \`(el) => el.innerText\` ), } : {}), + ...(cliArgs?.experimentalWorkers + ? { + workerId: zod + .string() + .optional() + .describe( + `The optional dedicated worker id to evaluate the script in. Call list_dedicated_workers to obtain the available worker ids. If provided, 'pageId' should be omitted. Note: 'args' (element UIDs) cannot be used when evaluating in a worker.`, + ), + } + : {}), }, blockedByDialog: true, verifyFilesSchema: ['filePath'], handler: async (request, response, context) => { const { serviceWorkerId, + workerId, args: uidArgs, function: fnString, pageId, @@ -75,6 +86,35 @@ Example with arguments: \`(el) => el.innerText\` filePath, } = request.params; + if (cliArgs?.experimentalWorkers && workerId) { + if (uidArgs && uidArgs.length > 0) { + throw new Error( + 'args (element uids) cannot be used when evaluating in a worker.', + ); + } + if (pageId) { + throw new Error('specify either a pageId or a workerId.'); + } + + const worker = getDedicatedWorker(context, workerId); + const result = await context + .getSelectedMcpPage() + .waitForEventsAfterAction( + async () => { + await performEvaluation(worker, fnString, [], response, { + filePath, + context, + }); + }, + {handleDialog: dialogAction ?? 'accept'}, + ); + if (result.dialogHandled) { + context.getSelectedMcpPage().clearDialog(); + } + response.attachWaitForResult(result); + return; + } + if (cliArgs?.categoryExtensions && serviceWorkerId) { if (uidArgs && uidArgs.length > 0) { throw new Error( @@ -137,6 +177,34 @@ Example with arguments: \`(el) => el.innerText\` }; }); +export const listDedicatedWorkers = defineTool({ + name: 'list_dedicated_workers', + description: `List the dedicated Web Workers running in the currently selected page. Returns a worker id for each one that can be passed as the 'workerId' argument to evaluate_script to run a script inside that worker's execution context.`, + annotations: { + category: ToolCategory.DEBUGGING, + readOnlyHint: true, + conditions: ['experimentalWorkers'], + }, + schema: {}, + blockedByDialog: false, + verifyFilesSchema: [], + handler: async (_request, response, context) => { + const workers = context.createDedicatedWorkersSnapshot(); + + if (!workers.length) { + response.appendResponseLine( + 'No dedicated workers found in the selected page.', + ); + return; + } + + response.appendResponseLine('## Dedicated Workers'); + for (const worker of workers) { + response.appendResponseLine(`${worker.id}: ${worker.url}`); + } + }, +}); + const performEvaluation = async ( evaluatable: Evaluatable, fnString: string, @@ -192,6 +260,20 @@ const getPageOrFrame = async ( return pageOrFrame; }; +const getDedicatedWorker = (context: Context, workerId: string): WebWorker => { + const dedicatedWorkers = context.createDedicatedWorkersSnapshot(); + + const dedicatedWorker = dedicatedWorkers.find( + worker => context.getDedicatedWorkerId(worker) === workerId, + ); + + if (!dedicatedWorker) { + throw new Error('Dedicated worker not found.'); + } + + return dedicatedWorker.worker; +}; + const getWebWorker = async ( context: Context, serviceWorkerId: string, diff --git a/src/types.ts b/src/types.ts index 60fc47519..df3c34988 100644 --- a/src/types.ts +++ b/src/types.ts @@ -4,7 +4,12 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type {SerializedAXNode, Viewport, Target} from './third_party/index.js'; +import type { + SerializedAXNode, + Viewport, + Target, + WebWorker, +} from './third_party/index.js'; export interface ExtensionServiceWorker { url: string; @@ -12,6 +17,12 @@ export interface ExtensionServiceWorker { id: string; } +export interface DedicatedWorker { + url: string; + worker: WebWorker; + id: string; +} + export interface TextSnapshotNode extends SerializedAXNode { id: string; backendNodeId?: number; diff --git a/tests/tools/script.test.ts b/tests/tools/script.test.ts index c2883ab70..ba65207d4 100644 --- a/tests/tools/script.test.ts +++ b/tests/tools/script.test.ts @@ -10,8 +10,9 @@ import {describe, it} from 'node:test'; import type {ParsedArguments} from '../../src/bin/chrome-devtools-mcp-cli-options.js'; import {TextSnapshot} from '../../src/TextSnapshot.js'; +import type {Page} from '../../src/third_party/index.js'; import {installExtension} from '../../src/tools/extensions.js'; -import {evaluateScript} from '../../src/tools/script.js'; +import {evaluateScript, listDedicatedWorkers} from '../../src/tools/script.js'; import {serverHooks} from '../server.js'; import { assertNoServiceWorkerReported, @@ -415,5 +416,150 @@ describe('script', () => { {categoryExtensions: true}, ); }); + + it('evaluates inside a dedicated worker', async () => { + await withMcpContext( + async (response, context) => { + const page = context.getSelectedMcpPage().pptrPage; + await spawnDedicatedWorker(page); + + const workers = context.createDedicatedWorkersSnapshot(); + assert.strictEqual(workers.length, 1); + const workerId = context.getDedicatedWorkerId(workers[0]!); + + await evaluateScript({ + experimentalWorkers: true, + } as ParsedArguments).handler( + { + params: { + function: String(() => self.constructor.name), + workerId, + }, + }, + response, + context, + ); + + const lineEvaluation = response.responseLines.at(2)!; + assert.strictEqual( + JSON.parse(lineEvaluation), + 'DedicatedWorkerGlobalScope', + ); + }, + {}, + {experimentalWorkers: true}, + ); + }); + + it('throws error when both pageId and workerId are provided', async () => { + await withMcpContext( + async (response, context) => { + await assert.rejects( + evaluateScript({ + experimentalWorkers: true, + } as ParsedArguments).handler( + { + params: { + function: String(() => 'test'), + workerId: 'worker-1', + pageId: '1', + }, + }, + response, + context, + ), + { + message: 'specify either a pageId or a workerId.', + }, + ); + }, + {}, + {experimentalWorkers: true}, + ); + }); + + it('throws error when args are provided with workerId', async () => { + await withMcpContext( + async (response, context) => { + await assert.rejects( + evaluateScript({ + experimentalWorkers: true, + } as ParsedArguments).handler( + { + params: { + function: String(() => 'test'), + workerId: 'worker-1', + args: ['1_1'], + }, + }, + response, + context, + ), + { + message: + 'args (element uids) cannot be used when evaluating in a worker.', + }, + ); + }, + {}, + {experimentalWorkers: true}, + ); + }); + }); + + describe('list_dedicated_workers', () => { + it('lists dedicated workers of the selected page', async () => { + await withMcpContext( + async (response, context) => { + const page = context.getSelectedMcpPage().pptrPage; + await spawnDedicatedWorker(page); + + await listDedicatedWorkers.handler({params: {}}, response, context); + + assert.strictEqual( + response.responseLines.at(0), + '## Dedicated Workers', + ); + assert.match(response.responseLines.at(1)!, /^worker-\d+: blob:/); + }, + {}, + {experimentalWorkers: true}, + ); + }); + + it('reports when there are no dedicated workers', async () => { + await withMcpContext( + async (response, context) => { + await listDedicatedWorkers.handler({params: {}}, response, context); + + assert.strictEqual( + response.responseLines.at(0), + 'No dedicated workers found in the selected page.', + ); + }, + {}, + {experimentalWorkers: true}, + ); + }); }); }); + +/** + * Spawns a dedicated worker in the page and resolves once Puppeteer has + * attached to it. + */ +async function spawnDedicatedWorker(page: Page): Promise { + const workerAttached = new Promise(resolve => { + page.once('workercreated', () => resolve()); + }); + await page.evaluate(() => { + const blob = new Blob(['self.onmessage = () => {};'], { + type: 'application/javascript', + }); + // Keep a reference so the worker is not garbage collected. + (globalThis as unknown as {__worker?: Worker}).__worker = new Worker( + URL.createObjectURL(blob), + ); + }); + await workerAttached; +}