diff --git a/README.md b/README.md index b63ac7376..5a68ff7e9 100644 --- a/README.md +++ b/README.md @@ -511,6 +511,12 @@ If you run into any issues, checkout our [troubleshooting guide](./docs/troubles - [`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) +- **Extensions** (5 tools) + - [`install_extension`](docs/tool-reference.md#install_extension) + - [`list_extensions`](docs/tool-reference.md#list_extensions) + - [`reload_extension`](docs/tool-reference.md#reload_extension) + - [`trigger_extension_action`](docs/tool-reference.md#trigger_extension_action) + - [`uninstall_extension`](docs/tool-reference.md#uninstall_extension) - **Memory** (1 tools) - [`take_memory_snapshot`](docs/tool-reference.md#take_memory_snapshot) @@ -612,6 +618,11 @@ The Chrome DevTools MCP server supports the following configuration option: - **Type:** boolean - **Default:** `true` +- **`--categoryExtensions`/ `--category-extensions`** + Set to true to include tools related to extensions. Note: This feature is currently only supported with a pipe connection. autoConnect, browserUrl, and wsEndpoint are not supported with this feature until 149 will be released. + - **Type:** boolean + - **Default:** `false` + - **`--performanceCrux`/ `--performance-crux`** Set to false to disable sending URLs from performance traces to CrUX API to get field performance data. - **Type:** boolean diff --git a/docs/tool-reference.md b/docs/tool-reference.md index 74fa725fa..b7f20ab37 100644 --- a/docs/tool-reference.md +++ b/docs/tool-reference.md @@ -36,6 +36,12 @@ - [`list_console_messages`](#list_console_messages) - [`take_screenshot`](#take_screenshot) - [`take_snapshot`](#take_snapshot) +- **[Extensions](#extensions)** (5 tools) + - [`install_extension`](#install_extension) + - [`list_extensions`](#list_extensions) + - [`reload_extension`](#reload_extension) + - [`trigger_extension_action`](#trigger_extension_action) + - [`uninstall_extension`](#uninstall_extension) - **[Memory](#memory)** (1 tools) - [`take_memory_snapshot`](#take_memory_snapshot) @@ -390,6 +396,58 @@ in the DevTools Elements panel (if any). --- +## Extensions + +> NOTE: Extensions are not active by default. Use the '--category-extensions' flag + +### `install_extension` + +**Description:** Installs a Chrome extension from the given path. + +**Parameters:** + +- **path** (string) **(required)**: Absolute path to the unpacked extension folder. + +--- + +### `list_extensions` + +**Description:** Lists all the Chrome extensions installed in the browser. This includes their name, ID, version, and enabled status. + +**Parameters:** None + +--- + +### `reload_extension` + +**Description:** Reloads an unpacked Chrome extension by its ID. + +**Parameters:** + +- **id** (string) **(required)**: ID of the extension to reload. + +--- + +### `trigger_extension_action` + +**Description:** Triggers the default action of an extension by its ID. + +**Parameters:** + +- **id** (string) **(required)**: ID of the extension to trigger the action for. + +--- + +### `uninstall_extension` + +**Description:** Uninstalls a Chrome extension by its ID. + +**Parameters:** + +- **id** (string) **(required)**: ID of the extension to uninstall. + +--- + ## Memory ### `take_memory_snapshot` diff --git a/scripts/generate-docs.ts b/scripts/generate-docs.ts index 280c0e332..4be8fcbd7 100644 --- a/scripts/generate-docs.ts +++ b/scripts/generate-docs.ts @@ -13,7 +13,11 @@ import {get_encoding} from 'tiktoken'; import {cliOptions} from '../build/src/bin/chrome-devtools-mcp-cli-options.js'; import type {ParsedArguments} from '../build/src/bin/chrome-devtools-mcp-cli-options.js'; -import {ToolCategory, labels} from '../build/src/tools/categories.js'; +import { + ToolCategory, + OFF_BY_DEFAULT_CATEGORIES, + labels, +} from '../build/src/tools/categories.js'; import {createTools} from '../build/src/tools/tools.js'; const OUTPUT_PATH = './docs/tool-reference.md'; @@ -351,6 +355,12 @@ async function generateReference( markdown += `## ${categoryName}\n\n`; + if (OFF_BY_DEFAULT_CATEGORIES.includes(category)) { + const flagName = `--category-${category}`; + + markdown += `> NOTE: ${categoryName} are not active by default. Use the '${flagName}' flag\n\n`; + } + // Sort tools within category categoryTools.sort((a: Tool, b: Tool) => a.name.localeCompare(b.name)); diff --git a/skills/chrome-devtools/SKILL.md b/skills/chrome-devtools/SKILL.md index 9b9fbce46..1a3cf6494 100644 --- a/skills/chrome-devtools/SKILL.md +++ b/skills/chrome-devtools/SKILL.md @@ -5,8 +5,7 @@ description: Uses Chrome DevTools via MCP for efficient debugging, troubleshooti ## Core Concepts -**Browser lifecycle**: Browser starts automatically on first tool call using a persistent Chrome profile. Configure via CLI args in the MCP server configuration: `npx chrome-devtools-mcp@latest --help`. - +**Browser lifecycle**: Browser starts automatically on first tool call using a persistent Chrome profile. Configure via CLI args in the MCP server configuration: `npx chrome-devtools-mcp@latest --help`. To enable extensions, use `--categoryExtensions`. **Page selection**: Tools operate on the currently selected page. Use `list_pages` to see available pages, then `select_page` to switch context. **Element interaction**: Use `take_snapshot` to get page structure with element `uid`s. Each element has a unique `uid` for interaction. If an element isn't found, take a fresh snapshot - the element may have been removed or the page changed. @@ -36,6 +35,14 @@ description: Uses Chrome DevTools via MCP for efficient debugging, troubleshooti You can send multiple tool calls in parallel, but maintain correct order: navigate → wait → snapshot → interact. +### Testing an extension + +1. **Install**: Use `install_extension` with the path to the unpacked extension. +2. **Identify**: Get the extension ID from the response or by calling `list_extensions`. +3. **Trigger Action**: Use `trigger_extension_action` to open the popup or side panel if applicable. +4. **Verify Service Worker**: Use `evaluate_script` with `serviceWorkerId` to check extension state or trigger background actions. +5. **Verify Page Behavior**: Navigate to a page where the extension operates and use `take_snapshot` to check if content scripts injected elements or modified the page correctly. + ## Troubleshooting If `chrome-devtools-mcp` is insufficient, guide users to use Chrome DevTools UI: diff --git a/skills/troubleshooting/SKILL.md b/skills/troubleshooting/SKILL.md index 0c86959c9..2eeb817c8 100644 --- a/skills/troubleshooting/SKILL.md +++ b/skills/troubleshooting/SKILL.md @@ -47,6 +47,13 @@ If the server starts successfully but only a limited subset of tools (like `list All tools in `chrome-devtools-mcp` are annotated with `readOnlyHint: true` (for safe, non-modifying tools) or `readOnlyHint: false` (for tools that modify browser state, like `emulate`, `click`, `navigate_page`). To access the full suite of tools, the user must disable read-only mode in their MCP client (e.g., by exiting "Plan Mode" in Gemini CLI or adjusting their client's tool safety settings). +#### Symptom: Extension tools are missing or extensions fail to load + +If the tools related to extensions (like `install_extension`) are not available, or if the extensions you load are not functioning: + +1. **Check for the `--categoryExtensions` flag**: Ensure this flag is passed in the MCP server configuration to enable the extension category tools. +2. **Make sure the MCP server in configured to launch Chrome instead of connecting to an instance**: Chrome before 149 is not able to load extensions when connecting to an existing instance (`--auto-connect`, `--browserUrl`). + #### Other Common Errors Identify other error messages from the failed tool call or the MCP initialization logs: diff --git a/src/bin/chrome-devtools-mcp-cli-options.ts b/src/bin/chrome-devtools-mcp-cli-options.ts index 804410c05..74ae8e130 100644 --- a/src/bin/chrome-devtools-mcp-cli-options.ts +++ b/src/bin/chrome-devtools-mcp-cli-options.ts @@ -12,7 +12,7 @@ export const cliOptions = { type: 'boolean', description: 'If specified, automatically connects to a browser (Chrome 144+) running locally from the user data directory identified by the channel param (default channel is stable). Requires the remote debugging server to be started in the Chrome instance via chrome://inspect/#remote-debugging.', - conflicts: ['isolated', 'executablePath', 'categoryExtensions'], + conflicts: ['isolated', 'executablePath'], default: false, coerce: (value: boolean | undefined) => { if (!value) { @@ -26,7 +26,7 @@ export const cliOptions = { description: 'Connect to a running, debuggable Chrome instance (e.g. `http://127.0.0.1:9222`). For more details see: https://github.com/ChromeDevTools/chrome-devtools-mcp#connecting-to-a-running-chrome-instance.', alias: 'u', - conflicts: ['wsEndpoint', 'categoryExtensions'], + conflicts: ['wsEndpoint'], coerce: (url: string | undefined) => { if (!url) { return; @@ -44,7 +44,7 @@ export const cliOptions = { description: 'WebSocket endpoint to connect to a running Chrome instance (e.g., ws://127.0.0.1:9222/devtools/browser/). Alternative to --browserUrl.', alias: 'w', - conflicts: ['browserUrl', 'categoryExtensions'], + conflicts: ['browserUrl'], coerce: (url: string | undefined) => { if (!url) { return; @@ -222,10 +222,10 @@ export const cliOptions = { }, categoryExtensions: { type: 'boolean', - hidden: true, - conflicts: ['browserUrl', 'autoConnect', 'wsEndpoint'], + hidden: false, + default: false, describe: - 'Set to true to include tools related to extensions. Note: This feature is only supported with a pipe connection. autoConnect is not supported.', + 'Set to true to include tools related to extensions. Note: This feature is currently only supported with a pipe connection. autoConnect, browserUrl, and wsEndpoint are not supported with this feature until 149 will be released.', }, categoryInPageTools: { type: 'boolean', diff --git a/src/index.ts b/src/index.ts index e228bae9e..24b7d42a0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -136,7 +136,7 @@ export async function createMcpServer( } if ( tool.annotations.category === ToolCategory.EXTENSIONS && - !serverArgs.categoryExtensions + serverArgs.categoryExtensions === false ) { return; } diff --git a/src/tools/categories.ts b/src/tools/categories.ts index 7dc151853..b0abe8bb5 100644 --- a/src/tools/categories.ts +++ b/src/tools/categories.ts @@ -27,3 +27,5 @@ export const labels = { [ToolCategory.IN_PAGE]: 'In-page tools', [ToolCategory.MEMORY]: 'Memory', }; + +export const OFF_BY_DEFAULT_CATEGORIES = [ToolCategory.EXTENSIONS]; diff --git a/src/tools/console.ts b/src/tools/console.ts index dd8059bbf..83acf9f77 100644 --- a/src/tools/console.ts +++ b/src/tools/console.ts @@ -37,58 +37,61 @@ const FILTERABLE_MESSAGE_TYPES: [ 'issue', ]; -export const listConsoleMessages = definePageTool({ - name: 'list_console_messages', - description: - 'List all console messages for the currently selected page since the last navigation.', - annotations: { - category: ToolCategory.DEBUGGING, - readOnlyHint: true, - }, - schema: { - pageSize: zod - .number() - .int() - .positive() - .optional() - .describe( - 'Maximum number of messages to return. When omitted, returns all messages.', - ), - pageIdx: zod - .number() - .int() - .min(0) - .optional() - .describe( - 'Page number to return (0-based). When omitted, returns the first page.', - ), - types: zod - .array(zod.enum(FILTERABLE_MESSAGE_TYPES)) - .optional() - .describe( - 'Filter messages to only return messages of the specified resource types. When omitted or empty, returns all messages.', - ), - includePreservedMessages: zod - .boolean() - .default(false) - .optional() - .describe( - 'Set to true to return the preserved messages over the last 3 navigations.', - ), - }, - handler: async (request, response) => { - response.setIncludeConsoleData(true, { - pageSize: request.params.pageSize, - pageIdx: request.params.pageIdx, - types: request.params.types, - includePreservedMessages: request.params.includePreservedMessages, - }); - }, +const LIST_CONSOLE_MESSAGES_TOOL_NAME = 'list_console_messages'; + +export const listConsoleMessages = definePageTool(cliArgs => { + return { + name: LIST_CONSOLE_MESSAGES_TOOL_NAME, + description: `List all console messages for the currently selected page since the last navigation.${cliArgs?.categoryExtensions ? ' This includes console messages originating from extensions content scripts.' : ''}`, + annotations: { + category: ToolCategory.DEBUGGING, + readOnlyHint: true, + }, + schema: { + pageSize: zod + .number() + .int() + .positive() + .optional() + .describe( + 'Maximum number of messages to return. When omitted, returns all messages.', + ), + pageIdx: zod + .number() + .int() + .min(0) + .optional() + .describe( + 'Page number to return (0-based). When omitted, returns the first page.', + ), + types: zod + .array(zod.enum(FILTERABLE_MESSAGE_TYPES)) + .optional() + .describe( + 'Filter messages to only return messages of the specified resource types. When omitted or empty, returns all messages.', + ), + includePreservedMessages: zod + .boolean() + .default(false) + .optional() + .describe( + 'Set to true to return the preserved messages over the last 3 navigations.', + ), + }, + handler: async (request, response) => { + response.setIncludeConsoleData(true, { + pageSize: request.params.pageSize, + pageIdx: request.params.pageIdx, + types: request.params.types, + includePreservedMessages: request.params.includePreservedMessages, + }); + }, + }; }); export const getConsoleMessage = definePageTool({ name: 'get_console_message', - description: `Gets a console message by its ID. You can get all messages by calling ${listConsoleMessages.name}.`, + description: `Gets a console message by its ID. You can get all messages by calling ${LIST_CONSOLE_MESSAGES_TOOL_NAME}.`, annotations: { category: ToolCategory.DEBUGGING, readOnlyHint: true, diff --git a/src/tools/extensions.ts b/src/tools/extensions.ts index d5d2f2557..0b7a83367 100644 --- a/src/tools/extensions.ts +++ b/src/tools/extensions.ts @@ -9,15 +9,12 @@ import {zod} from '../third_party/index.js'; import {ToolCategory} from './categories.js'; import {defineTool} from './ToolDefinition.js'; -const EXTENSIONS_CONDITION = 'experimentalExtensionSupport'; - export const installExtension = defineTool({ name: 'install_extension', description: 'Installs a Chrome extension from the given path.', annotations: { category: ToolCategory.EXTENSIONS, readOnlyHint: false, - conditions: [EXTENSIONS_CONDITION], }, schema: { path: zod @@ -37,7 +34,6 @@ export const uninstallExtension = defineTool({ annotations: { category: ToolCategory.EXTENSIONS, readOnlyHint: false, - conditions: [EXTENSIONS_CONDITION], }, schema: { id: zod.string().describe('ID of the extension to uninstall.'), @@ -52,11 +48,10 @@ export const uninstallExtension = defineTool({ export const listExtensions = defineTool({ name: 'list_extensions', description: - 'Lists all extensions via this server, including their name, ID, version, and enabled status.', + 'Lists all the Chrome extensions installed in the browser. This includes their name, ID, version, and enabled status.', annotations: { category: ToolCategory.EXTENSIONS, readOnlyHint: true, - conditions: [EXTENSIONS_CONDITION], }, schema: {}, handler: async (_request, response, _context) => { @@ -70,7 +65,6 @@ export const reloadExtension = defineTool({ annotations: { category: ToolCategory.EXTENSIONS, readOnlyHint: false, - conditions: [EXTENSIONS_CONDITION], }, schema: { id: zod.string().describe('ID of the extension to reload.'), @@ -88,18 +82,17 @@ export const reloadExtension = defineTool({ export const triggerExtensionAction = defineTool({ name: 'trigger_extension_action', - description: 'Triggers an action in a Chrome extension.', + description: 'Triggers the default action of an extension by its ID.', annotations: { category: ToolCategory.EXTENSIONS, readOnlyHint: false, - conditions: [EXTENSIONS_CONDITION], }, schema: { - id: zod.string().describe('ID of the extension.'), + id: zod.string().describe('ID of the extension to trigger the action for.'), }, handler: async (request, response, context) => { const {id} = request.params; await context.triggerExtensionAction(id); - response.appendResponseLine(`Extension action triggered. Id: ${id}`); + response.appendResponseLine(`Extension action triggered for ID ${id}`); }, }); diff --git a/src/tools/script.ts b/src/tools/script.ts index 337ad2e86..e91e3b8de 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. Returns the response as JSON, + 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.`, annotations: { category: ToolCategory.DEBUGGING, @@ -59,7 +59,7 @@ Example with arguments: \`(el) => { .string() .optional() .describe( - `An optional service worker id to evaluate the script in.`, + `The optional service worker id to evaluate the script in. If provided, 'pageId' should be omitted. Note: 'args' (element UIDs) cannot be used when evaluating in a service worker.`, ), } : {}), diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 44347af2e..95f9da824 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -17,6 +17,8 @@ describe('cli args parsing', () => { categoryPerformance: true, 'category-network': true, categoryNetwork: true, + 'category-extensions': false, + categoryExtensions: false, 'auto-connect': undefined, autoConnect: undefined, 'performance-crux': true, diff --git a/tests/index.test.ts b/tests/index.test.ts index 6fc08dc66..e9adb3537 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -12,7 +12,11 @@ import {Client} from '@modelcontextprotocol/sdk/client/index.js'; import {StdioClientTransport} from '@modelcontextprotocol/sdk/client/stdio.js'; import {executablePath} from 'puppeteer'; -import type {ToolDefinition} from '../src/tools/ToolDefinition'; +import { + OFF_BY_DEFAULT_CATEGORIES, + ToolCategory, +} from '../src/tools/categories.js'; +import type {ToolDefinition} from '../src/tools/ToolDefinition.js'; describe('e2e', () => { async function withClient( @@ -71,6 +75,56 @@ describe('e2e', () => { }); }); + it('has all tools with off by default categories', async () => { + await withClient( + async client => { + const {tools} = await client.listTools(); + const exposedNames = tools.map(t => t.name).sort(); + const files = fs.readdirSync('build/src/tools'); + const definedNames = []; + for (const file of files) { + if ( + file === 'ToolDefinition.js' || + file === 'tools.js' || + file === 'slim' + ) { + continue; + } + const fileTools = await import(`../src/tools/${file}`); + for (const maybeTool of Object.values(fileTools)) { + if (typeof maybeTool === 'function') { + const tool = (maybeTool as (val: boolean) => ToolDefinition)( + false, + ); + if (tool && typeof tool === 'object' && 'name' in tool) { + if (tool.annotations?.conditions) { + continue; + } + definedNames.push(tool.name); + } + continue; + } + if ( + typeof maybeTool === 'object' && + maybeTool !== null && + 'name' in maybeTool + ) { + const tool = maybeTool as ToolDefinition; + if (tool.annotations?.conditions) { + continue; + } + definedNames.push(tool.name); + } + } + } + + definedNames.sort(); + assert.deepStrictEqual(exposedNames, definedNames); + }, + OFF_BY_DEFAULT_CATEGORIES.map(category => `--category-${category}`), + ); + }); + it('has all tools', async () => { await withClient(async client => { const {tools} = await client.listTools(); @@ -93,6 +147,12 @@ describe('e2e', () => { if (tool.annotations?.conditions) { continue; } + if ( + tool.annotations?.category && + tool.annotations?.category === ToolCategory.EXTENSIONS + ) { + continue; + } definedNames.push(tool.name); } continue; @@ -106,10 +166,17 @@ describe('e2e', () => { if (tool.annotations?.conditions) { continue; } + if ( + tool.annotations?.category && + tool.annotations?.category === ToolCategory.EXTENSIONS + ) { + continue; + } definedNames.push(tool.name); } } } + definedNames.sort(); assert.deepStrictEqual(exposedNames, definedNames); }); diff --git a/tests/tools/console.test.ts b/tests/tools/console.test.ts index 26b760db7..82f27784b 100644 --- a/tests/tools/console.test.ts +++ b/tests/tools/console.test.ts @@ -25,7 +25,7 @@ describe('console', () => { describe('list_console_messages', () => { it('list messages', async () => { await withMcpContext(async (response, context) => { - await listConsoleMessages.handler( + await listConsoleMessages().handler( {params: {}, page: context.getSelectedMcpPage()}, response, context, @@ -40,7 +40,7 @@ describe('console', () => { await page.pptrPage.setContent( '', ); - await listConsoleMessages.handler( + await listConsoleMessages().handler( {params: {}, page: context.getSelectedMcpPage()}, response, context, @@ -57,7 +57,7 @@ describe('console', () => { await page.pptrPage.setContent( '', ); - await listConsoleMessages.handler( + await listConsoleMessages().handler( {params: {}, page: context.getSelectedMcpPage()}, response, context, @@ -72,7 +72,7 @@ describe('console', () => { await withMcpContext(async (response, context) => { const page = context.getSelectedMcpPage(); await page.pptrPage.setContent(''); - await listConsoleMessages.handler( + await listConsoleMessages().handler( {params: {}, page: context.getSelectedMcpPage()}, response, context, @@ -96,7 +96,7 @@ describe('console', () => { '', ); await issuePromise; - await listConsoleMessages.handler( + await listConsoleMessages().handler( {params: {}, page: context.getSelectedMcpPage()}, response, context, @@ -125,7 +125,7 @@ describe('console', () => { '', ); await issuePromise; - await listConsoleMessages.handler( + await listConsoleMessages().handler( {params: {}, page: context.getSelectedMcpPage()}, response, context, @@ -174,7 +174,7 @@ describe('console', () => { '', ); // The list is needed to populate the console messages in the context. - await listConsoleMessages.handler( + await listConsoleMessages().handler( {params: {}, page: context.getSelectedMcpPage()}, response, context, @@ -207,7 +207,7 @@ describe('console', () => { ); await context.createTextSnapshot(page); await issuePromise; - await listConsoleMessages.handler( + await listConsoleMessages().handler( {params: {}, page: context.getSelectedMcpPage()}, response, context, @@ -263,7 +263,7 @@ describe('console', () => { assert.ok(issueMsg); const id = context.getConsoleMessageStableId(issueMsg); assert.ok(id); - await listConsoleMessages.handler( + await listConsoleMessages().handler( {params: {types: ['issue']}, page: context.getSelectedMcpPage()}, response, context, diff --git a/tests/tools/extensions.test.ts b/tests/tools/extensions.test.ts index a6d0fe89b..59580ddd8 100644 --- a/tests/tools/extensions.test.ts +++ b/tests/tools/extensions.test.ts @@ -197,7 +197,9 @@ describe('extension', () => { await page.goto(url); - await listConsoleMessages.handler( + await listConsoleMessages({ + categoryExtensions: true, + } as ParsedArguments).handler( {params: {includePreservedMessages: true}, page: mcpPage}, response, context,