feat: support Chrome extensions debugging (#1922)

Adds ability to install, uninstall and reload extensions, trigger
extension actions and inspect extension pages, service workers and
content scripts. Specify `--categoryExtensions` to enable.

Closes https://github.com/ChromeDevTools/chrome-devtools-mcp/issues/1173
Closes https://github.com/ChromeDevTools/chrome-devtools-mcp/issues/96

---------

Co-authored-by: Mathias Bynens <mathias@qiwi.be>
Co-authored-by: Alex Rudenko <OrKoN@users.noreply.github.com>
Co-authored-by: Alex Rudenko <alexrudenko@chromium.org>
This commit is contained in:
Nicholas Roscino
2026-04-21 16:03:15 +02:00
committed by GitHub
parent 86ffd58c2f
commit 3ff21cf30d
15 changed files with 244 additions and 82 deletions
+11
View File
@@ -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
+58
View File
@@ -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`
+11 -1
View File
@@ -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));
+9 -2
View File
@@ -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:
+7
View File
@@ -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:
+6 -6
View File
@@ -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/<id>). 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',
+1 -1
View File
@@ -136,7 +136,7 @@ export async function createMcpServer(
}
if (
tool.annotations.category === ToolCategory.EXTENSIONS &&
!serverArgs.categoryExtensions
serverArgs.categoryExtensions === false
) {
return;
}
+2
View File
@@ -27,3 +27,5 @@ export const labels = {
[ToolCategory.IN_PAGE]: 'In-page tools',
[ToolCategory.MEMORY]: 'Memory',
};
export const OFF_BY_DEFAULT_CATEGORIES = [ToolCategory.EXTENSIONS];
+51 -48
View File
@@ -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,
+4 -11
View File
@@ -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}`);
},
});
+2 -2
View File
@@ -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.`,
),
}
: {}),
+2
View File
@@ -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,
+68 -1
View File
@@ -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<unknown>(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);
});
+9 -9
View File
@@ -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(
'<script>console.error("This is an error")</script>',
);
await listConsoleMessages.handler(
await listConsoleMessages().handler(
{params: {}, page: context.getSelectedMcpPage()},
response,
context,
@@ -57,7 +57,7 @@ describe('console', () => {
await page.pptrPage.setContent(
'<script>console.error(new Error("This is an error"))</script>',
);
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('<script>throw undefined;</script>');
await listConsoleMessages.handler(
await listConsoleMessages().handler(
{params: {}, page: context.getSelectedMcpPage()},
response,
context,
@@ -96,7 +96,7 @@ describe('console', () => {
'<input type="text" name="username" />',
);
await issuePromise;
await listConsoleMessages.handler(
await listConsoleMessages().handler(
{params: {}, page: context.getSelectedMcpPage()},
response,
context,
@@ -125,7 +125,7 @@ describe('console', () => {
'<input type="text" name="username" />',
);
await issuePromise;
await listConsoleMessages.handler(
await listConsoleMessages().handler(
{params: {}, page: context.getSelectedMcpPage()},
response,
context,
@@ -174,7 +174,7 @@ describe('console', () => {
'<script>console.error("This is an error")</script>',
);
// 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,
+3 -1
View File
@@ -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,