diff --git a/docs/tool-reference.md b/docs/tool-reference.md index f2b4e6234..6d36c4ce7 100644 --- a/docs/tool-reference.md +++ b/docs/tool-reference.md @@ -1,6 +1,6 @@ -# Chrome DevTools MCP Tool Reference (~7094 cl100k_base tokens) +# Chrome DevTools MCP Tool Reference (~7095 cl100k_base tokens) - **[Input automation](#input-automation)** (9 tools) - [`click`](#click) diff --git a/eslint.config.mjs b/eslint.config.mjs index db6ddd675..2c1cd0214 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -14,7 +14,7 @@ import tseslint from 'typescript-eslint'; import localPlugin from './scripts/eslint_rules/local-plugin.js'; export default defineConfig([ - globalIgnores(['**/node_modules', '**/build/']), + globalIgnores(['**/node_modules', '**/build/', 'tests/tools/fixtures/']), importPlugin.flatConfigs.typescript, { languageOptions: { diff --git a/src/McpContext.ts b/src/McpContext.ts index a36f0c197..56ea1f5a8 100644 --- a/src/McpContext.ts +++ b/src/McpContext.ts @@ -29,6 +29,7 @@ import type { ScreenRecorder, SerializedAXNode, Viewport, + Target, } from './third_party/index.js'; import {Locator} from './third_party/index.js'; import {PredefinedNetworkConditions} from './third_party/index.js'; @@ -50,6 +51,12 @@ export interface TextSnapshotNode extends SerializedAXNode { children: TextSnapshotNode[]; } +export interface ExtensionServiceWorker { + url: string; + target: Target; + id: string; +} + export interface GeolocationOptions { latitude: number; longitude: number; @@ -129,6 +136,8 @@ export class McpContext implements Context { #nextIsolatedContextId = 1; #pages: Page[] = []; + #extensionServiceWorkers: ExtensionServiceWorker[] = []; + #pageToDevToolsPage = new Map(); #selectedPage?: Page; #textSnapshot: TextSnapshot | null = null; @@ -146,6 +155,9 @@ export class McpContext implements Context { #pageIdMap = new WeakMap(); #nextPageId = 1; + #extensionServiceWorkerMap = new WeakMap(); + #nextExtensionServiceWorkerId = 1; + #nextSnapshotId = 1; #traceResults: TraceResult[] = []; @@ -185,6 +197,7 @@ export class McpContext implements Context { async #init() { const pages = await this.createPagesSnapshot(); + await this.createExtensionServiceWorkersSnapshot(); await this.#networkCollector.init(pages); await this.#consoleCollector.init(pages); await this.#devtoolsUniverseManager.init(pages); @@ -494,7 +507,7 @@ export class McpContext implements Context { } if (page.isClosed()) { throw new Error( - `The selected page has been closed. Call ${listPages.name} to see open pages.`, + `The selected page has been closed. Call ${listPages().name} to see open pages.`, ); } return page; @@ -584,6 +597,41 @@ export class McpContext implements Context { } } + /** + * Creates a snapshot of the extension service workers. + */ + async createExtensionServiceWorkersSnapshot(): Promise< + ExtensionServiceWorker[] + > { + const allTargets = await this.browser.targets(); + + const serviceWorkers = allTargets.filter(target => { + return ( + target.type() === 'service_worker' && + target.url().includes('chrome-extension://') + ); + }); + + for (const serviceWorker of serviceWorkers) { + if (!this.#extensionServiceWorkerMap.has(serviceWorker)) { + this.#extensionServiceWorkerMap.set( + serviceWorker, + 'sw-' + this.#nextExtensionServiceWorkerId++, + ); + } + } + + this.#extensionServiceWorkers = serviceWorkers.map(serviceWorker => { + return { + target: serviceWorker, + id: this.#extensionServiceWorkerMap.get(serviceWorker)!, + url: serviceWorker.url(), + }; + }); + + return this.#extensionServiceWorkers; + } + async createPagesSnapshot(): Promise { const allPages = await this.#getAllPages(); @@ -677,6 +725,16 @@ export class McpContext implements Context { } } + getExtensionServiceWorkers(): ExtensionServiceWorker[] { + return this.#extensionServiceWorkers; + } + + getExtensionServiceWorkerId( + extensionServiceWorker: ExtensionServiceWorker, + ): string | undefined { + return this.#extensionServiceWorkerMap.get(extensionServiceWorker.target); + } + getPages(): Page[] { return this.#pages; } diff --git a/src/McpResponse.ts b/src/McpResponse.ts index 5cdad8e5b..243cd81cf 100644 --- a/src/McpResponse.ts +++ b/src/McpResponse.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import type {ParsedArguments} from './cli.js'; import {ConsoleFormatter} from './formatters/ConsoleFormatter.js'; import {IssueFormatter} from './formatters/IssueFormatter.js'; import {NetworkFormatter} from './formatters/NetworkFormatter.js'; @@ -38,6 +39,7 @@ interface TraceInsightData { export class McpResponse implements Response { #includePages = false; + #includeExtensionServiceWorkers = false; #snapshotParams?: SnapshotParams; #attachedNetworkRequestId?: number; #attachedNetworkRequestOptions?: { @@ -65,6 +67,11 @@ export class McpResponse implements Response { #listExtensions?: boolean; #devToolsData?: DevToolsData; #tabId?: string; + #args: ParsedArguments; + + constructor(args: ParsedArguments) { + this.#args = args; + } attachDevToolsData(data: DevToolsData): void { this.#devToolsData = data; @@ -76,6 +83,10 @@ export class McpResponse implements Response { setIncludePages(value: boolean): void { this.#includePages = value; + + if (this.#args.categoryExtensions) { + this.#includeExtensionServiceWorkers = value; + } } includeSnapshot(params?: SnapshotParams): void { @@ -233,6 +244,10 @@ export class McpResponse implements Response { await context.createPagesSnapshot(); } + if (this.#includeExtensionServiceWorkers) { + await context.createExtensionServiceWorkersSnapshot(); + } + let snapshot: SnapshotFormatter | string | undefined; if (this.#snapshotParams) { await context.createTextSnapshot( @@ -438,6 +453,7 @@ export class McpResponse implements Response { }; pages?: object[]; pagination?: object; + extensionServiceWorkers?: object[]; } = {}; const response = [`# ${toolName} response`]; @@ -532,6 +548,26 @@ Call ${handleDialog.name} to handle it before continuing.`); }); } + if (this.#includeExtensionServiceWorkers) { + if (!context.getExtensionServiceWorkers().length) { + response.push(`## Extension Service Workers`); + } + + for (const extensionServiceWorker of context.getExtensionServiceWorkers()) { + response.push( + `${extensionServiceWorker.id}: ${extensionServiceWorker.url}`, + ); + } + structuredContent.extensionServiceWorkers = context + .getExtensionServiceWorkers() + .map(extensionServiceWorker => { + return { + id: extensionServiceWorker.id, + url: extensionServiceWorker.url, + }; + }); + } + if (this.#tabId) { structuredContent.tabId = this.#tabId; } diff --git a/src/main.ts b/src/main.ts index a8f9b5f2f..e646cc96e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -203,7 +203,10 @@ function registerTool(tool: ToolDefinition): void { const context = await getContext(); logger(`${tool.name} context: resolved`); await context.detectOpenDevToolsWindows(); - const response = args.slim ? new SlimMcpResponse() : new McpResponse(); + const response = args.slim + ? new SlimMcpResponse(args) + : new McpResponse(args); + await tool.handler( { params, diff --git a/src/tools/ToolDefinition.ts b/src/tools/ToolDefinition.ts index 70bfbfcbe..b2bea87a1 100644 --- a/src/tools/ToolDefinition.ts +++ b/src/tools/ToolDefinition.ts @@ -164,14 +164,16 @@ export function defineTool< Schema extends zod.ZodRawShape, Args extends ParsedArguments = ParsedArguments, >( - definition: (args: Args) => ToolDefinition, -): (args: Args) => ToolDefinition; + definition: (args?: Args) => ToolDefinition, +): (args?: Args) => ToolDefinition; export function defineTool< Schema extends zod.ZodRawShape, Args extends ParsedArguments = ParsedArguments, >( - definition: ToolDefinition | ((args: Args) => ToolDefinition), + definition: + | ToolDefinition + | ((args?: Args) => ToolDefinition), ) { return definition; } diff --git a/src/tools/pages.ts b/src/tools/pages.ts index b63590dfe..b3afe6192 100644 --- a/src/tools/pages.ts +++ b/src/tools/pages.ts @@ -11,17 +11,19 @@ import {zod} from '../third_party/index.js'; import {ToolCategory} from './categories.js'; import {CLOSE_PAGE_ERROR, defineTool, timeoutSchema} from './ToolDefinition.js'; -export const listPages = defineTool({ - name: 'list_pages', - description: `Get a list of pages open in the browser.`, - annotations: { - category: ToolCategory.NAVIGATION, - readOnlyHint: true, - }, - schema: {}, - handler: async (_request, response) => { - response.setIncludePages(true); - }, +export const listPages = defineTool(args => { + return { + name: 'list_pages', + description: `Get a list of pages ${args?.categoryExtensions ? 'including extension service workers' : ''} open in the browser.`, + annotations: { + category: ToolCategory.NAVIGATION, + readOnlyHint: true, + }, + schema: {}, + handler: async (_request, response) => { + response.setIncludePages(true); + }, + }; }); export const selectPage = defineTool({ @@ -35,7 +37,7 @@ export const selectPage = defineTool({ pageId: zod .number() .describe( - `The ID of the page to select. Call ${listPages.name} to get available pages.`, + `The ID of the page to select. Call ${listPages().name} to get available pages.`, ), bringToFront: zod .boolean() @@ -372,7 +374,7 @@ export const getTabId = defineTool({ pageId: zod .number() .describe( - `The ID of the page to get the tab ID for. Call ${listPages.name} to get available pages.`, + `The ID of the page to get the tab ID for. Call ${listPages().name} to get available pages.`, ), }, handler: async (request, response, context) => { diff --git a/src/tools/tools.ts b/src/tools/tools.ts index 8fb8659d9..238b684fc 100644 --- a/src/tools/tools.ts +++ b/src/tools/tools.ts @@ -42,8 +42,7 @@ export const createTools = (args: ParsedArguments) => { const tools: ToolDefinition[] = []; for (const tool of rawTools) { if (typeof tool === 'function') { - // @ts-expect-error none of the tools for now implement the function type tool has type "never" - tools.push(tool(args) as ToolDefinition); + tools.push(tool(args)); } else { tools.push(tool as ToolDefinition); } diff --git a/tests/tools/console.test.ts b/tests/tools/console.test.ts index 64ed12dce..813625cd5 100644 --- a/tests/tools/console.test.ts +++ b/tests/tools/console.test.ts @@ -7,6 +7,7 @@ import assert from 'node:assert'; import {before, describe, it} from 'node:test'; +import type {ParsedArguments} from '../../src/cli.js'; import {loadIssueDescriptions} from '../../src/issue-descriptions.js'; import {McpResponse} from '../../src/McpResponse.js'; import {DevTools} from '../../src/third_party/index.js'; @@ -170,7 +171,7 @@ describe('console', () => { await context.createTextSnapshot(); await issuePromise; await listConsoleMessages.handler({params: {}}, response, context); - const response2 = new McpResponse(); + const response2 = new McpResponse({} as ParsedArguments); await getConsoleMessage.handler( {params: {msgid: 1}}, response2, @@ -225,7 +226,7 @@ describe('console', () => { response, context, ); - const response2 = new McpResponse(); + const response2 = new McpResponse({} as ParsedArguments); await getConsoleMessage.handler( {params: {msgid: id}}, response2, diff --git a/tests/tools/fixtures/extension-sw/manifest.json b/tests/tools/fixtures/extension-sw/manifest.json new file mode 100644 index 000000000..d1d926c48 --- /dev/null +++ b/tests/tools/fixtures/extension-sw/manifest.json @@ -0,0 +1,11 @@ +{ + "manifest_version": 3, + "name": "Test Extension with SW", + "version": "1.0", + "background": { + "service_worker": "sw.js" + }, + "action": { + "default_popup": "popup.html" + } +} diff --git a/tests/tools/fixtures/extension-sw/popup.html b/tests/tools/fixtures/extension-sw/popup.html new file mode 100644 index 000000000..9f3116253 --- /dev/null +++ b/tests/tools/fixtures/extension-sw/popup.html @@ -0,0 +1,6 @@ + + + +

