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..0d76e5fe3 --- /dev/null +++ b/scripts/eval_scenarios/isolated_context_test.ts @@ -0,0 +1,31 @@ +/** + * @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..83697b5fb 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,17 @@ 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. #textSnapshot: TextSnapshot | null = null; #networkCollector: NetworkCollector; #consoleCollector: ConsoleCollector; @@ -187,6 +194,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 +280,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,6 +308,7 @@ export class McpContext implements Context { } const page = this.getPageById(pageId); await page.close({runBeforeUnload: false}); + this.#pageToIsolatedContextName.delete(page); } getNetworkRequestById(reqid: number): HTTPRequest { @@ -558,13 +584,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 +594,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 +612,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 +681,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); } @@ -857,7 +913,8 @@ export class McpContext implements Context { }, } as ListenerMap; }); - await this.#networkCollector.init(await this.browser.pages()); + const pages = await this.#getAllPages(); + await this.#networkCollector.init(pages); } async installExtension(extensionPath: string): Promise { diff --git a/src/McpResponse.ts b/src/McpResponse.ts index dbdcd9f2b..5cdad8e5b 100644 --- a/src/McpResponse.ts +++ b/src/McpResponse.ts @@ -504,17 +504,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..aee562b71 100644 --- a/src/tools/ToolDefinition.ts +++ b/src/tools/ToolDefinition.ts @@ -112,9 +112,10 @@ export type Context = Readonly<{ 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: { diff --git a/src/tools/pages.ts b/src/tools/pages.ts index 60fc8f3b6..b63590dfe 100644 --- a/src/tools/pages.ts +++ b/src/tools/pages.ts @@ -93,10 +93,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 () => { diff --git a/tests/tools/pages.test.ts b/tests/tools/pages.test.ts index cf75af222..12fcb364a 100644 --- a/tests/tools/pages.test.ts +++ b/tests/tools/pages.test.ts @@ -73,6 +73,109 @@ 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('close_page', () => { it('closes a page', async () => { await withMcpContext(async (response, context) => {