diff --git a/src/McpPage.ts b/src/McpPage.ts index 1e311bc62..36925f2ea 100644 --- a/src/McpPage.ts +++ b/src/McpPage.ts @@ -9,6 +9,7 @@ import type { ElementHandle, Page, Viewport, + WebMCPTool, } from './third_party/index.js'; import type {ToolGroup, ToolDefinition} from './tools/inPage.js'; import {takeSnapshot} from './tools/snapshot.js'; @@ -78,6 +79,10 @@ export class McpPage implements ContextPage { return this.inPageTools; } + getWebMcpTools(): WebMCPTool[] { + return this.pptrPage.webmcp.tools(); + } + get networkConditions(): string | null { return this.emulationSettings.networkConditions ?? null; } diff --git a/src/McpResponse.ts b/src/McpResponse.ts index c424401af..21c13c3f5 100644 --- a/src/McpResponse.ts +++ b/src/McpResponse.ts @@ -4,6 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ +import type {WebMCPTool} from 'puppeteer-core'; + import type {ParsedArguments} from './bin/chrome-devtools-mcp-cli-options.js'; import {ConsoleFormatter} from './formatters/ConsoleFormatter.js'; import {IssueFormatter} from './formatters/IssueFormatter.js'; @@ -181,6 +183,7 @@ export class McpResponse implements Response { }; #listExtensions?: boolean; #listInPageTools?: boolean; + #listWebMcpTools?: boolean; #devToolsData?: DevToolsData; #tabId?: string; #args: ParsedArguments; @@ -232,6 +235,10 @@ export class McpResponse implements Response { } } + setListWebMcpTools(): void { + this.#listWebMcpTools = true; + } + setIncludeNetworkRequests( value: boolean, options?: PaginationOptions & { @@ -374,6 +381,10 @@ export class McpResponse implements Response { return this.#snapshotParams; } + get listWebMcpTools(): boolean | undefined { + return this.#listWebMcpTools; + } + async handle( toolName: string, context: McpContext, @@ -490,6 +501,12 @@ export class McpResponse implements Response { page.inPageTools = inPageTools; } + let webmcpTools: WebMCPTool[] | undefined; + if (this.#listWebMcpTools && this.#args.experimentalWebmcp) { + const page = this.#page ?? context.getSelectedMcpPage(); + webmcpTools = page.getWebMcpTools(); + } + let consoleMessages: Array | undefined; if (this.#consoleDataOptions?.include) { if (!this.#page) { @@ -595,6 +612,7 @@ export class McpResponse implements Response { extensions, lighthouseResult: this.#attachedLighthouseResult, inPageTools, + webmcpTools, }); } @@ -612,6 +630,7 @@ export class McpResponse implements Response { extensions?: InstalledExtension[]; lighthouseResult?: LighthouseData; inPageTools?: ToolGroup; + webmcpTools?: WebMCPTool[]; }, ): {content: Array; structuredContent: object} { const structuredContent: { @@ -627,6 +646,7 @@ export class McpResponse implements Response { lighthouseResult?: object; extensions?: object[]; inPageTools?: object; + webmcpTools?: object[]; message?: string; networkConditions?: string; navigationTimeout?: number; @@ -884,6 +904,30 @@ Call ${handleDialog.name} to handle it before continuing.`); } } + if (this.#listWebMcpTools && data.webmcpTools) { + structuredContent.webmcpTools = data.webmcpTools.map( + ({name, description, inputSchema, annotations}) => ({ + name, + description, + inputSchema, + annotations, + }), + ); + response.push('## WebMCP tools'); + if (data.webmcpTools.length === 0) { + response.push('No WebMCP tools available.'); + } else { + const webmcpToolsMessage = data.webmcpTools + .map(tool => { + return `name="${tool.name}", description="${tool.description}", inputSchema=${JSON.stringify( + tool.inputSchema, + )}, annotations=${JSON.stringify(tool.annotations)}`; + }) + .join('\n'); + response.push(webmcpToolsMessage); + } + } + if (this.#networkRequestsOptions?.include && data.networkRequests) { const requests = data.networkRequests; diff --git a/src/bin/chrome-devtools-mcp-cli-options.ts b/src/bin/chrome-devtools-mcp-cli-options.ts index 3c8bcf07d..2b2a50c6b 100644 --- a/src/bin/chrome-devtools-mcp-cli-options.ts +++ b/src/bin/chrome-devtools-mcp-cli-options.ts @@ -185,6 +185,11 @@ export const cliOptions = { describe: 'Exposes experimental screencast tools (requires ffmpeg). Install ffmpeg https://www.ffmpeg.org/download.html and ensure it is available in the MCP server PATH.', }, + experimentalWebmcp: { + type: 'boolean', + describe: 'Set to true to enable debugging WebMCP tools.', + hidden: true, + }, chromeArg: { type: 'array', describe: diff --git a/src/bin/chrome-devtools.ts b/src/bin/chrome-devtools.ts index e3aab5f45..979533e84 100644 --- a/src/bin/chrome-devtools.ts +++ b/src/bin/chrome-devtools.ts @@ -51,6 +51,7 @@ delete startCliOptions.viewport; // tools, they need to be enabled during CLI generation. delete startCliOptions.experimentalPageIdRouting; delete startCliOptions.experimentalVision; +delete startCliOptions.experimentalWebmcp; delete startCliOptions.experimentalInteropTools; delete startCliOptions.experimentalScreencast; delete startCliOptions.categoryEmulation; diff --git a/src/index.ts b/src/index.ts index 105a311e4..415c6a031 100644 --- a/src/index.ts +++ b/src/index.ts @@ -164,6 +164,12 @@ export async function createMcpServer( ) { return; } + if ( + tool.annotations.conditions?.includes('experimentalWebmcp') && + !serverArgs.experimentalWebmcp + ) { + return; + } const schema = 'pageScoped' in tool && tool.pageScoped && diff --git a/src/telemetry/tool_call_metrics.json b/src/telemetry/tool_call_metrics.json index 743f0eb1d..c3e2c80fe 100644 --- a/src/telemetry/tool_call_metrics.json +++ b/src/telemetry/tool_call_metrics.json @@ -539,5 +539,9 @@ "argType": "number" } ] + }, + { + "name": "list_webmcp_tools", + "args": [] } ] diff --git a/src/tools/ToolDefinition.ts b/src/tools/ToolDefinition.ts index 5269c7eeb..0fa86bb63 100644 --- a/src/tools/ToolDefinition.ts +++ b/src/tools/ToolDefinition.ts @@ -134,6 +134,7 @@ export interface Response { setListExtensions(): void; attachLighthouseResult(result: LighthouseData): void; setListInPageTools(): void; + setListWebMcpTools(): void; } export type SupportedExtensions = diff --git a/src/tools/pages.ts b/src/tools/pages.ts index 184a51350..9cc776c81 100644 --- a/src/tools/pages.ts +++ b/src/tools/pages.ts @@ -28,6 +28,7 @@ export const listPages = defineTool(args => { handler: async (_request, response) => { response.setIncludePages(true); response.setListInPageTools(); + response.setListWebMcpTools(); }, }; }); @@ -55,6 +56,7 @@ export const selectPage = defineTool({ context.selectPage(page); response.setIncludePages(true); response.setListInPageTools(); + response.setListWebMcpTools(); if (request.params.bringToFront) { await page.pptrPage.bringToFront(); } @@ -280,6 +282,7 @@ export const navigatePage = definePageTool({ response.setIncludePages(true); response.setListInPageTools(); + response.setListWebMcpTools(); }, }); diff --git a/src/tools/tools.ts b/src/tools/tools.ts index 3c74115c3..b3477b906 100644 --- a/src/tools/tools.ts +++ b/src/tools/tools.ts @@ -22,6 +22,7 @@ import * as scriptTools from './script.js'; import * as slimTools from './slim/tools.js'; import * as snapshotTools from './snapshot.js'; import type {ToolDefinition} from './ToolDefinition.js'; +import * as webmcpTools from './webmcp.js'; export const createTools = (args: ParsedArguments) => { const rawTools = args.slim @@ -41,6 +42,7 @@ export const createTools = (args: ParsedArguments) => { ...Object.values(screenshotTools), ...Object.values(scriptTools), ...Object.values(snapshotTools), + ...Object.values(webmcpTools), ]; const tools = []; diff --git a/src/tools/webmcp.ts b/src/tools/webmcp.ts new file mode 100644 index 000000000..e52a5ac60 --- /dev/null +++ b/src/tools/webmcp.ts @@ -0,0 +1,22 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {ToolCategory} from './categories.js'; +import {definePageTool} from './ToolDefinition.js'; + +export const listWebMcpTools = definePageTool({ + name: 'list_webmcp_tools', + description: `Lists all WebMCP tools the page exposes.`, + annotations: { + category: ToolCategory.DEBUGGING, + readOnlyHint: true, + conditions: ['experimentalWebmcp'], + }, + schema: {}, + handler: async (_request, response, _context) => { + response.setListWebMcpTools(); + }, +}); diff --git a/tests/McpResponse.test.js.snapshot b/tests/McpResponse.test.js.snapshot index a0eb9bd1f..641187d2f 100644 --- a/tests/McpResponse.test.js.snapshot +++ b/tests/McpResponse.test.js.snapshot @@ -1281,3 +1281,113 @@ exports[`lighthouse > includes lighthouse report paths 2`] = ` } } `; + +exports[`webmcp > includes webmcp tools in list_pages response 1`] = ` +## Pages +1: about:blank [selected] +## WebMCP tools +name="test_tool", description="A test tool", inputSchema={}, annotations=undefined +`; + +exports[`webmcp > includes webmcp tools in list_pages response 2`] = ` +{ + "pages": [ + { + "id": 1, + "url": "about:blank", + "selected": true + } + ], + "webmcpTools": [ + { + "name": "test_tool", + "description": "A test tool", + "inputSchema": {} + } + ] +} +`; + +exports[`webmcp > includes webmcp tools in navigate_page response 1`] = ` +Successfully navigated to about:blank. +## Pages +1: about:blank [selected] +## WebMCP tools +name="test_tool", description="A test tool", inputSchema={}, annotations=undefined +`; + +exports[`webmcp > includes webmcp tools in navigate_page response 2`] = ` +{ + "message": "Successfully navigated to about:blank.", + "pages": [ + { + "id": 1, + "url": "about:blank", + "selected": true + } + ], + "webmcpTools": [ + { + "name": "test_tool", + "description": "A test tool", + "inputSchema": {} + } + ] +} +`; + +exports[`webmcp > includes webmcp tools in select_page response 1`] = ` +## Pages +1: about:blank [selected] +## WebMCP tools +name="test_tool", description="A test tool", inputSchema={}, annotations=undefined +`; + +exports[`webmcp > includes webmcp tools in select_page response 2`] = ` +{ + "pages": [ + { + "id": 1, + "url": "about:blank", + "selected": true + } + ], + "webmcpTools": [ + { + "name": "test_tool", + "description": "A test tool", + "inputSchema": {} + } + ] +} +`; + +exports[`webmcp > list no webmcp tools if experimentalWebmcp is false 1`] = ` +Successfully navigated to about:blank. +## Pages +1: about:blank [selected] +`; + +exports[`webmcp > list no webmcp tools if experimentalWebmcp is false 2`] = ` +{ + "message": "Successfully navigated to about:blank.", + "pages": [ + { + "id": 1, + "url": "about:blank", + "selected": true + } + ] +} +`; + +exports[`webmcp > list no webmcp tools if there are none 1`] = ` +## WebMCP tools +No WebMCP tools available. +`; + +exports[`webmcp > list no webmcp tools if there are none 2`] = ` +{ + "webmcpTools": [] +} +`; diff --git a/tests/McpResponse.test.ts b/tests/McpResponse.test.ts index 8bebe52ef..30ba25d83 100644 --- a/tests/McpResponse.test.ts +++ b/tests/McpResponse.test.ts @@ -1455,3 +1455,130 @@ describe('replaceHtmlElementsWithUids', () => { } }); }); + +describe('webmcp', () => { + async function testIncludesWebmcpTools( + t: it.TestContext, + parseArguments: ParsedArguments, + handlerAction: ( + response: McpResponse, + context: McpContext, + ) => Promise, + toolName: string, + ) { + await withMcpContext( + async (response, context) => { + response.setListWebMcpTools(); + + await handlerAction(response, context); + + const page = context.getSelectedMcpPage().pptrPage; + await page.setContent( + html`
`, + ); + + const {content, structuredContent} = await response.handle( + toolName, + context, + ); + assert.ok(getTextContent(content[0])); + t.assert.snapshot?.(getTextContent(content[0])); + t.assert.snapshot?.( + JSON.stringify( + stabilizeStructuredContent(structuredContent), + null, + 2, + ), + ); + }, + {args: ['--enable-features=WebMCPTesting,DevToolsWebMCPSupport']}, + parseArguments, + ); + } + + it('includes webmcp tools in list_pages response', async t => { + await testIncludesWebmcpTools( + t, + {experimentalWebmcp: true} as ParsedArguments, + async (response, context) => { + await listPages().handler({params: {}}, response, context); + }, + 'list_pages', + ); + }); + + it('includes webmcp tools in select_page response', async t => { + await testIncludesWebmcpTools( + t, + {experimentalWebmcp: true} as ParsedArguments, + async (response, context) => { + const pageId = + context.getPageId(context.getSelectedMcpPage().pptrPage) ?? 1; + await selectPage.handler({params: {pageId}}, response, context); + }, + 'select_page', + ); + }); + + it('includes webmcp tools in navigate_page response', async t => { + await testIncludesWebmcpTools( + t, + {experimentalWebmcp: true} as ParsedArguments, + async (response, context) => { + await navigatePage.handler( + { + params: {type: 'url', url: 'about:blank'}, + page: context.getSelectedMcpPage(), + }, + response, + context, + ); + }, + 'navigate_page', + ); + }); + + it('list no webmcp tools if there are none', async t => { + await withMcpContext( + async (response, context) => { + response.setListWebMcpTools(); + const {content, structuredContent} = await response.handle( + 'test', + context, + ); + assert.ok(getTextContent(content[0])); + t.assert.snapshot?.(getTextContent(content[0])); + t.assert.snapshot?.( + JSON.stringify( + stabilizeStructuredContent(structuredContent), + null, + 2, + ), + ); + }, + {args: ['--enable-features=WebMCPTesting,DevToolsWebMCPSupport']}, + {experimentalWebmcp: true} as ParsedArguments, + ); + }); + + it('list no webmcp tools if experimentalWebmcp is false', async t => { + await testIncludesWebmcpTools( + t, + {experimentalWebmcp: false} as ParsedArguments, + async (response, context) => { + await navigatePage.handler( + { + params: {type: 'url', url: 'about:blank'}, + page: context.getSelectedMcpPage(), + }, + response, + context, + ); + }, + 'navigate_page', + ); + }); +}); diff --git a/tests/tools/webmcp.test.ts b/tests/tools/webmcp.test.ts new file mode 100644 index 000000000..e2a7c6a45 --- /dev/null +++ b/tests/tools/webmcp.test.ts @@ -0,0 +1,40 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'node:assert'; +import {describe, it} from 'node:test'; + +import {listPages, navigatePage, selectPage} from '../../src/tools/pages.js'; +import {withMcpContext} from '../utils.js'; + +describe('webmcp', () => { + it('list webmcp tools in navigate_page response', async () => { + await withMcpContext(async (response, context) => { + await navigatePage.handler( + {params: {url: 'about:blank'}, page: context.getSelectedMcpPage()}, + response, + context, + ); + assert.ok(response.listWebMcpTools); + }); + }); + + it('list webmcp tools in list_pages response', async () => { + await withMcpContext(async (response, context) => { + await listPages().handler({params: {}}, response, context); + assert.ok(response.listWebMcpTools); + }); + }); + + it('list webmcp tools in select_page response', async () => { + await withMcpContext(async (response, context) => { + const pageId = + context.getPageId(context.getSelectedMcpPage().pptrPage) ?? 1; + await selectPage.handler({params: {pageId}}, response, context); + assert.ok(response.listWebMcpTools); + }); + }); +}); diff --git a/tests/utils.ts b/tests/utils.ts index f764fa9ce..6d4668fbf 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -64,6 +64,7 @@ export async function withBrowser( debug?: boolean; autoOpenDevTools?: boolean; executablePath?: string; + args?: string[]; } = {}, ) { const launchOptions: LaunchOptions = { @@ -74,7 +75,7 @@ export async function withBrowser( devtools: options.autoOpenDevTools ?? false, pipe: true, handleDevToolsAsPage: true, - args: ['--screen-info={3840x2160}'], + args: [...(options.args || []), '--screen-info={3840x2160}'], enableExtensions: true, }; const key = JSON.stringify(launchOptions); @@ -104,6 +105,7 @@ export async function withMcpContext( autoOpenDevTools?: boolean; performanceCrux?: boolean; executablePath?: string; + args?: string[]; } = {}, args: ParsedArguments = {} as ParsedArguments, ) {