Extension With Service Worker

+ + diff --git a/tests/tools/fixtures/extension-sw/sw.js b/tests/tools/fixtures/extension-sw/sw.js new file mode 100644 index 000000000..f44ddb739 --- /dev/null +++ b/tests/tools/fixtures/extension-sw/sw.js @@ -0,0 +1 @@ +console.log('Service worker loaded'); diff --git a/tests/tools/input.test.ts b/tests/tools/input.test.ts index b50d46b0d..bc21df87e 100644 --- a/tests/tools/input.test.ts +++ b/tests/tools/input.test.ts @@ -9,6 +9,7 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import {describe, it} from 'node:test'; +import type {ParsedArguments} from '../../src/cli.js'; import {McpResponse} from '../../src/McpResponse.js'; import { click, @@ -514,7 +515,7 @@ describe('input', () => { await context.createTextSnapshot(); // Fill email - const response1 = new McpResponse(); + const response1 = new McpResponse({} as ParsedArguments); await fill.handler( { params: { @@ -531,7 +532,7 @@ describe('input', () => { ); // Fill password - const response2 = new McpResponse(); + const response2 = new McpResponse({} as ParsedArguments); await fill.handler( { params: { diff --git a/tests/tools/pages.test.ts b/tests/tools/pages.test.ts index 12fcb364a..3a740e250 100644 --- a/tests/tools/pages.test.ts +++ b/tests/tools/pages.test.ts @@ -5,11 +5,14 @@ */ import assert from 'node:assert'; +import path from 'node:path'; import {afterEach, describe, it} from 'node:test'; import type {Dialog} from 'puppeteer-core'; import sinon from 'sinon'; +import type {ParsedArguments} from '../../src/cli.js'; +import {installExtension} from '../../src/tools/extensions.js'; import { listPages, newPage, @@ -22,6 +25,11 @@ import { } from '../../src/tools/pages.js'; import {html, withMcpContext} from '../utils.js'; +const EXTENSION_PATH = path.join( + import.meta.dirname, + '../../../tests/tools/fixtures/extension-sw', +); + describe('pages', () => { afterEach(() => { sinon.restore(); @@ -30,10 +38,61 @@ describe('pages', () => { describe('list_pages', () => { it('list pages', async () => { await withMcpContext(async (response, context) => { - await listPages.handler({params: {}}, response, context); + await listPages().handler({params: {}}, response, context); assert.ok(response.includePages); }); }); + for (const categoryExtensions of [true, false]) { + it(`list pages ${categoryExtensions ? 'with' : 'without'} --category-extensions`, async () => { + await withMcpContext( + async (response, context) => { + await installExtension.handler( + {params: {path: EXTENSION_PATH}}, + response, + context, + ); + + const swTarget = await context.browser.waitForTarget( + t => + t.type() === 'service_worker' && + t.url().includes('chrome-extension://'), + ); + const swUrl = swTarget.url(); + + response.resetResponseLineForTesting(); + + const listPageDef = listPages({ + categoryExtensions, + } as ParsedArguments); + await listPageDef.handler({params: {}}, response, context); + + const result = await response.handle(listPageDef.name, context); + const textContent = result.content.find(c => c.type === 'text') as { + type: 'text'; + text: string; + }; + assert.ok(textContent); + + if (categoryExtensions) { + assert.ok(textContent.text.includes(swUrl)); + const structured = result.structuredContent as { + extensionServiceWorkers: Array<{url: string}>; + }; + assert.deepStrictEqual( + structured.extensionServiceWorkers.map(sw => sw.url), + [swUrl], + ); + } else { + assert.ok(!textContent.text.includes(swUrl)); + } + }, + {}, + { + categoryExtensions, + } as ParsedArguments, + ); + }); + } }); describe('new_page', () => { it('create a page', async () => { diff --git a/tests/utils.ts b/tests/utils.ts index 977b223dd..d19679811 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -17,6 +17,7 @@ import type { } from 'puppeteer-core'; import sinon from 'sinon'; +import type {ParsedArguments} from '../src/cli.js'; import {McpContext} from '../src/McpContext.js'; import {McpResponse} from '../src/McpResponse.js'; import {stableIdSymbol} from '../src/PageCollector.js'; @@ -85,9 +86,10 @@ export async function withMcpContext( autoOpenDevTools?: boolean; performanceCrux?: boolean; } = {}, + args: ParsedArguments = {} as ParsedArguments, ) { await withBrowser(async browser => { - const response = new McpResponse(); + const response = new McpResponse(args); if (context) { context.dispose(); }