diff --git a/src/browser.ts b/src/browser.ts index 7deea75b4..d172e838e 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -20,6 +20,81 @@ import {puppeteer} from './third_party/index.js'; let browser: Browser | undefined; +// Multi-browser registry +const browserRegistry = new Map(); +let activeBrowserId = 'default'; + +export function switchActiveBrowser(browserId: string): Browser { + if (!browserRegistry.has(browserId)) { + const available = [...browserRegistry.keys()]; + if (available.length === 0) { + throw new Error( + `Browser '${browserId}' not found. No browsers are currently registered.`, + ); + } + throw new Error( + `Browser '${browserId}' not found. Available browsers: ${available.join(', ')}`, + ); + } + const targetBrowser = browserRegistry.get(browserId)!; + if (!targetBrowser?.connected) { + throw new Error( + `Browser '${browserId}' is disconnected. Remove it and add again with add_browser.`, + ); + } + activeBrowserId = browserId; + browser = targetBrowser; + return browser; +} + +export interface BrowserInfo { + id: string; + connected: boolean; + active: boolean; +} + +export function listBrowsers(): BrowserInfo[] { + const result: BrowserInfo[] = []; + for (const [id, b] of browserRegistry.entries()) { + result.push({ + id, + connected: b?.connected ?? false, + active: id === activeBrowserId, + }); + } + return result; +} + +export async function addBrowser( + browserId: string, + options: {browserURL?: string; wsEndpoint?: string; enableExtensions?: boolean}, +): Promise { + const connectOptions: Parameters[0] = { + targetFilter: makeTargetFilter(options.enableExtensions ?? false), + defaultViewport: null, + handleDevToolsAsPage: true, + }; + if (options.browserURL) { + connectOptions.browserURL = options.browserURL; + } else if (options.wsEndpoint) { + connectOptions.browserWSEndpoint = options.wsEndpoint; + } else { + throw new Error('Either browserURL or wsEndpoint must be provided'); + } + logger('Adding browser', browserId, JSON.stringify(connectOptions)); + const newBrowser = await puppeteer.connect(connectOptions); + browserRegistry.set(browserId, newBrowser); + if (!browser || browserRegistry.size === 1) { + browser = newBrowser; + activeBrowserId = browserId; + } + return newBrowser; +} + +export function getActiveBrowserId(): string { + return activeBrowserId; +} + function makeTargetFilter(enableExtensions = false) { const ignoredPrefixes = new Set(['chrome://', 'chrome-untrusted://']); if (!enableExtensions) { @@ -130,6 +205,7 @@ export async function ensureBrowserConnected(options: { ); } logger('Connected Puppeteer'); + browserRegistry.set(activeBrowserId, browser); return browser; } @@ -267,6 +343,7 @@ export async function ensureBrowserLaunched( return browser; } browser = await launch(options); + browserRegistry.set(activeBrowserId, browser); return browser; } diff --git a/src/index.ts b/src/index.ts index 2689e34a6..dd7447554 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,7 +8,7 @@ import type fs from 'node:fs'; import type {parseArguments} from './bin/chrome-devtools-mcp-cli-options.js'; import type {Channel} from './browser.js'; -import {ensureBrowserConnected, ensureBrowserLaunched} from './browser.js'; +import {ensureBrowserConnected, ensureBrowserLaunched, getActiveBrowserId} from './browser.js'; import {loadIssueDescriptions} from './issue-descriptions.js'; import {logger} from './logger.js'; import {McpContext} from './McpContext.js'; @@ -65,6 +65,7 @@ export async function createMcpServer( }; let context: McpContext; + const contextRegistry = new Map(); async function getContext(): Promise { const chromeArgs: string[] = (serverArgs.chromeArg ?? []).map(String); const ignoreDefaultChromeArgs: string[] = ( @@ -103,12 +104,17 @@ export async function createMcpServer( viaCli: serverArgs.viaCli, }); - if (context?.browser !== browser) { + const browserId = getActiveBrowserId(); + const existingContext = contextRegistry.get(browserId); + if (existingContext?.browser === browser && browser?.connected) { + context = existingContext; + } else if (context?.browser !== browser) { context = await McpContext.from(browser, logger, { experimentalDevToolsDebugging: devtools, experimentalIncludeAllPages: serverArgs.experimentalIncludeAllPages, performanceCrux: serverArgs.performanceCrux, }); + contextRegistry.set(browserId, context); } return context; } diff --git a/src/tools/multibrowser.ts b/src/tools/multibrowser.ts new file mode 100644 index 000000000..181ed002c --- /dev/null +++ b/src/tools/multibrowser.ts @@ -0,0 +1,116 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + switchActiveBrowser, + listBrowsers, + addBrowser, +} from '../browser.js'; +import {zod} from '../third_party/index.js'; +import {ToolCategory} from './categories.js'; +import {defineTool} from './ToolDefinition.js'; + +export const switch_browser = defineTool({ + name: 'switch_browser', + description: + 'Switch the active browser instance. All subsequent tool calls will operate on the selected browser.', + annotations: { + category: ToolCategory.NAVIGATION, + readOnlyHint: false, + }, + schema: { + browserId: zod + .string() + .describe( + 'The ID of the browser to switch to. Call list_browsers to see available browsers.', + ), + }, + handler: async (request, response) => { + const {browserId} = request.params; + switchActiveBrowser(browserId); + response.appendResponseLine( + `Switched to browser '${browserId}'. All future tool calls will target this browser.`, + ); + response.appendResponseLine(`Active browser: ${browserId}`); + }, +}); + +export const list_browsers = defineTool({ + name: 'list_browsers', + description: + 'List all connected browser instances with their IDs and connection status.', + annotations: { + category: ToolCategory.NAVIGATION, + readOnlyHint: true, + }, + schema: {}, + handler: async (_request, response) => { + const browsers = listBrowsers(); + if (browsers.length === 0) { + response.appendResponseLine('No browsers connected.'); + return; + } + response.appendResponseLine('## Connected browsers'); + for (const b of browsers) { + const marker = b.active ? ' [selected]' : ''; + const status = b.connected ? 'connected' : 'disconnected'; + response.appendResponseLine(`- ${b.id}: ${status}${marker}`); + } + }, +}); + +export const add_browser = defineTool({ + name: 'add_browser', + description: + 'Connect to an additional Chrome browser instance on a different debugging port.', + annotations: { + category: ToolCategory.NAVIGATION, + readOnlyHint: false, + }, + schema: { + browserId: zod + .string() + .describe( + 'A unique ID for this browser instance (e.g. "admin", "test-user", "dev").', + ), + browserUrl: zod + .string() + .describe( + 'The debugging URL of the Chrome instance (e.g. "http://127.0.0.1:9224").', + ), + switchTo: zod + .boolean() + .optional() + .describe( + 'Whether to immediately switch to this browser after connecting. Default is true.', + ), + }, + handler: async (request, response) => { + const {browserId, browserUrl, switchTo = true} = request.params; + try { + await addBrowser(browserId, {browserURL: browserUrl}); + response.appendResponseLine( + `Browser '${browserId}' connected successfully at ${browserUrl}.`, + ); + if (switchTo) { + switchActiveBrowser(browserId); + response.appendResponseLine(`Switched to browser '${browserId}'.`); + } + const browsers = listBrowsers(); + response.appendResponseLine('\n## All browsers'); + for (const b of browsers) { + const marker = b.active ? ' [selected]' : ''; + response.appendResponseLine( + `- ${b.id}: ${b.connected ? 'connected' : 'disconnected'}${marker}`, + ); + } + } catch (err) { + throw new Error( + `Failed to connect to browser '${browserId}' at ${browserUrl}: ${(err as Error).message}`, + ); + } + }, +}); diff --git a/src/tools/slim/tools.ts b/src/tools/slim/tools.ts index 712dcff63..039b3f52f 100644 --- a/src/tools/slim/tools.ts +++ b/src/tools/slim/tools.ts @@ -9,6 +9,8 @@ import {zod} from '../../third_party/index.js'; import {ToolCategory} from '../categories.js'; import {definePageTool} from '../ToolDefinition.js'; +export {switch_browser, list_browsers, add_browser} from '../multibrowser.js'; + export const screenshot = definePageTool({ name: 'screenshot', description: `Takes a screenshot`, diff --git a/src/tools/tools.ts b/src/tools/tools.ts index d448552b0..2a24e3a7b 100644 --- a/src/tools/tools.ts +++ b/src/tools/tools.ts @@ -20,6 +20,7 @@ import * as screenshotTools from './screenshot.js'; import * as scriptTools from './script.js'; import * as slimTools from './slim/tools.js'; import * as snapshotTools from './snapshot.js'; +import * as multibrowserTools from './multibrowser.js'; import type {ToolDefinition} from './ToolDefinition.js'; export const createTools = (args: ParsedArguments) => { @@ -39,6 +40,7 @@ export const createTools = (args: ParsedArguments) => { ...Object.values(screenshotTools), ...Object.values(scriptTools), ...Object.values(snapshotTools), + ...Object.values(multibrowserTools), ]; const tools = [];