Skip to content

Commit b69327d

Browse files
committed
feat: add browserContext parameter to new_page for multi-session support
Replace dedicated session tools with a simpler design: an optional browserContext parameter on new_page that creates isolated browser contexts managed inside McpContext. - Add browserContext?: string param to new_page tool schema - McpContext maintains name-to-BrowserContext mapping with auto-discovery of externally created contexts (browser-context-N naming) - Page list snapshots include browserContext=name labels (text + JSON) - closePage cleans up empty BrowserContexts automatically - Pattern C: close leftover about:blank on first context creation - Use WeakMap for page-to-context-name reverse lookup (GC-safe) - Deduplication-safe createPagesSnapshot via Set filtering
1 parent 2a9d906 commit b69327d

5 files changed

Lines changed: 196 additions & 60 deletions

File tree

src/McpContext.ts

Lines changed: 124 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,16 @@ import {
1515
urlsEqual,
1616
} from './DevtoolsUtils.js';
1717
import type {ListenerMap, UncaughtError} from './PageCollector.js';
18-
import {NetworkCollector, ConsoleCollector} from './PageCollector.js';
18+
import {
19+
asTargetEmitter,
20+
NetworkCollector,
21+
ConsoleCollector,
22+
} from './PageCollector.js';
1923
import {Locator} from './third_party/index.js';
2024
import type {DevTools} from './third_party/index.js';
2125
import type {
2226
Browser,
27+
BrowserContext,
2328
ConsoleMessage,
2429
Debugger,
2530
Dialog,
@@ -64,7 +69,7 @@ export interface TextSnapshot {
6469
verbose: boolean;
6570
}
6671

67-
interface McpContextOptions {
72+
export interface McpContextOptions {
6873
// Whether the DevTools windows are exposed as pages for debugging of DevTools.
6974
experimentalDevToolsDebugging: boolean;
7075
// Whether all page-like targets are exposed as pages.
@@ -109,11 +114,17 @@ export class McpContext implements Context {
109114
browser: Browser;
110115
logger: Debugger;
111116

112-
// The most recent page state.
117+
// Maps LLM-provided browserContext name → Puppeteer BrowserContext.
118+
#browserContexts = new Map<string, BrowserContext>();
119+
// Reverse lookup: Page → browserContext name (for snapshot labeling).
120+
// WeakMap so closed pages are garbage-collected automatically.
121+
#pageToBrowserContextName = new WeakMap<Page, string>();
122+
// Auto-generated name counter for when no name is provided.
123+
#nextBrowserContextId = 1;
124+
113125
#pages: Page[] = [];
114126
#pageToDevToolsPage = new Map<Page, Page>();
115127
#selectedPage?: Page;
116-
// The most recent snapshot.
117128
#textSnapshot: TextSnapshot | null = null;
118129
#networkCollector: NetworkCollector;
119130
#consoleCollector: ConsoleCollector;
@@ -151,9 +162,11 @@ export class McpContext implements Context {
151162
this.#locatorClass = locatorClass;
152163
this.#options = options;
153164

154-
this.#networkCollector = new NetworkCollector(this.browser);
165+
const targetEmitter = asTargetEmitter(this.browser);
155166

156-
this.#consoleCollector = new ConsoleCollector(this.browser, collect => {
167+
this.#networkCollector = new NetworkCollector(targetEmitter);
168+
169+
this.#consoleCollector = new ConsoleCollector(targetEmitter, collect => {
157170
return {
158171
console: event => {
159172
collect(event);
@@ -166,7 +179,7 @@ export class McpContext implements Context {
166179
},
167180
} as ListenerMap;
168181
});
169-
this.#devtoolsUniverseManager = new UniverseManager(this.browser);
182+
this.#devtoolsUniverseManager = new UniverseManager(targetEmitter);
170183
}
171184

172185
async #init() {
@@ -180,6 +193,12 @@ export class McpContext implements Context {
180193
this.#networkCollector.dispose();
181194
this.#consoleCollector.dispose();
182195
this.#devtoolsUniverseManager.dispose();
196+
for (const ctx of this.#browserContexts.values()) {
197+
if (!ctx.closed) {
198+
void ctx.close().catch(() => {});
199+
}
200+
}
201+
this.#browserContexts.clear();
183202
}
184203

185204
static async from(
@@ -262,8 +281,42 @@ export class McpContext implements Context {
262281
return this.#consoleCollector.getById(this.getSelectedPage(), id);
263282
}
264283

265-
async newPage(background?: boolean): Promise<Page> {
266-
const page = await this.browser.newPage({background});
284+
async newPage(
285+
background?: boolean,
286+
browserContextName?: string,
287+
): Promise<Page> {
288+
let page: Page;
289+
if (browserContextName !== undefined) {
290+
const isFirstBrowserContext = this.#browserContexts.size === 0;
291+
let ctx = this.#browserContexts.get(browserContextName);
292+
if (!ctx) {
293+
ctx = await this.browser.createBrowserContext();
294+
this.#browserContexts.set(browserContextName, ctx);
295+
}
296+
page = await ctx.newPage();
297+
this.#pageToBrowserContextName.set(page, browserContextName);
298+
299+
// On the first browser context creation, close any leftover
300+
// about:blank pages from the default context. Chrome always opens
301+
// an initial about:blank tab that is no longer needed once named
302+
// browser contexts are in use. We only do this once to avoid
303+
// closing pages the LLM may have explicitly opened in the default
304+
// context later.
305+
if (isFirstBrowserContext) {
306+
const defaultPages = await this.browser.defaultBrowserContext().pages();
307+
for (const dp of defaultPages) {
308+
if (dp.url() === 'about:blank') {
309+
try {
310+
await dp.close();
311+
} catch {
312+
// Page may already be closed.
313+
}
314+
}
315+
}
316+
}
317+
} else {
318+
page = await this.browser.newPage({background});
319+
}
267320
await this.createPagesSnapshot();
268321
this.selectPage(page);
269322
this.#networkCollector.addPage(page);
@@ -275,7 +328,20 @@ export class McpContext implements Context {
275328
throw new Error(CLOSE_PAGE_ERROR);
276329
}
277330
const page = this.getPageById(pageId);
331+
const browserContextName = this.#pageToBrowserContextName.get(page);
278332
await page.close({runBeforeUnload: false});
333+
this.#pageToBrowserContextName.delete(page);
334+
335+
if (browserContextName) {
336+
const ctx = this.#browserContexts.get(browserContextName);
337+
if (ctx && !ctx.closed) {
338+
const remainingPages = await ctx.pages();
339+
if (remainingPages.length === 0) {
340+
await ctx.close();
341+
this.#browserContexts.delete(browserContextName);
342+
}
343+
}
344+
}
279345
}
280346

281347
getNetworkRequestById(reqid: number): HTTPRequest {
@@ -481,13 +547,40 @@ export class McpContext implements Context {
481547
}
482548
}
483549

484-
/**
485-
* Creates a snapshot of the pages.
486-
*/
487550
async createPagesSnapshot(): Promise<Page[]> {
488-
const allPages = await this.browser.pages(
551+
// Auto-discover BrowserContexts not in our mapping (e.g., externally
552+
// created incognito contexts) and assign generated names.
553+
const defaultCtx = this.browser.defaultBrowserContext();
554+
const knownContexts = new Set(this.#browserContexts.values());
555+
for (const ctx of this.browser.browserContexts()) {
556+
if (ctx !== defaultCtx && !ctx.closed && !knownContexts.has(ctx)) {
557+
this.#browserContexts.set(
558+
`browser-context-${this.#nextBrowserContextId++}`,
559+
ctx,
560+
);
561+
}
562+
}
563+
564+
const contextPages: Page[] = [];
565+
for (const [name, ctx] of this.#browserContexts) {
566+
if (!ctx.closed) {
567+
const pages = await ctx.pages();
568+
for (const page of pages) {
569+
this.#pageToBrowserContextName.set(page, name);
570+
}
571+
contextPages.push(...pages);
572+
}
573+
}
574+
575+
// browser.pages() returns pages from ALL contexts (default + incognito).
576+
// Filter out pages that belong to named browser contexts to avoid duplicates.
577+
const contextPagesSet = new Set(contextPages);
578+
const browserPages = await this.browser.pages(
489579
this.#options.experimentalIncludeAllPages,
490580
);
581+
const defaultPages = browserPages.filter(p => !contextPagesSet.has(p));
582+
583+
const allPages = [...defaultPages, ...contextPages];
491584

492585
for (const page of allPages) {
493586
if (!this.#pageIdMap.has(page)) {
@@ -496,8 +589,6 @@ export class McpContext implements Context {
496589
}
497590

498591
this.#pages = allPages.filter(page => {
499-
// If we allow debugging DevTools windows, return all pages.
500-
// If we are in regular mode, the user should only see non-DevTools page.
501592
return (
502593
this.#options.experimentalDevToolsDebugging ||
503594
!page.url().startsWith('devtools://')
@@ -518,9 +609,18 @@ export class McpContext implements Context {
518609

519610
async detectOpenDevToolsWindows() {
520611
this.logger('Detecting open DevTools windows');
521-
const pages = await this.browser.pages(
612+
const contextPages: Page[] = [];
613+
for (const ctx of this.#browserContexts.values()) {
614+
if (!ctx.closed) {
615+
contextPages.push(...(await ctx.pages()));
616+
}
617+
}
618+
const contextPagesSet = new Set(contextPages);
619+
const browserPages = await this.browser.pages(
522620
this.#options.experimentalIncludeAllPages,
523621
);
622+
const defaultPages = browserPages.filter(p => !contextPagesSet.has(p));
623+
const pages = [...defaultPages, ...contextPages];
524624
this.#pageToDevToolsPage = new Map<Page, Page>();
525625
for (const devToolsPage of pages) {
526626
if (devToolsPage.url().startsWith('devtools://')) {
@@ -552,6 +652,10 @@ export class McpContext implements Context {
552652
return this.#pages;
553653
}
554654

655+
getBrowserContextName(page: Page): string | undefined {
656+
return this.#pageToBrowserContextName.get(page);
657+
}
658+
555659
getDevToolsPage(page: Page): Page | undefined {
556660
return this.#pageToDevToolsPage.get(page);
557661
}
@@ -770,7 +874,8 @@ export class McpContext implements Context {
770874
* We need to ignore favicon request as they make our test flaky
771875
*/
772876
async setUpNetworkCollectorForTesting() {
773-
this.#networkCollector = new NetworkCollector(this.browser, collect => {
877+
const targetEmitter = asTargetEmitter(this.browser);
878+
this.#networkCollector = new NetworkCollector(targetEmitter, collect => {
774879
return {
775880
request: req => {
776881
if (req.url().includes('favicon.ico')) {
@@ -780,7 +885,8 @@ export class McpContext implements Context {
780885
},
781886
} as ListenerMap;
782887
});
783-
await this.#networkCollector.init(await this.browser.pages());
888+
const pages = await this.browser.pages();
889+
await this.#networkCollector.init(pages);
784890
}
785891

786892
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 browserContextName = context.getBrowserContextName(page);
508+
const contextLabel = browserContextName
509+
? ` browserContext=${browserContextName}`
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 browserContextName = context.getBrowserContextName(page);
518+
const entry: {
519+
id: number | undefined;
520+
url: string;
521+
selected: boolean;
522+
browserContext?: string;
523+
} = {
514524
id: context.getPageId(page),
515525
url: page.url(),
516526
selected: context.isPageSelected(page),
517527
};
528+
if (browserContextName) {
529+
entry.browserContext = browserContextName;
530+
}
531+
return entry;
518532
});
519533
}
520534

0 commit comments

Comments
 (0)