diff --git a/docs/tool-reference.md b/docs/tool-reference.md index 619fd571d..52c1e9c6c 100644 --- a/docs/tool-reference.md +++ b/docs/tool-reference.md @@ -1,6 +1,6 @@ -# Chrome DevTools MCP Tool Reference (~6661 cl100k_base tokens) +# Chrome DevTools MCP Tool Reference (~6719 cl100k_base tokens) - **[Input automation](#input-automation)** (8 tools) - [`click`](#click) @@ -172,6 +172,7 @@ - **url** (string) **(required)**: URL to load in a new page. - **background** (boolean) _(optional)_: Whether to open the page in the background without bringing it to the front. Default is false (foreground). +- **isolatedContext** (string) _(optional)_: If specified, the page is created in an isolated browser context with the given name. Pages in the same browser context share cookies and storage. Pages in different browser contexts are fully isolated. - **timeout** (integer) _(optional)_: Maximum wait time in milliseconds. If set to 0, the default timeout will be used. --- diff --git a/scripts/eval_scenarios/isolated_context_test.ts b/scripts/eval_scenarios/isolated_context_test.ts new file mode 100644 index 000000000..5416aa622 --- /dev/null +++ b/scripts/eval_scenarios/isolated_context_test.ts @@ -0,0 +1,28 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'node:assert'; + +import type {TestScenario} from '../eval_gemini.ts'; + +export const scenario: TestScenario = { + prompt: + 'Create a new page in an isolated context called contextB. Take a screenshot there.', + maxTurns: 3, + htmlRoute: { + path: '/test.html', + htmlContent: ` +

test

