refactor: extract browser in a BrowserManager class (#2787)

This commit is contained in:
Nikolay Vitkov
2026-09-22 13:58:17 +00:00
committed by GitHub
parent 65a679e389
commit 3228f44af9
11 changed files with 814 additions and 501 deletions
+414
View File
@@ -0,0 +1,414 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {execSync} from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import type {ParsedArguments} from './config/mcp-options.js';
import type {
Browser,
ChromeReleaseChannel,
LaunchOptions,
} from './third_party/index.js';
import {Mutex, puppeteer} from './third_party/index.js';
import {logger, puppeteerLogger} from './utils/logger.js';
import {isAllowedUrl} from './utils/url.js';
export interface BrowserManagerOptions {
logFile?: fs.WriteStream;
}
export class BrowserManager {
#browser?: Browser;
#browserMode?: 'launched' | 'connected';
#initPromise?: Promise<Browser>;
#mutex = new Mutex();
#closingCount = 0;
#serverArgs: ParsedArguments;
#options: BrowserManagerOptions;
constructor(
serverArgs: ParsedArguments,
options: BrowserManagerOptions = {},
) {
this.#serverArgs = serverArgs;
this.#options = options;
}
static makeTargetFilter(enableExtensions = false) {
return function targetFilter(target: {url(): string}): boolean {
const url = target.url();
if (!url) {
return true;
}
return isAllowedUrl(url, {categoryExtensions: enableExtensions});
};
}
static detectDisplay(): void {
// Only detect display on Linux/UNIX.
if (os.platform() === 'win32' || os.platform() === 'darwin') {
return;
}
if (!process.env['DISPLAY']) {
try {
const result = execSync(
`ps -u $(id -u) -o pid= | xargs -I{} cat /proc/{}/environ 2>/dev/null | tr '\\0' '\\n' | grep -m1 '^DISPLAY=' | cut -d= -f2`,
);
const display = result.toString('utf8').trim();
process.env['DISPLAY'] = display;
} catch {
// no-op
}
}
}
/**
* Chrome refuses to start as root unless the sandbox is explicitly disabled and
* only says so on its stderr. Because we launch with `pipe: true`, Puppeteer
* never surfaces that stderr and the failure reaches the client as an opaque
* `Protocol error (Target.setDiscoverTargets): Target closed`. Detect the
* situation and explain the way out instead. See https://crbug.com/638180.
*
* Returns `undefined` when the failure cannot be explained by running as root,
* including on platforms without uids and when the sandbox was already disabled
* through `--chrome-arg` (in which case root is not what stopped Chrome).
*/
static rootSandboxLaunchError(
error: Error,
args: readonly string[],
uid = process.getuid?.(),
): Error | undefined {
if (uid !== 0) {
return undefined;
}
if (
args.some(
arg => arg === '--no-sandbox' || arg.startsWith('--no-sandbox='),
)
) {
return undefined;
}
return new Error(
`Chrome failed to start: ${error.message}\n\n` +
'chrome-devtools-mcp is running as root and Chrome does not start as root ' +
'(https://crbug.com/638180). Run chrome-devtools-mcp as a non-root user; in a ' +
'container, create an unprivileged user in the image and switch to it with ' +
"USER. For the setup that Chrome's sandbox needs, see " +
'https://pptr.dev/troubleshooting#setting-up-chrome-linux-sandbox.',
{
cause: error,
},
);
}
async ensureBrowser(): Promise<Browser> {
if (this.#initPromise) {
return await this.#initPromise;
}
const initPromise = this.#ensureBrowserLocked();
this.#initPromise = initPromise;
try {
return await initPromise;
} finally {
if (this.#initPromise === initPromise) {
this.#initPromise = undefined;
}
}
}
async #ensureBrowserLocked(): Promise<Browser> {
if (this.#closingCount > 0) {
throw new Error('Browser was closed while initializing.');
}
using _guard = await this.#mutex.acquire();
if (this.#closingCount > 0) {
throw new Error('Browser was closed while initializing.');
}
if (!this.#browser?.connected) {
await this.#initBrowser();
}
if (this.#closingCount > 0 || !this.#browser) {
await this.#closeBrowser();
throw new Error('Browser was closed while initializing.');
}
return this.#browser;
}
async #initBrowser(): Promise<Browser> {
if (
this.#serverArgs.browserUrl ||
this.#serverArgs.wsEndpoint ||
this.#serverArgs.autoConnect
) {
return await this.#connect();
}
return await this.#launch();
}
async #launch(): Promise<Browser> {
const {
channel,
executablePath,
headless,
isolated = false,
categoryExtensions: enableExtensions,
viaCli,
viewport,
acceptInsecureCerts,
experimentalDevtools: devtools = false,
blockedUrlPattern: blocklist,
allowedUrlPattern: allowlist,
ignoreDefaultChromeArg,
proxyServer,
} = this.#serverArgs;
const profileDirName =
channel && channel !== 'stable'
? `chrome-profile-${channel}`
: 'chrome-profile';
let userDataDir = this.#serverArgs.userDataDir;
if (!isolated && !userDataDir) {
userDataDir = path.join(
os.homedir(),
'.cache',
viaCli ? 'chrome-devtools-mcp-cli' : 'chrome-devtools-mcp',
profileDirName,
);
await fs.promises.mkdir(userDataDir, {
recursive: true,
});
}
const args: LaunchOptions['args'] = [...(this.#serverArgs.chromeArg ?? [])];
if (proxyServer) {
args.push(`--proxy-server=${proxyServer}`);
}
args.push('--hide-crash-restore-bubble');
const ignoreDefaultArgs: LaunchOptions['ignoreDefaultArgs'] =
ignoreDefaultChromeArg ?? false;
if (headless) {
args.push('--screen-info={3840x2160}');
}
let puppeteerChannel: ChromeReleaseChannel | undefined;
if (devtools) {
args.push('--auto-open-devtools-for-tabs');
}
if (!executablePath) {
puppeteerChannel =
channel && channel !== 'stable' ? `chrome-${channel}` : 'chrome';
}
if (!headless) {
BrowserManager.detectDisplay();
}
let browser: Browser | undefined;
try {
browser = await puppeteer.launch({
channel: puppeteerChannel,
targetFilter: BrowserManager.makeTargetFilter(enableExtensions),
executablePath,
defaultViewport: null,
userDataDir,
pipe: true,
headless,
args,
ignoreDefaultArgs,
acceptInsecureCerts,
handleDevToolsAsPage: true,
enableExtensions,
blocklist,
allowlist,
logger: puppeteerLogger,
});
if (this.#options.logFile) {
// FIXME: we are probably subscribing too late to catch startup logs. We
// should expose the process earlier or expose the getRecentLogs() getter.
browser.process()?.stderr?.pipe(this.#options.logFile);
browser.process()?.stdout?.pipe(this.#options.logFile);
}
if (viewport) {
const [page] = await browser.pages();
await page?.resize({
contentWidth: viewport.width,
contentHeight: viewport.height,
});
}
this.#browserMode = 'launched';
this.#browser = browser;
return browser;
} catch (error) {
await browser?.close().catch(() => {
// Best-effort cleanup if post-launch setup failed.
});
if (
userDataDir &&
error instanceof Error &&
error.message.includes('The browser is already running')
) {
throw new Error(
`The browser is already running for ${userDataDir}. Use --isolated to run multiple browser instances.`,
{
cause: error,
},
);
}
if (error instanceof Error) {
const rootError = BrowserManager.rootSandboxLaunchError(error, args);
if (rootError) {
throw rootError;
}
}
throw error;
}
}
async #connect(): Promise<Browser> {
const {
browserUrl: browserURL,
wsEndpoint,
wsHeaders,
autoConnect: isAutoConnect,
userDataDir,
blockedUrlPattern: blocklist,
allowedUrlPattern: allowlist,
} = this.#serverArgs;
// Important: only pass channel, if autoConnect is true.
const channel = isAutoConnect ? this.#serverArgs.channel : undefined;
const connectOptions: Parameters<typeof puppeteer.connect>[0] = {
targetFilter: BrowserManager.makeTargetFilter(),
defaultViewport: null,
handleDevToolsAsPage: true,
blocklist,
allowlist,
logger: puppeteerLogger,
};
let autoConnect = false;
if (wsEndpoint) {
connectOptions.browserWSEndpoint = wsEndpoint;
if (wsHeaders) {
connectOptions.headers = wsHeaders;
}
} else if (browserURL) {
connectOptions.browserURL = browserURL;
} else if (channel || userDataDir) {
if (userDataDir) {
autoConnect = true;
// TODO: re-expose this logic via Puppeteer.
const portPath = path.join(userDataDir, 'DevToolsActivePort');
try {
const fileContent = await fs.promises.readFile(portPath, 'utf8');
const [rawPort, rawPath] = fileContent
.split('\n')
.map(line => {
return line.trim();
})
.filter(line => {
return !!line;
});
if (!rawPort || !rawPath) {
throw new Error(
`Invalid DevToolsActivePort '${fileContent}' found`,
);
}
const port = parseInt(rawPort, 10);
if (isNaN(port) || port <= 0 || port > 65535) {
throw new Error(`Invalid port '${rawPort}' found`);
}
const browserWSEndpoint = `ws://127.0.0.1:${port}${rawPath}`;
connectOptions.browserWSEndpoint = browserWSEndpoint;
} catch (error) {
throw new Error(
`Could not connect to Chrome in ${userDataDir}. Check if Chrome is running and remote debugging is enabled by going to chrome://inspect/#remote-debugging.`,
{
cause: error,
},
);
}
} else {
if (!channel) {
throw new Error('Channel must be provided if userDataDir is missing');
}
connectOptions.channel =
channel === 'stable' ? 'chrome' : `chrome-${channel}`;
}
} else {
throw new Error(
'Either browserURL, wsEndpoint, channel or userDataDir must be provided',
);
}
logger?.('Connecting Puppeteer to ', JSON.stringify(connectOptions));
try {
const connected = await puppeteer.connect(connectOptions);
logger?.('Connected Puppeteer');
this.#browserMode = 'connected';
this.#browser = connected;
return connected;
} catch (err) {
throw new Error(
`Could not connect to Chrome. ${autoConnect ? `Check if Chrome is running and remote debugging is enabled by going to chrome://inspect/#remote-debugging.` : `Check if Chrome is running.`}`,
{
cause: err,
},
);
}
}
async #closeBrowser(): Promise<void> {
const browser = this.#browser;
const mode = this.#browserMode;
this.#browser = undefined;
this.#browserMode = undefined;
if (!browser || !browser.connected) {
return;
}
if (mode === 'launched') {
await browser.close().catch(err => {
logger?.('Failed to close browser', err);
});
return;
}
await browser.disconnect().catch(err => {
logger?.('Failed to disconnect from browser', err);
});
}
/**
* Shutdown hook for the active browser. Closes a launched browser (so the
* Chrome subprocess is reaped) or disconnects from an attached browser (so
* the user's Chrome instance stays alive). No-op if no browser is active or
* the connection has already been dropped.
*/
async close(): Promise<void> {
this.#initPromise = undefined;
this.#closingCount++;
using _guard = await this.#mutex.acquire();
try {
await this.#closeBrowser();
} finally {
this.#closingCount--;
}
}
[Symbol.dispose](): void {
this.close().catch(err => {
logger?.('Failed to dispose BrowserManager', err);
});
}
async [Symbol.asyncDispose](): Promise<void> {
await this.close();
}
}
+4 -4
View File
@@ -60,7 +60,7 @@ interface McpContextOptions {
// Whether source maps are enabled in DevTools.
sourceMaps?: boolean;
// The allow list of URL patterns to allow loading resources.
allowList?: string[];
allowlist?: string[];
// The block list of URL patterns to block loading resources.
blocklist?: string[];
// Whether to skip path validation when the client did not negotiate the roots
@@ -369,7 +369,7 @@ export class McpContext implements Context {
}
get #hasNetworkBlockOrAllowlist(): boolean {
return !!(this.#options.allowList || this.#options.blocklist);
return !!(this.#options.allowlist || this.#options.blocklist);
}
installPWA(options: InstallPWAOptions): Promise<string> {
@@ -898,10 +898,10 @@ export class McpContext implements Context {
}
#validateUrlAllowed(url: URL): void {
if (!this.#options.allowList) {
if (!this.#options.allowlist) {
return;
}
for (const allow of this.#options.allowList) {
for (const allow of this.#options.allowlist) {
const pattern = new URLPattern(allow);
if (pattern.test(url)) {
return;
+12 -6
View File
@@ -8,7 +8,7 @@ import '../utils/polyfill.js';
import process from 'node:process';
import {closeBrowser} from '../browser.js';
import {BrowserManager} from '../BrowserManager.js';
import {McpServer, logDisclaimers} from '../index.js';
import {ClearcutLogger} from '../telemetry/ClearcutLogger.js';
import {computeFlagUsage} from '../telemetry/flagUtils.js';
@@ -33,6 +33,14 @@ if (process.env['CHROME_DEVTOOLS_MCP_CRASH_ON_UNCAUGHT'] !== 'true') {
});
}
logger?.(`Starting Chrome DevTools MCP Server v${VERSION}`);
const browserManager = new BrowserManager(args, {
logFile,
});
const serverPromise = McpServer.from(args, {
browserManager,
});
// Shutdown on stdin EOF (stdio MCP convention — the client closes the
// transport to signal exit) and on standard termination signals. Without
// this, an active Chrome subprocess keeps the Node event loop ref'd after
@@ -52,7 +60,8 @@ async function shutdown(reason: string): Promise<void> {
logger?.('Shutdown timeout exceeded, forcing exit');
process.exit(0);
}, 5000).unref();
await closeBrowser();
const server = await serverPromise;
await server.close();
process.exit(0);
}
process.stdin.on('end', () => {
@@ -71,10 +80,7 @@ process.on('SIGHUP', () => {
void shutdown('SIGHUP');
});
logger?.(`Starting Chrome DevTools MCP Server v${VERSION}`);
const server = await McpServer.from(args, {
logFile,
});
const server = await serverPromise;
const transport = new StdioServerTransport();
await server.connect(transport);
logger?.('Chrome DevTools MCP Server connected');
-348
View File
@@ -1,348 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {execSync} from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import type {
Browser,
ChromeReleaseChannel,
LaunchOptions,
} from './third_party/index.js';
import {puppeteer} from './third_party/index.js';
import {logger, puppeteerLogger} from './utils/logger.js';
import {isAllowedUrl} from './utils/url.js';
let browser: Browser | undefined;
let browserMode: 'launched' | 'connected' | undefined;
export function makeTargetFilter(enableExtensions = false) {
return function targetFilter(target: {url(): string}): boolean {
const url = target.url();
if (!url) {
return true;
}
return isAllowedUrl(url, {categoryExtensions: enableExtensions});
};
}
export async function ensureBrowserConnected(options: {
browserURL?: string;
wsEndpoint?: string;
wsHeaders?: Record<string, string>;
devtools: boolean;
channel?: Channel;
userDataDir?: string;
enableExtensions?: boolean;
blocklist?: string[];
allowlist?: string[];
}) {
const {channel, enableExtensions} = options;
if (browser?.connected) {
return browser;
}
const connectOptions: Parameters<typeof puppeteer.connect>[0] = {
targetFilter: makeTargetFilter(enableExtensions),
defaultViewport: null,
handleDevToolsAsPage: true,
blocklist: options.blocklist,
allowlist: options.allowlist,
logger: puppeteerLogger,
};
let autoConnect = false;
if (options.wsEndpoint) {
connectOptions.browserWSEndpoint = options.wsEndpoint;
if (options.wsHeaders) {
connectOptions.headers = options.wsHeaders;
}
} else if (options.browserURL) {
connectOptions.browserURL = options.browserURL;
} else if (channel || options.userDataDir) {
const userDataDir = options.userDataDir;
if (userDataDir) {
autoConnect = true;
// TODO: re-expose this logic via Puppeteer.
const portPath = path.join(userDataDir, 'DevToolsActivePort');
try {
const fileContent = await fs.promises.readFile(portPath, 'utf8');
const [rawPort, rawPath] = fileContent
.split('\n')
.map(line => {
return line.trim();
})
.filter(line => {
return !!line;
});
if (!rawPort || !rawPath) {
throw new Error(`Invalid DevToolsActivePort '${fileContent}' found`);
}
const port = parseInt(rawPort, 10);
if (isNaN(port) || port <= 0 || port > 65535) {
throw new Error(`Invalid port '${rawPort}' found`);
}
const browserWSEndpoint = `ws://127.0.0.1:${port}${rawPath}`;
connectOptions.browserWSEndpoint = browserWSEndpoint;
} catch (error) {
throw new Error(
`Could not connect to Chrome in ${userDataDir}. Check if Chrome is running and remote debugging is enabled by going to chrome://inspect/#remote-debugging.`,
{
cause: error,
},
);
}
} else {
if (!channel) {
throw new Error('Channel must be provided if userDataDir is missing');
}
connectOptions.channel = (
channel === 'stable' ? 'chrome' : `chrome-${channel}`
) as ChromeReleaseChannel;
}
} else {
throw new Error(
'Either browserURL, wsEndpoint, channel or userDataDir must be provided',
);
}
logger?.('Connecting Puppeteer to ', JSON.stringify(connectOptions));
try {
// Assign mode before browser so a concurrent closeBrowser() never sees
// `browser` set with `browserMode` still undefined (would fall through
// to the disconnect() path and orphan a launched Chrome).
const connected = await puppeteer.connect(connectOptions);
browserMode = 'connected';
browser = connected;
} catch (err) {
throw new Error(
`Could not connect to Chrome. ${autoConnect ? `Check if Chrome is running and remote debugging is enabled by going to chrome://inspect/#remote-debugging.` : `Check if Chrome is running.`}`,
{
cause: err,
},
);
}
logger?.('Connected Puppeteer');
return browser;
}
interface McpLaunchOptions {
acceptInsecureCerts?: boolean;
executablePath?: string;
channel?: Channel;
userDataDir?: string;
headless: boolean;
isolated: boolean;
logFile?: fs.WriteStream;
viewport?: {
width: number;
height: number;
};
chromeArgs?: string[];
ignoreDefaultChromeArgs?: string[];
devtools: boolean;
enableExtensions?: boolean;
viaCli?: boolean;
blocklist?: string[];
allowlist?: string[];
}
export function detectDisplay(): void {
// Only detect display on Linux/UNIX.
if (os.platform() === 'win32' || os.platform() === 'darwin') {
return;
}
if (!process.env['DISPLAY']) {
try {
const result = execSync(
`ps -u $(id -u) -o pid= | xargs -I{} cat /proc/{}/environ 2>/dev/null | tr '\\0' '\\n' | grep -m1 '^DISPLAY=' | cut -d= -f2`,
);
const display = result.toString('utf8').trim();
process.env['DISPLAY'] = display;
} catch {
// no-op
}
}
}
/**
* Chrome refuses to start as root unless the sandbox is explicitly disabled and
* only says so on its stderr. Because we launch with `pipe: true`, Puppeteer
* never surfaces that stderr and the failure reaches the client as an opaque
* `Protocol error (Target.setDiscoverTargets): Target closed`. Detect the
* situation and explain the way out instead. See https://crbug.com/638180.
*
* Returns `undefined` when the failure cannot be explained by running as root,
* including on platforms without uids and when the sandbox was already disabled
* through `--chrome-arg` (in which case root is not what stopped Chrome).
*
* Exported for testing.
*/
export function rootSandboxLaunchError(
error: Error,
args: readonly string[],
uid = process.getuid?.(),
): Error | undefined {
if (uid !== 0) {
return undefined;
}
if (
args.some(arg => arg === '--no-sandbox' || arg.startsWith('--no-sandbox='))
) {
return undefined;
}
return new Error(
`Chrome failed to start: ${error.message}\n\n` +
'chrome-devtools-mcp is running as root and Chrome does not start as root ' +
'(https://crbug.com/638180). Run chrome-devtools-mcp as a non-root user; in a ' +
'container, create an unprivileged user in the image and switch to it with ' +
"USER. For the setup that Chrome's sandbox needs, see " +
'https://pptr.dev/troubleshooting#setting-up-chrome-linux-sandbox.',
{
cause: error,
},
);
}
export async function launch(options: McpLaunchOptions): Promise<Browser> {
const {channel, executablePath, headless, isolated} = options;
const profileDirName =
channel && channel !== 'stable'
? `chrome-profile-${channel}`
: 'chrome-profile';
let userDataDir = options.userDataDir;
if (!isolated && !userDataDir) {
userDataDir = path.join(
os.homedir(),
'.cache',
options.viaCli ? 'chrome-devtools-mcp-cli' : 'chrome-devtools-mcp',
profileDirName,
);
await fs.promises.mkdir(userDataDir, {
recursive: true,
});
}
const args: LaunchOptions['args'] = [
...(options.chromeArgs ?? []),
'--hide-crash-restore-bubble',
];
const ignoreDefaultArgs: LaunchOptions['ignoreDefaultArgs'] =
options.ignoreDefaultChromeArgs ?? false;
if (headless) {
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'
? (`chrome-${channel}` as ChromeReleaseChannel)
: 'chrome';
}
if (!headless) {
detectDisplay();
}
try {
const browser = await puppeteer.launch({
channel: puppeteerChannel,
targetFilter: makeTargetFilter(options.enableExtensions),
executablePath,
defaultViewport: null,
userDataDir,
pipe: true,
headless,
args,
ignoreDefaultArgs: ignoreDefaultArgs,
acceptInsecureCerts: options.acceptInsecureCerts,
handleDevToolsAsPage: true,
enableExtensions: options.enableExtensions,
blocklist: options.blocklist,
allowlist: options.allowlist,
logger: puppeteerLogger,
});
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.
browser.process()?.stderr?.pipe(options.logFile);
browser.process()?.stdout?.pipe(options.logFile);
}
if (options.viewport) {
const [page] = await browser.pages();
await page?.resize({
contentWidth: options.viewport.width,
contentHeight: options.viewport.height,
});
}
return browser;
} catch (error) {
if (
userDataDir &&
(error as Error).message.includes('The browser is already running')
) {
throw new Error(
`The browser is already running for ${userDataDir}. Use --isolated to run multiple browser instances.`,
{
cause: error,
},
);
}
const rootError = rootSandboxLaunchError(error as Error, args);
if (rootError) {
throw rootError;
}
throw error;
}
}
export async function ensureBrowserLaunched(
options: McpLaunchOptions,
): Promise<Browser> {
if (browser?.connected) {
return browser;
}
// Assign mode before browser; see the connect path above for rationale.
const launched = await launch(options);
browserMode = 'launched';
browser = launched;
return browser;
}
/**
* Shutdown hook for the active browser. Closes a launched browser (so the
* Chrome subprocess is reaped) or disconnects from an attached browser (so
* the user's Chrome instance stays alive). No-op if no browser is active or
* the connection has already been dropped. Called from the server entrypoint
* on stdin EOF / SIGTERM / SIGINT.
*/
export async function closeBrowser(): Promise<void> {
const b = browser;
const mode = browserMode;
browser = undefined;
browserMode = undefined;
if (!b || !b.connected) {
return;
}
if (mode === 'launched') {
await b.close().catch(err => {
logger?.('Failed to close browser', err);
});
return;
}
await b.disconnect().catch(err => {
logger?.('Failed to disconnect from browser', err);
});
}
export type Channel = 'stable' | 'canary' | 'beta' | 'dev';
+2
View File
@@ -121,11 +121,13 @@ export const browserOptions = {
},
chromeArg: {
type: 'array',
string: true,
describe:
'Additional arguments for Chrome. Only applies when Chrome is launched by chrome-devtools-mcp.',
},
ignoreDefaultChromeArg: {
type: 'array',
string: true,
describe:
'Explicitly disable default arguments for Chrome. Only applies when Chrome is launched by chrome-devtools-mcp.',
},
+3
View File
@@ -129,12 +129,14 @@ export const mcpOptions = {
},
blockedUrlPattern: {
type: 'array',
string: true,
describe:
"Restricts browser's network access by blocking specified URL patterns (uses https://urlpattern.spec.whatwg.org/). Silently detaches from targets with blocked URLs upon connection, and blocks runtime requests (including navigations and subresources). Accepts an array of patterns.",
conflicts: ['allowedUrlPattern'],
},
allowedUrlPattern: {
type: 'array',
string: true,
describe:
"Restricts browser's network access by allowing only specified URL patterns (uses https://urlpattern.spec.whatwg.org/). Requires Chrome 149+. Silently detaches from targets with unallowed URLs upon connection, and blocks runtime requests (including navigations and subresources). Accepts an array of patterns.",
conflicts: ['blockedUrlPattern'],
@@ -261,6 +263,7 @@ export const mcpOptions = {
},
filesystemRoot: {
type: 'array',
string: true,
alias: 'workspace',
default: DEFAULT_FILESYSTEM_ROOT,
defaultDescription: 'OS temp directory',
+35 -69
View File
@@ -8,8 +8,7 @@ import type fs from 'node:fs';
import path from 'node:path';
import {pathToFileURL} from 'node:url';
import type {Channel} from './browser.js';
import {ensureBrowserConnected, ensureBrowserLaunched} from './browser.js';
import {BrowserManager} from './BrowserManager.js';
import {type ParsedArguments} from './config/mcp-options.js';
import {loadIssueDescriptions} from './devtools/issueDescriptions.js';
import {McpContext} from './McpContext.js';
@@ -41,13 +40,14 @@ puppeteer.setFollowSymlinks(false);
const ROOTS_REQUEST_TIMEOUT = 5_000;
export interface McpServerOptions {
browserManager: BrowserManager;
logFile?: fs.WriteStream;
}
export class McpServer {
readonly server: SdkMcpServer;
#serverArgs: ParsedArguments;
#options: McpServerOptions;
#browserManager: BrowserManager;
#context?: McpContext;
/**
@@ -58,12 +58,9 @@ export class McpServer {
#lastClientRoots?: Root[];
#toolMutex = new Mutex();
private constructor(
serverArgs: ParsedArguments,
options: McpServerOptions = {},
) {
private constructor(serverArgs: ParsedArguments, options: McpServerOptions) {
this.#serverArgs = serverArgs;
this.#options = options;
this.#browserManager = options.browserManager;
if (this.#serverArgs.usageStatistics) {
ClearcutLogger.initialize({
@@ -125,14 +122,22 @@ export class McpServer {
* Closes the MCP connection and disposes internal context/listeners.
*/
async close(): Promise<void> {
this.#context?.dispose();
this.#context = undefined;
await this.server.close();
try {
this.#context?.dispose();
} catch (err) {
logger?.('Failed to dispose context', err);
} finally {
this.#context = undefined;
}
await Promise.allSettled([
this.#browserManager.close(),
this.server.close(),
]);
}
[Symbol.dispose](): void {
this.close().catch(() => {
// TODO: wire up the logger
this.close().catch(err => {
logger?.('Failed to dispose McpServer', err);
});
}
@@ -142,7 +147,7 @@ export class McpServer {
static async from(
serverArgs: ParsedArguments,
options: McpServerOptions = {},
options: McpServerOptions,
): Promise<McpServer> {
const server = new McpServer(serverArgs, options);
await server.#init();
@@ -163,7 +168,7 @@ export class McpServer {
? []
: (this.#serverArgs.filesystemRoot ?? [])
).map(root => {
const rootPath = path.resolve(String(root));
const rootPath = path.resolve(root);
return {
uri: pathToFileURL(rootPath).href,
name: path.basename(rootPath) || rootPath,
@@ -197,66 +202,19 @@ export class McpServer {
}
async #getContext(): Promise<McpContext> {
const chromeArgs: string[] = (this.#serverArgs.chromeArg ?? []).map(String);
const ignoreDefaultChromeArgs: string[] = (
this.#serverArgs.ignoreDefaultChromeArg ?? []
).map(String);
if (this.#serverArgs.proxyServer) {
chromeArgs.push(`--proxy-server=${this.#serverArgs.proxyServer}`);
}
const devtools = this.#serverArgs.experimentalDevtools ?? false;
const blocklist = this.#serverArgs.blockedUrlPattern
? this.#serverArgs.blockedUrlPattern.map(String)
: undefined;
const allowlist = this.#serverArgs.allowedUrlPattern
? this.#serverArgs.allowedUrlPattern.map(String)
: undefined;
const channel = this.#serverArgs.channel as Channel | undefined;
const browser =
this.#serverArgs.browserUrl ||
this.#serverArgs.wsEndpoint ||
this.#serverArgs.autoConnect
? await ensureBrowserConnected({
browserURL: this.#serverArgs.browserUrl,
wsEndpoint: this.#serverArgs.wsEndpoint,
wsHeaders: this.#serverArgs.wsHeaders,
// Important: only pass channel, if autoConnect is true.
channel: this.#serverArgs.autoConnect ? channel : undefined,
userDataDir: this.#serverArgs.userDataDir,
devtools,
blocklist,
allowlist,
})
: await ensureBrowserLaunched({
headless: this.#serverArgs.headless,
executablePath: this.#serverArgs.executablePath,
channel,
isolated: this.#serverArgs.isolated ?? false,
userDataDir: this.#serverArgs.userDataDir,
logFile: this.#options.logFile,
viewport: this.#serverArgs.viewport,
chromeArgs,
ignoreDefaultChromeArgs,
acceptInsecureCerts: this.#serverArgs.acceptInsecureCerts,
devtools,
enableExtensions: this.#serverArgs.categoryExtensions,
viaCli: this.#serverArgs.viaCli,
blocklist,
allowlist,
});
const browser = await this.#browserManager.ensureBrowser();
if (this.#context?.browser !== browser) {
this.#context?.dispose();
this.#context = await McpContext.from(browser, logger, {
experimentalDevToolsDebugging: devtools,
experimentalDevToolsDebugging:
this.#serverArgs.experimentalDevtools ?? false,
experimentalIncludeAllPages:
this.#serverArgs.experimentalIncludeAllPages,
performanceCrux: this.#serverArgs.performanceCrux,
sourceMaps: this.#serverArgs.sourceMaps,
allowList: allowlist,
blocklist: blocklist,
allowlist: this.#serverArgs.allowedUrlPattern,
blocklist: this.#serverArgs.blockedUrlPattern,
allowUnrestrictedPaths: this.#serverArgs.allowUnrestrictedPaths,
// Surfaces a one-time note in the next response after a reconnect.
reconnected: this.#context !== undefined,
@@ -319,9 +277,17 @@ export class McpServer {
*/
export async function createMcpServer(
serverArgs: ParsedArguments,
options: McpServerOptions = {},
options: {
logFile?: fs.WriteStream;
},
): Promise<{server: SdkMcpServer}> {
const server = await McpServer.from(serverArgs, options);
const browserManager = new BrowserManager(serverArgs, {
logFile: options.logFile,
});
const server = await McpServer.from(serverArgs, {
browserManager,
...options,
});
return {server: server.server};
}
+1
View File
@@ -56,6 +56,7 @@ export {
export {default as puppeteer} from 'puppeteer-core';
export type * from 'puppeteer-core';
export {PipeTransport} from 'puppeteer-core/internal/node/PipeTransport.js';
export {CdpBrowser} from 'puppeteer-core/internal/cdp/Browser.js';
export {CdpFrame} from 'puppeteer-core/internal/cdp/Frame.js';
export {CdpPage} from 'puppeteer-core/internal/cdp/Page.js';
export {CdpExtension} from 'puppeteer-core/internal/cdp/Extension.js';
+328 -73
View File
@@ -7,19 +7,18 @@
import assert from 'node:assert';
import os from 'node:os';
import path from 'node:path';
import {describe, it} from 'node:test';
import {afterEach, describe, it} from 'node:test';
import {executablePath} from 'puppeteer';
import sinon from 'sinon';
import {BrowserManager} from '../src/BrowserManager.js';
import {puppeteer, type Browser} from '../src/third_party/index.js';
import {
detectDisplay,
ensureBrowserConnected,
launch,
makeTargetFilter,
rootSandboxLaunchError,
} from '../src/browser.js';
import type {Browser} from '../src/third_party/index.js';
createMockParsedArguments,
createMockPuppeteerBrowser,
} from './mocks.js';
import {serverHooks} from './server.js';
async function safeClose(browser: Browser) {
@@ -50,7 +49,7 @@ async function runWithRetry(fn: () => Promise<void>) {
]);
return;
} catch (e) {
lastError = e as Error;
lastError = e instanceof Error ? e : new Error(String(e));
await new Promise(r => setTimeout(r, 500));
}
}
@@ -58,8 +57,232 @@ async function runWithRetry(fn: () => Promise<void>) {
}
describe('browser', () => {
afterEach(() => {
sinon.restore();
});
it('detects display does not crash', () => {
detectDisplay();
BrowserManager.detectDisplay();
});
describe('BrowserManager', () => {
it('launches a browser when no connect options are set and closes it on close()', async () => {
const pptrBrowser = createMockPuppeteerBrowser();
const launchStub = sinon.stub(puppeteer, 'launch').resolves(pptrBrowser);
const connectStub = sinon.stub(puppeteer, 'connect');
const args = createMockParsedArguments({
headless: true,
isolated: true,
channel: 'canary',
proxyServer: 'http://localhost:8080',
chromeArg: ['--custom-arg'],
});
const manager = new BrowserManager(args);
const browser1 = await manager.ensureBrowser();
const browser2 = await manager.ensureBrowser();
assert.strictEqual(browser1, pptrBrowser);
assert.strictEqual(browser2, pptrBrowser);
sinon.assert.calledOnce(launchStub);
sinon.assert.notCalled(connectStub);
sinon.assert.calledWithMatch(launchStub, {
channel: 'chrome-canary',
headless: true,
args: [
'--custom-arg',
'--proxy-server=http://localhost:8080',
'--hide-crash-restore-bubble',
'--screen-info={3840x2160}',
],
});
await manager.close();
sinon.assert.calledOnceWithExactly(pptrBrowser.close);
sinon.assert.notCalled(pptrBrowser.disconnect);
});
it('connects to a browser when browserUrl is set and disconnects on close()', async () => {
const pptrBrowser = createMockPuppeteerBrowser();
const launchStub = sinon.stub(puppeteer, 'launch');
const connectStub = sinon
.stub(puppeteer, 'connect')
.resolves(pptrBrowser);
const args = createMockParsedArguments({
browserUrl: 'http://127.0.0.1:9222',
channel: 'stable',
});
const manager = new BrowserManager(args);
const browser = await manager.ensureBrowser();
assert.strictEqual(browser, pptrBrowser);
sinon.assert.calledOnce(connectStub);
sinon.assert.notCalled(launchStub);
sinon.assert.calledWithMatch(connectStub, {
browserURL: 'http://127.0.0.1:9222',
});
await manager.close();
sinon.assert.calledOnceWithExactly(pptrBrowser.disconnect);
sinon.assert.notCalled(pptrBrowser.close);
});
it('deduplicates concurrent ensureBrowser() calls while launch is in-flight', async () => {
const pptrBrowser = createMockPuppeteerBrowser();
const {promise, resolve} = Promise.withResolvers<Browser>();
const launchStub = sinon.stub(puppeteer, 'launch').returns(promise);
const args = createMockParsedArguments({
headless: true,
isolated: true,
});
const manager = new BrowserManager(args);
const call1 = manager.ensureBrowser();
const call2 = manager.ensureBrowser();
resolve(pptrBrowser);
const [browser1, browser2] = await Promise.all([call1, call2]);
assert.strictEqual(browser1, pptrBrowser);
assert.strictEqual(browser2, pptrBrowser);
sinon.assert.calledOnce(launchStub);
});
it('clears pending state on launch failure so subsequent ensureBrowser() retries', async () => {
const pptrBrowser = createMockPuppeteerBrowser();
const launchStub = sinon
.stub(puppeteer, 'launch')
.onFirstCall()
.rejects(new Error('launch failed'))
.onSecondCall()
.resolves(pptrBrowser);
const args = createMockParsedArguments({
headless: true,
isolated: true,
});
const manager = new BrowserManager(args);
await assert.rejects(manager.ensureBrowser(), /launch failed/);
const browser = await manager.ensureBrowser();
assert.strictEqual(browser, pptrBrowser);
sinon.assert.calledTwice(launchStub);
});
it('reconnects when existing browser is no longer connected', async () => {
const pptrBrowser1 = createMockPuppeteerBrowser();
const pptrBrowser2 = createMockPuppeteerBrowser();
let isConnected = true;
sinon.stub(pptrBrowser1, 'connected').get(() => isConnected);
const launchStub = sinon
.stub(puppeteer, 'launch')
.onFirstCall()
.resolves(pptrBrowser1)
.onSecondCall()
.resolves(pptrBrowser2);
const args = createMockParsedArguments({
headless: true,
isolated: true,
});
const manager = new BrowserManager(args);
const first = await manager.ensureBrowser();
assert.strictEqual(first, pptrBrowser1);
isConnected = false;
const second = await manager.ensureBrowser();
assert.strictEqual(second, pptrBrowser2);
sinon.assert.calledTwice(launchStub);
});
it('waits for in-flight launch, closes browser, and rejects ensureBrowser() when close() is called mid-launch', async () => {
const pptrBrowser = createMockPuppeteerBrowser();
const launchStarted = Promise.withResolvers<void>();
const launchDeferred = Promise.withResolvers<Browser>();
const closeDeferred = Promise.withResolvers<void>();
const launchStub = sinon.stub(puppeteer, 'launch').callsFake(() => {
launchStarted.resolve();
return launchDeferred.promise;
});
let browserClosed = false;
pptrBrowser.close.callsFake(async () => {
await closeDeferred.promise;
browserClosed = true;
});
const args = createMockParsedArguments({
headless: true,
isolated: true,
});
const manager = new BrowserManager(args);
const ensurePromise1 = manager.ensureBrowser();
await launchStarted.promise;
const ensurePromise2 = manager.ensureBrowser();
const closePromise = manager.close();
launchDeferred.resolve(pptrBrowser);
closeDeferred.resolve();
await Promise.all([
assert.rejects(ensurePromise1, err => {
assert.strictEqual(browserClosed, true);
assert.match(String(err), /Browser was closed while initializing/);
return true;
}),
assert.rejects(ensurePromise2, err => {
assert.strictEqual(browserClosed, true);
assert.match(String(err), /Browser was closed while initializing/);
return true;
}),
closePromise,
]);
sinon.assert.calledOnce(launchStub);
sinon.assert.calledOnceWithExactly(pptrBrowser.close);
});
it('rejects ensureBrowser() without launching a new browser when called while close() is in flight', async () => {
const pptrBrowser = createMockPuppeteerBrowser();
const closeStarted = Promise.withResolvers<void>();
const closeDeferred = Promise.withResolvers<void>();
const launchStub = sinon.stub(puppeteer, 'launch').resolves(pptrBrowser);
pptrBrowser.close.callsFake(() => {
closeStarted.resolve();
return closeDeferred.promise;
});
const args = createMockParsedArguments({
headless: true,
isolated: true,
});
const manager = new BrowserManager(args);
const browser = await manager.ensureBrowser();
assert.strictEqual(browser, pptrBrowser);
const closePromise = manager.close();
await closeStarted.promise;
const ensurePromise = manager.ensureBrowser();
closeDeferred.resolve();
await Promise.all([
assert.rejects(ensurePromise, /Browser was closed while initializing/),
closePromise,
]);
sinon.assert.calledOnce(launchStub);
sinon.assert.calledOnceWithExactly(pptrBrowser.close);
});
});
describe('rootSandboxLaunchError', () => {
@@ -68,7 +291,7 @@ describe('browser', () => {
);
it('explains an opaque launch failure when running as root', () => {
const error = rootSandboxLaunchError(targetClosed, [], 0);
const error = BrowserManager.rootSandboxLaunchError(targetClosed, [], 0);
assert.ok(error);
assert.match(error.message, /non-root user/);
assert.match(error.message, /pptr\.dev\/troubleshooting/);
@@ -79,35 +302,51 @@ describe('browser', () => {
it('does not explain failures when not running as root', () => {
assert.strictEqual(
rootSandboxLaunchError(targetClosed, [], 1000),
BrowserManager.rootSandboxLaunchError(targetClosed, [], 1000),
undefined,
);
});
it('does not explain failures on platforms without uids', () => {
assert.strictEqual(
rootSandboxLaunchError(targetClosed, [], undefined),
BrowserManager.rootSandboxLaunchError(targetClosed, [], undefined),
undefined,
);
});
it('does not explain failures when the sandbox is already disabled', () => {
assert.strictEqual(
rootSandboxLaunchError(targetClosed, ['--no-sandbox'], 0),
BrowserManager.rootSandboxLaunchError(
targetClosed,
['--no-sandbox'],
0,
),
undefined,
);
assert.strictEqual(
rootSandboxLaunchError(targetClosed, ['--no-sandbox=true'], 0),
BrowserManager.rootSandboxLaunchError(
targetClosed,
['--no-sandbox=true'],
0,
),
undefined,
);
});
it('is not fooled by unrelated arguments that start the same', () => {
assert.ok(
rootSandboxLaunchError(targetClosed, ['--no-sandbox-and-elevated'], 0),
BrowserManager.rootSandboxLaunchError(
targetClosed,
['--no-sandbox-and-elevated'],
0,
),
);
assert.ok(
rootSandboxLaunchError(targetClosed, ['--disable-setuid-sandbox'], 0),
BrowserManager.rootSandboxLaunchError(
targetClosed,
['--disable-setuid-sandbox'],
0,
),
);
});
});
@@ -119,27 +358,32 @@ describe('browser', () => {
tmpDir,
`temp-folder-${crypto.randomUUID()}`,
);
const browser1 = await launch({
headless: true,
isolated: false,
userDataDir: folderPath,
executablePath: await executablePath(),
devtools: false,
});
const manager1 = new BrowserManager(
createMockParsedArguments({
headless: true,
isolated: false,
userDataDir: folderPath,
executablePath: await executablePath(),
}),
);
const browser1 = await manager1.ensureBrowser();
try {
try {
const browser2 = await launch({
headless: true,
isolated: false,
userDataDir: folderPath,
executablePath: await executablePath(),
devtools: false,
});
const manager2 = new BrowserManager(
createMockParsedArguments({
headless: true,
isolated: false,
userDataDir: folderPath,
executablePath: await executablePath(),
}),
);
const browser2 = await manager2.ensureBrowser();
await safeClose(browser2);
assert.fail('not reached');
} catch (err) {
assert.ok(err instanceof Error);
assert.strictEqual(
(err as Error).message,
err.message,
`The browser is already running for ${folderPath}. Use --isolated to run multiple browser instances.`,
);
}
@@ -156,17 +400,19 @@ describe('browser', () => {
tmpDir,
`temp-folder-${crypto.randomUUID()}`,
);
const browser = await launch({
headless: true,
isolated: false,
userDataDir: folderPath,
executablePath: await executablePath(),
viewport: {
width: 1501,
height: 801,
},
devtools: false,
});
const manager = new BrowserManager(
createMockParsedArguments({
headless: true,
isolated: false,
userDataDir: folderPath,
executablePath: await executablePath(),
viewport: {
width: 1501,
height: 801,
},
}),
);
const browser = await manager.ensureBrowser();
try {
const [page] = await browser.pages();
const result = await page.evaluate(() => {
@@ -189,22 +435,27 @@ describe('browser', () => {
tmpDir,
`temp-folder-${crypto.randomUUID()}`,
);
const browser = await launch({
headless: true,
isolated: false,
userDataDir: folderPath,
executablePath: await executablePath(),
devtools: false,
chromeArgs: ['--remote-debugging-port=0'],
});
try {
const connectedBrowser = await ensureBrowserConnected({
const launchManager = new BrowserManager(
createMockParsedArguments({
headless: true,
isolated: false,
userDataDir: folderPath,
devtools: false,
});
executablePath: await executablePath(),
chromeArg: ['--remote-debugging-port=0'],
}),
);
const browser = await launchManager.ensureBrowser();
try {
const manager = new BrowserManager(
createMockParsedArguments({
userDataDir: folderPath,
autoConnect: true,
}),
);
const connectedBrowser = await manager.ensureBrowser();
assert.ok(connectedBrowser);
assert.ok(connectedBrowser.connected);
connectedBrowser.disconnect();
await manager.close();
} finally {
await safeClose(browser);
}
@@ -225,13 +476,15 @@ describe('browser', () => {
'<html><body>Blocked</body></html>',
);
const browser = await launch({
headless: true,
isolated: true,
executablePath: await executablePath(),
devtools: false,
blocklist: ['*://*:*/blocked.html'],
});
const manager = new BrowserManager(
createMockParsedArguments({
headless: true,
isolated: true,
executablePath: await executablePath(),
blockedUrlPattern: ['*://*:*/blocked.html'],
}),
);
const browser = await manager.ensureBrowser();
try {
const page = await browser.newPage();
@@ -268,13 +521,15 @@ describe('browser', () => {
'<html><body>Blocked</body></html>',
);
const browser = await launch({
headless: true,
isolated: true,
executablePath: await executablePath(),
devtools: false,
allowlist: ['*://*:*/allowed.html'],
});
const manager = new BrowserManager(
createMockParsedArguments({
headless: true,
isolated: true,
executablePath: await executablePath(),
allowedUrlPattern: ['*://*:*/allowed.html'],
}),
);
const browser = await manager.ensureBrowser();
try {
const page = await browser.newPage();
@@ -303,8 +558,8 @@ describe('browser', () => {
describe('makeTargetFilter', () => {
it('filters internal chrome and extension targets', () => {
const filterWithoutExtensions = makeTargetFilter(false);
const filterWithExtensions = makeTargetFilter(true);
const filterWithoutExtensions = BrowserManager.makeTargetFilter(false);
const filterWithExtensions = BrowserManager.makeTargetFilter(true);
const mockTarget = (url: string) => ({
url: () => url,
+14
View File
@@ -38,12 +38,14 @@ import type {
} from '../src/processors/HeapSnapshotManager.js';
import {stableIdSymbol} from '../src/utils/id.js';
import {
CdpBrowser,
CdpExtension,
CdpFrame,
CdpPage,
DevTools,
} from '../src/third_party/index.js';
import type {
Browser,
Extension,
Page,
Result,
@@ -116,6 +118,18 @@ export function mockListener() {
};
}
export function createMockPuppeteerBrowser(): sinon.SinonStubbedInstance<Browser> {
const browser = sinon.createStubInstance(
CdpBrowser,
) as unknown as sinon.SinonStubbedInstance<Browser>;
sinon.stub(browser, 'connected').get(() => true);
browser.close.resolves();
browser.disconnect.resolves();
browser.pages.resolves([]);
browser.process.returns(null);
return browser;
}
export function createMockPuppeteerPage(): sinon.SinonStubbedInstance<Page> {
const page = sinon.createStubInstance(
CdpPage,
+1 -1
View File
@@ -194,7 +194,7 @@ export async function withMcpContext(
experimentalDevToolsDebugging: false,
performanceCrux: options.performanceCrux ?? true,
sourceMaps: options.sourceMaps ?? true,
allowList: options.allowedUrlPattern,
allowlist: options.allowedUrlPattern,
blocklist: options.blockedUrlPattern,
allowUnrestrictedPaths: options.allowUnrestrictedPaths ?? false,
navigationTimeout: