Skip to content

Commit d581c59

Browse files
committed
feat: add SessionManager and session lifecycle tools
Introduce SessionManager class that manages multiple isolated Chrome browser instances, each identified by a unique sessionId. Add three new session tools: create_session, list_sessions, and close_session. - Per-session Mutex ensures tool serialization within a session while allowing cross-session parallelism - Orphan prevention: browser is closed if McpContext creation fails - Auto-purge via browser 'disconnected' event listener - Graceful shutdown with #shuttingDown guard - SESSION category added to ToolCategory enum
1 parent d459266 commit d581c59

4 files changed

Lines changed: 296 additions & 1 deletion

File tree

src/SessionManager.ts

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import crypto from 'node:crypto';
8+
9+
import type {Channel} from './browser.js';
10+
import {launch} from './browser.js';
11+
import {logger} from './logger.js';
12+
import {McpContext} from './McpContext.js';
13+
import {Mutex} from './Mutex.js';
14+
import type {Browser} from './third_party/index.js';
15+
16+
export interface SessionInfo {
17+
sessionId: string;
18+
browser: Browser;
19+
context: McpContext;
20+
mutex: Mutex;
21+
createdAt: Date;
22+
label?: string;
23+
}
24+
25+
export interface CreateSessionOptions {
26+
headless?: boolean;
27+
executablePath?: string;
28+
channel?: Channel;
29+
userDataDir?: string;
30+
viewport?: {width: number; height: number};
31+
chromeArgs?: string[];
32+
ignoreDefaultChromeArgs?: string[];
33+
acceptInsecureCerts?: boolean;
34+
devtools?: boolean;
35+
enableExtensions?: boolean;
36+
label?: string;
37+
}
38+
39+
export interface McpContextOptions {
40+
experimentalDevToolsDebugging: boolean;
41+
experimentalIncludeAllPages?: boolean;
42+
performanceCrux: boolean;
43+
}
44+
45+
export class SessionManager {
46+
readonly #sessions = new Map<string, SessionInfo>();
47+
readonly #contextOptions: McpContextOptions;
48+
#shuttingDown = false;
49+
50+
constructor(contextOptions: McpContextOptions) {
51+
this.#contextOptions = contextOptions;
52+
}
53+
54+
async createSession(options: CreateSessionOptions): Promise<SessionInfo> {
55+
if (this.#shuttingDown) {
56+
throw new Error('Server is shutting down. Cannot create new sessions.');
57+
}
58+
59+
const sessionId = crypto.randomUUID().slice(0, 8);
60+
logger(`Creating session ${sessionId}`);
61+
62+
let browser: Browser | undefined;
63+
try {
64+
browser = await launch({
65+
headless: options.headless ?? false,
66+
executablePath: options.executablePath,
67+
channel: options.channel,
68+
userDataDir: options.userDataDir,
69+
// Always isolated to avoid profile conflicts between concurrent sessions
70+
isolated: true,
71+
viewport: options.viewport,
72+
chromeArgs: options.chromeArgs ?? [],
73+
ignoreDefaultChromeArgs: options.ignoreDefaultChromeArgs ?? [],
74+
acceptInsecureCerts: options.acceptInsecureCerts,
75+
devtools: options.devtools ?? false,
76+
enableExtensions: options.enableExtensions,
77+
});
78+
79+
const context = await McpContext.from(
80+
browser,
81+
logger,
82+
this.#contextOptions,
83+
);
84+
const mutex = new Mutex();
85+
86+
const session: SessionInfo = {
87+
sessionId,
88+
browser,
89+
context,
90+
mutex,
91+
createdAt: new Date(),
92+
label: options.label,
93+
};
94+
95+
browser.on('disconnected', () => {
96+
logger(`Session ${sessionId} browser disconnected unexpectedly`);
97+
this.#purgeDisconnectedSession(sessionId);
98+
});
99+
100+
this.#sessions.set(sessionId, session);
101+
logger(`Session ${sessionId} created`);
102+
return session;
103+
} catch (err) {
104+
if (browser?.connected) {
105+
try {
106+
await browser.close();
107+
} catch (closeErr) {
108+
logger(`Failed to close browser after creation failure:`, closeErr);
109+
}
110+
}
111+
throw err;
112+
}
113+
}
114+
115+
getSession(sessionId: string): SessionInfo {
116+
const session = this.#sessions.get(sessionId);
117+
if (!session) {
118+
const available = [...this.#sessions.keys()].join(', ');
119+
throw new Error(
120+
`Session "${sessionId}" not found. Available sessions: ${available || 'none. Create one with create_session.'}`,
121+
);
122+
}
123+
if (!session.browser.connected) {
124+
this.#purgeDisconnectedSession(sessionId);
125+
throw new Error(
126+
`Session "${sessionId}" browser is disconnected. Create a new session.`,
127+
);
128+
}
129+
return session;
130+
}
131+
132+
listSessions(): Array<{
133+
sessionId: string;
134+
createdAt: string;
135+
label?: string;
136+
connected: boolean;
137+
}> {
138+
const result: Array<{
139+
sessionId: string;
140+
createdAt: string;
141+
label?: string;
142+
connected: boolean;
143+
}> = [];
144+
145+
for (const [, session] of this.#sessions) {
146+
result.push({
147+
sessionId: session.sessionId,
148+
createdAt: session.createdAt.toISOString(),
149+
label: session.label,
150+
connected: session.browser.connected,
151+
});
152+
}
153+
return result;
154+
}
155+
156+
async closeSession(sessionId: string): Promise<void> {
157+
const session = this.#sessions.get(sessionId);
158+
if (!session) {
159+
throw new Error(`Session "${sessionId}" not found.`);
160+
}
161+
162+
logger(`Closing session ${sessionId} (acquiring mutex)`);
163+
const guard = await session.mutex.acquire();
164+
try {
165+
session.context.dispose();
166+
if (session.browser.connected) {
167+
await session.browser.close();
168+
}
169+
} catch (err) {
170+
logger(`Error closing session ${sessionId}:`, err);
171+
} finally {
172+
guard.dispose();
173+
this.#sessions.delete(sessionId);
174+
logger(`Session ${sessionId} closed`);
175+
}
176+
}
177+
178+
async closeAllSessions(): Promise<void> {
179+
this.#shuttingDown = true;
180+
const ids = [...this.#sessions.keys()];
181+
await Promise.allSettled(ids.map(id => this.closeSession(id)));
182+
}
183+
184+
get sessionCount(): number {
185+
return this.#sessions.size;
186+
}
187+
188+
get isShuttingDown(): boolean {
189+
return this.#shuttingDown;
190+
}
191+
192+
#purgeDisconnectedSession(sessionId: string): void {
193+
const session = this.#sessions.get(sessionId);
194+
if (!session) {
195+
return;
196+
}
197+
try {
198+
session.context.dispose();
199+
} catch (err) {
200+
logger(`Error disposing context for disconnected session ${sessionId}:`, err);
201+
}
202+
this.#sessions.delete(sessionId);
203+
logger(`Purged disconnected session ${sessionId}`);
204+
}
205+
}

src/tools/categories.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
*/
66

77
export enum ToolCategory {
8+
SESSION = 'session',
89
INPUT = 'input',
910
NAVIGATION = 'navigation',
1011
EMULATION = 'emulation',
@@ -15,6 +16,7 @@ export enum ToolCategory {
1516
}
1617

1718
export const labels = {
19+
[ToolCategory.SESSION]: 'Session management',
1820
[ToolCategory.INPUT]: 'Input automation',
1921
[ToolCategory.NAVIGATION]: 'Navigation automation',
2022
[ToolCategory.EMULATION]: 'Emulation',

src/tools/session.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import {zod} from '../third_party/index.js';
8+
9+
import {ToolCategory} from './categories.js';
10+
import {defineTool} from './ToolDefinition.js';
11+
12+
export const createSession = defineTool({
13+
name: 'create_session',
14+
description: `Creates a new Chrome browser session and returns its unique session ID. Each session runs an isolated Chrome instance. You MUST use the returned sessionId in all subsequent tool calls. Multiple sessions can run simultaneously for parallel testing.`,
15+
annotations: {
16+
category: ToolCategory.SESSION,
17+
readOnlyHint: false,
18+
},
19+
schema: {
20+
headless: zod
21+
.boolean()
22+
.optional()
23+
.describe('Whether to run in headless (no UI) mode. Default is false.'),
24+
viewport: zod
25+
.string()
26+
.optional()
27+
.describe(
28+
'Initial viewport size, e.g. "1280x720". If omitted, uses browser default.',
29+
),
30+
label: zod
31+
.string()
32+
.optional()
33+
.describe(
34+
'A human-readable label for this session, e.g. "login-test" or "mobile-view".',
35+
),
36+
url: zod
37+
.string()
38+
.optional()
39+
.describe(
40+
'URL to navigate to after creating the session. If omitted, opens about:blank.',
41+
),
42+
},
43+
handler: async (_request, response) => {
44+
response.appendResponseLine(
45+
'SESSION_PLACEHOLDER: This handler is replaced by main.ts',
46+
);
47+
},
48+
});
49+
50+
export const listSessions = defineTool({
51+
name: 'list_sessions',
52+
description: `Lists all active Chrome browser sessions with their session IDs, creation times, and connection status.`,
53+
annotations: {
54+
category: ToolCategory.SESSION,
55+
readOnlyHint: true,
56+
},
57+
schema: {},
58+
handler: async (_request, response) => {
59+
response.appendResponseLine(
60+
'SESSION_PLACEHOLDER: This handler is replaced by main.ts',
61+
);
62+
},
63+
});
64+
65+
export const closeSession = defineTool({
66+
name: 'close_session',
67+
description: `Closes a Chrome browser session and its associated browser instance. The sessionId cannot be used after closing.`,
68+
annotations: {
69+
category: ToolCategory.SESSION,
70+
readOnlyHint: false,
71+
},
72+
schema: {
73+
sessionId: zod
74+
.string()
75+
.describe('The session ID to close.'),
76+
},
77+
handler: async (_request, response) => {
78+
response.appendResponseLine(
79+
'SESSION_PLACEHOLDER: This handler is replaced by main.ts',
80+
);
81+
},
82+
});

src/tools/tools.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,16 @@ import * as pagesTools from './pages.js';
1313
import * as performanceTools from './performance.js';
1414
import * as screenshotTools from './screenshot.js';
1515
import * as scriptTools from './script.js';
16+
import * as sessionTools from './session.js';
1617
import * as snapshotTools from './snapshot.js';
1718
import type {ToolDefinition} from './ToolDefinition.js';
1819

20+
const sessionToolNames = new Set(
21+
Object.values(sessionTools).map(t => t.name),
22+
);
23+
1924
const tools = [
25+
...Object.values(sessionTools),
2026
...Object.values(consoleTools),
2127
...Object.values(emulationTools),
2228
...Object.values(extensionTools),
@@ -33,4 +39,4 @@ tools.sort((a, b) => {
3339
return a.name.localeCompare(b.name);
3440
});
3541

36-
export {tools};
42+
export {tools, sessionToolNames};

0 commit comments

Comments
 (0)