Skip to content

Commit f84a246

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 f84a246

5 files changed

Lines changed: 141 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: 109 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,14 @@ export class McpContext implements Context {
187194
this.#networkCollector.dispose();
188195
this.#consoleCollector.dispose();
189196
this.#devtoolsUniverseManager.dispose();
197+
for (const ctx of this.#isolatedContexts.values()) {
198+
if (!ctx.closed) {
199+
void ctx.close().catch(() => {
200+
// Swallow errors during cleanup.
201+
});
202+
}
203+
}
204+
this.#isolatedContexts.clear();
190205
}
191206

192207
static async from(
@@ -269,8 +284,41 @@ export class McpContext implements Context {
269284
return this.#consoleCollector.getById(this.getSelectedPage(), id);
270285
}
271286

272-
async newPage(background?: boolean): Promise<Page> {
273-
const page = await this.browser.newPage({background});
287+
async newPage(
288+
background?: boolean,
289+
isolatedContextName?: string,
290+
): Promise<Page> {
291+
let page: Page;
292+
if (isolatedContextName !== undefined) {
293+
const isFirstIsolatedContext = this.#isolatedContexts.size === 0;
294+
let ctx = this.#isolatedContexts.get(isolatedContextName);
295+
if (!ctx) {
296+
ctx = await this.browser.createBrowserContext();
297+
this.#isolatedContexts.set(isolatedContextName, ctx);
298+
}
299+
page = await ctx.newPage();
300+
this.#pageToIsolatedContextName.set(page, isolatedContextName);
301+
302+
// On the first isolated context creation, close any leftover
303+
// about:blank pages from the default context. Chrome always opens
304+
// an initial about:blank tab that is no longer needed once isolated
305+
// contexts are in use. We only do this once to avoid closing pages
306+
// the LLM may have explicitly opened in the default context later.
307+
if (isFirstIsolatedContext) {
308+
const defaultPages = await this.browser.defaultBrowserContext().pages();
309+
for (const dp of defaultPages) {
310+
if (dp.url() === 'about:blank') {
311+
try {
312+
await dp.close();
313+
} catch {
314+
// Page may already be closed.
315+
}
316+
}
317+
}
318+
}
319+
} else {
320+
page = await this.browser.newPage({background});
321+
}
274322
await this.createPagesSnapshot();
275323
this.selectPage(page);
276324
this.#networkCollector.addPage(page);
@@ -282,7 +330,20 @@ export class McpContext implements Context {
282330
throw new Error(CLOSE_PAGE_ERROR);
283331
}
284332
const page = this.getPageById(pageId);
333+
const isolatedContextName = this.#pageToIsolatedContextName.get(page);
285334
await page.close({runBeforeUnload: false});
335+
this.#pageToIsolatedContextName.delete(page);
336+
337+
if (isolatedContextName) {
338+
const ctx = this.#isolatedContexts.get(isolatedContextName);
339+
if (ctx && !ctx.closed) {
340+
const remainingPages = await ctx.pages();
341+
if (remainingPages.length === 0) {
342+
await ctx.close();
343+
this.#isolatedContexts.delete(isolatedContextName);
344+
}
345+
}
346+
}
286347
}
287348

288349
getNetworkRequestById(reqid: number): HTTPRequest {
@@ -558,13 +619,8 @@ export class McpContext implements Context {
558619
}
559620
}
560621

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

569625
for (const page of allPages) {
570626
if (!this.#pageIdMap.has(page)) {
@@ -573,8 +629,6 @@ export class McpContext implements Context {
573629
}
574630

575631
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.
578632
return (
579633
this.#options.experimentalDevToolsDebugging ||
580634
!page.url().startsWith('devtools://')
@@ -593,11 +647,44 @@ export class McpContext implements Context {
593647
return this.#pages;
594648
}
595649

596-
async detectOpenDevToolsWindows() {
597-
this.logger('Detecting open DevTools windows');
598-
const pages = await this.browser.pages(
650+
async #getAllPages(): Promise<Page[]> {
651+
const defaultCtx = this.browser.defaultBrowserContext();
652+
const allPages = await this.browser.pages(
599653
this.#options.experimentalIncludeAllPages,
600654
);
655+
656+
// Build a reverse lookup from BrowserContext instance → name.
657+
const contextToName = new Map<BrowserContext, string>();
658+
for (const [name, ctx] of this.#isolatedContexts) {
659+
contextToName.set(ctx, name);
660+
}
661+
662+
// Auto-discover BrowserContexts not in our mapping (e.g., externally
663+
// created incognito contexts) and assign generated names.
664+
const knownContexts = new Set(this.#isolatedContexts.values());
665+
for (const ctx of this.browser.browserContexts()) {
666+
if (ctx !== defaultCtx && !ctx.closed && !knownContexts.has(ctx)) {
667+
const name = `isolated-context-${this.#nextIsolatedContextId++}`;
668+
this.#isolatedContexts.set(name, ctx);
669+
contextToName.set(ctx, name);
670+
}
671+
}
672+
673+
// Use page.browserContext() to determine each page's context membership.
674+
for (const page of allPages) {
675+
const ctx = page.browserContext();
676+
const name = contextToName.get(ctx);
677+
if (name) {
678+
this.#pageToIsolatedContextName.set(page, name);
679+
}
680+
}
681+
682+
return allPages;
683+
}
684+
685+
async detectOpenDevToolsWindows() {
686+
this.logger('Detecting open DevTools windows');
687+
const pages = await this.#getAllPages();
601688
this.#pageToDevToolsPage = new Map<Page, Page>();
602689
for (const devToolsPage of pages) {
603690
if (devToolsPage.url().startsWith('devtools://')) {
@@ -629,6 +716,10 @@ export class McpContext implements Context {
629716
return this.#pages;
630717
}
631718

719+
getIsolatedContextName(page: Page): string | undefined {
720+
return this.#pageToIsolatedContextName.get(page);
721+
}
722+
632723
getDevToolsPage(page: Page): Page | undefined {
633724
return this.#pageToDevToolsPage.get(page);
634725
}
@@ -857,7 +948,8 @@ export class McpContext implements Context {
857948
},
858949
} as ListenerMap;
859950
});
860-
await this.#networkCollector.init(await this.browser.pages());
951+
const pages = await this.browser.pages();
952+
await this.#networkCollector.init(pages);
861953
}
862954

863955
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 () => {

0 commit comments

Comments
 (0)