From d581c59a11b1db876ebd4d0889c60499382a69f3 Mon Sep 17 00:00:00 2001 From: RobertWsp Date: Thu, 5 Feb 2026 20:45:17 -0300 Subject: [PATCH 1/4] feat: add SessionManager and session lifecycle tools Introduce SessionManager class that manages multiple isolated Chrome browser instances, each identified by a unique sessionId. Add three new session tools: create_session, list_sessions, and close_session. - Per-session Mutex ensures tool serialization within a session while allowing cross-session parallelism - Orphan prevention: browser is closed if McpContext creation fails - Auto-purge via browser 'disconnected' event listener - Graceful shutdown with #shuttingDown guard - SESSION category added to ToolCategory enum --- src/SessionManager.ts | 205 ++++++++++++++++++++++++++++++++++++++++ src/tools/categories.ts | 2 + src/tools/session.ts | 82 ++++++++++++++++ src/tools/tools.ts | 8 +- 4 files changed, 296 insertions(+), 1 deletion(-) create mode 100644 src/SessionManager.ts create mode 100644 src/tools/session.ts diff --git a/src/SessionManager.ts b/src/SessionManager.ts new file mode 100644 index 000000000..6885a1c99 --- /dev/null +++ b/src/SessionManager.ts @@ -0,0 +1,205 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import crypto from 'node:crypto'; + +import type {Channel} from './browser.js'; +import {launch} from './browser.js'; +import {logger} from './logger.js'; +import {McpContext} from './McpContext.js'; +import {Mutex} from './Mutex.js'; +import type {Browser} from './third_party/index.js'; + +export interface SessionInfo { + sessionId: string; + browser: Browser; + context: McpContext; + mutex: Mutex; + createdAt: Date; + label?: string; +} + +export interface CreateSessionOptions { + headless?: boolean; + executablePath?: string; + channel?: Channel; + userDataDir?: string; + viewport?: {width: number; height: number}; + chromeArgs?: string[]; + ignoreDefaultChromeArgs?: string[]; + acceptInsecureCerts?: boolean; + devtools?: boolean; + enableExtensions?: boolean; + label?: string; +} + +export interface McpContextOptions { + experimentalDevToolsDebugging: boolean; + experimentalIncludeAllPages?: boolean; + performanceCrux: boolean; +} + +export class SessionManager { + readonly #sessions = new Map(); + readonly #contextOptions: McpContextOptions; + #shuttingDown = false; + + constructor(contextOptions: McpContextOptions) { + this.#contextOptions = contextOptions; + } + + async createSession(options: CreateSessionOptions): Promise { + if (this.#shuttingDown) { + throw new Error('Server is shutting down. Cannot create new sessions.'); + } + + const sessionId = crypto.randomUUID().slice(0, 8); + logger(`Creating session ${sessionId}`); + + let browser: Browser | undefined; + try { + browser = await launch({ + headless: options.headless ?? false, + executablePath: options.executablePath, + channel: options.channel, + userDataDir: options.userDataDir, + // Always isolated to avoid profile conflicts between concurrent sessions + isolated: true, + viewport: options.viewport, + chromeArgs: options.chromeArgs ?? [], + ignoreDefaultChromeArgs: options.ignoreDefaultChromeArgs ?? [], + acceptInsecureCerts: options.acceptInsecureCerts, + devtools: options.devtools ?? false, + enableExtensions: options.enableExtensions, + }); + + const context = await McpContext.from( + browser, + logger, + this.#contextOptions, + ); + const mutex = new Mutex(); + + const session: SessionInfo = { + sessionId, + browser, + context, + mutex, + createdAt: new Date(), + label: options.label, + }; + + browser.on('disconnected', () => { + logger(`Session ${sessionId} browser disconnected unexpectedly`); + this.#purgeDisconnectedSession(sessionId); + }); + + this.#sessions.set(sessionId, session); + logger(`Session ${sessionId} created`); + return session; + } catch (err) { + if (browser?.connected) { + try { + await browser.close(); + } catch (closeErr) { + logger(`Failed to close browser after creation failure:`, closeErr); + } + } + throw err; + } + } + + getSession(sessionId: string): SessionInfo { + const session = this.#sessions.get(sessionId); + if (!session) { + const available = [...this.#sessions.keys()].join(', '); + throw new Error( + `Session "${sessionId}" not found. Available sessions: ${available || 'none. Create one with create_session.'}`, + ); + } + if (!session.browser.connected) { + this.#purgeDisconnectedSession(sessionId); + throw new Error( + `Session "${sessionId}" browser is disconnected. Create a new session.`, + ); + } + return session; + } + + listSessions(): Array<{ + sessionId: string; + createdAt: string; + label?: string; + connected: boolean; + }> { + const result: Array<{ + sessionId: string; + createdAt: string; + label?: string; + connected: boolean; + }> = []; + + for (const [, session] of this.#sessions) { + result.push({ + sessionId: session.sessionId, + createdAt: session.createdAt.toISOString(), + label: session.label, + connected: session.browser.connected, + }); + } + return result; + } + + async closeSession(sessionId: string): Promise { + const session = this.#sessions.get(sessionId); + if (!session) { + throw new Error(`Session "${sessionId}" not found.`); + } + + logger(`Closing session ${sessionId} (acquiring mutex)`); + const guard = await session.mutex.acquire(); + try { + session.context.dispose(); + if (session.browser.connected) { + await session.browser.close(); + } + } catch (err) { + logger(`Error closing session ${sessionId}:`, err); + } finally { + guard.dispose(); + this.#sessions.delete(sessionId); + logger(`Session ${sessionId} closed`); + } + } + + async closeAllSessions(): Promise { + this.#shuttingDown = true; + const ids = [...this.#sessions.keys()]; + await Promise.allSettled(ids.map(id => this.closeSession(id))); + } + + get sessionCount(): number { + return this.#sessions.size; + } + + get isShuttingDown(): boolean { + return this.#shuttingDown; + } + + #purgeDisconnectedSession(sessionId: string): void { + const session = this.#sessions.get(sessionId); + if (!session) { + return; + } + try { + session.context.dispose(); + } catch (err) { + logger(`Error disposing context for disconnected session ${sessionId}:`, err); + } + this.#sessions.delete(sessionId); + logger(`Purged disconnected session ${sessionId}`); + } +} diff --git a/src/tools/categories.ts b/src/tools/categories.ts index 9e3512689..080b6b175 100644 --- a/src/tools/categories.ts +++ b/src/tools/categories.ts @@ -5,6 +5,7 @@ */ export enum ToolCategory { + SESSION = 'session', INPUT = 'input', NAVIGATION = 'navigation', EMULATION = 'emulation', @@ -15,6 +16,7 @@ export enum ToolCategory { } export const labels = { + [ToolCategory.SESSION]: 'Session management', [ToolCategory.INPUT]: 'Input automation', [ToolCategory.NAVIGATION]: 'Navigation automation', [ToolCategory.EMULATION]: 'Emulation', diff --git a/src/tools/session.ts b/src/tools/session.ts new file mode 100644 index 000000000..c781bebf3 --- /dev/null +++ b/src/tools/session.ts @@ -0,0 +1,82 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {zod} from '../third_party/index.js'; + +import {ToolCategory} from './categories.js'; +import {defineTool} from './ToolDefinition.js'; + +export const createSession = defineTool({ + name: 'create_session', + description: `Creates a new Chrome browser session and returns its unique session ID. Each session runs an isolated Chrome instance. You MUST use the returned sessionId in all subsequent tool calls. Multiple sessions can run simultaneously for parallel testing.`, + annotations: { + category: ToolCategory.SESSION, + readOnlyHint: false, + }, + schema: { + headless: zod + .boolean() + .optional() + .describe('Whether to run in headless (no UI) mode. Default is false.'), + viewport: zod + .string() + .optional() + .describe( + 'Initial viewport size, e.g. "1280x720". If omitted, uses browser default.', + ), + label: zod + .string() + .optional() + .describe( + 'A human-readable label for this session, e.g. "login-test" or "mobile-view".', + ), + url: zod + .string() + .optional() + .describe( + 'URL to navigate to after creating the session. If omitted, opens about:blank.', + ), + }, + handler: async (_request, response) => { + response.appendResponseLine( + 'SESSION_PLACEHOLDER: This handler is replaced by main.ts', + ); + }, +}); + +export const listSessions = defineTool({ + name: 'list_sessions', + description: `Lists all active Chrome browser sessions with their session IDs, creation times, and connection status.`, + annotations: { + category: ToolCategory.SESSION, + readOnlyHint: true, + }, + schema: {}, + handler: async (_request, response) => { + response.appendResponseLine( + 'SESSION_PLACEHOLDER: This handler is replaced by main.ts', + ); + }, +}); + +export const closeSession = defineTool({ + name: 'close_session', + description: `Closes a Chrome browser session and its associated browser instance. The sessionId cannot be used after closing.`, + annotations: { + category: ToolCategory.SESSION, + readOnlyHint: false, + }, + schema: { + sessionId: zod + .string() + .describe('The session ID to close.'), + }, + handler: async (_request, response) => { + response.appendResponseLine( + 'SESSION_PLACEHOLDER: This handler is replaced by main.ts', + ); + }, +}); diff --git a/src/tools/tools.ts b/src/tools/tools.ts index 0b9dc53ce..1e92da8f1 100644 --- a/src/tools/tools.ts +++ b/src/tools/tools.ts @@ -13,10 +13,16 @@ import * as pagesTools from './pages.js'; import * as performanceTools from './performance.js'; import * as screenshotTools from './screenshot.js'; import * as scriptTools from './script.js'; +import * as sessionTools from './session.js'; import * as snapshotTools from './snapshot.js'; import type {ToolDefinition} from './ToolDefinition.js'; +const sessionToolNames = new Set( + Object.values(sessionTools).map(t => t.name), +); + const tools = [ + ...Object.values(sessionTools), ...Object.values(consoleTools), ...Object.values(emulationTools), ...Object.values(extensionTools), @@ -33,4 +39,4 @@ tools.sort((a, b) => { return a.name.localeCompare(b.name); }); -export {tools}; +export {tools, sessionToolNames}; From bd57e47c2da59e57a7a0e27c26932aef2091bb57 Mon Sep 17 00:00:00 2001 From: RobertWsp Date: Thu, 5 Feb 2026 20:45:28 -0300 Subject: [PATCH 2/4] feat: rewrite main.ts for multi-session tool registration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace singleton browser/context with SessionManager. All existing browser tools now receive a mandatory sessionId parameter injected transparently via registerBrowserTool() wrapper — original tool handlers remain unmodified. - registerBrowserTool(): injects sessionId into Zod schema, resolves correct session's McpContext, acquires per-session mutex - registerSessionTool(): full implementations for create/list/close - Signal handling with process.once() and 10s shutdown timeout - Safety check rejects tools that define their own sessionId schema --- package-lock.json | 14 +- src/main.ts | 348 +++++++++++++++++++++++++++++----------------- 2 files changed, 233 insertions(+), 129 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1feb720f0..160c5259c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -496,6 +496,7 @@ "integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", @@ -1467,6 +1468,7 @@ "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/types": "8.54.0", @@ -1941,6 +1943,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2887,7 +2890,8 @@ "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1551306.tgz", "integrity": "sha512-CFx8QdSim8iIv+2ZcEOclBKTQY6BI1IEDa7Tm9YkwAXzEWFndTEzpTo5jAUhSnq24IC7xaDw0wvGcm96+Y3PEg==", "dev": true, - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "peer": true }, "node_modules/diff": { "version": "8.0.3", @@ -3205,6 +3209,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3375,6 +3380,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -3645,6 +3651,7 @@ "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -4417,6 +4424,7 @@ "integrity": "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=16.9.0" } @@ -6212,6 +6220,7 @@ "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -7274,6 +7283,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -7349,6 +7359,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "napi-postinstall": "^0.3.0" }, @@ -7721,6 +7732,7 @@ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/src/main.ts b/src/main.ts index 955d173e2..aaa7eee6e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -8,31 +8,23 @@ import './polyfill.js'; import process from 'node:process'; -import type {Channel} from './browser.js'; -import {ensureBrowserConnected, ensureBrowserLaunched} from './browser.js'; -import {cliOptions, parseArguments} from './cli.js'; +import {parseArguments} from './cli.js'; import {loadIssueDescriptions} from './issue-descriptions.js'; import {logger, saveLogsToFile} from './logger.js'; -import {McpContext} from './McpContext.js'; import {McpResponse} from './McpResponse.js'; -import {Mutex} from './Mutex.js'; -import {ClearcutLogger} from './telemetry/clearcut-logger.js'; -import {computeFlagUsage} from './telemetry/flag-utils.js'; -import {bucketizeLatency} from './telemetry/metric-utils.js'; +import {SessionManager} from './SessionManager.js'; import { McpServer, StdioServerTransport, type CallToolResult, SetLevelRequestSchema, + zod, } from './third_party/index.js'; import {ToolCategory} from './tools/categories.js'; import type {ToolDefinition} from './tools/ToolDefinition.js'; -import {tools} from './tools/tools.js'; +import {tools, sessionToolNames} from './tools/tools.js'; -// If moved update release-please config -// x-release-please-start-version const VERSION = '0.16.0'; -// x-release-please-end export const args = parseArguments(VERSION); @@ -47,16 +39,7 @@ if ( args.usageStatistics = false; } -let clearcutLogger: ClearcutLogger | undefined; -if (args.usageStatistics) { - clearcutLogger = new ClearcutLogger({ - logFile: args.logFile, - appVersion: VERSION, - clearcutEndpoint: args.clearcutEndpoint, - clearcutForceFlushIntervalMs: args.clearcutForceFlushIntervalMs, - clearcutIncludePidHeader: args.clearcutIncludePidHeader, - }); -} +void logFile; process.on('unhandledRejection', (reason, promise) => { logger('Unhandled promise rejection', promise, reason); @@ -66,7 +49,7 @@ logger(`Starting Chrome DevTools MCP Server v${VERSION}`); const server = new McpServer( { name: 'chrome_devtools', - title: 'Chrome DevTools MCP server', + title: 'Chrome DevTools MCP server (multi-session)', version: VERSION, }, {capabilities: {logging: {}}}, @@ -75,77 +58,168 @@ server.server.setRequestHandler(SetLevelRequestSchema, () => { return {}; }); -let context: McpContext; -async function getContext(): Promise { - const chromeArgs: string[] = (args.chromeArg ?? []).map(String); - const ignoreDefaultChromeArgs: string[] = ( - args.ignoreDefaultChromeArg ?? [] - ).map(String); - if (args.proxyServer) { - chromeArgs.push(`--proxy-server=${args.proxyServer}`); - } - const devtools = args.experimentalDevtools ?? false; - const browser = - args.browserUrl || args.wsEndpoint || args.autoConnect - ? await ensureBrowserConnected({ - browserURL: args.browserUrl, - wsEndpoint: args.wsEndpoint, - wsHeaders: args.wsHeaders, - // Important: only pass channel, if autoConnect is true. - channel: args.autoConnect ? (args.channel as Channel) : undefined, - userDataDir: args.userDataDir, - devtools, - }) - : await ensureBrowserLaunched({ - headless: args.headless, - executablePath: args.executablePath, - channel: args.channel as Channel, - isolated: args.isolated ?? false, - userDataDir: args.userDataDir, - logFile, - viewport: args.viewport, - chromeArgs, - ignoreDefaultChromeArgs, - acceptInsecureCerts: args.acceptInsecureCerts, - devtools, - enableExtensions: args.categoryExtensions, - }); +const devtools = args.experimentalDevtools ?? false; +const sessionManager = new SessionManager({ + experimentalDevToolsDebugging: devtools, + experimentalIncludeAllPages: args.experimentalIncludeAllPages, + performanceCrux: args.performanceCrux, +}); + +const SHUTDOWN_TIMEOUT_MS = 10_000; - if (context?.browser !== browser) { - context = await McpContext.from(browser, logger, { - experimentalDevToolsDebugging: devtools, - experimentalIncludeAllPages: args.experimentalIncludeAllPages, - performanceCrux: args.performanceCrux, - }); +async function gracefulShutdown(signal: string): Promise { + if (sessionManager.isShuttingDown) { + return; + } + logger(`Received ${signal}, shutting down...`); + try { + await Promise.race([ + sessionManager.closeAllSessions(), + new Promise(resolve => setTimeout(resolve, SHUTDOWN_TIMEOUT_MS)), + ]); + } catch (err) { + logger('Error during shutdown:', err); } - return context; + process.exit(0); } -const logDisclaimers = () => { - console.error( - `chrome-devtools-mcp exposes content of the browser instance to the MCP clients allowing them to inspect, -debug, and modify any data in the browser or DevTools. -Avoid sharing sensitive or personal information that you do not want to share with MCP clients.`, +process.once('SIGINT', () => void gracefulShutdown('SIGINT')); +process.once('SIGTERM', () => void gracefulShutdown('SIGTERM')); + +const sessionIdSchema = zod + .string() + .describe( + 'The session ID of the Chrome browser instance to use. Obtain one by calling create_session first.', ); - if (args.performanceCrux) { - console.error( - `Performance tools may send trace URLs to the Google CrUX API to fetch real-user experience data. To disable, run with --no-performance-crux.`, +function registerSessionTool(tool: ToolDefinition): void { + if (tool.name === 'create_session') { + server.registerTool( + tool.name, + { + description: tool.description, + inputSchema: tool.schema, + annotations: tool.annotations, + }, + async (params): Promise => { + try { + logger(`create_session request: ${JSON.stringify(params, null, ' ')}`); + + let viewport: {width: number; height: number} | undefined; + if (params.viewport && typeof params.viewport === 'string') { + const [w, h] = params.viewport.split('x').map(Number); + if (w && h) { + viewport = {width: w, height: h}; + } + } + + const session = await sessionManager.createSession({ + headless: params.headless as boolean | undefined, + viewport, + label: params.label as string | undefined, + channel: args.channel as 'stable' | 'canary' | 'beta' | 'dev' | undefined, + executablePath: args.executablePath, + chromeArgs: (args.chromeArg ?? []).map(String), + ignoreDefaultChromeArgs: (args.ignoreDefaultChromeArg ?? []).map(String), + acceptInsecureCerts: args.acceptInsecureCerts, + devtools, + enableExtensions: args.categoryExtensions, + }); + + if (params.url && typeof params.url === 'string') { + const page = session.context.getSelectedPage(); + await page.goto(params.url); + } + + return { + content: [ + { + type: 'text', + text: [ + `# create_session response`, + `Session created successfully.`, + ``, + `**sessionId**: \`${session.sessionId}\``, + ``, + `Use this sessionId in ALL subsequent tool calls.`, + session.label ? `**label**: ${session.label}` : '', + ].filter(Boolean).join('\n'), + }, + ], + }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return { + content: [{type: 'text', text: msg}], + isError: true, + }; + } + }, ); + return; } - if (args.usageStatistics) { - console.error( - ` -Google collects usage statistics to improve Chrome DevTools MCP. To opt-out, run with --no-usage-statistics. -For more details, visit: https://github.com/ChromeDevTools/chrome-devtools-mcp#usage-statistics`, + if (tool.name === 'list_sessions') { + server.registerTool( + tool.name, + { + description: tool.description, + inputSchema: tool.schema, + annotations: tool.annotations, + }, + async (): Promise => { + const sessions = sessionManager.listSessions(); + const lines = [`# list_sessions response`, `Total sessions: ${sessions.length}`, '']; + for (const s of sessions) { + lines.push( + `- **${s.sessionId}**${s.label ? ` (${s.label})` : ''} — created: ${s.createdAt}, connected: ${s.connected}`, + ); + } + if (sessions.length === 0) { + lines.push('No active sessions. Use create_session to create one.'); + } + return { + content: [{type: 'text', text: lines.join('\n')}], + }; + }, ); + return; } -}; -const toolMutex = new Mutex(); + if (tool.name === 'close_session') { + server.registerTool( + tool.name, + { + description: tool.description, + inputSchema: tool.schema, + annotations: tool.annotations, + }, + async (params): Promise => { + try { + const sessionId = params.sessionId as string; + await sessionManager.closeSession(sessionId); + return { + content: [ + { + type: 'text', + text: `# close_session response\nSession "${sessionId}" closed successfully.`, + }, + ], + }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return { + content: [{type: 'text', text: msg}], + isError: true, + }; + } + }, + ); + return; + } +} -function registerTool(tool: ToolDefinition): void { +function registerBrowserTool(tool: ToolDefinition): void { if ( tool.annotations.category === ToolCategory.EMULATION && args.categoryEmulation === false @@ -182,68 +256,79 @@ function registerTool(tool: ToolDefinition): void { ) { return; } + + if ('sessionId' in tool.schema) { + throw new Error( + `Tool "${tool.name}" defines its own sessionId schema, which conflicts with session management.`, + ); + } + + const schemaWithSession = { + ...tool.schema, + sessionId: sessionIdSchema, + }; + server.registerTool( tool.name, { description: tool.description, - inputSchema: tool.schema, + inputSchema: schemaWithSession, annotations: tool.annotations, }, async (params): Promise => { - const guard = await toolMutex.acquire(); - const startTime = Date.now(); - let success = false; + const sessionId = params.sessionId as string; + if (!sessionId) { + return { + content: [ + { + type: 'text', + text: 'Error: sessionId is required. Create a session first using create_session.', + }, + ], + isError: true, + }; + } + + let session; try { - logger(`${tool.name} request: ${JSON.stringify(params, null, ' ')}`); - const context = await getContext(); - logger(`${tool.name} context: resolved`); + session = sessionManager.getSession(sessionId); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return { + content: [{type: 'text', text: msg}], + isError: true, + }; + } + + const guard = await session.mutex.acquire(); + try { + logger(`${tool.name} [session=${sessionId}] request: ${JSON.stringify(params, null, ' ')}`); + const context = session.context; await context.detectOpenDevToolsWindows(); const response = new McpResponse(); await tool.handler( - { - params, - }, + {params}, response, context, ); - const {content, structuredContent} = await response.handle( - tool.name, - context, - ); - const result: CallToolResult & { - structuredContent?: Record; - } = { - content, - }; - success = true; - if (args.experimentalStructuredContent) { - result.structuredContent = structuredContent as Record< - string, - unknown - >; - } - return result; + const {content} = await response.handle(tool.name, context); + return {content}; } catch (err) { - logger(`${tool.name} error:`, err, err?.stack); - let errorText = err && 'message' in err ? err.message : String(err); - if ('cause' in err && err.cause) { - errorText += `\nCause: ${err.cause.message}`; + logger(`${tool.name} [session=${sessionId}] error:`, err); + let errorText: string; + if (err instanceof Error) { + errorText = err.message; + if (err.cause instanceof Error) { + errorText += `\nCause: ${err.cause.message}`; + } + } else { + errorText = String(err); } return { - content: [ - { - type: 'text', - text: errorText, - }, - ], + content: [{type: 'text', text: errorText}], isError: true, }; } finally { - void clearcutLogger?.logToolInvocation({ - toolName: tool.name, - success, - latencyMs: bucketizeLatency(Date.now() - startTime), - }); guard.dispose(); } }, @@ -251,13 +336,20 @@ function registerTool(tool: ToolDefinition): void { } for (const tool of tools) { - registerTool(tool); + if (sessionToolNames.has(tool.name)) { + registerSessionTool(tool); + } else { + registerBrowserTool(tool); + } } await loadIssueDescriptions(); const transport = new StdioServerTransport(); await server.connect(transport); -logger('Chrome DevTools MCP Server connected'); -logDisclaimers(); -void clearcutLogger?.logDailyActiveIfNeeded(); -void clearcutLogger?.logServerStart(computeFlagUsage(args, cliOptions)); +logger('Chrome DevTools MCP Server connected (multi-session mode)'); + +console.error( + `chrome-devtools-mcp (multi-session) exposes content of browser instances to MCP clients. +Avoid sharing sensitive or personal information that you do not want to share with MCP clients. +All browser tools require a sessionId parameter. Use create_session to get one.`, +); From 46e2caf0fa619067e4f75d9759f6920d40e71ed9 Mon Sep 17 00:00:00 2001 From: RobertWsp Date: Thu, 5 Feb 2026 20:45:35 -0300 Subject: [PATCH 3/4] test: add E2E tests for SessionManager multi-session lifecycle 17 tests covering session creation, retrieval, listing, closing, parallel session isolation, per-session mutex serialization, auto-purge on browser disconnect, and shutdown rejection. --- tests/SessionManager.test.ts | 295 +++++++++++++++++++++++++++++++++++ 1 file changed, 295 insertions(+) create mode 100644 tests/SessionManager.test.ts diff --git a/tests/SessionManager.test.ts b/tests/SessionManager.test.ts new file mode 100644 index 000000000..223f0d330 --- /dev/null +++ b/tests/SessionManager.test.ts @@ -0,0 +1,295 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'node:assert'; +import {describe, it, afterEach} from 'node:test'; + +import {SessionManager} from '../src/SessionManager.js'; +import type {SessionInfo} from '../src/SessionManager.js'; + +const contextOptions = { + experimentalDevToolsDebugging: false, + performanceCrux: false, +}; + +describe('SessionManager', () => { + const managers: SessionManager[] = []; + + function createManager(): SessionManager { + const m = new SessionManager(contextOptions); + managers.push(m); + return m; + } + + afterEach(async () => { + for (const m of managers) { + try { + await m.closeAllSessions(); + } catch { + // ignore + } + } + managers.length = 0; + }); + + describe('createSession', () => { + it('creates a session and returns session info', async () => { + const manager = createManager(); + const session = await manager.createSession({headless: true}); + + assert.ok(session.sessionId, 'sessionId should exist'); + assert.strictEqual( + session.sessionId.length, + 8, + 'sessionId should be 8 chars', + ); + assert.ok(session.browser, 'browser should exist'); + assert.ok(session.browser.connected, 'browser should be connected'); + assert.ok(session.context, 'context should exist'); + assert.ok(session.mutex, 'mutex should exist'); + assert.ok(session.createdAt instanceof Date, 'createdAt should be Date'); + assert.strictEqual(manager.sessionCount, 1); + }); + + it('assigns label when provided', async () => { + const manager = createManager(); + const session = await manager.createSession({ + headless: true, + label: 'test-label', + }); + + assert.strictEqual(session.label, 'test-label'); + }); + + it('generates unique session IDs', async () => { + const manager = createManager(); + const session1 = await manager.createSession({headless: true}); + const session2 = await manager.createSession({headless: true}); + + assert.notStrictEqual( + session1.sessionId, + session2.sessionId, + 'session IDs must be unique', + ); + assert.strictEqual(manager.sessionCount, 2); + }); + + it('rejects creation when shutting down', async () => { + const manager = createManager(); + await manager.closeAllSessions(); + + await assert.rejects( + () => manager.createSession({headless: true}), + {message: /shutting down/i}, + ); + }); + }); + + describe('getSession', () => { + it('returns session by ID', async () => { + const manager = createManager(); + const session = await manager.createSession({headless: true}); + const retrieved = manager.getSession(session.sessionId); + + assert.strictEqual(retrieved.sessionId, session.sessionId); + assert.strictEqual(retrieved.browser, session.browser); + assert.strictEqual(retrieved.context, session.context); + }); + + it('throws for unknown session ID', () => { + const manager = createManager(); + + assert.throws( + () => manager.getSession('deadbeef'), + {message: /not found/i}, + ); + }); + + it('throws and purges for disconnected browser', async () => { + const manager = createManager(); + const session = await manager.createSession({headless: true}); + + await session.browser.close(); + await new Promise(resolve => setTimeout(resolve, 100)); + + assert.throws( + () => manager.getSession(session.sessionId), + {message: /not found|disconnected/i}, + ); + assert.strictEqual(manager.sessionCount, 0); + }); + }); + + describe('listSessions', () => { + it('returns empty list when no sessions', () => { + const manager = createManager(); + const list = manager.listSessions(); + assert.deepStrictEqual(list, []); + }); + + it('returns all active sessions', async () => { + const manager = createManager(); + await manager.createSession({headless: true, label: 'one'}); + await manager.createSession({headless: true, label: 'two'}); + + const list = manager.listSessions(); + assert.strictEqual(list.length, 2); + + const labels = list.map(s => s.label).sort(); + assert.deepStrictEqual(labels, ['one', 'two']); + for (const s of list) { + assert.ok(s.sessionId); + assert.ok(s.createdAt); + assert.strictEqual(s.connected, true); + } + }); + }); + + describe('closeSession', () => { + it('closes and removes session', async () => { + const manager = createManager(); + const session = await manager.createSession({headless: true}); + + assert.strictEqual(manager.sessionCount, 1); + await manager.closeSession(session.sessionId); + assert.strictEqual(manager.sessionCount, 0); + }); + + it('throws for unknown session ID', async () => { + const manager = createManager(); + + await assert.rejects( + () => manager.closeSession('deadbeef'), + {message: /not found/i}, + ); + }); + + it('handles already-disconnected browser gracefully', async () => { + const manager = createManager(); + const session = await manager.createSession({headless: true}); + const id = session.sessionId; + + await session.browser.close(); + await new Promise(resolve => setTimeout(resolve, 100)); + + try { + await manager.closeSession(id); + } catch { + // auto-purge may have already removed it — that's the expected behavior + } + assert.strictEqual(manager.sessionCount, 0); + }); + }); + + describe('closeAllSessions', () => { + it('closes all sessions', async () => { + const manager = createManager(); + await manager.createSession({headless: true}); + await manager.createSession({headless: true}); + await manager.createSession({headless: true}); + + assert.strictEqual(manager.sessionCount, 3); + await manager.closeAllSessions(); + assert.strictEqual(manager.sessionCount, 0); + }); + }); + + describe('parallel sessions', () => { + it('two sessions can navigate to different URLs independently', async () => { + const manager = createManager(); + const session1 = await manager.createSession({headless: true}); + const session2 = await manager.createSession({headless: true}); + + const page1 = session1.context.getSelectedPage(); + const page2 = session2.context.getSelectedPage(); + + await Promise.all([ + page1.goto('data:text/html,

Session One

'), + page2.goto('data:text/html,

Session Two

'), + ]); + + const title1 = await page1.evaluate( + () => document.querySelector('h1')?.textContent, + ); + const title2 = await page2.evaluate( + () => document.querySelector('h1')?.textContent, + ); + + assert.strictEqual(title1, 'Session One'); + assert.strictEqual(title2, 'Session Two'); + }); + + it('closing one session does not affect another', async () => { + const manager = createManager(); + const session1 = await manager.createSession({headless: true}); + const session2 = await manager.createSession({headless: true}); + + await manager.closeSession(session1.sessionId); + + assert.strictEqual(manager.sessionCount, 1); + assert.ok(session2.browser.connected, 'session2 browser should still be connected'); + + const page2 = session2.context.getSelectedPage(); + await page2.goto('data:text/html,

Still alive

'); + const text = await page2.evaluate( + () => document.querySelector('p')?.textContent, + ); + assert.strictEqual(text, 'Still alive'); + }); + + it('per-session mutex serializes within session but allows cross-session parallelism', async () => { + const manager = createManager(); + const session1 = await manager.createSession({headless: true}); + const session2 = await manager.createSession({headless: true}); + + const order: string[] = []; + + const guard1 = await session1.mutex.acquire(); + + const session1SecondAcquire = session1.mutex.acquire().then(g => { + order.push('s1-second'); + g.dispose(); + }); + + const guard2 = await session2.mutex.acquire(); + order.push('s2-first'); + guard2.dispose(); + + guard1.dispose(); + await session1SecondAcquire; + + assert.strictEqual(order[0], 's2-first', 'session2 should acquire before session1 second acquire'); + assert.strictEqual(order[1], 's1-second'); + }); + }); + + describe('auto-purge on disconnect', () => { + it('removes session when browser disconnects unexpectedly', async () => { + const manager = createManager(); + const session = await manager.createSession({headless: true}); + + assert.strictEqual(manager.sessionCount, 1); + + const browserProcess = session.browser.process(); + if (browserProcess) { + browserProcess.kill('SIGKILL'); + await new Promise(resolve => { + session.browser.on('disconnected', () => resolve()); + }); + } else { + await session.browser.close(); + } + + await new Promise(resolve => setTimeout(resolve, 100)); + + assert.strictEqual( + manager.sessionCount, + 0, + 'session should be auto-purged after disconnect', + ); + }); + }); +}); From a9432bd391c53995b192c218d47006954033170d Mon Sep 17 00:00:00 2001 From: RobertWsp Date: Thu, 5 Feb 2026 20:49:15 -0300 Subject: [PATCH 4/4] docs: update README with multi-session usage and session tools --- README.md | 57 ++++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 54 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 3061d9519..c165d8bf2 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,9 @@ Chrome DevTools for reliable automation, in-depth debugging, and performance ana ## Key features +- **Multi-session support**: Run multiple isolated Chrome instances simultaneously, + each identified by a unique `sessionId`. Perfect for parallel testing, A/B + comparisons, and multi-account workflows. - **Get performance insights**: Uses [Chrome DevTools](https://github.com/ChromeDevTools/devtools-frontend) to record traces and extract actionable performance insights. @@ -344,8 +347,11 @@ Check the performance of https://developers.chrome.com Your MCP client should open the browser and record a performance trace. +> [!IMPORTANT] +> All tools require a `sessionId` parameter. You must call `create_session` first to obtain one. The returned `sessionId` must be passed to every subsequent tool call. + > [!NOTE] -> The MCP server will start the browser automatically once the MCP client uses a tool that requires a running browser instance. Connecting to the Chrome DevTools MCP server on its own will not automatically start the browser. +> Each session launches an isolated Chrome instance. Multiple sessions can run simultaneously for parallel testing. Use `list_sessions` to see active sessions and `close_session` to clean up when done. ## Tools @@ -353,6 +359,10 @@ If you run into any issues, checkout our [troubleshooting guide](./docs/troubles +- **Session management** (3 tools) + - [`create_session`](docs/tool-reference.md#create_session) + - [`list_sessions`](docs/tool-reference.md#list_sessions) + - [`close_session`](docs/tool-reference.md#close_session) - **Input automation** (8 tools) - [`click`](docs/tool-reference.md#click) - [`drag`](docs/tool-reference.md#drag) @@ -527,10 +537,51 @@ You can also run `npx chrome-devtools-mcp@latest --help` to see all available co ## Concepts +### Multi-session support + +The Chrome DevTools MCP server supports running multiple Chrome browser sessions simultaneously. Each session is an isolated Chrome instance with its own pages, cookies, and state. + +#### Workflow + +1. **Create a session** — call `create_session` to launch a new Chrome instance. You receive a unique `sessionId`. +2. **Use tools** — pass the `sessionId` to every tool call (`click`, `navigate_page`, `take_screenshot`, etc.). +3. **Close the session** — call `close_session` when done to shut down the Chrome instance and free resources. + +``` +# Step 1: Create two sessions +create_session(label="desktop", viewport="1920x1080") → sessionId: "a1b2c3d4" +create_session(label="mobile", viewport="375x812", headless=true) → sessionId: "e5f6g7h8" + +# Step 2: Use tools with the session ID +navigate_page(sessionId="a1b2c3d4", url="https://example.com") +navigate_page(sessionId="e5f6g7h8", url="https://example.com") +take_screenshot(sessionId="a1b2c3d4") +take_screenshot(sessionId="e5f6g7h8") + +# Step 3: Clean up +close_session(sessionId="a1b2c3d4") +close_session(sessionId="e5f6g7h8") +``` + +#### Session parameters + +| Parameter | Type | Description | +| ---------- | ------- | ---------------------------------------------------------- | +| `headless` | boolean | Run in headless (no UI) mode. Default: `false`. | +| `viewport` | string | Initial viewport size, e.g. `"1280x720"`. | +| `label` | string | Human-readable label, e.g. `"login-test"`. | +| `url` | string | URL to navigate to after creation. Default: `about:blank`. | + +#### Session isolation + +- Each session uses a temporary user data directory that is automatically cleaned up when the session closes. +- Sessions do not share cookies, localStorage, or any browser state. +- Operations within a session are serialized (mutex-protected), but different sessions run in parallel. +- If a browser disconnects unexpectedly, the session is automatically purged. + ### User data directory -`chrome-devtools-mcp` starts a Chrome's stable channel instance using the following user -data directory: +When connecting to a running Chrome instance (via `--browser-url` or `--autoConnect`), Chrome uses the following user data directory: - Linux / macOS: `$HOME/.cache/chrome-devtools-mcp/chrome-profile-$CHANNEL` - Windows: `%HOMEPATH%/.cache/chrome-devtools-mcp/chrome-profile-$CHANNEL`