diff --git a/README.md b/README.md index 6f83fe56e..61be16952 100644 --- a/README.md +++ b/README.md @@ -560,6 +560,11 @@ The Chrome DevTools MCP server supports the following configuration option: If enabled, ignores errors relative to self-signed and expired certificates. Use with caution. - **Type:** boolean +- **`--skipUnresponsiveTabs`/ `--skip-unresponsive-tabs`** + When connecting to an existing browser, skip tabs that don't respond to CDP commands (e.g. discarded or sleeping tabs) instead of hanging. Recommended when using --browserUrl with many open tabs. + - **Type:** boolean + - **Default:** `false` + - **`--experimentalVision`/ `--experimental-vision`** Whether to enable coordinate-based tools such as click_at(x,y). Usually requires a computer-use model able to produce accurate coordinates by looking at screenshots. - **Type:** boolean @@ -725,6 +730,8 @@ trace. You can connect to a running Chrome instance by using the `--browser-url` option. This is useful if you are running the MCP server in a sandboxed environment that does not allow starting a new Chrome instance. +If you have many tabs open, some may be discarded by the browser to save memory. These tabs don't respond to CDP commands, which can cause the MCP server to hang on startup. Use `--skip-unresponsive-tabs` to skip these tabs instead of blocking. + Here is a step-by-step guide on how to connect to a running Chrome instance: **Step 1: Configure the MCP client** diff --git a/src/McpContext.ts b/src/McpContext.ts index 32b7413e6..def96e691 100644 --- a/src/McpContext.ts +++ b/src/McpContext.ts @@ -56,6 +56,8 @@ interface McpContextOptions { experimentalIncludeAllPages?: boolean; // Whether CrUX data should be fetched. performanceCrux: boolean; + // Whether to skip unresponsive tabs when enumerating pages. + skipUnresponsiveTabs?: boolean; } const DEFAULT_TIMEOUT = 5_000; @@ -562,9 +564,53 @@ export class McpContext implements Context { isolatedContextNames: Map; }> { const defaultCtx = this.browser.defaultBrowserContext(); - const allPages = await this.browser.pages( - this.#options.experimentalIncludeAllPages, - ); + + let allPages: Page[]; + if (this.#options.skipUnresponsiveTabs) { + // Tolerant page enumeration: browser.pages() hangs indefinitely when + // any tab is unresponsive (e.g. discarded/sleeping tabs). Fall back to + // per-target enumeration so that one bad tab doesn't block the server. + try { + allPages = await Promise.race([ + this.browser.pages(this.#options.experimentalIncludeAllPages), + new Promise((_, reject) => + setTimeout( + () => reject(new Error('browser.pages() timed out')), + 10_000, + ), + ), + ]); + } catch { + this.logger( + 'browser.pages() timed out, falling back to per-target enumeration', + ); + const pageTargets = this.browser + .targets() + .filter(t => t.type() === 'page'); + allPages = []; + await Promise.all( + pageTargets.map(async target => { + try { + const page = await Promise.race([ + target.page(), + new Promise((_, reject) => + setTimeout(() => reject(new Error('timeout')), 5_000), + ), + ]); + if (page) { + allPages.push(page); + } + } catch { + this.logger(`Skipping unresponsive tab: ${target.url()}`); + } + }), + ); + } + } else { + allPages = await this.browser.pages( + this.#options.experimentalIncludeAllPages, + ); + } const allTargets = this.browser.targets(); const extensionTargets = allTargets.filter(target => { diff --git a/src/bin/chrome-devtools-mcp-cli-options.ts b/src/bin/chrome-devtools-mcp-cli-options.ts index 3cfbacac0..c02dd9974 100644 --- a/src/bin/chrome-devtools-mcp-cli-options.ts +++ b/src/bin/chrome-devtools-mcp-cli-options.ts @@ -147,6 +147,11 @@ export const cliOptions = { type: 'boolean', description: `If enabled, ignores errors relative to self-signed and expired certificates. Use with caution.`, }, + skipUnresponsiveTabs: { + type: 'boolean', + description: `When connecting to an existing browser, skip tabs that don't respond to CDP commands (e.g. discarded or sleeping tabs) instead of hanging. Recommended when using --browserUrl with many open tabs.`, + default: false, + }, experimentalPageIdRouting: { type: 'boolean', describe: diff --git a/src/browser.ts b/src/browser.ts index 7deea75b4..d2ba3b89b 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -51,6 +51,7 @@ export async function ensureBrowserConnected(options: { channel?: Channel; userDataDir?: string; enableExtensions?: boolean; + skipUnresponsiveTabs?: boolean; }) { const {channel, enableExtensions} = options; if (browser?.connected) { @@ -61,6 +62,7 @@ export async function ensureBrowserConnected(options: { targetFilter: makeTargetFilter(enableExtensions), defaultViewport: null, handleDevToolsAsPage: true, + ...(options.skipUnresponsiveTabs ? {protocolTimeout: 15_000} : {}), }; let autoConnect = false; diff --git a/src/index.ts b/src/index.ts index 362f2348a..8eab4d8b5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -86,6 +86,7 @@ export async function createMcpServer( : undefined, userDataDir: serverArgs.userDataDir, devtools, + skipUnresponsiveTabs: serverArgs.skipUnresponsiveTabs, }) : await ensureBrowserLaunched({ headless: serverArgs.headless, @@ -108,6 +109,7 @@ export async function createMcpServer( experimentalDevToolsDebugging: devtools, experimentalIncludeAllPages: serverArgs.experimentalIncludeAllPages, performanceCrux: serverArgs.performanceCrux, + skipUnresponsiveTabs: serverArgs.skipUnresponsiveTabs, }); } return context; diff --git a/tests/McpContext.test.ts b/tests/McpContext.test.ts index 31a6c88b3..ab96b6bdd 100644 --- a/tests/McpContext.test.ts +++ b/tests/McpContext.test.ts @@ -37,6 +37,17 @@ describe('McpContext', () => { }); }); + it('list pages with skipUnresponsiveTabs enabled', async () => { + await withMcpContext( + async (_response, context) => { + const page = context.getSelectedMcpPage(); + assert.ok(page, 'Should have a selected page'); + assert.ok(page.pptrPage.url(), 'Page should have a URL'); + }, + {skipUnresponsiveTabs: true}, + ); + }); + it('can store and retrieve the latest performance trace', async () => { await withMcpContext(async (_response, context) => { const fakeTrace1 = {} as unknown as TraceResult; diff --git a/tests/cli.test.ts b/tests/cli.test.ts index b18a4532f..9c7b53a62 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -23,6 +23,8 @@ describe('cli args parsing', () => { performanceCrux: true, 'usage-statistics': true, usageStatistics: true, + 'skip-unresponsive-tabs': false, + skipUnresponsiveTabs: false, }; it('parses with default args', async () => { @@ -54,6 +56,17 @@ describe('cli args parsing', () => { }); }); + it('parses --skipUnresponsiveTabs', async () => { + const args = parseArguments('1.0.0', [ + 'node', + 'main.js', + '--browserUrl', + 'http://localhost:9222', + '--skipUnresponsiveTabs', + ]); + assert.strictEqual(args.skipUnresponsiveTabs, true); + }); + it('parses with user data dir', async () => { const args = parseArguments('1.0.0', [ 'node', diff --git a/tests/utils.ts b/tests/utils.ts index 23257616f..195c1a44e 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -103,6 +103,7 @@ export async function withMcpContext( debug?: boolean; autoOpenDevTools?: boolean; performanceCrux?: boolean; + skipUnresponsiveTabs?: boolean; executablePath?: string; } = {}, args: ParsedArguments = {} as ParsedArguments, @@ -118,6 +119,7 @@ export async function withMcpContext( { experimentalDevToolsDebugging: false, performanceCrux: options.performanceCrux ?? true, + skipUnresponsiveTabs: options.skipUnresponsiveTabs, }, Locator, );