Skip to content

Commit cd9c325

Browse files
committed
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
1 parent 33446d4 commit cd9c325

6 files changed

Lines changed: 227 additions & 22 deletions

File tree

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
---

src/McpContext.ts

Lines changed: 92 additions & 17 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,
@@ -74,7 +75,7 @@ interface EmulationSettings {
7475
viewport?: Viewport | null;
7576
}
7677

77-
interface McpContextOptions {
78+
export interface McpContextOptions {
7879
// Whether the DevTools windows are exposed as pages for debugging of DevTools.
7980
experimentalDevToolsDebugging: boolean;
8081
// Whether all page-like targets are exposed as pages.
@@ -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,9 @@ 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.
190200
}
191201

192202
static async from(
@@ -269,8 +279,41 @@ export class McpContext implements Context {
269279
return this.#consoleCollector.getById(this.getSelectedPage(), id);
270280
}
271281

272-
async newPage(background?: boolean): Promise<Page> {
273-
const page = await this.browser.newPage({background});
282+
async newPage(
283+
background?: boolean,
284+
isolatedContextName?: string,
285+
): Promise<Page> {
286+
let page: Page;
287+
if (isolatedContextName !== undefined) {
288+
const isFirstIsolatedContext = this.#isolatedContexts.size === 0;
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+
297+
// On the first isolated context creation, close any leftover
298+
// about:blank pages from the default context. Chrome always opens
299+
// an initial about:blank tab that is no longer needed once isolated
300+
// contexts are in use. We only do this once to avoid closing pages
301+
// the LLM may have explicitly opened in the default context later.
302+
if (isFirstIsolatedContext) {
303+
const defaultPages = await this.browser.defaultBrowserContext().pages();
304+
for (const dp of defaultPages) {
305+
if (dp.url() === 'about:blank') {
306+
try {
307+
await dp.close();
308+
} catch {
309+
// Page may already be closed.
310+
}
311+
}
312+
}
313+
}
314+
} else {
315+
page = await this.browser.newPage({background});
316+
}
274317
await this.createPagesSnapshot();
275318
this.selectPage(page);
276319
this.#networkCollector.addPage(page);
@@ -283,6 +326,7 @@ export class McpContext implements Context {
283326
}
284327
const page = this.getPageById(pageId);
285328
await page.close({runBeforeUnload: false});
329+
this.#pageToIsolatedContextName.delete(page);
286330
}
287331

288332
getNetworkRequestById(reqid: number): HTTPRequest {
@@ -558,13 +602,8 @@ export class McpContext implements Context {
558602
}
559603
}
560604

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

569608
for (const page of allPages) {
570609
if (!this.#pageIdMap.has(page)) {
@@ -573,8 +612,6 @@ export class McpContext implements Context {
573612
}
574613

575614
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.
578615
return (
579616
this.#options.experimentalDevToolsDebugging ||
580617
!page.url().startsWith('devtools://')
@@ -593,11 +630,44 @@ export class McpContext implements Context {
593630
return this.#pages;
594631
}
595632

596-
async detectOpenDevToolsWindows() {
597-
this.logger('Detecting open DevTools windows');
598-
const pages = await this.browser.pages(
633+
async #getAllPages(): Promise<Page[]> {
634+
const defaultCtx = this.browser.defaultBrowserContext();
635+
const allPages = await this.browser.pages(
599636
this.#options.experimentalIncludeAllPages,
600637
);
638+
639+
// Build a reverse lookup from BrowserContext instance → name.
640+
const contextToName = new Map<BrowserContext, string>();
641+
for (const [name, ctx] of this.#isolatedContexts) {
642+
contextToName.set(ctx, name);
643+
}
644+
645+
// Auto-discover BrowserContexts not in our mapping (e.g., externally
646+
// created incognito contexts) and assign generated names.
647+
const knownContexts = new Set(this.#isolatedContexts.values());
648+
for (const ctx of this.browser.browserContexts()) {
649+
if (ctx !== defaultCtx && !ctx.closed && !knownContexts.has(ctx)) {
650+
const name = `isolated-context-${this.#nextIsolatedContextId++}`;
651+
this.#isolatedContexts.set(name, ctx);
652+
contextToName.set(ctx, name);
653+
}
654+
}
655+
656+
// Use page.browserContext() to determine each page's context membership.
657+
for (const page of allPages) {
658+
const ctx = page.browserContext();
659+
const name = contextToName.get(ctx);
660+
if (name) {
661+
this.#pageToIsolatedContextName.set(page, name);
662+
}
663+
}
664+
665+
return allPages;
666+
}
667+
668+
async detectOpenDevToolsWindows() {
669+
this.logger('Detecting open DevTools windows');
670+
const pages = await this.#getAllPages();
601671
this.#pageToDevToolsPage = new Map<Page, Page>();
602672
for (const devToolsPage of pages) {
603673
if (devToolsPage.url().startsWith('devtools://')) {
@@ -629,6 +699,10 @@ export class McpContext implements Context {
629699
return this.#pages;
630700
}
631701

702+
getIsolatedContextName(page: Page): string | undefined {
703+
return this.#pageToIsolatedContextName.get(page);
704+
}
705+
632706
getDevToolsPage(page: Page): Page | undefined {
633707
return this.#pageToDevToolsPage.get(page);
634708
}
@@ -857,7 +931,8 @@ export class McpContext implements Context {
857931
},
858932
} as ListenerMap;
859933
});
860-
await this.#networkCollector.init(await this.browser.pages());
934+
const pages = await this.browser.pages();
935+
await this.#networkCollector.init(pages);
861936
}
862937

863938
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)