豆豆友情提示:这是一个非官方 GitHub 代理镜像,主要用于网络测试或访问加速。请勿在此进行登录、注册或处理任何敏感信息。进行这些操作请务必访问官方网站 github.com。 Raw 内容也通过此代理提供。
Skip to content
This repository was archived by the owner on Apr 16, 2026. It is now read-only.

Commit 59f6477

Browse files
RobertWspOrKoN
andauthored
feat: add storage-isolated browser contexts (ChromeDevTools#991)
## Summary Adds storage-isolated browser contexts via an optional `isolatedContext` parameter on the `new_page` tool, following the simplified design proposed by @OrKoN in ChromeDevTools#926. Pages created with the same `isolatedContext` name share cookies, localStorage, and storage. Pages in different isolated contexts (or the default context) are fully isolated — ideal for testing multi-user real-time features like chat, notifications, or collaborative editing. ## Changes ### `new_page` tool - New optional `isolatedContext: string` parameter - If specified, creates/reuses a named `BrowserContext` and opens a page in it - If omitted, uses the default browser context (existing behavior unchanged) ### `McpContext` - `#isolatedContexts` Map: LLM-provided names → Puppeteer `BrowserContext` instances - `#pageToIsolatedContextName` WeakMap: GC-safe page → context name reverse lookup - Auto-discovery: externally created browser contexts get `isolated-context-1`, `isolated-context-2`, etc. - `getIsolatedContextName(page)`: returns the isolated context name for a page (used by response formatting) - `page.browserContext()` used for context membership detection (no custom target event forwarding needed) - No context cleanup in `dispose()` or `closePage()` — either the entire browser is closed or we disconnect without destroying state ### `McpResponse` - Page list includes `isolatedContext=${name}` labels (both text and structured JSON output) ### `ToolDefinition` - `Context` interface extended with `getIsolatedContextName(page)` method ## What's NOT included (by design) - **No `TargetEventEmitter`**: Puppeteer forwards target events from `BrowserContext` → `Browser` internally - **No context cleanup**: Browser contexts are not closed on `dispose()` or page close, per maintainer guidance - **No `about:blank` cleanup**: Default context and isolated contexts coexist side-by-side ## Example ``` > new_page url="https://app.example.com/chat" isolatedContext="userA" > new_page url="https://app.example.com/chat" isolatedContext="userB" > list_pages Page 1: [app.example.com/chat] isolatedContext=userA Page 2: [app.example.com/chat] isolatedContext=userB [selected] ``` Pages in different isolated contexts have fully independent cookies, localStorage, IndexedDB, and WebSocket connections. ## Tests - 6 new tests covering `isolatedContext` feature in `tests/tools/pages.test.ts` - All existing tests pass (333+) - Zero type errors, lint clean Closes ChromeDevTools#926 --------- Co-authored-by: Alex Rudenko <alexrudenko@chromium.org>
1 parent 9f88127 commit 59f6477

File tree

7 files changed

+239
-21
lines changed

7 files changed

+239
-21
lines changed

docs/tool-reference.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<!-- AUTO GENERATED DO NOT EDIT - run 'npm run docs' to update-->
22

3-
# Chrome DevTools MCP Tool Reference (~6661 cl100k_base tokens)
3+
# Chrome DevTools MCP Tool Reference (~6719 cl100k_base tokens)
44

55
- **[Input automation](#input-automation)** (8 tools)
66
- [`click`](#click)
@@ -172,6 +172,7 @@
172172

173173
- **url** (string) **(required)**: URL to load in a new page.
174174
- **background** (boolean) _(optional)_: Whether to open the page in the background without bringing it to the front. Default is false (foreground).
175+
- **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.
175176
- **timeout** (integer) _(optional)_: Maximum wait time in milliseconds. If set to 0, the default timeout will be used.
176177

177178
---
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import assert from 'node:assert';
8+
9+
import type {TestScenario} from '../eval_gemini.ts';
10+
11+
export const scenario: TestScenario = {
12+
prompt:
13+
'Create a new page <TEST_URL> in an isolated context called contextB. Take a screenshot there.',
14+
maxTurns: 3,
15+
htmlRoute: {
16+
path: '/test.html',
17+
htmlContent: `
18+
<h1>test</h1>
19+
`,
20+
},
21+
expectations: calls => {
22+
console.log(JSON.stringify(calls, null, 2));
23+
assert.strictEqual(calls.length, 2);
24+
assert.ok(calls[0].name === 'new_page', 'First call should be navigation');
25+
assert.deepStrictEqual(calls[0].args.isolatedContext, 'contextB');
26+
assert.ok(
27+
calls[1].name === 'take_screenshot',
28+
'Second call should be a screenshot',
29+
);
30+
},
31+
};

src/McpContext.ts

Lines changed: 73 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {NetworkCollector, ConsoleCollector} from './PageCollector.js';
1919
import type {DevTools} from './third_party/index.js';
2020
import type {
2121
Browser,
22+
BrowserContext,
2223
ConsoleMessage,
2324
Debugger,
2425
Dialog,
@@ -119,11 +120,17 @@ export class McpContext implements Context {
119120
browser: Browser;
120121
logger: Debugger;
121122

122-
// The most recent page state.
123+
// Maps LLM-provided isolatedContext name → Puppeteer BrowserContext.
124+
#isolatedContexts = new Map<string, BrowserContext>();
125+
// Reverse lookup: Page → isolatedContext name (for snapshot labeling).
126+
// WeakMap so closed pages are garbage-collected automatically.
127+
#pageToIsolatedContextName = new WeakMap<Page, string>();
128+
// Auto-generated name counter for when no name is provided.
129+
#nextIsolatedContextId = 1;
130+
123131
#pages: Page[] = [];
124132
#pageToDevToolsPage = new Map<Page, Page>();
125133
#selectedPage?: Page;
126-
// The most recent snapshot.
127134
#textSnapshot: TextSnapshot | null = null;
128135
#networkCollector: NetworkCollector;
129136
#consoleCollector: ConsoleCollector;
@@ -187,6 +194,10 @@ export class McpContext implements Context {
187194
this.#networkCollector.dispose();
188195
this.#consoleCollector.dispose();
189196
this.#devtoolsUniverseManager.dispose();
197+
// Isolated contexts are intentionally not closed here.
198+
// Either the entire browser will be closed or we disconnect
199+
// without destroying browser state.
200+
this.#isolatedContexts.clear();
190201
}
191202

192203
static async from(
@@ -269,8 +280,22 @@ export class McpContext implements Context {
269280
return this.#consoleCollector.getById(this.getSelectedPage(), id);
270281
}
271282

272-
async newPage(background?: boolean): Promise<Page> {
273-
const page = await this.browser.newPage({background});
283+
async newPage(
284+
background?: boolean,
285+
isolatedContextName?: string,
286+
): Promise<Page> {
287+
let page: Page;
288+
if (isolatedContextName !== undefined) {
289+
let ctx = this.#isolatedContexts.get(isolatedContextName);
290+
if (!ctx) {
291+
ctx = await this.browser.createBrowserContext();
292+
this.#isolatedContexts.set(isolatedContextName, ctx);
293+
}
294+
page = await ctx.newPage();
295+
this.#pageToIsolatedContextName.set(page, isolatedContextName);
296+
} else {
297+
page = await this.browser.newPage({background});
298+
}
274299
await this.createPagesSnapshot();
275300
this.selectPage(page);
276301
this.#networkCollector.addPage(page);
@@ -283,6 +308,7 @@ export class McpContext implements Context {
283308
}
284309
const page = this.getPageById(pageId);
285310
await page.close({runBeforeUnload: false});
311+
this.#pageToIsolatedContextName.delete(page);
286312
}
287313

288314
getNetworkRequestById(reqid: number): HTTPRequest {
@@ -558,13 +584,8 @@ export class McpContext implements Context {
558584
}
559585
}
560586

561-
/**
562-
* Creates a snapshot of the pages.
563-
*/
564587
async createPagesSnapshot(): Promise<Page[]> {
565-
const allPages = await this.browser.pages(
566-
this.#options.experimentalIncludeAllPages,
567-
);
588+
const allPages = await this.#getAllPages();
568589

569590
for (const page of allPages) {
570591
if (!this.#pageIdMap.has(page)) {
@@ -573,8 +594,6 @@ export class McpContext implements Context {
573594
}
574595

575596
this.#pages = allPages.filter(page => {
576-
// If we allow debugging DevTools windows, return all pages.
577-
// If we are in regular mode, the user should only see non-DevTools page.
578597
return (
579598
this.#options.experimentalDevToolsDebugging ||
580599
!page.url().startsWith('devtools://')
@@ -593,11 +612,44 @@ export class McpContext implements Context {
593612
return this.#pages;
594613
}
595614

596-
async detectOpenDevToolsWindows() {
597-
this.logger('Detecting open DevTools windows');
598-
const pages = await this.browser.pages(
615+
async #getAllPages(): Promise<Page[]> {
616+
const defaultCtx = this.browser.defaultBrowserContext();
617+
const allPages = await this.browser.pages(
599618
this.#options.experimentalIncludeAllPages,
600619
);
620+
621+
// Build a reverse lookup from BrowserContext instance → name.
622+
const contextToName = new Map<BrowserContext, string>();
623+
for (const [name, ctx] of this.#isolatedContexts) {
624+
contextToName.set(ctx, name);
625+
}
626+
627+
// Auto-discover BrowserContexts not in our mapping (e.g., externally
628+
// created incognito contexts) and assign generated names.
629+
const knownContexts = new Set(this.#isolatedContexts.values());
630+
for (const ctx of this.browser.browserContexts()) {
631+
if (ctx !== defaultCtx && !ctx.closed && !knownContexts.has(ctx)) {
632+
const name = `isolated-context-${this.#nextIsolatedContextId++}`;
633+
this.#isolatedContexts.set(name, ctx);
634+
contextToName.set(ctx, name);
635+
}
636+
}
637+
638+
// Use page.browserContext() to determine each page's context membership.
639+
for (const page of allPages) {
640+
const ctx = page.browserContext();
641+
const name = contextToName.get(ctx);
642+
if (name) {
643+
this.#pageToIsolatedContextName.set(page, name);
644+
}
645+
}
646+
647+
return allPages;
648+
}
649+
650+
async detectOpenDevToolsWindows() {
651+
this.logger('Detecting open DevTools windows');
652+
const pages = await this.#getAllPages();
601653
this.#pageToDevToolsPage = new Map<Page, Page>();
602654
for (const devToolsPage of pages) {
603655
if (devToolsPage.url().startsWith('devtools://')) {
@@ -629,6 +681,10 @@ export class McpContext implements Context {
629681
return this.#pages;
630682
}
631683

684+
getIsolatedContextName(page: Page): string | undefined {
685+
return this.#pageToIsolatedContextName.get(page);
686+
}
687+
632688
getDevToolsPage(page: Page): Page | undefined {
633689
return this.#pageToDevToolsPage.get(page);
634690
}
@@ -857,7 +913,8 @@ export class McpContext implements Context {
857913
},
858914
} as ListenerMap;
859915
});
860-
await this.#networkCollector.init(await this.browser.pages());
916+
const pages = await this.#getAllPages();
917+
await this.#networkCollector.init(pages);
861918
}
862919

863920
async installExtension(extensionPath: string): Promise<string> {

src/McpResponse.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -504,17 +504,31 @@ Call ${handleDialog.name} to handle it before continuing.`);
504504
if (this.#includePages) {
505505
const parts = [`## Pages`];
506506
for (const page of context.getPages()) {
507+
const isolatedContextName = context.getIsolatedContextName(page);
508+
const contextLabel = isolatedContextName
509+
? ` isolatedContext=${isolatedContextName}`
510+
: '';
507511
parts.push(
508-
`${context.getPageId(page)}: ${page.url()}${context.isPageSelected(page) ? ' [selected]' : ''}`,
512+
`${context.getPageId(page)}: ${page.url()}${context.isPageSelected(page) ? ' [selected]' : ''}${contextLabel}`,
509513
);
510514
}
511515
response.push(...parts);
512516
structuredContent.pages = context.getPages().map(page => {
513-
return {
517+
const isolatedContextName = context.getIsolatedContextName(page);
518+
const entry: {
519+
id: number | undefined;
520+
url: string;
521+
selected: boolean;
522+
isolatedContext?: string;
523+
} = {
514524
id: context.getPageId(page),
515525
url: page.url(),
516526
selected: context.isPageSelected(page),
517527
};
528+
if (isolatedContextName) {
529+
entry.isolatedContext = isolatedContextName;
530+
}
531+
return entry;
518532
});
519533
}
520534

src/tools/ToolDefinition.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,9 +112,10 @@ export type Context = Readonly<{
112112
getPageById(pageId: number): Page;
113113
getPageId(page: Page): number | undefined;
114114
isPageSelected(page: Page): boolean;
115-
newPage(background?: boolean): Promise<Page>;
115+
newPage(background?: boolean, isolatedContextName?: string): Promise<Page>;
116116
closePage(pageId: number): Promise<void>;
117117
selectPage(page: Page): void;
118+
getIsolatedContextName(page: Page): string | undefined;
118119
getElementByUid(uid: string): Promise<ElementHandle<Element>>;
119120
getAXNodeByUid(uid: string): TextSnapshotNode | undefined;
120121
emulate(options: {

src/tools/pages.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,10 +93,21 @@ export const newPage = defineTool({
9393
.describe(
9494
'Whether to open the page in the background without bringing it to the front. Default is false (foreground).',
9595
),
96+
isolatedContext: zod
97+
.string()
98+
.optional()
99+
.describe(
100+
'If specified, the page is created in an isolated browser context with the given name. ' +
101+
'Pages in the same browser context share cookies and storage. ' +
102+
'Pages in different browser contexts are fully isolated.',
103+
),
96104
...timeoutSchema,
97105
},
98106
handler: async (request, response, context) => {
99-
const page = await context.newPage(request.params.background);
107+
const page = await context.newPage(
108+
request.params.background,
109+
request.params.isolatedContext,
110+
);
100111

101112
await context.waitForEventsAfterAction(
102113
async () => {

tests/tools/pages.test.ts

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,109 @@ describe('pages', () => {
7373
});
7474
});
7575
});
76+
describe('new_page with isolatedContext', () => {
77+
it('creates a page in an isolated context', async () => {
78+
await withMcpContext(async (response, context) => {
79+
await newPage.handler(
80+
{params: {url: 'about:blank', isolatedContext: 'session-a'}},
81+
response,
82+
context,
83+
);
84+
const page = context.getSelectedPage();
85+
assert.strictEqual(context.getIsolatedContextName(page), 'session-a');
86+
assert.ok(response.includePages);
87+
});
88+
});
89+
90+
it('reuses the same context for the same isolatedContext name', async () => {
91+
await withMcpContext(async (response, context) => {
92+
await newPage.handler(
93+
{params: {url: 'about:blank', isolatedContext: 'session-a'}},
94+
response,
95+
context,
96+
);
97+
const page1 = context.getSelectedPage();
98+
await newPage.handler(
99+
{params: {url: 'about:blank', isolatedContext: 'session-a'}},
100+
response,
101+
context,
102+
);
103+
const page2 = context.getSelectedPage();
104+
assert.notStrictEqual(page1, page2);
105+
assert.strictEqual(context.getIsolatedContextName(page1), 'session-a');
106+
assert.strictEqual(context.getIsolatedContextName(page2), 'session-a');
107+
assert.strictEqual(page1.browserContext(), page2.browserContext());
108+
});
109+
});
110+
111+
it('creates separate contexts for different isolatedContext names', async () => {
112+
await withMcpContext(async (response, context) => {
113+
await newPage.handler(
114+
{params: {url: 'about:blank', isolatedContext: 'session-a'}},
115+
response,
116+
context,
117+
);
118+
const pageA = context.getSelectedPage();
119+
await newPage.handler(
120+
{params: {url: 'about:blank', isolatedContext: 'session-b'}},
121+
response,
122+
context,
123+
);
124+
const pageB = context.getSelectedPage();
125+
assert.strictEqual(context.getIsolatedContextName(pageA), 'session-a');
126+
assert.strictEqual(context.getIsolatedContextName(pageB), 'session-b');
127+
assert.notStrictEqual(pageA.browserContext(), pageB.browserContext());
128+
});
129+
});
130+
131+
it('includes isolatedContext in page listing', async () => {
132+
await withMcpContext(async (response, context) => {
133+
await newPage.handler(
134+
{params: {url: 'about:blank', isolatedContext: 'session-a'}},
135+
response,
136+
context,
137+
);
138+
const result = await response.handle('new_page', context);
139+
const pages = (
140+
result.structuredContent as {pages: Array<{isolatedContext?: string}>}
141+
).pages;
142+
const isolatedPage = pages.find(p => p.isolatedContext === 'session-a');
143+
assert.ok(isolatedPage);
144+
});
145+
});
146+
147+
it('does not set isolatedContext for pages in the default context', async () => {
148+
await withMcpContext(async (response, context) => {
149+
const page = context.getSelectedPage();
150+
assert.strictEqual(context.getIsolatedContextName(page), undefined);
151+
await newPage.handler(
152+
{params: {url: 'about:blank'}},
153+
response,
154+
context,
155+
);
156+
assert.strictEqual(
157+
context.getIsolatedContextName(context.getSelectedPage()),
158+
undefined,
159+
);
160+
});
161+
});
162+
163+
it('closes an isolated page without errors', async () => {
164+
await withMcpContext(async (response, context) => {
165+
await newPage.handler(
166+
{params: {url: 'about:blank', isolatedContext: 'session-a'}},
167+
response,
168+
context,
169+
);
170+
const page = context.getSelectedPage();
171+
const pageId = context.getPageId(page)!;
172+
assert.ok(!page.isClosed());
173+
await closePage.handler({params: {pageId}}, response, context);
174+
assert.ok(page.isClosed());
175+
});
176+
});
177+
});
178+
76179
describe('close_page', () => {
77180
it('closes a page', async () => {
78181
await withMcpContext(async (response, context) => {

0 commit comments

Comments
 (0)