Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,21 @@ The Chrome DevTools MCP server supports the following configuration option:
Additional arguments for Chrome. Only applies when Chrome is launched by chrome-devtools-mcp.
- **Type:** array

- **`--includeExtensionTargets`**
Include extension-related targets (service workers, background pages, offscreen documents) during bootstrap. Disabled by default to avoid long startup times with heavy extensions such as MetaMask.
- **Type:** boolean
- **Default:** `false`

- **`--bootstrapTimeoutMs`**
Maximum time in milliseconds to wait for a first page to auto-attach during browser bootstrap before continuing.
- **Type:** number
- **Default:** `2000`

- **`--verboseBootstrap`**
Enable verbose bootstrap logging (disable with `--no-verboseBootstrap`).
- **Type:** boolean
- **Default:** `true`

- **`--categoryEmulation`**
Set to false to exclude tools related to emulation.
- **Type:** boolean
Expand Down
4 changes: 3 additions & 1 deletion docs/tool-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,9 @@

### `list_pages`

**Description:** Get a list of pages open in the browser.
**Description:** Get a list of pages open in the browser. The response includes a JSON array of objects with the fields `index`, `id`, `title`, `url`, and `selected`.

Extension and DevTools targets are filtered out by default to keep bootstrap fast. Pass `--includeExtensionTargets` when starting the server to include them.

**Parameters:** None

Expand Down
9 changes: 9 additions & 0 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,15 @@ This indicates that the browser could not be started. Make sure that no Chrome
instances are running or close them. Make sure you have the latest stable Chrome
installed and that [your system is able to run Chrome](https://support.google.com/chrome/a/answer/7100626?hl=en).

### Browser appears stuck when extensions like MetaMask are enabled

Some extensions register background pages, service workers, offscreen documents, and iframes that keep Chrome busy during startup. To prevent hangs, the MCP server filters these extension targets during bootstrap and ignores them in the `list_pages` tool.

- Leave the default behavior to get a responsive startup and see only regular web pages.
- Launch the server with `--includeExtensionTargets` if you need to work with extension targets.
- Adjust `--bootstrapTimeoutMs` to wait longer than the default 2000 ms before giving up on extension targets.
- Enable `--verboseBootstrap` (default) to see detailed logs about the new filtered auto-attach loop, and use `--no-verboseBootstrap` to reduce logging noise.

### Remote debugging between virtual machine (VM) and host fails

When connecting DevTools inside a VM to Chrome running on the host, any domain is rejected by Chrome because of host header validation. Tunneling the port over SSH bypasses this restriction. In the VM, run:
Expand Down
12 changes: 11 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"test:only": "npm run build && node --require ./build/tests/setup.js --no-warnings=ExperimentalWarning --test-reporter spec --test-force-exit --test --test-only \"build/tests/**/*.test.js\"",
"test:only:no-build": "node --require ./build/tests/setup.js --no-warnings=ExperimentalWarning --test-reporter spec --test-force-exit --test --test-only \"build/tests/**/*.test.js\"",
"test:update-snapshots": "npm run build && node --require ./build/tests/setup.js --no-warnings=ExperimentalWarning --test-force-exit --test --test-update-snapshots \"build/tests/**/*.test.js\"",
"test:remote": "npm run build && node --experimental-strip-types --no-warnings=ExperimentalWarning scripts/test-remote-bootstrap.ts",
"prepare": "node --experimental-strip-types scripts/prepare.ts",
"verify-server-json-version": "node --experimental-strip-types scripts/verify-server-json-version.ts"
},
Expand Down
198 changes: 198 additions & 0 deletions scripts/test-remote-bootstrap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import assert from 'node:assert';
import fs from 'node:fs/promises';
import path from 'node:path';
import process from 'node:process';

import {Client} from '@modelcontextprotocol/sdk/client/index.js';
import {StdioClientTransport} from '@modelcontextprotocol/sdk/client/stdio.js';

const DEFAULT_BROWSER_URL = 'http://127.0.0.1:9222';
const DEFAULT_FIRST_TOOL_TIMEOUT_MS = 10_000;
const DEFAULT_LIST_PAGES_TIMEOUT_MS = 2_000;
const REQUIRED_LOG_LINES = [
'bootstrap: setDiscoverTargets',
'bootstrap: setAutoAttach',
'bootstrap: waiting for first page or timeout',
];

function getElapsedMilliseconds(start: bigint): number {
const diff = process.hrtime.bigint() - start;
return Number(diff / BigInt(1_000_000));
}

async function wait(ms: number) {
await new Promise(resolve => {
setTimeout(resolve, ms);
});
}