+ `, + }, + expectations: calls => { + console.log(JSON.stringify(calls, null, 2)) + assert.strictEqual(calls.length, 2); + assert.ok(calls[0].name === 'new_page', 'First call should be navigation'); + assert.deepStrictEqual(calls[0].args.isolatedContext, "contextB"); + assert.ok(calls[1].name === 'take_screenshot', 'Second call should be a screenshot'); + }, +}; diff --git a/src/McpContext.ts b/src/McpContext.ts index feaf78267..df5eeb764 100644 --- a/src/McpContext.ts +++ b/src/McpContext.ts @@ -19,6 +19,7 @@ import {NetworkCollector, ConsoleCollector} from './PageCollector.js'; import type {DevTools} from './third_party/index.js'; import type { Browser, + BrowserContext, ConsoleMessage, Debugger, Dialog, @@ -119,11 +120,19 @@ export class McpContext implements Context { browser: Browser; logger: Debugger; - // The most recent page state. + // Maps LLM-provided isolatedContext name → Puppeteer BrowserContext. + #isolatedContexts = new Map(); + // Reverse lookup: Page → isolatedContext name (for snapshot labeling). + // WeakMap so closed pages are garbage-collected automatically. + #pageToIsolatedContextName = new WeakMap(); + // Auto-generated name counter for when no name is provided. + #nextIsolatedContextId = 1; + #pages: Page[] = []; #pageToDevToolsPage = new Map(); #selectedPage?: Page; - // The most recent snapshot. + // Per-context selected page tracking for parallel agent support. + #contextSelectedPage = new Map(); #textSnapshot: TextSnapshot | null = null; #networkCollector: NetworkCollector; #consoleCollector: ConsoleCollector; @@ -187,6 +196,10 @@ export class McpContext implements Context { this.#networkCollector.dispose(); this.#consoleCollector.dispose(); this.#devtoolsUniverseManager.dispose(); + // Isolated contexts are intentionally not closed here. + // Either the entire browser will be closed or we disconnect + // without destroying browser state. + this.#isolatedContexts.clear(); } static async from( @@ -269,8 +282,22 @@ export class McpContext implements Context { return this.#consoleCollector.getById(this.getSelectedPage(), id); } - async newPage(background?: boolean): Promise { - const page = await this.browser.newPage({background}); + async newPage( + background?: boolean, + isolatedContextName?: string, + ): Promise { + let page: Page; + if (isolatedContextName !== undefined) { + let ctx = this.#isolatedContexts.get(isolatedContextName); + if (!ctx) { + ctx = await this.browser.createBrowserContext(); + this.#isolatedContexts.set(isolatedContextName, ctx); + } + page = await ctx.newPage(); + this.#pageToIsolatedContextName.set(page, isolatedContextName); + } else { + page = await this.browser.newPage({background}); + } await this.createPagesSnapshot(); this.selectPage(page); this.#networkCollector.addPage(page); @@ -283,21 +310,25 @@ export class McpContext implements Context { } const page = this.getPageById(pageId); await page.close({runBeforeUnload: false}); + this.#pageToIsolatedContextName.delete(page); } getNetworkRequestById(reqid: number): HTTPRequest { return this.#networkCollector.getById(this.getSelectedPage(), reqid); } - async emulate(options: { - networkConditions?: string | null; - cpuThrottlingRate?: number | null; - geolocation?: GeolocationOptions | null; - userAgent?: string | null; - colorScheme?: 'dark' | 'light' | 'auto' | null; - viewport?: Viewport | null; - }): Promise { - const page = this.getSelectedPage(); + async emulate( + options: { + networkConditions?: string | null; + cpuThrottlingRate?: number | null; + geolocation?: GeolocationOptions | null; + userAgent?: string | null; + colorScheme?: 'dark' | 'light' | 'auto' | null; + viewport?: Viewport | null; + }, + targetPage?: Page, + ): Promise { + const page = targetPage ?? this.getSelectedPage(); const currentSettings = this.#emulationSettingsMap.get(page) ?? {}; const newSettings: EmulationSettings = {...currentSettings}; let timeoutsNeedUpdate = false; @@ -474,6 +505,41 @@ export class McpContext implements Context { return page; } + resolvePageByContext(isolatedContext?: string): Page { + if (isolatedContext === undefined) { + return this.getSelectedPage(); + } + + // Try the per-context selected page first. + const tracked = this.#contextSelectedPage.get(isolatedContext); + if (tracked && !tracked.isClosed()) { + return tracked; + } + + // Fall back: find any non-closed page in the context. + const ctx = this.#isolatedContexts.get(isolatedContext); + if (!ctx) { + throw new Error( + `No isolated context named "${isolatedContext}" exists. ` + + `Create one first with new_page(isolatedContext: "${isolatedContext}").`, + ); + } + + for (const page of this.#pages) { + if ( + !page.isClosed() && + this.#pageToIsolatedContextName.get(page) === isolatedContext + ) { + this.#contextSelectedPage.set(isolatedContext, page); + return page; + } + } + + throw new Error( + `No open page found in isolated context "${isolatedContext}".`, + ); + } + getPageById(pageId: number): Page { const page = this.#pages.find(p => this.#pageIdMap.get(p) === pageId); if (!page) { @@ -508,6 +574,12 @@ export class McpContext implements Context { void newPage.emulateFocusedPage(true).catch(error => { this.logger('Error turning on focused page emulation', error); }); + + // Track per-context selected page for parallel agent routing. + const contextName = this.#pageToIsolatedContextName.get(newPage); + if (contextName) { + this.#contextSelectedPage.set(contextName, newPage); + } } #updateSelectedPageTimeouts() { @@ -558,13 +630,8 @@ export class McpContext implements Context { } } - /** - * Creates a snapshot of the pages. - */ async createPagesSnapshot(): Promise { - const allPages = await this.browser.pages( - this.#options.experimentalIncludeAllPages, - ); + const allPages = await this.#getAllPages(); for (const page of allPages) { if (!this.#pageIdMap.has(page)) { @@ -573,8 +640,6 @@ export class McpContext implements Context { } this.#pages = allPages.filter(page => { - // If we allow debugging DevTools windows, return all pages. - // If we are in regular mode, the user should only see non-DevTools page. return ( this.#options.experimentalDevToolsDebugging || !page.url().startsWith('devtools://') @@ -593,11 +658,44 @@ export class McpContext implements Context { return this.#pages; } - async detectOpenDevToolsWindows() { - this.logger('Detecting open DevTools windows'); - const pages = await this.browser.pages( + async #getAllPages(): Promise { + const defaultCtx = this.browser.defaultBrowserContext(); + const allPages = await this.browser.pages( this.#options.experimentalIncludeAllPages, ); + + // Build a reverse lookup from BrowserContext instance → name. + const contextToName = new Map(); + for (const [name, ctx] of this.#isolatedContexts) { + contextToName.set(ctx, name); + } + + // Auto-discover BrowserContexts not in our mapping (e.g., externally + // created incognito contexts) and assign generated names. + const knownContexts = new Set(this.#isolatedContexts.values()); + for (const ctx of this.browser.browserContexts()) { + if (ctx !== defaultCtx && !ctx.closed && !knownContexts.has(ctx)) { + const name = `isolated-context-${this.#nextIsolatedContextId++}`; + this.#isolatedContexts.set(name, ctx); + contextToName.set(ctx, name); + } + } + + // Use page.browserContext() to determine each page's context membership. + for (const page of allPages) { + const ctx = page.browserContext(); + const name = contextToName.get(ctx); + if (name) { + this.#pageToIsolatedContextName.set(page, name); + } + } + + return allPages; + } + + async detectOpenDevToolsWindows() { + this.logger('Detecting open DevTools windows'); + const pages = await this.#getAllPages(); this.#pageToDevToolsPage = new Map(); for (const devToolsPage of pages) { if (devToolsPage.url().startsWith('devtools://')) { @@ -629,6 +727,10 @@ export class McpContext implements Context { return this.#pages; } + getIsolatedContextName(page: Page): string | undefined { + return this.#pageToIsolatedContextName.get(page); + } + getDevToolsPage(page: Page): Page | undefined { return this.#pageToDevToolsPage.get(page); } @@ -673,8 +775,9 @@ export class McpContext implements Context { async createTextSnapshot( verbose = false, devtoolsData: DevToolsData | undefined = undefined, + targetPage?: Page, ): Promise { - const page = this.getSelectedPage(); + const page = targetPage ?? this.getSelectedPage(); const rootNode = await page.accessibility.snapshot({ includeIframes: true, interestingOnly: !verbose, @@ -825,8 +928,12 @@ export class McpContext implements Context { return this.#networkCollector.getIdForResource(request); } - waitForTextOnPage(text: string, timeout?: number): Promise { - const page = this.getSelectedPage(); + waitForTextOnPage( + text: string, + timeout?: number, + targetPage?: Page, + ): Promise { + const page = targetPage ?? this.getSelectedPage(); const frames = page.frames(); let locator = this.#locatorClass.race( @@ -857,7 +964,8 @@ export class McpContext implements Context { }, } as ListenerMap; }); - await this.#networkCollector.init(await this.browser.pages()); + const pages = await this.browser.pages(); + await this.#networkCollector.init(pages); } async installExtension(extensionPath: string): Promise { diff --git a/src/McpResponse.ts b/src/McpResponse.ts index dbdcd9f2b..0f8d92ab7 100644 --- a/src/McpResponse.ts +++ b/src/McpResponse.ts @@ -238,6 +238,7 @@ export class McpResponse implements Response { await context.createTextSnapshot( this.#snapshotParams.verbose, this.#devToolsData, + this.#snapshotParams.page, ); const textSnapshot = context.getTextSnapshot(); if (textSnapshot) { @@ -504,17 +505,31 @@ Call ${handleDialog.name} to handle it before continuing.`); if (this.#includePages) { const parts = [`## Pages`]; for (const page of context.getPages()) { + const isolatedContextName = context.getIsolatedContextName(page); + const contextLabel = isolatedContextName + ? ` isolatedContext=${isolatedContextName}` + : ''; parts.push( - `${context.getPageId(page)}: ${page.url()}${context.isPageSelected(page) ? ' [selected]' : ''}`, + `${context.getPageId(page)}: ${page.url()}${context.isPageSelected(page) ? ' [selected]' : ''}${contextLabel}`, ); } response.push(...parts); structuredContent.pages = context.getPages().map(page => { - return { + const isolatedContextName = context.getIsolatedContextName(page); + const entry: { + id: number | undefined; + url: string; + selected: boolean; + isolatedContext?: string; + } = { id: context.getPageId(page), url: page.url(), selected: context.isPageSelected(page), }; + if (isolatedContextName) { + entry.isolatedContext = isolatedContextName; + } + return entry; }); } diff --git a/src/tools/ToolDefinition.ts b/src/tools/ToolDefinition.ts index 9975753ae..7b61c3c1a 100644 --- a/src/tools/ToolDefinition.ts +++ b/src/tools/ToolDefinition.ts @@ -53,6 +53,7 @@ export interface ImageContentData { export interface SnapshotParams { verbose?: boolean; filePath?: string; + page?: Page; } export interface DevToolsData { @@ -107,24 +108,29 @@ export type Context = Readonly<{ recordedTraces(): TraceResult[]; storeTraceRecording(result: TraceResult): void; getSelectedPage(): Page; + resolvePageByContext(isolatedContext?: string): Page; getDialog(): Dialog | undefined; clearDialog(): void; getPageById(pageId: number): Page; getPageId(page: Page): number | undefined; isPageSelected(page: Page): boolean; - newPage(background?: boolean): Promise; + newPage(background?: boolean, isolatedContextName?: string): Promise; closePage(pageId: number): Promise; selectPage(page: Page): void; + getIsolatedContextName(page: Page): string | undefined; getElementByUid(uid: string): Promise>; getAXNodeByUid(uid: string): TextSnapshotNode | undefined; - emulate(options: { - networkConditions?: string | null; - cpuThrottlingRate?: number | null; - geolocation?: GeolocationOptions | null; - userAgent?: string | null; - colorScheme?: 'dark' | 'light' | 'auto' | null; - viewport?: Viewport | null; - }): Promise; + emulate( + options: { + networkConditions?: string | null; + cpuThrottlingRate?: number | null; + geolocation?: GeolocationOptions | null; + userAgent?: string | null; + colorScheme?: 'dark' | 'light' | 'auto' | null; + viewport?: Viewport | null; + }, + targetPage?: Page, + ): Promise; getNetworkConditions(): string | null; getCpuThrottlingRate(): number; getGeolocation(): GeolocationOptions | null; @@ -143,7 +149,11 @@ export type Context = Readonly<{ action: () => Promise, options?: {timeout?: number}, ): Promise; - waitForTextOnPage(text: string, timeout?: number): Promise; + waitForTextOnPage( + text: string, + timeout?: number, + page?: Page, + ): Promise; getDevToolsData(): Promise; /** * Returns a reqid for a cdpRequestId. @@ -172,6 +182,18 @@ export function defineTool( export const CLOSE_PAGE_ERROR = 'The last open page cannot be closed. It is fine to keep it open.'; +export const isolatedContextSchema = { + isolatedContext: zod + .string() + .optional() + .describe( + 'The name of the isolated browser context to resolve the page from. ' + + 'When provided, the tool operates on the page belonging to this context ' + + 'instead of the globally selected page. ' + + 'Use this to avoid race conditions when multiple agents work in parallel.', + ), +}; + export const timeoutSchema = { timeout: zod .number() diff --git a/src/tools/emulation.ts b/src/tools/emulation.ts index ea0538fd1..084b311cd 100644 --- a/src/tools/emulation.ts +++ b/src/tools/emulation.ts @@ -8,7 +8,7 @@ import {zod, PredefinedNetworkConditions} from '../third_party/index.js'; import {ToolCategory} from './categories.js'; -import {defineTool} from './ToolDefinition.js'; +import {defineTool, isolatedContextSchema} from './ToolDefinition.js'; const throttlingOptions: [string, ...string[]] = [ 'No emulation', @@ -24,6 +24,7 @@ export const emulate = defineTool({ readOnlyHint: false, }, schema: { + ...isolatedContextSchema, networkConditions: zod .enum(throttlingOptions) .optional() @@ -104,6 +105,9 @@ export const emulate = defineTool({ ), }, handler: async (request, _response, context) => { - await context.emulate(request.params); + const page = context.resolvePageByContext( + request.params.isolatedContext, + ); + await context.emulate(request.params, page); }, }); diff --git a/src/tools/input.ts b/src/tools/input.ts index c309326e4..f599f0079 100644 --- a/src/tools/input.ts +++ b/src/tools/input.ts @@ -7,11 +7,11 @@ import {logger} from '../logger.js'; import type {McpContext, TextSnapshotNode} from '../McpContext.js'; import {zod} from '../third_party/index.js'; -import type {ElementHandle} from '../third_party/index.js'; +import type {ElementHandle, Page} from '../third_party/index.js'; import {parseKey} from '../utils/keyboard.js'; import {ToolCategory} from './categories.js'; -import {defineTool} from './ToolDefinition.js'; +import {defineTool, isolatedContextSchema} from './ToolDefinition.js'; const dblClickSchema = zod .boolean() @@ -83,13 +83,16 @@ export const clickAt = defineTool({ conditions: ['computerVision'], }, schema: { + ...isolatedContextSchema, x: zod.number().describe('The x coordinate'), y: zod.number().describe('The y coordinate'), dblClick: dblClickSchema, includeSnapshot: includeSnapshotSchema, }, handler: async (request, response, context) => { - const page = context.getSelectedPage(); + const page = context.resolvePageByContext( + request.params.isolatedContext, + ); await context.waitForEventsAfterAction(async () => { await page.mouse.click(request.params.x, request.params.y, { clickCount: request.params.dblClick ? 2 : 1, @@ -101,7 +104,7 @@ export const clickAt = defineTool({ : `Successfully clicked at the coordinates`, ); if (request.params.includeSnapshot) { - response.includeSnapshot(); + response.includeSnapshot({ page }); } }, }); @@ -185,6 +188,7 @@ async function fillFormElement( uid: string, value: string, context: McpContext, + page?: Page, ) { const handle = await context.getElementByUid(uid); try { @@ -196,8 +200,9 @@ async function fillFormElement( } else { // Increase timeout for longer input values. const timeoutPerChar = 10; // ms + const targetPage = page ?? context.getSelectedPage(); const fillTimeout = - context.getSelectedPage().getDefaultTimeout() + + targetPage.getDefaultTimeout() + value.length * timeoutPerChar; await handle.asLocator().setTimeout(fillTimeout).fill(value); } @@ -216,6 +221,7 @@ export const fill = defineTool({ readOnlyHint: false, }, schema: { + ...isolatedContextSchema, uid: zod .string() .describe( @@ -225,16 +231,20 @@ export const fill = defineTool({ includeSnapshot: includeSnapshotSchema, }, handler: async (request, response, context) => { + const page = context.resolvePageByContext( + request.params.isolatedContext, + ); await context.waitForEventsAfterAction(async () => { await fillFormElement( request.params.uid, request.params.value, context as McpContext, + page, ); }); response.appendResponseLine(`Successfully filled out the element`); if (request.params.includeSnapshot) { - response.includeSnapshot(); + response.includeSnapshot({ page }); } }, }); @@ -279,6 +289,7 @@ export const fillForm = defineTool({ readOnlyHint: false, }, schema: { + ...isolatedContextSchema, elements: zod .array( zod.object({ @@ -290,18 +301,22 @@ export const fillForm = defineTool({ includeSnapshot: includeSnapshotSchema, }, handler: async (request, response, context) => { + const page = context.resolvePageByContext( + request.params.isolatedContext, + ); for (const element of request.params.elements) { await context.waitForEventsAfterAction(async () => { await fillFormElement( element.uid, element.value, context as McpContext, + page, ); }); } response.appendResponseLine(`Successfully filled out the form`); if (request.params.includeSnapshot) { - response.includeSnapshot(); + response.includeSnapshot({ page }); } }, }); @@ -314,6 +329,7 @@ export const uploadFile = defineTool({ readOnlyHint: false, }, schema: { + ...isolatedContextSchema, uid: zod .string() .describe( @@ -335,7 +351,9 @@ export const uploadFile = defineTool({ // a type=file element. In this case, we want to default to // Page.waitForFileChooser() and upload the file this way. try { - const page = context.getSelectedPage(); + const page = context.resolvePageByContext( + request.params.isolatedContext, + ); const [fileChooser] = await Promise.all([ page.waitForFileChooser({timeout: 3000}), handle.asLocator().click(), @@ -348,7 +366,10 @@ export const uploadFile = defineTool({ } } if (request.params.includeSnapshot) { - response.includeSnapshot(); + const page = context.resolvePageByContext( + request.params.isolatedContext, + ); + response.includeSnapshot({ page }); } response.appendResponseLine(`File uploaded from ${filePath}.`); } finally { @@ -365,6 +386,7 @@ export const pressKey = defineTool({ readOnlyHint: false, }, schema: { + ...isolatedContextSchema, key: zod .string() .describe( @@ -373,7 +395,9 @@ export const pressKey = defineTool({ includeSnapshot: includeSnapshotSchema, }, handler: async (request, response, context) => { - const page = context.getSelectedPage(); + const page = context.resolvePageByContext( + request.params.isolatedContext, + ); const tokens = parseKey(request.params.key); const [key, ...modifiers] = tokens; @@ -391,7 +415,7 @@ export const pressKey = defineTool({ `Successfully pressed key: ${request.params.key}`, ); if (request.params.includeSnapshot) { - response.includeSnapshot(); + response.includeSnapshot({ page }); } }, }); diff --git a/src/tools/pages.ts b/src/tools/pages.ts index 60fc8f3b6..226d91fd3 100644 --- a/src/tools/pages.ts +++ b/src/tools/pages.ts @@ -9,7 +9,12 @@ import type {Dialog} from '../third_party/index.js'; import {zod} from '../third_party/index.js'; import {ToolCategory} from './categories.js'; -import {CLOSE_PAGE_ERROR, defineTool, timeoutSchema} from './ToolDefinition.js'; +import { + CLOSE_PAGE_ERROR, + defineTool, + isolatedContextSchema, + timeoutSchema, +} from './ToolDefinition.js'; export const listPages = defineTool({ name: 'list_pages', @@ -93,10 +98,21 @@ export const newPage = defineTool({ .describe( 'Whether to open the page in the background without bringing it to the front. Default is false (foreground).', ), + isolatedContext: zod + .string() + .optional() + .describe( + 'If specified, the page is created in an isolated browser context with the given name. ' + + 'Pages in the same browser context share cookies and storage. ' + + 'Pages in different browser contexts are fully isolated.', + ), ...timeoutSchema, }, handler: async (request, response, context) => { - const page = await context.newPage(request.params.background); + const page = await context.newPage( + request.params.background, + request.params.isolatedContext, + ); await context.waitForEventsAfterAction( async () => { @@ -119,6 +135,7 @@ export const navigatePage = defineTool({ readOnlyHint: false, }, schema: { + ...isolatedContextSchema, type: zod .enum(['url', 'back', 'forward', 'reload']) .optional() @@ -145,7 +162,9 @@ export const navigatePage = defineTool({ ...timeoutSchema, }, handler: async (request, response, context) => { - const page = context.getSelectedPage(); + const page = context.resolvePageByContext( + request.params.isolatedContext, + ); const options = { timeout: request.params.timeout, }; @@ -268,11 +287,14 @@ export const resizePage = defineTool({ readOnlyHint: false, }, schema: { + ...isolatedContextSchema, width: zod.number().describe('Page width'), height: zod.number().describe('Page height'), }, handler: async (request, response, context) => { - const page = context.getSelectedPage(); + const page = context.resolvePageByContext( + request.params.isolatedContext, + ); try { const browser = page.browser(); diff --git a/src/tools/performance.ts b/src/tools/performance.ts index 393d38f15..eff3e0ade 100644 --- a/src/tools/performance.ts +++ b/src/tools/performance.ts @@ -17,7 +17,7 @@ import { import {ToolCategory} from './categories.js'; import type {Context, Response} from './ToolDefinition.js'; -import {defineTool} from './ToolDefinition.js'; +import {defineTool, isolatedContextSchema} from './ToolDefinition.js'; const filePathSchema = zod .string() @@ -34,6 +34,7 @@ export const startTrace = defineTool({ readOnlyHint: false, }, schema: { + ...isolatedContextSchema, reload: zod .boolean() .describe( @@ -55,7 +56,9 @@ export const startTrace = defineTool({ } context.setIsRunningPerformanceTrace(true); - const page = context.getSelectedPage(); + const page = context.resolvePageByContext( + request.params.isolatedContext, + ); const pageUrlForTracing = page.url(); if (request.params.reload) { @@ -121,13 +124,16 @@ export const stopTrace = defineTool({ readOnlyHint: false, }, schema: { + ...isolatedContextSchema, filePath: filePathSchema, }, handler: async (request, response, context) => { if (!context.isRunningPerformanceTrace()) { return; } - const page = context.getSelectedPage(); + const page = context.resolvePageByContext( + request.params.isolatedContext, + ); await stopTracingAndAppendOutput( page, response, diff --git a/src/tools/screencast.ts b/src/tools/screencast.ts index d24d9b0fd..57578fbfb 100644 --- a/src/tools/screencast.ts +++ b/src/tools/screencast.ts @@ -12,7 +12,7 @@ import {zod} from '../third_party/index.js'; import type {ScreenRecorder} from '../third_party/index.js'; import {ToolCategory} from './categories.js'; -import {defineTool} from './ToolDefinition.js'; +import {defineTool, isolatedContextSchema} from './ToolDefinition.js'; async function generateTempFilePath(): Promise { const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'chrome-devtools-mcp-')); @@ -29,6 +29,7 @@ export const startScreencast = defineTool({ conditions: ['screencast'], }, schema: { + ...isolatedContextSchema, path: zod .string() .optional() @@ -47,7 +48,9 @@ export const startScreencast = defineTool({ const filePath = request.params.path ?? (await generateTempFilePath()); const resolvedPath = path.resolve(filePath); - const page = context.getSelectedPage(); + const page = context.resolvePageByContext( + request.params.isolatedContext, + ); let recorder: ScreenRecorder; try { diff --git a/src/tools/screenshot.ts b/src/tools/screenshot.ts index 4312c02aa..24ae0715a 100644 --- a/src/tools/screenshot.ts +++ b/src/tools/screenshot.ts @@ -8,7 +8,7 @@ import {zod} from '../third_party/index.js'; import type {ElementHandle, Page} from '../third_party/index.js'; import {ToolCategory} from './categories.js'; -import {defineTool} from './ToolDefinition.js'; +import {defineTool, isolatedContextSchema} from './ToolDefinition.js'; export const screenshot = defineTool({ name: 'take_screenshot', @@ -19,6 +19,7 @@ export const screenshot = defineTool({ readOnlyHint: false, }, schema: { + ...isolatedContextSchema, format: zod .enum(['png', 'jpeg', 'webp']) .default('png') @@ -59,7 +60,9 @@ export const screenshot = defineTool({ if (request.params.uid) { pageOrHandle = await context.getElementByUid(request.params.uid); } else { - pageOrHandle = context.getSelectedPage(); + pageOrHandle = context.resolvePageByContext( + request.params.isolatedContext, + ); } const format = request.params.format; diff --git a/src/tools/script.ts b/src/tools/script.ts index f3bc3c3c5..9a7fa47a9 100644 --- a/src/tools/script.ts +++ b/src/tools/script.ts @@ -8,7 +8,7 @@ import {zod} from '../third_party/index.js'; import type {Frame, JSHandle, Page} from '../third_party/index.js'; import {ToolCategory} from './categories.js'; -import {defineTool} from './ToolDefinition.js'; +import {defineTool, isolatedContextSchema} from './ToolDefinition.js'; export const evaluateScript = defineTool({ name: 'evaluate_script', @@ -19,6 +19,7 @@ so returned values have to be JSON-serializable.`, readOnlyHint: false, }, schema: { + ...isolatedContextSchema, function: zod.string().describe( `A JavaScript function declaration to be executed by the tool in the currently selected page. Example without arguments: \`() => { @@ -60,7 +61,9 @@ Example with arguments: \`(el) => { "Elements from different frames can't be evaluated together.", ); } else { - pageOrFrame = [...frames.values()][0] ?? context.getSelectedPage(); + pageOrFrame = + [...frames.values()][0] ?? + context.resolvePageByContext(request.params.isolatedContext); } const fn = await pageOrFrame.evaluateHandle( `(${request.params.function})`, diff --git a/src/tools/snapshot.ts b/src/tools/snapshot.ts index 143d04093..6ca5e85de 100644 --- a/src/tools/snapshot.ts +++ b/src/tools/snapshot.ts @@ -7,7 +7,11 @@ import {zod} from '../third_party/index.js'; import {ToolCategory} from './categories.js'; -import {defineTool, timeoutSchema} from './ToolDefinition.js'; +import { + defineTool, + isolatedContextSchema, + timeoutSchema, +} from './ToolDefinition.js'; export const takeSnapshot = defineTool({ name: 'take_snapshot', @@ -20,6 +24,7 @@ in the DevTools Elements panel (if any).`, readOnlyHint: false, }, schema: { + ...isolatedContextSchema, verbose: zod .boolean() .optional() @@ -33,10 +38,14 @@ in the DevTools Elements panel (if any).`, 'The absolute path, or a path relative to the current working directory, to save the snapshot to instead of attaching it to the response.', ), }, - handler: async (request, response) => { + handler: async (request, response, context) => { + const page = context.resolvePageByContext( + request.params.isolatedContext, + ); response.includeSnapshot({ verbose: request.params.verbose ?? false, filePath: request.params.filePath, + page, }); }, }); @@ -49,19 +58,24 @@ export const waitFor = defineTool({ readOnlyHint: true, }, schema: { + ...isolatedContextSchema, text: zod.string().describe('Text to appear on the page'), ...timeoutSchema, }, handler: async (request, response, context) => { + const page = context.resolvePageByContext( + request.params.isolatedContext, + ); await context.waitForTextOnPage( request.params.text, request.params.timeout, + page, ); response.appendResponseLine( `Element with text "${request.params.text}" found.`, ); - response.includeSnapshot(); + response.includeSnapshot({ page }); }, }); diff --git a/tests/tools/emulation.test.ts b/tests/tools/emulation.test.ts index 2aa31dad6..671ba1b23 100644 --- a/tests/tools/emulation.test.ts +++ b/tests/tools/emulation.test.ts @@ -8,6 +8,7 @@ import assert from 'node:assert'; import {beforeEach, describe, it} from 'node:test'; import {emulate} from '../../src/tools/emulation.js'; +import {newPage, selectPage} from '../../src/tools/pages.js'; import {serverHooks} from '../server.js'; import {html, withMcpContext} from '../utils.js'; @@ -470,6 +471,67 @@ describe('emulation', () => { }); }); + describe('isolatedContext routing', () => { + beforeEach(() => { + server.addHtmlRoute('/emulate-test', html`

Emulate Test

`); + }); + + it('emulates viewport on the isolatedContext page, not the global selection', async () => { + await withMcpContext(async (response, context) => { + // Create an isolated page. + await newPage.handler( + { + params: { + url: server.baseUrl + '/emulate-test', + isolatedContext: 'emulate-ctx', + }, + }, + response, + context, + ); + const isolatedPage = context.getSelectedPage(); + + // Switch global selection back to the default page. + await selectPage.handler({params: {pageId: 1}}, response, context); + const defaultPage = context.getSelectedPage(); + assert.notStrictEqual(defaultPage, isolatedPage); + + // Emulate viewport on the isolated page via isolatedContext. + await emulate.handler( + { + params: { + isolatedContext: 'emulate-ctx', + viewport: { + width: 390, + height: 844, + isMobile: true, + hasTouch: true, + }, + }, + }, + response, + context, + ); + + // Verify the isolated page received the viewport. + const isolatedViewport = await isolatedPage.evaluate(() => ({ + width: window.innerWidth, + height: window.innerHeight, + hasTouch: navigator.maxTouchPoints > 0, + })); + assert.strictEqual(isolatedViewport.width, 390); + assert.strictEqual(isolatedViewport.height, 844); + assert.strictEqual(isolatedViewport.hasTouch, true); + + // Verify the default page was NOT affected. + const defaultViewport = await defaultPage.evaluate(() => ({ + width: window.innerWidth, + })); + assert.notStrictEqual(defaultViewport.width, 390); + }); + }); + }); + describe('colorScheme', () => { it('emulates color scheme', async () => { await withMcpContext(async (response, context) => { diff --git a/tests/tools/pages.test.ts b/tests/tools/pages.test.ts index cf75af222..4910dc4a1 100644 --- a/tests/tools/pages.test.ts +++ b/tests/tools/pages.test.ts @@ -73,6 +73,211 @@ describe('pages', () => { }); }); }); + describe('new_page with isolatedContext', () => { + it('creates a page in an isolated context', async () => { + await withMcpContext(async (response, context) => { + await newPage.handler( + {params: {url: 'about:blank', isolatedContext: 'session-a'}}, + response, + context, + ); + const page = context.getSelectedPage(); + assert.strictEqual(context.getIsolatedContextName(page), 'session-a'); + assert.ok(response.includePages); + }); + }); + + it('reuses the same context for the same isolatedContext name', async () => { + await withMcpContext(async (response, context) => { + await newPage.handler( + {params: {url: 'about:blank', isolatedContext: 'session-a'}}, + response, + context, + ); + const page1 = context.getSelectedPage(); + await newPage.handler( + {params: {url: 'about:blank', isolatedContext: 'session-a'}}, + response, + context, + ); + const page2 = context.getSelectedPage(); + assert.notStrictEqual(page1, page2); + assert.strictEqual(context.getIsolatedContextName(page1), 'session-a'); + assert.strictEqual(context.getIsolatedContextName(page2), 'session-a'); + assert.strictEqual(page1.browserContext(), page2.browserContext()); + }); + }); + + it('creates separate contexts for different isolatedContext names', async () => { + await withMcpContext(async (response, context) => { + await newPage.handler( + {params: {url: 'about:blank', isolatedContext: 'session-a'}}, + response, + context, + ); + const pageA = context.getSelectedPage(); + await newPage.handler( + {params: {url: 'about:blank', isolatedContext: 'session-b'}}, + response, + context, + ); + const pageB = context.getSelectedPage(); + assert.strictEqual(context.getIsolatedContextName(pageA), 'session-a'); + assert.strictEqual(context.getIsolatedContextName(pageB), 'session-b'); + assert.notStrictEqual(pageA.browserContext(), pageB.browserContext()); + }); + }); + + it('includes isolatedContext in page listing', async () => { + await withMcpContext(async (response, context) => { + await newPage.handler( + {params: {url: 'about:blank', isolatedContext: 'session-a'}}, + response, + context, + ); + const result = await response.handle('new_page', context); + const pages = ( + result.structuredContent as {pages: Array<{isolatedContext?: string}>} + ).pages; + const isolatedPage = pages.find(p => p.isolatedContext === 'session-a'); + assert.ok(isolatedPage); + }); + }); + + it('does not set isolatedContext for pages in the default context', async () => { + await withMcpContext(async (response, context) => { + const page = context.getSelectedPage(); + assert.strictEqual(context.getIsolatedContextName(page), undefined); + await newPage.handler( + {params: {url: 'about:blank'}}, + response, + context, + ); + assert.strictEqual( + context.getIsolatedContextName(context.getSelectedPage()), + undefined, + ); + }); + }); + + it('closes an isolated page without errors', async () => { + await withMcpContext(async (response, context) => { + await newPage.handler( + {params: {url: 'about:blank', isolatedContext: 'session-a'}}, + response, + context, + ); + const page = context.getSelectedPage(); + const pageId = context.getPageId(page)!; + assert.ok(!page.isClosed()); + await closePage.handler({params: {pageId}}, response, context); + assert.ok(page.isClosed()); + }); + }); + }); + + describe('resolvePageByContext', () => { + it('returns the correct page regardless of global selection', async () => { + await withMcpContext(async (response, context) => { + // Create two pages in separate isolated contexts with different content. + await newPage.handler( + { + params: { + url: 'data:text/html,

Page A

', + isolatedContext: 'ctx-a', + }, + }, + response, + context, + ); + const pageA = context.getSelectedPage(); + + await newPage.handler( + { + params: { + url: 'data:text/html,

Page B

', + isolatedContext: 'ctx-b', + }, + }, + response, + context, + ); + const pageB = context.getSelectedPage(); + + // Global selection is now pageB (the last created page). + assert.strictEqual(context.getSelectedPage(), pageB); + + // resolvePageByContext should return the correct page for each context, + // regardless of which page is globally selected. + assert.strictEqual(context.resolvePageByContext('ctx-a'), pageA); + assert.strictEqual(context.resolvePageByContext('ctx-b'), pageB); + }); + }); + + it('falls back to getSelectedPage when no isolatedContext is provided', async () => { + await withMcpContext(async (_response, context) => { + const selectedPage = context.getSelectedPage(); + assert.strictEqual( + context.resolvePageByContext(undefined), + selectedPage, + ); + }); + }); + + it('throws for an unknown context name', async () => { + await withMcpContext(async (_response, context) => { + assert.throws( + () => context.resolvePageByContext('nonexistent'), + /No isolated context named "nonexistent" exists/, + ); + }); + }); + + it('navigate_page targets the isolatedContext page, not the global selection', async () => { + await withMcpContext(async (response, context) => { + await newPage.handler( + { + params: { + url: 'data:text/html,

Initial

', + isolatedContext: 'nav-ctx', + }, + }, + response, + context, + ); + const isolatedPage = context.getSelectedPage(); + + // Switch global selection back to the default page. + await selectPage.handler({params: {pageId: 1}}, response, context); + assert.notStrictEqual(context.getSelectedPage(), isolatedPage); + + // Navigate using isolatedContext; should target the isolated page. + await navigatePage.handler( + { + params: { + url: 'data:text/html,

Navigated

', + isolatedContext: 'nav-ctx', + }, + }, + response, + context, + ); + + // Verify the isolated page was navigated. + const content = await isolatedPage.evaluate( + () => document.querySelector('h1')?.textContent, + ); + assert.strictEqual(content, 'Navigated'); + + // Verify the default page was NOT affected. + const defaultContent = await context + .getSelectedPage() + .evaluate(() => document.querySelector('h1')?.textContent); + assert.notStrictEqual(defaultContent, 'Navigated'); + }); + }); + }); + describe('close_page', () => { it('closes a page', async () => { await withMcpContext(async (response, context) => { diff --git a/tests/tools/screenshot.test.ts b/tests/tools/screenshot.test.ts index dab541412..1eda6615d 100644 --- a/tests/tools/screenshot.test.ts +++ b/tests/tools/screenshot.test.ts @@ -10,6 +10,7 @@ import {tmpdir} from 'node:os'; import {join} from 'node:path'; import {describe, it} from 'node:test'; +import {newPage, selectPage} from '../../src/tools/pages.js'; import {screenshot} from '../../src/tools/screenshot.js'; import {screenshots} from '../snapshot.js'; import {html, withMcpContext} from '../utils.js'; @@ -260,5 +261,45 @@ describe('screenshot', () => { ); }); }); + + it('screenshots the isolatedContext page, not the global selection', async () => { + await withMcpContext(async (response, context) => { + // Set distinct content on the default page. + const defaultPage = context.getSelectedPage(); + await defaultPage.setContent( + html`
`, + ); + + // Create an isolated page with different content. + await newPage.handler( + { + params: { + url: 'data:text/html,
', + isolatedContext: 'screenshot-ctx', + }, + }, + response, + context, + ); + + // Switch global selection back to the default page. + await selectPage.handler({params: {pageId: 1}}, response, context); + assert.strictEqual(context.getSelectedPage(), defaultPage); + + // Take a screenshot using isolatedContext. + const {McpResponse} = await import('../../src/McpResponse.js'); + const screenshotResponse = new McpResponse(); + await screenshot.handler( + {params: {format: 'png', isolatedContext: 'screenshot-ctx'}}, + screenshotResponse, + context, + ); + + // Should have produced an image (basic sanity: it didn't crash and + // returned something from the isolated page, not the default). + assert.equal(screenshotResponse.images.length, 1); + assert.equal(screenshotResponse.images[0].mimeType, 'image/png'); + }); + }); }); }); diff --git a/tests/tools/snapshot.test.ts b/tests/tools/snapshot.test.ts index 795e3d416..b20f5042b 100644 --- a/tests/tools/snapshot.test.ts +++ b/tests/tools/snapshot.test.ts @@ -7,6 +7,7 @@ import assert from 'node:assert'; import {describe, it} from 'node:test'; +import {newPage, selectPage} from '../../src/tools/pages.js'; import {takeSnapshot, waitFor} from '../../src/tools/snapshot.js'; import {html, withMcpContext} from '../utils.js'; @@ -124,4 +125,81 @@ describe('snapshot', () => { }); }); }); + + describe('isolatedContext routing', () => { + it('take_snapshot returns content from the isolatedContext page, not the global selection', async () => { + await withMcpContext(async (response, context) => { + // Create an isolated page with unique content. + await newPage.handler( + { + params: { + url: 'data:text/html,

Isolated Snapshot Content

', + isolatedContext: 'snap-ctx', + }, + }, + response, + context, + ); + + // Switch global selection back to the default page. + await selectPage.handler({params: {pageId: 1}}, response, context); + + // Take snapshot using isolatedContext. + const snapshotResponse = new (await import('../../src/McpResponse.js')).McpResponse(); + await takeSnapshot.handler( + {params: {isolatedContext: 'snap-ctx'}}, + snapshotResponse, + context, + ); + + // The snapshot should reflect the isolated page's content. + const result = await snapshotResponse.handle('take_snapshot', context); + const text = result.content + .filter(c => c.type === 'text') + .map(c => (c as {text: string}).text) + .join(''); + assert.ok( + text.includes('Isolated Snapshot Content'), + `Expected snapshot to contain "Isolated Snapshot Content" but got: ${text.slice(0, 200)}`, + ); + }); + }); + + it('wait_for finds text on the isolatedContext page, not the global selection', async () => { + await withMcpContext(async (response, context) => { + // Create an isolated page with target text. + await newPage.handler( + { + params: { + url: 'data:text/html,

Unique Isolated Text

', + isolatedContext: 'wait-ctx', + }, + }, + response, + context, + ); + + // Switch global selection away. + await selectPage.handler({params: {pageId: 1}}, response, context); + + // wait_for should find text on the isolated page. + const waitResponse = new (await import('../../src/McpResponse.js')).McpResponse(); + await waitFor.handler( + { + params: { + text: 'Unique Isolated Text', + isolatedContext: 'wait-ctx', + }, + }, + waitResponse, + context, + ); + + assert.equal( + waitResponse.responseLines[0], + 'Element with text "Unique Isolated Text" found.', + ); + }); + }); + }); });