Skip to content

Commit 69b3594

Browse files
committed
fix: avoid extension bootstrap hangs
1 parent a2ddb39 commit 69b3594

File tree

16 files changed

+771
-67
lines changed

16 files changed

+771
-67
lines changed

README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,21 @@ The Chrome DevTools MCP server supports the following configuration option:
336336
Additional arguments for Chrome. Only applies when Chrome is launched by chrome-devtools-mcp.
337337
- **Type:** array
338338

339+
- **`--includeExtensionTargets`**
340+
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.
341+
- **Type:** boolean
342+
- **Default:** `false`
343+
344+
- **`--bootstrapTimeoutMs`**
345+
Maximum time in milliseconds to wait for a first page to auto-attach during browser bootstrap before continuing.
346+
- **Type:** number
347+
- **Default:** `2000`
348+
349+
- **`--verboseBootstrap`**
350+
Enable verbose bootstrap logging (disable with `--no-verboseBootstrap`).
351+
- **Type:** boolean
352+
- **Default:** `true`
353+
339354
- **`--categoryEmulation`**
340355
Set to false to exclude tools related to emulation.
341356
- **Type:** boolean

docs/tool-reference.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,9 @@
136136

137137
### `list_pages`
138138

139-
**Description:** Get a list of pages open in the browser.
139+
**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`.
140+
141+
Extension and DevTools targets are filtered out by default to keep bootstrap fast. Pass `--includeExtensionTargets` when starting the server to include them.
140142

141143
**Parameters:** None
142144

docs/troubleshooting.md

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

31+
### Browser appears stuck when extensions like MetaMask are enabled
32+
33+
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.
34+
35+
- Leave the default behavior to get a responsive startup and see only regular web pages.
36+
- Launch the server with `--includeExtensionTargets` if you need to work with extension targets.
37+
- Adjust `--bootstrapTimeoutMs` to wait longer than the default 2000 ms before giving up on extension targets.
38+
- Enable `--verboseBootstrap` (default) to see detailed logs about the new filtered auto-attach loop, and use `--no-verboseBootstrap` to reduce logging noise.
39+
3140
### Remote debugging between virtual machine (VM) and host fails
3241

3342
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:

package-lock.json

Lines changed: 11 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
"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\"",
2222
"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\"",
2323
"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\"",
24+
"test:remote": "npm run build && node --experimental-strip-types --no-warnings=ExperimentalWarning scripts/test-remote-bootstrap.ts",
2425
"prepare": "node --experimental-strip-types scripts/prepare.ts",
2526
"verify-server-json-version": "node --experimental-strip-types scripts/verify-server-json-version.ts"
2627
},

scripts/test-remote-bootstrap.ts

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import assert from 'node:assert';
8+
import fs from 'node:fs/promises';
9+
import path from 'node:path';
10+
import process from 'node:process';
11+
12+
import {Client} from '@modelcontextprotocol/sdk/client/index.js';
13+
import {StdioClientTransport} from '@modelcontextprotocol/sdk/client/stdio.js';
14+
15+
const DEFAULT_BROWSER_URL = 'http://127.0.0.1:9222';
16+
const DEFAULT_FIRST_TOOL_TIMEOUT_MS = 10_000;
17+
const DEFAULT_LIST_PAGES_TIMEOUT_MS = 2_000;
18+
const REQUIRED_LOG_LINES = [
19+
'bootstrap: setDiscoverTargets',
20+
'bootstrap: setAutoAttach',
21+
'bootstrap: waiting for first page or timeout',
22+
];
23+
24+
function getElapsedMilliseconds(start: bigint): number {
25+
const diff = process.hrtime.bigint() - start;
26+
return Number(diff / BigInt(1_000_000));
27+
}
28+
29+
async function wait(ms: number) {
30+
await new Promise(resolve => {
31+
setTimeout(resolve, ms);
32+
});
33+
}
34+
35+
async function main(): Promise<void> {
36+
const browserUrl = process.env.REMOTE_CHROME_URL ?? DEFAULT_BROWSER_URL;
37+
const bootstrapTimeoutMs = Number(
38+
process.env.BOOTSTRAP_TIMEOUT_MS ?? 2_000,
39+
);
40+
const logDir =
41+
process.env.MCP_LOG_DIR ?? path.join(process.cwd(), 'tmp', 'logs');
42+
await fs.mkdir(logDir, {recursive: true});
43+
const logFile = path.join(
44+
logDir,
45+
`remote-bootstrap-${Date.now()}.log`,
46+
);
47+
48+
const transport = new StdioClientTransport({
49+
command: 'node',
50+
args: [
51+
'build/src/index.js',
52+
'--browserUrl',
53+
browserUrl,
54+
'--bootstrapTimeoutMs',
55+
String(bootstrapTimeoutMs),
56+
'--logFile',
57+
logFile,
58+
],
59+
});
60+
61+
const client = new Client(
62+
{
63+
name: 'remote-bootstrap-test',
64+
version: '1.0.0',
65+
},
66+
{
67+
capabilities: {},
68+
},
69+
);
70+
71+
try {
72+
await assertBrowserReachable(browserUrl);
73+
await client.connect(transport);
74+
75+
// First tool: select_page to avoid list_pages at startup.
76+
const firstToolStart = process.hrtime.bigint();
77+
await client.callTool({
78+
name: 'select_page',
79+
arguments: {pageIdx: 0},
80+
});
81+
const firstToolDuration = getElapsedMilliseconds(firstToolStart);
82+
assert(
83+
firstToolDuration <= DEFAULT_FIRST_TOOL_TIMEOUT_MS,
84+
`First tool took ${firstToolDuration}ms (> ${DEFAULT_FIRST_TOOL_TIMEOUT_MS}ms).`,
85+
);
86+
87+
// list_pages should complete quickly and exclude extension/devtools URLs.
88+
const listPagesStart = process.hrtime.bigint();
89+
const listPagesResult = await client.callTool({
90+
name: 'list_pages',
91+
arguments: {},
92+
});
93+
const listPagesDuration = getElapsedMilliseconds(listPagesStart);
94+
assert(
95+
listPagesDuration <= DEFAULT_LIST_PAGES_TIMEOUT_MS,
96+
`list_pages took ${listPagesDuration}ms (> ${DEFAULT_LIST_PAGES_TIMEOUT_MS}ms).`,
97+
);
98+
99+
const textContent = (listPagesResult.content ?? [])
100+
.filter((entry): entry is {type: string; text: string} => {
101+
return Boolean(entry && entry.type === 'text' && entry.text);
102+
})
103+
.map(entry => entry.text)
104+
.join('\n');
105+
if (!textContent) {
106+
throw new Error('list_pages returned no textual content to inspect.');
107+
}
108+
109+
const jsonStart = textContent.indexOf('[');
110+
const jsonEnd = textContent.lastIndexOf(']');
111+
assert(
112+
jsonStart !== -1 && jsonEnd !== -1 && jsonEnd > jsonStart,
113+
'Unable to locate pages JSON payload in list_pages response.',
114+
);
115+
const pagesJson = textContent.slice(jsonStart, jsonEnd + 1);
116+
const pages = JSON.parse(pagesJson) as Array<{
117+
index: number;
118+
url: string;
119+
title?: string;
120+
selected?: boolean;
121+
}>;
122+
assert(Array.isArray(pages), 'list_pages payload is not an array.');
123+
const forbidden = pages.filter(page => {
124+
const lower = page.url.toLowerCase();
125+
return (
126+
lower.startsWith('chrome-extension://') ||
127+
lower.startsWith('devtools://') ||
128+
lower.includes('snaps/index.html') ||
129+
lower.includes('offscreen.html')
130+
);
131+
});
132+
assert(
133+
forbidden.length === 0,
134+
`list_pages included filtered URLs: ${forbidden.map(p => p.url).join(', ')}`,
135+
);
136+
137+
await wait(250); // allow log stream to flush
138+
const logContent = await fs.readFile(logFile, 'utf-8');
139+
for (const line of REQUIRED_LOG_LINES) {
140+
assert(
141+
logContent.includes(line),
142+
`Missing expected log line: "${line}"`,
143+
);
144+
}
145+
assert(
146+
/bootstrap: (first page attached|timed out, continuing)/.test(
147+
logContent,
148+
),
149+
'Missing bootstrap completion log (first page attached or timed out).',
150+
);
151+
152+
console.log(
153+
JSON.stringify(
154+
{
155+
browserUrl,
156+
bootstrapTimeoutMs,
157+
firstToolDurationMs: firstToolDuration,
158+
listPagesDurationMs: listPagesDuration,
159+
pagesCount: pages.length,
160+
logFile,
161+
},
162+
null,
163+
2,
164+
),
165+
);
166+
} finally {
167+
await client.close();
168+
}
169+
}
170+
171+
async function assertBrowserReachable(browserUrl: string): Promise<void> {
172+
const versionUrl = new URL('/json/version', browserUrl).toString();
173+
const controller = new AbortController();
174+
const timeout = setTimeout(() => {
175+
controller.abort();
176+
}, 3_000);
177+
try {
178+
const response = await fetch(versionUrl, {signal: controller.signal});
179+
if (!response.ok) {
180+
throw new Error(`Unexpected status ${response.status}`);
181+
}
182+
await response.json();
183+
} catch (error) {
184+
throw new Error(
185+
`Unable to reach Chromium debugger at ${versionUrl}. ` +
186+
`Ensure Chrome is running with --remote-debugging-port. Original error: ${
187+
(error as Error).message
188+
}`,
189+
);
190+
} finally {
191+
clearTimeout(timeout);
192+
}
193+
}
194+
195+
main().catch(error => {
196+
console.error('Remote bootstrap test failed:', error);
197+
process.exitCode = 1;
198+
});

0 commit comments

Comments
 (0)