From d888981ab12a340e6f1f01796ee17e67dab09bc6 Mon Sep 17 00:00:00 2001 From: Nicholas Roscino Date: Tue, 28 Apr 2026 16:27:53 +0200 Subject: [PATCH 01/10] chore: update CLI generator to generate all possible tools --- scripts/generate-cli.ts | 35 ++-- skills/chrome-devtools-cli/SKILL.md | 21 +++ src/index.ts | 188 ++++++++++++++------- src/tools/categories.ts | 5 +- tests/e2e/chrome-devtools-commands.test.ts | 35 ++++ 5 files changed, 212 insertions(+), 72 deletions(-) diff --git a/scripts/generate-cli.ts b/scripts/generate-cli.ts index 6cb63647e..2247ca1cc 100644 --- a/scripts/generate-cli.ts +++ b/scripts/generate-cli.ts @@ -11,7 +11,7 @@ import {Client} from '@modelcontextprotocol/sdk/client/index.js'; import {StdioClientTransport} from '@modelcontextprotocol/sdk/client/stdio.js'; import {parseArguments} from '../build/src/bin/chrome-devtools-mcp-cli-options.js'; -import {labels} from '../build/src/tools/categories.js'; +import {labels, ToolCategory} from '../build/src/tools/categories.js'; import {createTools} from '../build/src/tools/tools.js'; const OUTPUT_PATH = path.join( @@ -29,7 +29,7 @@ async function fetchTools() { const transport = new StdioClientTransport({ command: 'node', - args: [serverPath], + args: [serverPath, '--viaCli'], env: {...process.env, CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS: 'true'}, }); @@ -83,7 +83,10 @@ function schemaToCLIOptions(schema: JsonSchema): CliOption[] { const properties = schema.properties; return Object.entries(properties).map(([name, prop]) => { const isRequired = required.includes(name); - const description = prop.description || ''; + let description = prop.description || ''; + if (isRequired) { + description += ' (required)'; + } if (typeof prop.type !== 'string') { throw new Error( `Property ${name} has a complex type not supported by CLI.`, @@ -103,6 +106,15 @@ function schemaToCLIOptions(schema: JsonSchema): CliOption[] { async function generateCli() { const tools = await fetchTools(); + const staticTools = createTools(parseArguments()); + const toolNameToCategory = new Map(); + for (const tool of staticTools) { + toolNameToCategory.set( + tool.name, + labels[tool.annotations.category as keyof typeof labels], + ); + } + // Sort tools by name const sortedTools = tools .sort((a, b) => a.name.localeCompare(b.name)) @@ -117,18 +129,17 @@ async function generateCli() { if (tool.name === 'wait_for') { return false; } + // Skipping get_tab_id as it is for internal integrations + if (tool.name === 'get_tab_id') { + return false; + } + // Skipping in_page tools as they are not launched yet + if (toolNameToCategory.get(tool.name) === labels[ToolCategory.IN_PAGE]) { + return false; + } return true; }); - const staticTools = createTools(parseArguments()); - const toolNameToCategory = new Map(); - for (const tool of staticTools) { - toolNameToCategory.set( - tool.name, - labels[tool.annotations.category as keyof typeof labels], - ); - } - const commands: Record< string, {description: string; category: string; args: Record} diff --git a/skills/chrome-devtools-cli/SKILL.md b/skills/chrome-devtools-cli/SKILL.md index 2b251c69b..fb80aa830 100644 --- a/skills/chrome-devtools-cli/SKILL.md +++ b/skills/chrome-devtools-cli/SKILL.md @@ -123,6 +123,27 @@ chrome-devtools take_snapshot # Take a text snapshot of the page from the a11y t chrome-devtools take_snapshot --verbose true --filePath "s.txt" # Take a verbose snapshot and save to file ``` +## Extensions + +```bash +chrome-devtools list_extensions # Lists all the Chrome extensions installed in the browser +chrome-devtools install_extension "/path/to/extension" # Installs a Chrome extension from the given path +chrome-devtools uninstall_extension "extension_id" # Uninstalls a Chrome extension by its ID +chrome-devtools reload_extension "extension_id" # Reloads an unpacked Chrome extension by its ID +chrome-devtools trigger_extension_action "extension_id" # Triggers the default action of an extension by its ID +``` + +## Experimental Features + +Experimental tools are disabled by default. Enable them with the corresponding flag during `start`. + +```bash +chrome-devtools click_at 100 200 # Clicks at the provided coordinates (requires --experimentalVision=true) +chrome-devtools screencast_start # Starts a screencast recording (requires --experimentalScreencast=true and ffmpeg) +chrome-devtools screencast_stop # Stops the active screencast +chrome-devtools list_webmcp_tools # List all WebMCP tools (requires --experimentalWebmcp=true) +``` + ## Service Management ```bash diff --git a/src/index.ts b/src/index.ts index f98539ef5..fff7bed7e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -24,12 +24,121 @@ import { ListRootsResultSchema, RootsListChangedNotificationSchema, } from './third_party/index.js'; -import {ToolCategory} from './tools/categories.js'; +import { + ToolCategory, + labels, + OFF_BY_DEFAULT_CATEGORIES, +} from './tools/categories.js'; import type {DefinedPageTool, ToolDefinition} from './tools/ToolDefinition.js'; import {pageIdSchema} from './tools/ToolDefinition.js'; import {createTools} from './tools/tools.js'; import {VERSION} from './version.js'; +const CONDITION_TO_FLAG: Record = { + computerVision: 'experimentalVision', + experimentalMemory: 'experimentalMemory', + experimentalInteropTools: 'experimentalInteropTools', + screencast: 'experimentalScreencast', + experimentalWebmcp: 'experimentalWebmcp', +}; + +function buildDisabledMessage( + toolName: string, + flag: string, + categoryLabel?: string, +): string { + const reason = categoryLabel + ? `is in category ${categoryLabel} which` + : `requires experimental feature ${flag} and`; + + return `Tool ${toolName} ${reason} is currently disabled. Enable it by running chrome-devtools start ${flag}=true. For more information check the README.`; +} + +function getCategoryStatus( + category: ToolCategory, + serverArgs: ReturnType, +): {categoryFlag?: string; disabled: boolean} { + const categoryFlag = + category === ToolCategory.IN_PAGE + ? 'categoryInPageTools' + : `category${category.charAt(0).toUpperCase() + category.slice(1)}`; + + const flagValue = serverArgs[categoryFlag]; + + const isDisabled = OFF_BY_DEFAULT_CATEGORIES.includes(category) + ? !flagValue + : flagValue === false; + + if (isDisabled) { + return { + categoryFlag, + disabled: true, + }; + } + + return { + disabled: false, + }; +} + +function getConditionStatus( + condition: string, + serverArgs: ReturnType, +): {conditionFlag?: string; disabled: boolean} { + const experimentalFlag = CONDITION_TO_FLAG[condition]; + if (experimentalFlag && !serverArgs[experimentalFlag]) { + return {conditionFlag: experimentalFlag, disabled: true}; + } + + return {disabled: false}; +} + +function getToolStatusInfo( + tool: ToolDefinition | DefinedPageTool, + serverArgs: ReturnType, +): {disabled: boolean; reason?: string} { + const category = tool.annotations.category; + const categoryCheck = getCategoryStatus(category, serverArgs); + + if (category && categoryCheck.disabled) { + if (!categoryCheck.categoryFlag) { + throw new Error( + 'when the category is disabled there should always be a flag set', + ); + } + + return { + disabled: true, + reason: buildDisabledMessage( + tool.name, + `--${categoryCheck.categoryFlag}`, + labels[category!], + ), + }; + } + + for (const condition of tool.annotations.conditions || []) { + const conditionCheck = getConditionStatus(condition, serverArgs); + if (conditionCheck.disabled) { + if (!conditionCheck.conditionFlag) { + throw new Error( + 'when the condition is disabled there should always be a flag set', + ); + } + + return { + disabled: true, + reason: buildDisabledMessage( + tool.name, + `--${conditionCheck.conditionFlag}`, + ), + }; + } + } + + return {disabled: false}; +} + export async function createMcpServer( serverArgs: ReturnType, options: { @@ -143,66 +252,15 @@ export async function createMcpServer( const toolMutex = new Mutex(); function registerTool(tool: ToolDefinition | DefinedPageTool): void { - if ( - tool.annotations.category === ToolCategory.EMULATION && - serverArgs.categoryEmulation === false - ) { - return; - } - if ( - tool.annotations.category === ToolCategory.PERFORMANCE && - serverArgs.categoryPerformance === false - ) { - return; - } - if ( - tool.annotations.category === ToolCategory.NETWORK && - serverArgs.categoryNetwork === false - ) { - return; - } - if ( - tool.annotations.category === ToolCategory.EXTENSIONS && - serverArgs.categoryExtensions === false - ) { - return; - } - if ( - tool.annotations.category === ToolCategory.IN_PAGE && - !serverArgs.categoryInPageTools - ) { - return; - } - if ( - tool.annotations.conditions?.includes('computerVision') && - !serverArgs.experimentalVision - ) { - return; - } - if ( - tool.annotations.conditions?.includes('experimentalMemory') && - !serverArgs.experimentalMemory - ) { - return; - } - if ( - tool.annotations.conditions?.includes('experimentalInteropTools') && - !serverArgs.experimentalInteropTools - ) { - return; - } - if ( - tool.annotations.conditions?.includes('screencast') && - !serverArgs.experimentalScreencast - ) { - return; - } - if ( - tool.annotations.conditions?.includes('experimentalWebmcp') && - !serverArgs.experimentalWebmcp - ) { + const {disabled, reason: disabledReason} = getToolStatusInfo( + tool, + serverArgs, + ); + + if (disabled && !serverArgs.viaCli) { return; } + const schema = 'pageScoped' in tool && tool.pageScoped && @@ -219,6 +277,18 @@ export async function createMcpServer( annotations: tool.annotations, }, async (params): Promise => { + if (disabledReason) { + return { + content: [ + { + type: 'text', + text: disabledReason, + }, + ], + isError: true, + }; + } + const guard = await toolMutex.acquire(); const startTime = Date.now(); let success = false; diff --git a/src/tools/categories.ts b/src/tools/categories.ts index b0abe8bb5..37dda0936 100644 --- a/src/tools/categories.ts +++ b/src/tools/categories.ts @@ -28,4 +28,7 @@ export const labels = { [ToolCategory.MEMORY]: 'Memory', }; -export const OFF_BY_DEFAULT_CATEGORIES = [ToolCategory.EXTENSIONS]; +export const OFF_BY_DEFAULT_CATEGORIES = [ + ToolCategory.EXTENSIONS, + ToolCategory.IN_PAGE, +]; diff --git a/tests/e2e/chrome-devtools-commands.test.ts b/tests/e2e/chrome-devtools-commands.test.ts index 7bca276d8..4884d4f82 100644 --- a/tests/e2e/chrome-devtools-commands.test.ts +++ b/tests/e2e/chrome-devtools-commands.test.ts @@ -71,4 +71,39 @@ describe('chrome-devtools', () => { 'take_screenshot output is unexpected', ); }); + + it('fails to invoke list_network_requests when categoryNetwork is disabled', async () => { + await runCli(['start', '--categoryNetwork=false'], sessionId); + + const result = await runCli(['list_network_requests'], sessionId); + assert.strictEqual(result.status, 0); + + assert( + result.stdout.includes( + 'Tool list_network_requests is in category Network which is currently disabled', + ), + 'error message is unexpected: ' + result.stdout, + ); + assert( + result.stdout.includes('chrome-devtools start --categoryNetwork=true'), + 'restart command suggestion is missing: ' + result.stdout, + ); + }); + + it('fails to invoke click_at when experimentalVision is disabled (default)', async () => { + await runCli(['start'], sessionId); + + const result = await runCli(['click_at', '100', '100'], sessionId); + assert.strictEqual(result.status, 0); + assert( + result.stdout.includes( + 'Tool click_at requires experimental feature --experimentalVision and is currently disabled', + ), + 'error message is unexpected: ' + result.stdout, + ); + assert( + result.stdout.includes('chrome-devtools start --experimentalVision=true'), + 'restart command suggestion is miss: ' + result.stdout, + ); + }); }); From 81150931bec25d9e6ef8103a38f0bbabf77a44ac Mon Sep 17 00:00:00 2001 From: Nicholas Roscino Date: Tue, 28 Apr 2026 16:30:13 +0200 Subject: [PATCH 02/10] docs: generate cli options --- src/bin/chrome-devtools-cli-options.ts | 242 ++++++++++++++++++++++--- 1 file changed, 221 insertions(+), 21 deletions(-) diff --git a/src/bin/chrome-devtools-cli-options.ts b/src/bin/chrome-devtools-cli-options.ts index bf5a77420..9839daf88 100644 --- a/src/bin/chrome-devtools-cli-options.ts +++ b/src/bin/chrome-devtools-cli-options.ts @@ -31,7 +31,38 @@ export const commands: Commands = { name: 'uid', type: 'string', description: - 'The uid of an element on the page from the page content snapshot', + 'The uid of an element on the page from the page content snapshot (required)', + required: true, + }, + dblClick: { + name: 'dblClick', + type: 'boolean', + description: 'Set to true for double clicks. Default is false.', + required: false, + }, + includeSnapshot: { + name: 'includeSnapshot', + type: 'boolean', + description: + 'Whether to include a snapshot in the response. Default is false.', + required: false, + }, + }, + }, + click_at: { + description: 'Clicks at the provided coordinates', + category: 'Input automation', + args: { + x: { + name: 'x', + type: 'number', + description: 'The x coordinate (required)', + required: true, + }, + y: { + name: 'y', + type: 'number', + description: 'The y coordinate (required)', required: true, }, dblClick: { @@ -58,7 +89,7 @@ export const commands: Commands = { name: 'pageId', type: 'number', description: - 'The ID of the page to close. Call list_pages to list pages.', + 'The ID of the page to close. Call list_pages to list pages. (required)', required: true, }, }, @@ -70,13 +101,13 @@ export const commands: Commands = { from_uid: { name: 'from_uid', type: 'string', - description: 'The uid of the element to drag', + description: 'The uid of the element to drag (required)', required: true, }, to_uid: { name: 'to_uid', type: 'string', - description: 'The uid of the element to drop into', + description: 'The uid of the element to drop into (required)', required: true, }, includeSnapshot: { @@ -146,7 +177,7 @@ export const commands: Commands = { name: 'function', type: 'string', description: - 'A JavaScript function declaration to be executed by the tool in the currently selected page.\nExample without arguments: `() => {\n return document.title\n}` or `async () => {\n return await fetch("example.com")\n}`.\nExample with arguments: `(el) => {\n return el.innerText;\n}`\n', + 'A JavaScript function declaration to be executed by the tool in the currently selected page.\nExample without arguments: `() => {\n return document.title\n}` or `async () => {\n return await fetch("example.com")\n}`.\nExample with arguments: `(el) => {\n return el.innerText;\n}`\n (required)', required: true, }, args: { @@ -164,6 +195,25 @@ export const commands: Commands = { }, }, }, + execute_webmcp_tool: { + description: 'Executes a WebMCP tool exposed by the page.', + category: 'Debugging', + args: { + toolName: { + name: 'toolName', + type: 'string', + description: 'The name of the WebMCP tool to execute (required)', + required: true, + }, + input: { + name: 'input', + type: 'string', + description: + 'The JSON-stringified parameters to pass to the WebMCP tool', + required: false, + }, + }, + }, fill: { description: 'Type text into an input, text area or select an option from a