/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {logger} from './logger.js';
import type {
Browser,
ChromeReleaseChannel,
LaunchOptions,
Target,
} 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;
}
// Could be the only page opened in the browser.
if (target.url().startsWith('chrome://inspect')) {
return true;
}
for (const prefix of ignoredPrefixes) {
if (target.url().startsWith(prefix)) {
return false;
}
}
return true;
};
}
export async function ensureBrowserConnected(options: {
browserURL?: string;
wsEndpoint?: string;
wsHeaders?: Record<string, string>;
devtools: boolean;
channel?: Channel;
userDataDir?: string;
}) {
const {channel} = options;
if (browser?.connected) {
return browser;
}
const connectOptions: Parameters<typeof puppeteer.connect>[0] = {
targetFilter: makeTargetFilter(),
defaultViewport: null,
handleDevToolsAsPage: true,
};
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) {
// 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.`,
{
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 {
browser = await puppeteer.connect(connectOptions);
} catch (err) {
throw new Error(
'Could not connect to Chrome. Check if Chrome is running and remote debugging is enabled by going to chrome://inspect/#remote-debugging.',
{
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;
};
args?: string[];
devtools: boolean;
}
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',
'chrome-devtools-mcp',
profileDirName,
);
await fs.promises.mkdir(userDataDir, {
recursive: true,
});
}
const args: LaunchOptions['args'] = [
...(options.args ?? []),
'--hide-crash-restore-bubble',
];
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';
}
try {
const browser = await puppeteer.launch({
channel: puppeteerChannel,
targetFilter: makeTargetFilter(),
executablePath,
defaultViewport: null,
userDataDir,
pipe: true,
headless,
args,
acceptInsecureCerts: options.acceptInsecureCerts,
handleDevToolsAsPage: true,
});
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();
// @ts-expect-error internal API for now.
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,
},
);
}
throw error;
}
}
export async function ensureBrowserLaunched(
options: McpLaunchOptions,
): Promise<Browser> {
if (browser?.connected) {
return browser;
}
browser = await launch(options);
return browser;
}
export type Channel = 'stable' | 'canary' | 'beta' | 'dev';