diff --git a/README.md b/README.md index ebb3a4b09..cdc842d46 100644 --- a/README.md +++ b/README.md @@ -584,6 +584,10 @@ The Chrome DevTools MCP server supports the following configuration option: 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 +- **`--experimentalWebmcp`/ `--experimental-webmcp`** + Set to true to enable debugging WebMCP tools. Requires Chrome 149+ with the following flags: `--enable-features=WebMCPTesting,DevToolsWebMCPSupport` + - **Type:** boolean + - **`--chromeArg`/ `--chrome-arg`** Additional arguments for Chrome. Only applies when Chrome is launched by chrome-devtools-mcp. - **Type:** array diff --git a/src/bin/chrome-devtools-mcp-cli-options.ts b/src/bin/chrome-devtools-mcp-cli-options.ts index 2b2a50c6b..c08038d12 100644 --- a/src/bin/chrome-devtools-mcp-cli-options.ts +++ b/src/bin/chrome-devtools-mcp-cli-options.ts @@ -187,8 +187,8 @@ export const cliOptions = { }, experimentalWebmcp: { type: 'boolean', - describe: 'Set to true to enable debugging WebMCP tools.', - hidden: true, + describe: + 'Set to true to enable debugging WebMCP tools. Requires Chrome 149+ with the following flags: `--enable-features=WebMCPTesting,DevToolsWebMCPSupport`', }, chromeArg: { type: 'array', diff --git a/src/telemetry/tool_call_metrics.json b/src/telemetry/tool_call_metrics.json index c3e2c80fe..cdaff57d5 100644 --- a/src/telemetry/tool_call_metrics.json +++ b/src/telemetry/tool_call_metrics.json @@ -543,5 +543,18 @@ { "name": "list_webmcp_tools", "args": [] + }, + { + "name": "execute_webmcp_tool", + "args": [ + { + "name": "tool_name_length", + "argType": "number" + }, + { + "name": "input_length", + "argType": "number" + } + ] } ] diff --git a/src/tools/webmcp.ts b/src/tools/webmcp.ts index e52a5ac60..d7f06afec 100644 --- a/src/tools/webmcp.ts +++ b/src/tools/webmcp.ts @@ -4,6 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ +import {zod} from '../third_party/index.js'; + import {ToolCategory} from './categories.js'; import {definePageTool} from './ToolDefinition.js'; @@ -20,3 +22,49 @@ export const listWebMcpTools = definePageTool({ response.setListWebMcpTools(); }, }); + +export const executeWebMcpTool = definePageTool({ + name: 'execute_webmcp_tool', + description: `Executes a WebMCP tool exposed by the page.`, + annotations: { + category: ToolCategory.DEBUGGING, + readOnlyHint: false, + conditions: ['experimentalWebmcp'], + }, + schema: { + toolName: zod.string().describe('The name of the WebMCP tool to execute'), + input: zod + .string() + .optional() + .describe('The JSON-stringified parameters to pass to the WebMCP tool'), + }, + handler: async (request, response) => { + const toolName = request.params.toolName; + + let input: Record = {}; + if (request.params.input) { + try { + const parsed = JSON.parse(request.params.input); + if (typeof parsed === 'object' && parsed !== null) { + input = parsed; + } else { + throw new Error('Parsed input is not an object'); + } + } catch (e) { + const errorMessage = e instanceof Error ? e.message : String(e); + throw new Error(`Failed to parse input as JSON: ${errorMessage}`); + } + } + + const tools = request.page.pptrPage.webmcp.tools(); + const tool = tools.find(t => t.name === toolName); + if (!tool) { + throw new Error(`Tool ${toolName} not found`); + } + + const {status, output, errorText} = await tool.execute(input); + response.appendResponseLine( + JSON.stringify({status, output, errorText}, null, 2), + ); + }, +}); diff --git a/tests/index.test.ts b/tests/index.test.ts index f08350c08..6fc08dc66 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -162,4 +162,19 @@ describe('e2e', () => { ['--experimental-interop-tools'], ); }); + + it('has experimental webmcp', async () => { + await withClient( + async client => { + const {tools} = await client.listTools(); + const listWebMcpTools = tools.find(t => t.name === 'list_webmcp_tools'); + const executeWebMcpTool = tools.find( + t => t.name === 'execute_webmcp_tool', + ); + assert.ok(listWebMcpTools); + assert.ok(executeWebMcpTool); + }, + ['--experimental-webmcp'], + ); + }); }); diff --git a/tests/tools/webmcp.test.ts b/tests/tools/webmcp.test.ts index e2a7c6a45..fcbe5f3e1 100644 --- a/tests/tools/webmcp.test.ts +++ b/tests/tools/webmcp.test.ts @@ -7,34 +7,126 @@ import assert from 'node:assert'; import {describe, it} from 'node:test'; +import type {ParsedArguments} from '../../src/bin/chrome-devtools-mcp-cli-options.js'; +import type {McpPage} from '../../src/McpPage.js'; import {listPages, navigatePage, selectPage} from '../../src/tools/pages.js'; -import {withMcpContext} from '../utils.js'; +import {executeWebMcpTool} from '../../src/tools/webmcp.js'; +import {html, 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); + describe('list_webmcp_tools', () => { + 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 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); + }); }); }); - 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); + describe('execute_webmcp_tool', () => { + async function setupWebMcpTool(page: McpPage) { + await page.pptrPage.setContent( + html`
`, + ); + } + + // TODO: Remove `.skip` once Chrome 149 reaches stable channel. + it.skip('executes a tool successfully', async () => { + await withMcpContext( + async (response, context) => { + const page = context.getSelectedMcpPage(); + await setupWebMcpTool(page); + + await executeWebMcpTool.handler( + {params: {toolName: 'test_tool', input: JSON.stringify({})}, page}, + response, + context, + ); + assert.strictEqual( + response.responseLines[0], + JSON.stringify({status: 'Completed', output: 'hello'}, null, 2), + ); + }, + {args: ['--enable-features=WebMCPTesting,DevToolsWebMCPSupport']}, + {experimentalWebmcp: true} as ParsedArguments, + ); + }); + + it('throws if tool is not found', async () => { + await withMcpContext( + async (response, context) => { + await assert.rejects( + async () => { + await executeWebMcpTool.handler( + { + params: {toolName: 'missing-tool', input: JSON.stringify({})}, + page: context.getSelectedMcpPage(), + }, + response, + context, + ); + }, + {message: /Tool missing-tool not found/}, + ); + }, + {args: ['--enable-features=WebMCPTesting,DevToolsWebMCPSupport']}, + {experimentalWebmcp: true} as ParsedArguments, + ); + }); + + it('throws if input is invalid', async () => { + await withMcpContext( + async (response, context) => { + await assert.rejects( + async () => { + const page = context.getSelectedMcpPage(); + await setupWebMcpTool(page); + + await executeWebMcpTool.handler( + {params: {toolName: 'test_tool', input: 'invalid'}, page}, + response, + context, + ); + }, + { + message: + /Failed to parse input as JSON: Unexpected token 'i', "invalid" is not valid JSON/, + }, + ); + }, + {args: ['--enable-features=WebMCPTesting,DevToolsWebMCPSupport']}, + {experimentalWebmcp: true} as ParsedArguments, + ); }); }); });