From 69b3594f8462aa1195001eb7c86f060f8bdf99c0 Mon Sep 17 00:00:00 2001 From: edxeth Date: Mon, 3 Nov 2025 20:30:11 +0100 Subject: [PATCH] fix: avoid extension bootstrap hangs --- README.md | 15 ++ docs/tool-reference.md | 4 +- docs/troubleshooting.md | 9 + package-lock.json | 12 +- package.json | 1 + scripts/test-remote-bootstrap.ts | 198 ++++++++++++++++++++++ src/McpContext.ts | 130 +++++++++++++-- src/browser.ts | 278 +++++++++++++++++++++++++++---- src/cli.ts | 28 ++++ src/main.ts | 8 + src/tools/ToolDefinition.ts | 3 +- src/tools/pages.ts | 39 ++++- tests/cli.test.ts | 37 ++++ tests/index.test.ts | 56 +++++-- tests/tools/pages.test.ts | 19 ++- tests/utils.ts | 1 + 16 files changed, 771 insertions(+), 67 deletions(-) create mode 100644 scripts/test-remote-bootstrap.ts diff --git a/README.md b/README.md index 54086fbf5..1d707b270 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/tool-reference.md b/docs/tool-reference.md index 72a41da7c..9afdc73f2 100644 --- a/docs/tool-reference.md +++ b/docs/tool-reference.md @@ -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 diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 7fc62eac6..34a3934ab 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -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: diff --git a/package-lock.json b/package-lock.json index 69b5731b2..54b08a2c1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1227,6 +1227,7 @@ "integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.2", "@typescript-eslint/types": "8.46.2", @@ -1702,6 +1703,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2576,7 +2578,8 @@ "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1521046.tgz", "integrity": "sha512-vhE6eymDQSKWUXwwA37NtTTVEzjtGVfDr3pRbsWEQ5onH/Snp2c+2xZHWJJawG/0hCCJLRGt4xVtEVUVILol4w==", "dev": true, - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "peer": true }, "node_modules/diff": { "version": "7.0.0", @@ -2877,6 +2880,7 @@ "integrity": "sha512-iy2GE3MHrYTL5lrCtMZ0X1KLEKKUjmK0kzwcnefhR66txcEmXZD2YWgR5GNdcEwkNx3a0siYkSvl0vIC+Svjmg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3047,6 +3051,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -3317,6 +3322,7 @@ "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.0", @@ -5612,6 +5618,7 @@ "integrity": "sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -6617,6 +6624,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6692,6 +6700,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "napi-postinstall": "^0.3.0" }, @@ -6990,6 +6999,7 @@ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index a391fcae4..6a8ebc300 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/scripts/test-remote-bootstrap.ts b/scripts/test-remote-bootstrap.ts new file mode 100644 index 000000000..e0f88b489 --- /dev/null +++ b/scripts/test-remote-bootstrap.ts @@ -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 { + 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 { + 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; +}); diff --git a/src/McpContext.ts b/src/McpContext.ts index 59fc72b1b..5782329e4 100644 --- a/src/McpContext.ts +++ b/src/McpContext.ts @@ -42,14 +42,62 @@ export interface TextSnapshot { selectedElementUid?: string; } +export type VisiblePagesSummary = { + pages: Array<{ + index: number; + id: string; + title: string; + url: string; + selected: boolean; + }>; + filtered: Array<{url: string; reason: string}>; +}; + interface McpContextOptions { // Whether the DevTools windows are exposed as pages for debugging of DevTools. experimentalDevToolsDebugging: boolean; + includeExtensionTargets: boolean; } const DEFAULT_TIMEOUT = 5_000; const NAVIGATION_TIMEOUT = 10_000; +function getPageFilterReasonForUrl( + url: string, + options: McpContextOptions, +): string | undefined { + if (!url) { + return; + } + + const normalized = url.toLowerCase(); + + if ( + !options.experimentalDevToolsDebugging && + normalized.startsWith('devtools://') + ) { + return 'devtools target'; + } + + if (options.includeExtensionTargets) { + return; + } + + if (normalized.includes('snaps/index.html')) { + return 'extension snaps page'; + } + + if (normalized.includes('offscreen.html')) { + return 'extension offscreen page'; + } + + if (normalized.startsWith('chrome-extension://')) { + return 'extension target'; + } + + return; +} + function getNetworkMultiplierFromString(condition: string | null): number { const puppeteerCondition = condition as keyof typeof PredefinedNetworkConditions; @@ -87,6 +135,7 @@ export class McpContext implements Context { #pages: Page[] = []; #pageToDevToolsPage = new Map(); #selectedPageIdx = 0; + #lastFilteredPages: Array<{url: string; reason: string}> = []; // The most recent snapshot. #textSnapshot: TextSnapshot | null = null; #networkCollector: NetworkCollector; @@ -364,20 +413,9 @@ export class McpContext implements Context { * Creates a snapshot of the pages. */ async createPagesSnapshot(): Promise { - const allPages = await this.browser.pages(); + const {visible} = await this.#refreshPages(); - this.#pages = allPages.filter(page => { - // If we allow debugging DevTools windows, return all pages. - // If we are in regular mode, the user should only see non-DevTools page. - return ( - this.#options.experimentalDevToolsDebugging || - !page.url().startsWith('devtools://') - ); - }); - - await this.detectOpenDevToolsWindows(); - - return this.#pages; + return visible; } async detectOpenDevToolsWindows() { @@ -418,6 +456,72 @@ export class McpContext implements Context { return this.#pageToDevToolsPage.get(page); } + async getVisiblePagesSummary(): Promise { + const {visible, filtered} = await this.#refreshPages(); + + const summaries = await Promise.all( + visible.map(async (page, index) => { + let title = ''; + try { + title = await page.title(); + } catch (error) { + this.logger('Unable to resolve page title', error); + } + const target = page.target() as unknown as { + _targetId?: string; + }; + const stableId = + target._targetId && target._targetId.length > 0 + ? target._targetId + : `${page.url()}#${index}`; + return { + index, + id: stableId, + title, + url: page.url(), + selected: index === this.#selectedPageIdx, + }; + }), + ); + + return {pages: summaries, filtered}; + } + + #filterPages(pages: Page[]): { + visible: Page[]; + filtered: Array<{url: string; reason: string}>; + } { + const visible: Page[] = []; + const filtered: Array<{url: string; reason: string}> = []; + for (const page of pages) { + const reason = getPageFilterReasonForUrl(page.url(), this.#options); + if (reason) { + filtered.push({ + url: page.url(), + reason, + }); + continue; + } + visible.push(page); + } + return {visible, filtered}; + } + + async #refreshPages(): Promise<{ + visible: Page[]; + filtered: Array<{url: string; reason: string}>; + }> { + const allPages = await this.browser.pages(); + const result = this.#filterPages(allPages); + + this.#pages = result.visible; + this.#lastFilteredPages = result.filtered; + + await this.detectOpenDevToolsWindows(); + + return result; + } + async getDevToolsData(): Promise { try { this.logger('Getting DevTools UI data'); diff --git a/src/browser.ts b/src/browser.ts index f3f3d9e11..ae3380454 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -13,51 +13,53 @@ import type { Browser, ChromeReleaseChannel, LaunchOptions, - Target, + CDPSession, } from './third_party/index.js'; import {puppeteer} from './third_party/index.js'; let browser: Browser | undefined; -function makeTargetFilter() { - const ignoredPrefixes = new Set([ - 'chrome://', - 'chrome-extension://', - 'chrome-untrusted://', - ]); - - return function targetFilter(target: Target): boolean { - if (target.url() === 'chrome://newtab/') { - return true; - } - if (target.url().startsWith('https://ogs.google.com/widget/app/so')) { - // Some special frame on the NTP that is not picked up by CDP-auto-attach. - return false; - } - for (const prefix of ignoredPrefixes) { - if (target.url().startsWith(prefix)) { - return false; - } - } - return true; - }; +export interface BootstrapOptions { + includeExtensionTargets: boolean; + bootstrapTimeoutMs: number; + verboseBootstrap: boolean; } +type TargetFilterEntry = { + type?: string; + exclude?: boolean; +}; + +type BootstrapState = { + options: BootstrapOptions; + promise: Promise; +}; + +type RawCDPSession = CDPSession & { + send: (method: string, params?: unknown) => Promise; + on: (event: string, listener: (event: unknown) => void) => void; + off?: (event: string, listener: (event: unknown) => void) => void; + removeListener?: (event: string, listener: (event: unknown) => void) => void; +}; + +const bootstrapStates = new WeakMap(); + export async function ensureBrowserConnected(options: { browserURL?: string; wsEndpoint?: string; wsHeaders?: Record; devtools: boolean; + bootstrap: BootstrapOptions; }) { if (browser?.connected) { return browser; } const connectOptions: Parameters[0] = { - targetFilter: makeTargetFilter(), defaultViewport: null, handleDevToolsAsPage: true, }; + (connectOptions as {waitForInitialPage?: boolean}).waitForInitialPage = false; if (options.wsEndpoint) { connectOptions.browserWSEndpoint = options.wsEndpoint; @@ -71,7 +73,15 @@ export async function ensureBrowserConnected(options: { } logger('Connecting Puppeteer to ', JSON.stringify(connectOptions)); - browser = await puppeteer.connect(connectOptions); + const connectedBrowser = await puppeteer.connect(connectOptions); + logger('bootstrap: puppeteer.connect resolved'); + try { + await bootstrapBrowser(connectedBrowser, options.bootstrap); + } catch (error) { + connectedBrowser.disconnect(); + throw error; + } + browser = connectedBrowser; logger('Connected Puppeteer'); return browser; } @@ -131,9 +141,8 @@ export async function launch(options: McpLaunchOptions): Promise { } try { - const browser = await puppeteer.launch({ + const launchOptions: Parameters[0] = { channel: puppeteerChannel, - targetFilter: makeTargetFilter(), executablePath, defaultViewport: null, userDataDir, @@ -142,7 +151,9 @@ export async function launch(options: McpLaunchOptions): Promise { args, acceptInsecureCerts: options.acceptInsecureCerts, handleDevToolsAsPage: true, - }); + }; + (launchOptions as {waitForInitialPage?: boolean}).waitForInitialPage = false; + const browser = await puppeteer.launch(launchOptions); if (options.logFile) { // FIXME: we are probably subscribing too late to catch startup logs. We // should expose the process earlier or expose the getRecentLogs() getter. @@ -175,13 +186,220 @@ export async function launch(options: McpLaunchOptions): Promise { } export async function ensureBrowserLaunched( - options: McpLaunchOptions, + options: McpLaunchOptions & {bootstrap: BootstrapOptions}, ): Promise { if (browser?.connected) { return browser; } - browser = await launch(options); + const {bootstrap, ...launchOptions} = options; + const launchedBrowser = await launch(launchOptions as McpLaunchOptions); + try { + await bootstrapBrowser(launchedBrowser, bootstrap); + } catch (error) { + await launchedBrowser.close().catch(() => {}); + throw error; + } + browser = launchedBrowser; return browser; } export type Channel = 'stable' | 'canary' | 'beta' | 'dev'; + +function bootstrapOptionsEqual( + a: BootstrapOptions, + b: BootstrapOptions, +): boolean { + return ( + a.includeExtensionTargets === b.includeExtensionTargets && + a.bootstrapTimeoutMs === b.bootstrapTimeoutMs && + a.verboseBootstrap === b.verboseBootstrap + ); +} + +async function bootstrapBrowser( + instance: Browser, + options: BootstrapOptions, +): Promise { + logger( + `bootstrap: start (includeExtensionTargets=${options.includeExtensionTargets}, timeout=${options.bootstrapTimeoutMs}, verbose=${options.verboseBootstrap})`, + ); + const existingState = bootstrapStates.get(instance); + if (existingState) { + if (bootstrapOptionsEqual(existingState.options, options)) { + await existingState.promise; + return; + } + } + + const promise = configureBootstrap(instance, options).catch(error => { + bootstrapStates.delete(instance); + throw error; + }); + bootstrapStates.set(instance, { + options: {...options}, + promise, + }); + await promise; +} + +async function configureBootstrap( + instance: Browser, + options: BootstrapOptions, +): Promise { + if (options.verboseBootstrap) { + logger('bootstrap: creating root CDP session'); + } + const session = (await instance + .target() + .createCDPSession()) as unknown as RawCDPSession; + if (options.verboseBootstrap) { + logger('bootstrap: root CDP session created'); + } + + const autoAttachFilter: TargetFilterEntry[] = options.includeExtensionTargets + ? [{}] + : [ + {type: 'service_worker', exclude: true}, + {type: 'background_page', exclude: true}, + {type: 'iframe', exclude: true}, + {type: 'worker', exclude: true}, + {type: 'shared_worker', exclude: true}, + {type: 'utility', exclude: true}, + ]; + + const waitForFirstPage = waitForFirstPageOrTimeout( + session, + options.bootstrapTimeoutMs, + options.verboseBootstrap, + ); + + if (options.verboseBootstrap) { + logger( + 'bootstrap: setDiscoverTargets (discover=true, filter=[{}])', + ); + } + await sendWithFallback(session, 'Target.setDiscoverTargets', { + discover: true, + filter: [{}], + }); + + if (options.verboseBootstrap) { + logger( + `bootstrap: setAutoAttach (flatten=true, waitForDebuggerOnStart=false, filter=${JSON.stringify(autoAttachFilter)})`, + ); + } + await sendWithFallback(session, 'Target.setAutoAttach', { + autoAttach: true, + flatten: true, + waitForDebuggerOnStart: false, + filter: autoAttachFilter, + }); + + await waitForFirstPage; +} + +async function sendWithFallback( + session: RawCDPSession, + method: 'Target.setDiscoverTargets' | 'Target.setAutoAttach', + params: Record, +): Promise { + try { + await session.send(method, params); + return; + } catch (error) { + if (!isFilterNotSupportedError(error)) { + throw error; + } + const fallbackParams = + method === 'Target.setDiscoverTargets' + ? {discover: true} + : { + autoAttach: true, + flatten: true, + waitForDebuggerOnStart: false, + }; + logger( + `${method}: filter unsupported, retrying without filter (${(error as Error).message})`, + ); + await session.send(method, fallbackParams); + } +} + +function isFilterNotSupportedError(error: unknown): boolean { + if (!error || typeof error !== 'object') { + return false; + } + const message = 'message' in error ? String((error as Error).message) : ''; + return /filter/i.test(message) || /Invalid parameters/.test(message); +} + +function waitForFirstPageOrTimeout( + session: RawCDPSession, + timeoutMs: number, + verbose: boolean, +): Promise { + if (timeoutMs <= 0) { + if (verbose) { + logger('bootstrap: waiting for first page skipped (timeout <= 0)'); + } + return Promise.resolve(); + } + + if (verbose) { + logger( + `bootstrap: waiting for first page or timeout (${timeoutMs} ms)`, + ); + } + + return new Promise(resolve => { + let settled = false; + let timer: NodeJS.Timeout; + + const cleanup = () => { + clearTimeout(timer); + if (typeof session.off === 'function') { + session.off('Target.attachedToTarget', onAttached); + } else if (typeof session.removeListener === 'function') { + session.removeListener('Target.attachedToTarget', onAttached); + } + }; + + const done = (logMessage?: string) => { + if (settled) { + return; + } + settled = true; + if (logMessage && verbose) { + logger(logMessage); + } + cleanup(); + resolve(); + }; + + const onAttached = (event: unknown) => { + if (!event || typeof event !== 'object') { + return; + } + const attachedEvent = event as { + targetInfo?: { + type?: string; + url?: string; + }; + }; + const targetInfo = attachedEvent.targetInfo; + if (!targetInfo) { + return; + } + if (targetInfo.type === 'page' || targetInfo.type === 'tab') { + const urlPart = targetInfo.url ? ` ${targetInfo.url}` : ''; + done(`bootstrap: first page attached (${targetInfo.type}${urlPart})`); + } + }; + + timer = setTimeout(() => { + done('bootstrap: timed out, continuing'); + }, timeoutMs); + + session.on('Target.attachedToTarget', onAttached); + }); +} diff --git a/src/cli.ts b/src/cli.ts index b7d7306b2..af62be6c0 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -139,6 +139,34 @@ export const cliOptions = { describe: 'Additional arguments for Chrome. Only applies when Chrome is launched by chrome-devtools-mcp.', }, + includeExtensionTargets: { + type: 'boolean', + default: false, + describe: + 'Include extension-related targets (service workers, background pages, iframes) during bootstrap. Enable only if you need to interact with extension targets.', + }, + bootstrapTimeoutMs: { + type: 'number', + default: 2_000, + describe: + 'Maximum time in milliseconds to wait for the first page to auto-attach during bootstrap before continuing.', + coerce: (value: number | undefined) => { + if (value === undefined) { + return; + } + const numberValue = Number(value); + if (!Number.isFinite(numberValue) || numberValue < 0) { + throw new Error('bootstrapTimeoutMs must be a non-negative number.'); + } + return numberValue; + }, + }, + verboseBootstrap: { + type: 'boolean', + default: true, + describe: + 'Enable verbose logs for the browser bootstrap process. Disable with --no-verboseBootstrap.', + }, categoryEmulation: { type: 'boolean', default: true, diff --git a/src/main.ts b/src/main.ts index 395f07a85..8558f84b3 100644 --- a/src/main.ts +++ b/src/main.ts @@ -60,6 +60,11 @@ async function getContext(): Promise { extraArgs.push(`--proxy-server=${args.proxyServer}`); } const devtools = args.experimentalDevtools ?? false; + const bootstrapOptions = { + includeExtensionTargets: args.includeExtensionTargets ?? false, + bootstrapTimeoutMs: args.bootstrapTimeoutMs ?? 2_000, + verboseBootstrap: args.verboseBootstrap ?? true, + }; const browser = args.browserUrl || args.wsEndpoint ? await ensureBrowserConnected({ @@ -67,6 +72,7 @@ async function getContext(): Promise { wsEndpoint: args.wsEndpoint, wsHeaders: args.wsHeaders, devtools, + bootstrap: bootstrapOptions, }) : await ensureBrowserLaunched({ headless: args.headless, @@ -78,11 +84,13 @@ async function getContext(): Promise { args: extraArgs, acceptInsecureCerts: args.acceptInsecureCerts, devtools, + bootstrap: bootstrapOptions, }); if (context?.browser !== browser) { context = await McpContext.from(browser, logger, { experimentalDevToolsDebugging: devtools, + includeExtensionTargets: args.includeExtensionTargets ?? false, }); } return context; diff --git a/src/tools/ToolDefinition.ts b/src/tools/ToolDefinition.ts index 9651718bf..768adea7b 100644 --- a/src/tools/ToolDefinition.ts +++ b/src/tools/ToolDefinition.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type {TextSnapshotNode} from '../McpContext.js'; +import type {TextSnapshotNode, VisiblePagesSummary} from '../McpContext.js'; import {zod} from '../third_party/index.js'; import type {Dialog, ElementHandle, Page} from '../third_party/index.js'; import type {TraceResult} from '../trace-processing/parse.js'; @@ -119,6 +119,7 @@ export type Context = Readonly<{ * Returns a reqid for a cdpRequestId. */ resolveCdpElementId(cdpBackendNodeId: number): string | undefined; + getVisiblePagesSummary(): Promise; }>; export function defineTool( diff --git a/src/tools/pages.ts b/src/tools/pages.ts index d77a36765..66b6ec75d 100644 --- a/src/tools/pages.ts +++ b/src/tools/pages.ts @@ -18,8 +18,43 @@ export const listPages = defineTool({ readOnlyHint: true, }, schema: {}, - handler: async (_request, response) => { - response.setIncludePages(true); + handler: async (_request, response, context) => { + const summary = await context.getVisiblePagesSummary(); + + const filteredCounts = new Map(); + for (const entry of summary.filtered) { + filteredCounts.set( + entry.reason, + (filteredCounts.get(entry.reason) ?? 0) + 1, + ); + } + + if (summary.filtered.length > 0) { + const parts: string[] = []; + for (const [reason, count] of filteredCounts.entries()) { + parts.push(`${reason}: ${count}`); + } + logger( + `list_pages: returning ${summary.pages.length} pages; filtered ${summary.filtered.length} (${parts.join(', ')})`, + ); + } else { + logger( + `list_pages: returning ${summary.pages.length} pages; filtered 0`, + ); + } + + const pagesPayload = summary.pages.map(page => { + return { + index: page.index, + id: page.id, + selected: page.selected, + title: page.title, + url: page.url, + }; + }); + + response.appendResponseLine('pages:'); + response.appendResponseLine(JSON.stringify(pagesPayload, null, 2)); }, }); diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 19502e28c..6122e4d70 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -10,6 +10,12 @@ import {parseArguments} from '../src/cli.js'; describe('cli args parsing', () => { const defaultArgs = { + 'include-extension-targets': false, + includeExtensionTargets: false, + 'bootstrap-timeout-ms': 2_000, + bootstrapTimeoutMs: 2_000, + 'verbose-bootstrap': true, + verboseBootstrap: true, 'category-emulation': true, categoryEmulation: true, 'category-performance': true, @@ -69,6 +75,37 @@ describe('cli args parsing', () => { }); }); + it('parses includeExtensionTargets', async () => { + const args = parseArguments('1.0.0', [ + 'node', + 'main.js', + '--includeExtensionTargets', + ]); + assert.equal(args.includeExtensionTargets, true); + assert.equal(args['include-extension-targets'], true); + }); + + it('parses bootstrapTimeoutMs', async () => { + const args = parseArguments('1.0.0', [ + 'node', + 'main.js', + '--bootstrapTimeoutMs', + '4500', + ]); + assert.equal(args.bootstrapTimeoutMs, 4_500); + assert.equal(args['bootstrap-timeout-ms'], 4_500); + }); + + it('parses verboseBootstrap', async () => { + const args = parseArguments('1.0.0', [ + 'node', + 'main.js', + '--no-verboseBootstrap', + ]); + assert.equal(args.verboseBootstrap, false); + assert.equal(args['verbose-bootstrap'], false); + }); + it('parses with executable path', async () => { const args = parseArguments('1.0.0', [ 'node', diff --git a/tests/index.test.ts b/tests/index.test.ts index 4bbe3ddd6..525492359 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -42,39 +42,59 @@ describe('e2e', () => { } it('calls a tool', async () => { await withClient(async client => { + await client.callTool({ + name: 'select_page', + arguments: { + pageIdx: 0, + }, + }); const result = await client.callTool({ name: 'list_pages', arguments: {}, }); - assert.deepStrictEqual(result, { - content: [ - { - type: 'text', - text: '# list_pages response\n## Pages\n0: about:blank [selected]', - }, - ], - }); + const content = (result.content ?? []) as Array< + {type: string; text?: string} | undefined + >; + assert.equal(content.length, 1); + const first = content[0]; + const text = first?.type === 'text' ? first.text ?? '' : ''; + assert.ok( + text.startsWith('# list_pages response\npages:\n['), + `Unexpected content: ${text}`, + ); + const serializedPages = text.slice(text.indexOf('pages:\n') + 'pages:\n'.length); + const pages = JSON.parse(serializedPages); + assert.equal(pages.length, 1); + assert.equal(pages[0]?.index, 0); + assert.equal(pages[0]?.url, 'about:blank'); + assert.equal(pages[0]?.selected, true); + assert.equal(typeof pages[0]?.id, 'string'); }); }); it('calls a tool multiple times', async () => { await withClient(async client => { - let result = await client.callTool({ + await client.callTool({ + name: 'select_page', + arguments: { + pageIdx: 0, + }, + }); + await client.callTool({ name: 'list_pages', arguments: {}, }); - result = await client.callTool({ + const result = await client.callTool({ name: 'list_pages', arguments: {}, }); - assert.deepStrictEqual(result, { - content: [ - { - type: 'text', - text: '# list_pages response\n## Pages\n0: about:blank [selected]', - }, - ], - }); + const content = (result.content ?? []) as Array< + {type: string; text?: string} | undefined + >; + assert.equal(content.length, 1); + const first = content[0]; + const text = first?.type === 'text' ? first.text ?? '' : ''; + assert.ok(text.includes('"index": 0')); }); }); diff --git a/tests/tools/pages.test.ts b/tests/tools/pages.test.ts index cf482ae89..615b99d37 100644 --- a/tests/tools/pages.test.ts +++ b/tests/tools/pages.test.ts @@ -24,7 +24,24 @@ describe('pages', () => { it('list pages', async () => { await withBrowser(async (response, context) => { await listPages.handler({params: {}}, response, context); - assert.ok(response.includePages); + assert.strictEqual(response.responseLines[0], 'pages:'); + const serializedPages = response.responseLines.at(1); + assert.ok(serializedPages); + const pages = JSON.parse(serializedPages ?? '[]'); + assert.equal(pages.length, 1); + const page = pages[0] as { + id: string; + index: number; + selected: boolean; + title: string; + url: string; + }; + assert.equal(page.index, 0); + assert.equal(page.url, 'about:blank'); + assert.equal(page.selected, true); + assert.equal(page.title, ''); + assert.equal(typeof page.id, 'string'); + assert.ok(!response.includePages); }); }); }); diff --git a/tests/utils.ts b/tests/utils.ts index 6dca14a71..06d07f4fe 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -53,6 +53,7 @@ export async function withBrowser( logger('test'), { experimentalDevToolsDebugging: false, + includeExtensionTargets: false, }, Locator, );