Skip to content

Commit e611c86

Browse files
jaidhyaniJai
authored andcommitted
feat: add --skipUnresponsiveTabs flag for connecting to browsers with many tabs
When connecting to a running browser via --browserUrl, browser.pages() hangs indefinitely if any tab is unresponsive (discarded by memory management, dead localhost, etc). This blocks all tool calls since page enumeration runs on every request. Add a new --skipUnresponsiveTabs CLI flag (default: false). When enabled: 1. Sets protocolTimeout (15s) on puppeteer.connect() so CDP commands fail with an error instead of hanging forever. 2. Wraps browser.pages() in #getAllPages() with a 10s timeout. If it fails, falls back to per-target enumeration: iterates browser.targets(), calls target.page() on each with a 5s timeout, and skips tabs that don't respond. When the flag is off, behavior is identical to before. Tested with Chrome/Brave 145, 28 open tabs (5 discarded). Without this flag the server hangs forever on the first tool call. With the flag it returns 23 responsive pages in ~12s and logs the 5 it skipped. Related: puppeteer/puppeteer#5633, puppeteer/puppeteer#14708, #870, #978
1 parent 9236834 commit e611c86

8 files changed

Lines changed: 91 additions & 3 deletions

File tree

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -560,6 +560,11 @@ The Chrome DevTools MCP server supports the following configuration option:
560560
If enabled, ignores errors relative to self-signed and expired certificates. Use with caution.
561561
- **Type:** boolean
562562

563+
- **`--skipUnresponsiveTabs`/ `--skip-unresponsive-tabs`**
564+
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.
565+
- **Type:** boolean
566+
- **Default:** `false`
567+
563568
- **`--experimentalVision`/ `--experimental-vision`**
564569
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.
565570
- **Type:** boolean
@@ -725,6 +730,8 @@ trace.
725730

726731
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.
727732

733+
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.
734+
728735
Here is a step-by-step guide on how to connect to a running Chrome instance:
729736

730737
**Step 1: Configure the MCP client**

src/McpContext.ts

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ interface McpContextOptions {
5656
experimentalIncludeAllPages?: boolean;
5757
// Whether CrUX data should be fetched.
5858
performanceCrux: boolean;
59+
// Whether to skip unresponsive tabs when enumerating pages.
60+
skipUnresponsiveTabs?: boolean;
5961
}
6062

