From 276bf42081a9e4bb4fa324f1d814fabed3a20a76 Mon Sep 17 00:00:00 2001 From: Alex Rudenko Date: Fri, 10 Oct 2025 13:54:07 +0200 Subject: [PATCH 1/2] chore: experimental devtools --- src/McpContext.ts | 32 ++++++++++++++++++--- src/browser.ts | 66 +++++++++++++++++++++++++++---------------- src/cli.ts | 5 ++++ src/main.ts | 14 +++++++-- tests/browser.test.ts | 3 ++ tests/utils.ts | 4 ++- 6 files changed, 93 insertions(+), 31 deletions(-) diff --git a/src/McpContext.ts b/src/McpContext.ts index d1037935..a89a01c7 100644 --- a/src/McpContext.ts +++ b/src/McpContext.ts @@ -89,8 +89,16 @@ export class McpContext implements Context { #nextSnapshotId = 1; #traceResults: TraceResult[] = []; - - private constructor(browser: Browser, logger: Debugger) { + #devtools = false; + + private constructor( + browser: Browser, + logger: Debugger, + options: { + devtools: boolean; + }, + ) { + this.#devtools = options.devtools; this.browser = browser; this.logger = logger; @@ -123,8 +131,14 @@ export class McpContext implements Context { await this.#consoleCollector.init(); } - static async from(browser: Browser, logger: Debugger) { - const context = new McpContext(browser, logger); + static async from( + browser: Browser, + logger: Debugger, + options: { + devtools: boolean; + }, + ) { + const context = new McpContext(browser, logger, options); await context.#init(); return context; } @@ -302,6 +316,16 @@ export class McpContext implements Context { */ async createPagesSnapshot(): Promise { this.#pages = await this.browser.pages(); + // if (this.#devtools) { + // for (const target of this.browser.targets()) { + // if ( + // target.type() === 'other' && + // target.url().startsWith('devtools://') + // ) { + // this.#pages.push(await target.asPage()); + // } + // } + // } return this.#pages; } diff --git a/src/browser.ts b/src/browser.ts index 083e9c09..faaaf790 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -11,7 +11,6 @@ import path from 'node:path'; import type { Browser, ChromeReleaseChannel, - ConnectOptions, LaunchOptions, Target, } from 'puppeteer-core'; @@ -19,37 +18,46 @@ import puppeteer from 'puppeteer-core'; let browser: Browser | undefined; -const ignoredPrefixes = new Set([ - 'chrome://', - 'chrome-extension://', - 'chrome-untrusted://', - 'devtools://', -]); +function makeTargetFilter(devtools: boolean) { + const ignoredPrefixes = new Set([ + 'chrome://', + 'chrome-extension://', + 'chrome-untrusted://', + ]); -function targetFilter(target: Target): boolean { - if (target.url() === 'chrome://newtab/') { - return true; + if (!devtools) { + ignoredPrefixes.add('devtools://'); } - for (const prefix of ignoredPrefixes) { - if (target.url().startsWith(prefix)) { - return false; + return function targetFilter(target: Target): boolean { + if (target.url() === 'chrome://newtab/') { + return true; } - } - return true; + for (const prefix of ignoredPrefixes) { + if (target.url().startsWith(prefix)) { + return false; + } + } + return true; + }; } -const connectOptions: ConnectOptions = { - targetFilter, -}; - -export async function ensureBrowserConnected(browserURL: string) { +export async function ensureBrowserConnected(options: { + browserURL: string; + devtools: boolean; +}) { if (browser?.connected) { return browser; } browser = await puppeteer.connect({ - ...connectOptions, - browserURL, + targetFilter: makeTargetFilter(options.devtools), + browserURL: options.browserURL, defaultViewport: null, + // @ts-expect-error no types. + _isPageTarget(target) { + return ( + target.type() === 'other' && target.url().startsWith('devtools://') + ); + }, }); return browser; } @@ -68,7 +76,8 @@ interface McpLaunchOptions { height: number; }; args?: string[]; -} + devtools: boolean; +}; export async function launch(options: McpLaunchOptions): Promise { const {channel, executablePath, customDevTools, headless, isolated} = options; @@ -101,6 +110,9 @@ export async function launch(options: McpLaunchOptions): Promise { args.push('--screen-info={3840x2160}'); } let puppeteerChannel: ChromeReleaseChannel | undefined; + if (options.devtools) { + args.push('--auto-open-devtools-for-tabs'); + } if (!executablePath) { puppeteerChannel = channel && channel !== 'stable' @@ -110,8 +122,8 @@ export async function launch(options: McpLaunchOptions): Promise { try { const browser = await puppeteer.launch({ - ...connectOptions, channel: puppeteerChannel, + targetFilter: makeTargetFilter(options.devtools), executablePath, defaultViewport: null, userDataDir, @@ -119,6 +131,12 @@ export async function launch(options: McpLaunchOptions): Promise { headless, args, acceptInsecureCerts: options.acceptInsecureCerts, + // @ts-expect-error no types. + _isPageTarget(target) { + return ( + target.type() === 'other' && target.url().startsWith('devtools://') + ); + }, }); if (options.logFile) { // FIXME: we are probably subscribing too late to catch startup logs. We diff --git a/src/cli.ts b/src/cli.ts index 909d1a21..17b1df63 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -88,6 +88,11 @@ export const cliOptions = { type: 'boolean', description: `If enabled, ignores errors relative to self-signed and expired certificates. Use with caution.`, }, + experimentalDevtools: { + type: 'boolean' as const, + describe: 'Whether to enable automation over DevTools targets', + hidden: true, + }, } satisfies Record; export function parseArguments(version: string, argv = process.argv) { diff --git a/src/main.ts b/src/main.ts index 2663d3c1..f8693ba5 100644 --- a/src/main.ts +++ b/src/main.ts @@ -73,8 +73,12 @@ async function getContext(): Promise { if (args.proxyServer) { extraArgs.push(`--proxy-server=${args.proxyServer}`); } + const devtools = args.experimentalDevtools ?? false; const browser = args.browserUrl - ? await ensureBrowserConnected(args.browserUrl) + ? await ensureBrowserConnected({ + browserURL: args.browserUrl, + devtools, + }) : await ensureBrowserLaunched({ headless: args.headless, executablePath: args.executablePath, @@ -85,10 +89,13 @@ async function getContext(): Promise { viewport: args.viewport, args: extraArgs, acceptInsecureCerts: args.acceptInsecureCerts, + devtools, }); if (context?.browser !== browser) { - context = await McpContext.from(browser, logger); + context = await McpContext.from(browser, logger, { + devtools, + }); } return context; } @@ -143,6 +150,9 @@ function registerTool(tool: ToolDefinition): void { isError: true, }; } + } catch (err) { + logger(`${tool.name} error: ${err.message}`); + throw err; } finally { guard.dispose(); } diff --git a/tests/browser.test.ts b/tests/browser.test.ts index b4811202..8066da5f 100644 --- a/tests/browser.test.ts +++ b/tests/browser.test.ts @@ -21,6 +21,7 @@ describe('browser', () => { isolated: false, userDataDir: folderPath, executablePath: executablePath(), + devtools: false, }); try { try { @@ -29,6 +30,7 @@ describe('browser', () => { isolated: false, userDataDir: folderPath, executablePath: executablePath(), + devtools: false, }); await browser2.close(); assert.fail('not reached'); @@ -55,6 +57,7 @@ describe('browser', () => { width: 1501, height: 801, }, + devtools: false, }); try { const [page] = await browser.pages(); diff --git a/tests/utils.ts b/tests/utils.ts index 0197e181..f0042fb9 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -35,7 +35,9 @@ export async function withBrowser( }), ); const response = new McpResponse(); - const context = await McpContext.from(browser, logger('test')); + const context = await McpContext.from(browser, logger('test'), { + devtools: false, + }); await cb(response, context); } From d921c7af0f16372163aa153fb89894e9a60cf46c Mon Sep 17 00:00:00 2001 From: Alex Rudenko Date: Fri, 10 Oct 2025 15:57:44 +0200 Subject: [PATCH 2/2] chore: experimental devtools --- src/McpContext.ts | 32 ++++---------------------------- src/browser.ts | 16 +++------------- src/main.ts | 10 ++++------ tests/utils.ts | 4 +--- 4 files changed, 12 insertions(+), 50 deletions(-) diff --git a/src/McpContext.ts b/src/McpContext.ts index a89a01c7..d1037935 100644 --- a/src/McpContext.ts +++ b/src/McpContext.ts @@ -89,16 +89,8 @@ export class McpContext implements Context { #nextSnapshotId = 1; #traceResults: TraceResult[] = []; - #devtools = false; - - private constructor( - browser: Browser, - logger: Debugger, - options: { - devtools: boolean; - }, - ) { - this.#devtools = options.devtools; + + private constructor(browser: Browser, logger: Debugger) { this.browser = browser; this.logger = logger; @@ -131,14 +123,8 @@ export class McpContext implements Context { await this.#consoleCollector.init(); } - static async from( - browser: Browser, - logger: Debugger, - options: { - devtools: boolean; - }, - ) { - const context = new McpContext(browser, logger, options); + static async from(browser: Browser, logger: Debugger) { + const context = new McpContext(browser, logger); await context.#init(); return context; } @@ -316,16 +302,6 @@ export class McpContext implements Context { */ async createPagesSnapshot(): Promise { this.#pages = await this.browser.pages(); - // if (this.#devtools) { - // for (const target of this.browser.targets()) { - // if ( - // target.type() === 'other' && - // target.url().startsWith('devtools://') - // ) { - // this.#pages.push(await target.asPage()); - // } - // } - // } return this.#pages; } diff --git a/src/browser.ts b/src/browser.ts index faaaf790..b0a7aaa5 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -52,12 +52,7 @@ export async function ensureBrowserConnected(options: { targetFilter: makeTargetFilter(options.devtools), browserURL: options.browserURL, defaultViewport: null, - // @ts-expect-error no types. - _isPageTarget(target) { - return ( - target.type() === 'other' && target.url().startsWith('devtools://') - ); - }, + handleDevToolsAsPage: options.devtools, }); return browser; } @@ -77,7 +72,7 @@ interface McpLaunchOptions { }; args?: string[]; devtools: boolean; -}; +} export async function launch(options: McpLaunchOptions): Promise { const {channel, executablePath, customDevTools, headless, isolated} = options; @@ -131,12 +126,7 @@ export async function launch(options: McpLaunchOptions): Promise { headless, args, acceptInsecureCerts: options.acceptInsecureCerts, - // @ts-expect-error no types. - _isPageTarget(target) { - return ( - target.type() === 'other' && target.url().startsWith('devtools://') - ); - }, + handleDevToolsAsPage: options.devtools, }); if (options.logFile) { // FIXME: we are probably subscribing too late to catch startup logs. We diff --git a/src/main.ts b/src/main.ts index f8693ba5..fdf18550 100644 --- a/src/main.ts +++ b/src/main.ts @@ -76,9 +76,9 @@ async function getContext(): Promise { const devtools = args.experimentalDevtools ?? false; const browser = args.browserUrl ? await ensureBrowserConnected({ - browserURL: args.browserUrl, - devtools, - }) + browserURL: args.browserUrl, + devtools, + }) : await ensureBrowserLaunched({ headless: args.headless, executablePath: args.executablePath, @@ -93,9 +93,7 @@ async function getContext(): Promise { }); if (context?.browser !== browser) { - context = await McpContext.from(browser, logger, { - devtools, - }); + context = await McpContext.from(browser, logger); } return context; } diff --git a/tests/utils.ts b/tests/utils.ts index f0042fb9..0197e181 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -35,9 +35,7 @@ export async function withBrowser( }), ); const response = new McpResponse(); - const context = await McpContext.from(browser, logger('test'), { - devtools: false, - }); + const context = await McpContext.from(browser, logger('test')); await cb(response, context); }