From 4006ea92b7072c730bd16925141489d99a1a3c2e Mon Sep 17 00:00:00 2001 From: RobertWsp Date: Fri, 20 Feb 2026 10:59:13 -0300 Subject: [PATCH 1/6] feat: add isolatedContext parameter to new_page for multi-session support Add optional `isolatedContext` parameter to `new_page` tool that creates pages in isolated browser contexts (separate cookies, storage, WebSocket connections). This enables testing multi-user scenarios where an LLM needs simultaneous sessions as different users. Implementation: - new_page accepts optional isolatedContext string parameter - McpContext manages a Map of named BrowserContexts - Pages created with the same context name share an isolated environment - Pages list displays context labels for easy identification - Uses page.browserContext() for reverse-lookup instead of iterating contexts Closes #926 --- docs/tool-reference.md | 3 +- src/McpContext.ts | 90 +++++++++++++++++++++++++------ src/McpResponse.ts | 18 ++++++- src/tools/ToolDefinition.ts | 3 +- src/tools/pages.ts | 13 ++++- tests/tools/pages.test.ts | 103 ++++++++++++++++++++++++++++++++++++ 6 files changed, 208 insertions(+), 22 deletions(-) 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/src/McpContext.ts b/src/McpContext.ts index feaf78267..3e591b31f 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, @@ -74,7 +75,7 @@ interface EmulationSettings { viewport?: Viewport | null; } -interface McpContextOptions { +export interface McpContextOptions { // Whether the DevTools windows are exposed as pages for debugging of DevTools. experimentalDevToolsDebugging: boolean; // Whether all page-like targets are exposed as pages. @@ -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,9 @@ 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. } static async from( @@ -269,8 +279,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 +307,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 +583,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 +593,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 +611,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 +680,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 +912,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..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) => { From c03a05a7da644b0607352f4e7a15cc3a24db60c5 Mon Sep 17 00:00:00 2001 From: Alex Rudenko Date: Mon, 23 Feb 2026 11:43:00 +0100 Subject: [PATCH 2/6] chore: eval isolated context --- .../eval_scenarios/isolated_context_test.ts | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 scripts/eval_scenarios/isolated_context_test.ts 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'); + }, +}; From eec4df91aff980458f2f68e3819a0b746475ebb2 Mon Sep 17 00:00:00 2001 From: Alex Rudenko Date: Mon, 23 Feb 2026 11:45:36 +0100 Subject: [PATCH 3/6] chore: remove export --- src/McpContext.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/McpContext.ts b/src/McpContext.ts index 3e591b31f..6010b7226 100644 --- a/src/McpContext.ts +++ b/src/McpContext.ts @@ -75,7 +75,7 @@ interface EmulationSettings { viewport?: Viewport | null; } -export interface McpContextOptions { +interface McpContextOptions { // Whether the DevTools windows are exposed as pages for debugging of DevTools. experimentalDevToolsDebugging: boolean; // Whether all page-like targets are exposed as pages. From ec0c4d1c5915bfde335a8c32067052943d3c4648 Mon Sep 17 00:00:00 2001 From: Alex Rudenko Date: Mon, 23 Feb 2026 11:47:28 +0100 Subject: [PATCH 4/6] chore: clear ref --- src/McpContext.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/McpContext.ts b/src/McpContext.ts index 6010b7226..fffd15f6e 100644 --- a/src/McpContext.ts +++ b/src/McpContext.ts @@ -197,6 +197,7 @@ export class McpContext implements Context { // 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( From 4a9c7d20e4a7dca7f98ae4f083e23110fce05100 Mon Sep 17 00:00:00 2001 From: Alex Rudenko Date: Mon, 23 Feb 2026 11:53:34 +0100 Subject: [PATCH 5/6] chore: fix getter --- src/McpContext.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/McpContext.ts b/src/McpContext.ts index fffd15f6e..83697b5fb 100644 --- a/src/McpContext.ts +++ b/src/McpContext.ts @@ -913,7 +913,7 @@ export class McpContext implements Context { }, } as ListenerMap; }); - const pages = await this.browser.pages(); + const pages = await this.#getAllPages(); await this.#networkCollector.init(pages); } From b24c2677f3439da4882c1f440eedaae738434280 Mon Sep 17 00:00:00 2001 From: Alex Rudenko Date: Mon, 23 Feb 2026 11:54:25 +0100 Subject: [PATCH 6/6] chore: fix format --- scripts/eval_scenarios/isolated_context_test.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/scripts/eval_scenarios/isolated_context_test.ts b/scripts/eval_scenarios/isolated_context_test.ts index 5416aa622..0d76e5fe3 100644 --- a/scripts/eval_scenarios/isolated_context_test.ts +++ b/scripts/eval_scenarios/isolated_context_test.ts @@ -19,10 +19,13 @@ export const scenario: TestScenario = { `, }, expectations: calls => { - console.log(JSON.stringify(calls, null, 2)) + 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'); + assert.deepStrictEqual(calls[0].args.isolatedContext, 'contextB'); + assert.ok( + calls[1].name === 'take_screenshot', + 'Second call should be a screenshot', + ); }, };