6163
const DEFAULT_TIMEOUT = 5_000;
@@ -562,9 +564,53 @@ export class McpContext implements Context {
562564
isolatedContextNames: Map<Page, string>;
563565
}> {
564566
const defaultCtx = this.browser.defaultBrowserContext();
565-
const allPages = await this.browser.pages(
566-
this.#options.experimentalIncludeAllPages,
567-
);
567+
568+
let allPages: Page[];
569+
if (this.#options.skipUnresponsiveTabs) {
570+
// Tolerant page enumeration: browser.pages() hangs indefinitely when
571+
// any tab is unresponsive (e.g. discarded/sleeping tabs). Fall back to
572+
// per-target enumeration so that one bad tab doesn't block the server.
573+
try {
574+
allPages = await Promise.race([
575+
this.browser.pages(this.#options.experimentalIncludeAllPages),
576+
new Promise<never>((_, reject) =>
577+
setTimeout(
578+
() => reject(new Error('browser.pages() timed out')),
579+
10_000,
580+
),
581+
),
582+
]);
583+
} catch {
584+
this.logger(
585+
'browser.pages() timed out, falling back to per-target enumeration',
586+
);
587+
const pageTargets = this.browser
588+
.targets()
589+
.filter(t => t.type() === 'page');
590+
allPages = [];
591+
await Promise.all(
592+
pageTargets.map(async target => {
593+
try {
594+
const page = await Promise.race([
595+
target.page(),
596+
new Promise<never>((_, reject) =>
597+
setTimeout(() => reject(new Error('timeout')), 5_000),
598+
),
599+
]);
600+
if (page) {
601+
allPages.push(page);
602+
}
603+
} catch {
604+
this.logger(`Skipping unresponsive tab: ${target.url()}`);
605+
}
606+
}),
607+
);
608+
}
609+
} else {
610+
allPages = await this.browser.pages(
611+
this.#options.experimentalIncludeAllPages,
612+
);
613+
}
568614

569615
const allTargets = this.browser.targets();
570616
const extensionTargets = allTargets.filter(target => {

src/bin/chrome-devtools-mcp-cli-options.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,11 @@ export const cliOptions = {
147147
type: 'boolean',
148148
description: `If enabled, ignores errors relative to self-signed and expired certificates. Use with caution.`,
149149
},
150+
skipUnresponsiveTabs: {
151+
type: 'boolean',
152+
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.`,
153+
default: false,
154+
},
150155
experimentalPageIdRouting: {
151156
type: 'boolean',
152157
describe:

src/browser.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ export async function ensureBrowserConnected(options: {
5151
channel?: Channel;
5252
userDataDir?: string;
5353
enableExtensions?: boolean;
54+
skipUnresponsiveTabs?: boolean;
5455
}) {
5556
const {channel, enableExtensions} = options;
5657
if (browser?.connected) {
@@ -61,6 +62,7 @@ export async function ensureBrowserConnected(options: {
6162
targetFilter: makeTargetFilter(enableExtensions),
6263
defaultViewport: null,
6364
handleDevToolsAsPage: true,
65+
...(options.skipUnresponsiveTabs ? {protocolTimeout: 15_000} : {}),
6466
};
6567

6668
let autoConnect = false;

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ export async function createMcpServer(
8686
: undefined,
8787
userDataDir: serverArgs.userDataDir,
8888
devtools,
89+
skipUnresponsiveTabs: serverArgs.skipUnresponsiveTabs,
8990
})
9091
: await ensureBrowserLaunched({
9192
headless: serverArgs.headless,
@@ -108,6 +109,7 @@ export async function createMcpServer(
108109
experimentalDevToolsDebugging: devtools,
109110
experimentalIncludeAllPages: serverArgs.experimentalIncludeAllPages,
110111
performanceCrux: serverArgs.performanceCrux,
112+
skipUnresponsiveTabs: serverArgs.skipUnresponsiveTabs,
111113
});
112114
}
113115
return context;

tests/McpContext.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,17 @@ describe('McpContext', () => {
3737
});
3838
});
3939

40+
it('list pages with skipUnresponsiveTabs enabled', async () => {
41+
await withMcpContext(
42+
async (_response, context) => {
43+
const page = context.getSelectedMcpPage();
44+
assert.ok(page, 'Should have a selected page');
45+
assert.ok(page.pptrPage.url(), 'Page should have a URL');
46+
},
47+
{skipUnresponsiveTabs: true},
48+
);
49+
});
50+
4051
it('can store and retrieve the latest performance trace', async () => {
4152
await withMcpContext(async (_response, context) => {
4253
const fakeTrace1 = {} as unknown as TraceResult;

tests/cli.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ describe('cli args parsing', () => {
2323
performanceCrux: true,
2424
'usage-statistics': true,
2525
usageStatistics: true,
26+
'skip-unresponsive-tabs': false,
27+
skipUnresponsiveTabs: false,
2628
};
2729

2830
it('parses with default args', async () => {
@@ -54,6 +56,17 @@ describe('cli args parsing', () => {
5456
});
5557
});
5658

59+
it('parses --skipUnresponsiveTabs', async () => {
60+
const args = parseArguments('1.0.0', [
61+
'node',
62+
'main.js',
63+
'--browserUrl',
64+
'http://localhost:9222',
65+
'--skipUnresponsiveTabs',
66+
]);
67+
assert.strictEqual(args.skipUnresponsiveTabs, true);
68+
});
69+
5770
it('parses with user data dir', async () => {
5871
const args = parseArguments('1.0.0', [
5972
'node',

tests/utils.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ export async function withMcpContext(
103103
debug?: boolean;
104104
autoOpenDevTools?: boolean;
105105
performanceCrux?: boolean;
106+
skipUnresponsiveTabs?: boolean;
106107
executablePath?: string;
107108
} = {},
108109
args: ParsedArguments = {} as ParsedArguments,
@@ -118,6 +119,7 @@ export async function withMcpContext(
118119
{
119120
experimentalDevToolsDebugging: false,
120121
performanceCrux: options.performanceCrux ?? true,
122+
skipUnresponsiveTabs: options.skipUnresponsiveTabs,
121123
},
122124
Locator,
123125
);

0 commit comments

Comments
 (0)