async function main(): Promise<void> {
const browserUrl = process.env.REMOTE_CHROME_URL ?? DEFAULT_BROWSER_URL;
const bootstrapTimeoutMs = Number(
process.env.BOOTSTRAP_TIMEOUT_MS ?? 2_000,
);
const logDir =
process.env.MCP_LOG_DIR ?? path.join(process.cwd(), 'tmp', 'logs');
await fs.mkdir(logDir, {recursive: true});
const logFile = path.join(
logDir,
`remote-bootstrap-${Date.now()}.log`,
);

const transport = new StdioClientTransport({
command: 'node',
args: [
'build/src/index.js',
'--browserUrl',
browserUrl,
'--bootstrapTimeoutMs',
String(bootstrapTimeoutMs),
'--logFile',
logFile,
],
});

const client = new Client(
{
name: 'remote-bootstrap-test',
version: '1.0.0',
},
{
capabilities: {},
},
);

try {
await assertBrowserReachable(browserUrl);
await client.connect(transport);

// First tool: select_page to avoid list_pages at startup.
const firstToolStart = process.hrtime.bigint();
await client.callTool({
name: 'select_page',
arguments: {pageIdx: 0},
});
const firstToolDuration = getElapsedMilliseconds(firstToolStart);
assert(
firstToolDuration <= DEFAULT_FIRST_TOOL_TIMEOUT_MS,
`First tool took ${firstToolDuration}ms (> ${DEFAULT_FIRST_TOOL_TIMEOUT_MS}ms).`,
);

// list_pages should complete quickly and exclude extension/devtools URLs.
const listPagesStart = process.hrtime.bigint();
const listPagesResult = await client.callTool({
name: 'list_pages',
arguments: {},
});
const listPagesDuration = getElapsedMilliseconds(listPagesStart);
assert(
listPagesDuration <= DEFAULT_LIST_PAGES_TIMEOUT_MS,
`list_pages took ${listPagesDuration}ms (> ${DEFAULT_LIST_PAGES_TIMEOUT_MS}ms).`,
);

const textContent = (listPagesResult.content ?? [])
.filter((entry): entry is {type: string; text: string} => {
return Boolean(entry && entry.type === 'text' && entry.text);
})
.map(entry => entry.text)
.join('\n');
if (!textContent) {
throw new Error('list_pages returned no textual content to inspect.');
}

const jsonStart = textContent.indexOf('[');
const jsonEnd = textContent.lastIndexOf(']');
assert(
jsonStart !== -1 && jsonEnd !== -1 && jsonEnd > jsonStart,
'Unable to locate pages JSON payload in list_pages response.',
);
const pagesJson = textContent.slice(jsonStart, jsonEnd + 1);
const pages = JSON.parse(pagesJson) as Array<{
index: number;
url: string;
title?: string;
selected?: boolean;
}>;
assert(Array.isArray(pages), 'list_pages payload is not an array.');
const forbidden = pages.filter(page => {
const lower = page.url.toLowerCase();
return (
lower.startsWith('chrome-extension://') ||
lower.startsWith('devtools://') ||
lower.includes('snaps/index.html') ||
lower.includes('offscreen.html')
);
});
assert(
forbidden.length === 0,
`list_pages included filtered URLs: ${forbidden.map(p => p.url).join(', ')}`,
);

await wait(250); // allow log stream to flush
const logContent = await fs.readFile(logFile, 'utf-8');
for (const line of REQUIRED_LOG_LINES) {
assert(
logContent.includes(line),
`Missing expected log line: "${line}"`,
);
}
assert(
/bootstrap: (first page attached|timed out, continuing)/.test(
logContent,
),
'Missing bootstrap completion log (first page attached or timed out).',
);

console.log(
JSON.stringify(
{
browserUrl,
bootstrapTimeoutMs,
firstToolDurationMs: firstToolDuration,
listPagesDurationMs: listPagesDuration,
pagesCount: pages.length,
logFile,
},
null,
2,
),
);
} finally {
await client.close();
}
}

async function assertBrowserReachable(browserUrl: string): Promise<void> {
const versionUrl = new URL('/json/version', browserUrl).toString();
const controller = new AbortController();
const timeout = setTimeout(() => {
controller.abort();
}, 3_000);
try {
const response = await fetch(versionUrl, {signal: controller.signal});
if (!response.ok) {
throw new Error(`Unexpected status ${response.status}`);
}
await response.json();
} catch (error) {
throw new Error(
`Unable to reach Chromium debugger at ${versionUrl}. ` +
`Ensure Chrome is running with --remote-debugging-port. Original error: ${
(error as Error).message
}`,
);
} finally {
clearTimeout(timeout);
}
}

main().catch(error => {
console.error('Remote bootstrap test failed:', error);
process.exitCode = 1;
});
Loading