Skip to content

Commit 4661c7a

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 b0b1245 commit 4661c7a

5 files changed

Lines changed: 195 additions & 60 deletions

File tree

src/McpContext.ts

Lines changed: 124 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,15 @@ 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 type {DevTools} from './third_party/index.js';
2024
import type {
2125
Browser,
26+
BrowserContext,
2227
ConsoleMessage,
2328
Debugger,
2429
Dialog,
@@ -74,7 +79,7 @@ interface EmulationSettings {
7479
viewport?: Viewport | null;
7580
}
7681

77-
interface McpContextOptions {
82+
export interface McpContextOptions {
7883
// Whether the DevTools windows are exposed as pages for debugging of DevTools.
7984
experimentalDevToolsDebugging: boolean;
8085
// Whether all page-like targets are exposed as pages.
@@ -119,11 +124,17 @@ export class McpContext implements Context {
119124
browser: Browser;
120125
logger: Debugger;
121126

122-
// The most recent page state.
127+
// Maps LLM-provided browserContext name → Puppeteer BrowserContext.
128+
#browserContexts = new Map<string, BrowserContext>();
129+
// Reverse lookup: Page → browserContext name (for snapshot labeling).
130+
// WeakMap so closed pages are garbage-collected automatically.
131+
#pageToBrowserContextName = new WeakMap<Page, string>();
132+
// Auto-generated name counter for when no name is provided.
133+
#nextBrowserContextId = 1;
134+
123135
#pages: Page[] = [];
124136
#pageToDevToolsPage = new Map<Page, Page>();
125137
#selectedPage?: Page;
126-
// The most recent snapshot.
127138
#textSnapshot: TextSnapshot | null = null;
128139
#networkCollector: NetworkCollector;
129140
#consoleCollector: ConsoleCollector;
@@ -158,9 +169,11 @@ export class McpContext implements Context {
158169
this.#locatorClass = locatorClass;
159170
this.#options = options;
160171

161-
this.#networkCollector = new NetworkCollector(this.browser);
172+
const targetEmitter = asTargetEmitter(this.browser);
162173

163-
this.#consoleCollector = new ConsoleCollector(this.browser, collect => {
174+
this.#networkCollector = new NetworkCollector(targetEmitter);
175+
176+
this.#consoleCollector = new ConsoleCollector(targetEmitter, collect => {
164177
return {
165178
console: event => {
166179
collect(event);
@@ -173,7 +186,7 @@ export class McpContext implements Context {
173186
},
174187
} as ListenerMap;
175188
});
176-
this.#devtoolsUniverseManager = new UniverseManager(this.browser);
189+
this.#devtoolsUniverseManager = new UniverseManager(targetEmitter);
177190
}
178191

179192
async #init() {
@@ -187,6 +200,12 @@ export class McpContext implements Context {
187200
this.#networkCollector.dispose();
188201
this.#consoleCollector.dispose();
189202
this.#devtoolsUniverseManager.dispose();
203+
for (const ctx of this.#browserContexts.values()) {
204+
if (!ctx.closed) {
205+
void ctx.close().catch(() => {});
206+
}
207+
}
208+
this.#browserContexts.clear();
190209
}
191210

192211
static async from(
@@ -269,8 +288,42 @@ export class McpContext implements Context {
269288
return this.#consoleCollector.getById(this.getSelectedPage(), id);
270289
}
271290

272-
async newPage(background?: boolean): Promise<Page> {
273-
const page = await this.browser.newPage({background});
291+
async newPage(
292+
background?: boolean,
293+
browserContextName?: string,
294+
): Promise<Page> {
295+
let page: Page;
296+
if (browserContextName !== undefined) {
297+
const isFirstBrowserContext = this.#browserContexts.size === 0;
298+
let ctx = this.#browserContexts.get(browserContextName);
299+
if (!ctx) {
300+
ctx = await this.browser.createBrowserContext();
301+
this.#browserContexts.set(browserContextName, ctx);
302+
}
303+
page = await ctx.newPage();
304+
this.#pageToBrowserContextName.set(page, browserContextName);
305+
306+
// On the first browser context creation, close any leftover
307+
// about:blank pages from the default context. Chrome always opens
308+
// an initial about:blank tab that is no longer needed once named
309+
// browser contexts are in use. We only do this once to avoid
310+
// closing pages the LLM may have explicitly opened in the default
311+
// context later.
312+
if (isFirstBrowserContext) {
313+
const defaultPages = await this.browser.defaultBrowserContext().pages();
314+
for (const dp of defaultPages) {
315+
if (dp.url() === 'about:blank') {
316+
try {
317+
await dp.close();
318+
} catch {
319+
// Page may already be closed.
320+
}
321+
}
322+
}
323+
}
324+
} else {
325+
page = await this.browser.newPage({background});
326+
}
274327
await this.createPagesSnapshot();
275328
this.selectPage(page);
276329
this.#networkCollector.addPage(page);
@@ -282,7 +335,20 @@ export class McpContext implements Context {
282335
throw new Error(CLOSE_PAGE_ERROR);
283336
}
284337
const page = this.getPageById(pageId);
338+
const browserContextName = this.#pageToBrowserContextName.get(page);
285339
await page.close({runBeforeUnload: false});
340+
this.#pageToBrowserContextName.delete(page);
341+
342+
if (browserContextName) {
343+
const ctx = this.#browserContexts.get(browserContextName);
344+
if (ctx && !ctx.closed) {
345+
const remainingPages = await ctx.pages();
346+
if (remainingPages.length === 0) {
347+
await ctx.close();
348+
this.#browserContexts.delete(browserContextName);
349+
}
350+
}
351+
}
286352
}
287353

288354
getNetworkRequestById(reqid: number): HTTPRequest {
@@ -558,13 +624,40 @@ export class McpContext implements Context {
558624
}
559625
}
560626

561-
/**
562-
* Creates a snapshot of the pages.
563-
*/
564627
async createPagesSnapshot(): Promise<Page[]> {
565-
const allPages = await this.browser.pages(
628+
// Auto-discover BrowserContexts not in our mapping (e.g., externally
629+
// created incognito contexts) and assign generated names.
630+
const defaultCtx = this.browser.defaultBrowserContext();
631+
const knownContexts = new Set(this.#browserContexts.values());
632+
for (const ctx of this.browser.browserContexts()) {
633+
if (ctx !== defaultCtx && !ctx.closed && !knownContexts.has(ctx)) {
634+
this.#browserContexts.set(
635+
`browser-context-${this.#nextBrowserContextId++}`,
636+
ctx,
637+
);
638+
}
639+
}
640+
641+
const contextPages: Page[] = [];
642+
for (const [name, ctx] of this.#browserContexts) {
643+
if (!ctx.closed) {
644+
const pages = await ctx.pages();
645+
for (const page of pages) {
646+
this.#pageToBrowserContextName.set(page, name);
647+
}
648+
contextPages.push(...pages);
649+
}
650+
}
651+
652+
// browser.pages() returns pages from ALL contexts (default + incognito).
653+
// Filter out pages that belong to named browser contexts to avoid duplicates.
654+
const contextPagesSet = new Set(contextPages);
655+
const browserPages = await this.browser.pages(
566656
this.#options.experimentalIncludeAllPages,
567657
);
658+
const defaultPages = browserPages.filter(p => !contextPagesSet.has(p));
659+
660+
const allPages = [...defaultPages, ...contextPages];
568661

569662
for (const page of allPages) {
570663
if (!this.#pageIdMap.has(page)) {
@@ -573,8 +666,6 @@ export class McpContext implements Context {
573666
}
574667

575668
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.
578669
return (
579670
this.#options.experimentalDevToolsDebugging ||
580671
!page.url().startsWith('devtools://')
@@ -595,9 +686,18 @@ export class McpContext implements Context {
595686

596687
async detectOpenDevToolsWindows() {
597688
this.logger('Detecting open DevTools windows');
598-
const pages = await this.browser.pages(
689+
const contextPages: Page[] = [];
690+
for (const ctx of this.#browserContexts.values()) {
691+
if (!ctx.closed) {
692+
contextPages.push(...(await ctx.pages()));
693+
}
694+
}
695+
const contextPagesSet = new Set(contextPages);
696+
const browserPages = await this.browser.pages(
599697
this.#options.experimentalIncludeAllPages,
600698
);
699+
const defaultPages = browserPages.filter(p => !contextPagesSet.has(p));
700+
const pages = [...defaultPages, ...contextPages];
601701
this.#pageToDevToolsPage = new Map<Page, Page>();
602702
for (const devToolsPage of pages) {
603703
if (devToolsPage.url().startsWith('devtools://')) {
@@ -629,6 +729,10 @@ export class McpContext implements Context {
629729
return this.#pages;
630730
}
631731

732+
getBrowserContextName(page: Page): string | undefined {
733+
return this.#pageToBrowserContextName.get(page);
734+
}
735+
632736
getDevToolsPage(page: Page): Page | undefined {
633737
return this.#pageToDevToolsPage.get(page);
634738
}
@@ -847,7 +951,8 @@ export class McpContext implements Context {
847951
* We need to ignore favicon request as they make our test flaky
848952
*/
849953
async setUpNetworkCollectorForTesting() {
850-
this.#networkCollector = new NetworkCollector(this.browser, collect => {
954+
const targetEmitter = asTargetEmitter(this.browser);
955+
this.#networkCollector = new NetworkCollector(targetEmitter, collect => {
851956
return {
852957
request: req => {
853958
if (req.url().includes('favicon.ico')) {
@@ -857,7 +962,8 @@ export class McpContext implements Context {
857962
},
858963
} as ListenerMap;
859964
});
860-
await this.#networkCollector.init(await this.browser.pages());
965+
const pages = await this.browser.pages();
966+
await this.#networkCollector.init(pages);
861967
}
862968

863969
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

src/main.ts

Lines changed: 41 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -77,48 +77,52 @@ server.server.setRequestHandler(SetLevelRequestSchema, () => {
7777
return {};
7878
});
7979

80-
let context: McpContext;
81-
async function getContext(): Promise<McpContext> {
80+
const devtools = args.experimentalDevtools ?? false;
81+
const mcpContextOptions = {
82+
experimentalDevToolsDebugging: devtools,
83+
experimentalIncludeAllPages: args.experimentalIncludeAllPages,
84+
performanceCrux: args.performanceCrux,
85+
};
86+
87+
async function getBrowser() {
8288
const chromeArgs: string[] = (args.chromeArg ?? []).map(String);
8389
const ignoreDefaultChromeArgs: string[] = (
8490
args.ignoreDefaultChromeArg ?? []
8591
).map(String);
8692
if (args.proxyServer) {
8793
chromeArgs.push(`--proxy-server=${args.proxyServer}`);
8894
}
89-
const devtools = args.experimentalDevtools ?? false;
90-
const browser =
91-
args.browserUrl || args.wsEndpoint || args.autoConnect
92-
? await ensureBrowserConnected({
93-
browserURL: args.browserUrl,
94-
wsEndpoint: args.wsEndpoint,
95-
wsHeaders: args.wsHeaders,
96-
// Important: only pass channel, if autoConnect is true.
97-
channel: args.autoConnect ? (args.channel as Channel) : undefined,
98-
userDataDir: args.userDataDir,
99-
devtools,
100-
})
101-
: await ensureBrowserLaunched({
102-
headless: args.headless,
103-
executablePath: args.executablePath,
104-
channel: args.channel as Channel,
105-
isolated: args.isolated ?? false,
106-
userDataDir: args.userDataDir,
107-
logFile,
108-
viewport: args.viewport,
109-
chromeArgs,
110-
ignoreDefaultChromeArgs,
111-
acceptInsecureCerts: args.acceptInsecureCerts,
112-
devtools,
113-
enableExtensions: args.categoryExtensions,
114-
});
95+
return args.browserUrl || args.wsEndpoint || args.autoConnect
96+
? await ensureBrowserConnected({
97+
browserURL: args.browserUrl,
98+
wsEndpoint: args.wsEndpoint,
99+
wsHeaders: args.wsHeaders,
100+
// Important: only pass channel, if autoConnect is true.
101+
channel: args.autoConnect ? (args.channel as Channel) : undefined,
102+
userDataDir: args.userDataDir,
103+
devtools,
104+
})
105+
: await ensureBrowserLaunched({
106+
headless: args.headless,
107+
executablePath: args.executablePath,
108+
channel: args.channel as Channel,
109+
isolated: args.isolated ?? false,
110+
userDataDir: args.userDataDir,
111+
logFile,
112+
viewport: args.viewport,
113+
chromeArgs,
114+
ignoreDefaultChromeArgs,
115+
acceptInsecureCerts: args.acceptInsecureCerts,
116+
devtools,
117+
enableExtensions: args.categoryExtensions,
118+
});
119+
}
115120

121+
let context: McpContext;
122+
async function getContext(): Promise<McpContext> {
123+
const browser = await getBrowser();
116124
if (context?.browser !== browser) {
117-
context = await McpContext.from(browser, logger, {
118-
experimentalDevToolsDebugging: devtools,
119-
experimentalIncludeAllPages: args.experimentalIncludeAllPages,
120-
performanceCrux: args.performanceCrux,
121-
});
125+
context = await McpContext.from(browser, logger, mcpContextOptions);
122126
}
123127
return context;
124128
}
@@ -203,20 +207,19 @@ function registerTool(tool: ToolDefinition): void {
203207
let success = false;
204208
try {
205209
logger(`${tool.name} request: ${JSON.stringify(params, null, ' ')}`);
206-
const context = await getContext();
207-
logger(`${tool.name} context: resolved`);
208-
await context.detectOpenDevToolsWindows();
210+
const activeContext = await getContext();
211+
await activeContext.detectOpenDevToolsWindows();
209212
const response = new McpResponse();
210213
await tool.handler(
211214
{
212215
params,
213216
},
214217
response,
215-
context,
218+
activeContext,
216219
);
217220
const {content, structuredContent} = await response.handle(
218221
tool.name,
219-
context,
222+
activeContext,
220223
);
221224
const result: CallToolResult & {
222225
structuredContent?: Record<string, unknown>;

0 commit comments

Comments
 (0)