Skip to content

Commit 0574514

Browse files
authored
feat(mcp): support shared browser context (#37463)
1 parent 8a6a452 commit 0574514

File tree

7 files changed

+202
-4
lines changed

7 files changed

+202
-4
lines changed

packages/playwright/src/mcp/browser/browserContextFactory.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ import type { LaunchOptions } from '../../../../playwright-core/src/client/types
3131
import type { ClientInfo } from '../sdk/server';
3232

3333
export function contextFactory(config: FullConfig): BrowserContextFactory {
34+
if (config.sharedBrowserContext)
35+
return SharedContextFactory.create(config);
3436
if (config.browser.remoteEndpoint)
3537
return new RemoteContextFactory(config);
3638
if (config.browser.cdpEndpoint)
@@ -259,3 +261,51 @@ async function startTraceServer(config: FullConfig, tracesDir: string): Promise<
259261
function createHash(data: string): string {
260262
return crypto.createHash('sha256').update(data).digest('hex').slice(0, 7);
261263
}
264+
265+
export class SharedContextFactory implements BrowserContextFactory {
266+
private _contextPromise: Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> | undefined;
267+
private _baseFactory: BrowserContextFactory;
268+
private static _instance: SharedContextFactory | undefined;
269+
270+
static create(config: FullConfig) {
271+
if (SharedContextFactory._instance)
272+
throw new Error('SharedContextFactory already exists');
273+
const baseConfig = { ...config, sharedBrowserContext: false };
274+
const baseFactory = contextFactory(baseConfig);
275+
SharedContextFactory._instance = new SharedContextFactory(baseFactory);
276+
return SharedContextFactory._instance;
277+
}
278+
279+
private constructor(baseFactory: BrowserContextFactory) {
280+
this._baseFactory = baseFactory;
281+
}
282+
283+
async createContext(clientInfo: ClientInfo, abortSignal: AbortSignal, toolName: string | undefined): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
284+
if (!this._contextPromise) {
285+
testDebug('create shared browser context');
286+
this._contextPromise = this._baseFactory.createContext(clientInfo, abortSignal, toolName);
287+
}
288+
289+
const { browserContext } = await this._contextPromise;
290+
testDebug(`shared context client connected`);
291+
return {
292+
browserContext,
293+
close: async () => {
294+
testDebug(`shared context client disconnected`);
295+
},
296+
};
297+
}
298+
299+
static async dispose() {
300+
await SharedContextFactory._instance?._dispose();
301+
}
302+
303+
private async _dispose() {
304+
const contextPromise = this._contextPromise;
305+
this._contextPromise = undefined;
306+
if (!contextPromise)
307+
return;
308+
const { close } = await contextPromise;
309+
await close();
310+
}
311+
}

packages/playwright/src/mcp/browser/config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ export type CLIOptions = {
5252
saveSession?: boolean;
5353
saveTrace?: boolean;
5454
secrets?: Record<string, string>;
55+
sharedBrowserContext?: boolean;
5556
storageState?: string;
5657
timeoutAction?: number;
5758
timeoutNavigation?: number;
@@ -212,6 +213,7 @@ export function configFromCLIOptions(cliOptions: CLIOptions): Config {
212213
saveSession: cliOptions.saveSession,
213214
saveTrace: cliOptions.saveTrace,
214215
secrets: cliOptions.secrets,
216+
sharedBrowserContext: cliOptions.sharedBrowserContext,
215217
outputDir: cliOptions.outputDir,
216218
imageResponses: cliOptions.imageResponses,
217219
timeouts: {

packages/playwright/src/mcp/browser/watchdog.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
* limitations under the License.
1515
*/
1616

17+
import { SharedContextFactory } from './browserContextFactory';
1718
import { Context } from './context';
1819

1920
export function setupExitWatchdog() {
@@ -25,6 +26,7 @@ export function setupExitWatchdog() {
2526
// eslint-disable-next-line no-restricted-properties
2627
setTimeout(() => process.exit(0), 15000);
2728
await Context.disposeAll();
29+
await SharedContextFactory.dispose();
2830
// eslint-disable-next-line no-restricted-properties
2931
process.exit(0);
3032
};

packages/playwright/src/mcp/config.d.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,11 @@ export type Config = {
100100
*/
101101
saveTrace?: boolean;
102102

103+
/**
104+
* Reuse the same browser context between all connected HTTP clients.
105+
*/
106+
sharedBrowserContext?: boolean;
107+
103108
/**
104109
* Secrets are used to prevent LLM from getting sensitive data while
105110
* automating scenarios such as authentication.

packages/playwright/src/mcp/program.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ export function decorateCommand(command: Command, version: string) {
5353
.option('--save-session', 'Whether to save the Playwright MCP session into the output directory.')
5454
.option('--save-trace', 'Whether to save the Playwright Trace of the session into the output directory.')
5555
.option('--secrets <path>', 'path to a file containing secrets in the dotenv format', dotenvFileLoader)
56+
.option('--shared-browser-context', 'reuse the same browser context between all connected HTTP clients.')
5657
.option('--storage-state <path>', 'path to the storage state file for isolated sessions.')
5758
.option('--timeout-action <timeout>', 'specify action timeout in milliseconds, defaults to 5000ms', numberParser)
5859
.option('--timeout-navigation <timeout>', 'specify navigation timeout in milliseconds, defaults to 60000ms', numberParser)

tests/mcp/http.spec.ts

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import { test as baseTest, expect, mcpServerPath } from './fixtures';
2424
import type { Config } from '../../packages/playwright/src/mcp/config';
2525
import { ListRootsRequestSchema } from 'packages/playwright/lib/mcp/sdk/bundle';
2626

27-
const test = baseTest.extend<{ serverEndpoint: (options?: { args?: string[], noPort?: boolean }) => Promise<{ url: URL, stderr: () => string }> }>({
27+
const test = baseTest.extend<{ serverEndpoint: (options?: { args?: string[], noPort?: boolean }) => Promise<{ url: URL, stderr: () => string, kill: () => void }> }>({
2828
serverEndpoint: async ({ mcpHeadless }, use, testInfo) => {
2929
let cp: ChildProcess | undefined;
3030
const userDataDir = testInfo.outputPath('user-data-dir');
@@ -55,7 +55,10 @@ const test = baseTest.extend<{ serverEndpoint: (options?: { args?: string[], noP
5555
resolve(match[1]);
5656
}));
5757

58-
return { url: new URL(url), stderr: () => stderr };
58+
return { url: new URL(url), stderr: () => stderr, kill: () => {
59+
cp?.kill('SIGTERM');
60+
cp = undefined;
61+
} };
5962
});
6063
cp?.kill('SIGTERM');
6164
},
@@ -245,6 +248,73 @@ test('http transport browser lifecycle (persistent, multiclient)', async ({ serv
245248
await client2.close();
246249
});
247250

251+
test('http transport shared context', async ({ serverEndpoint, server }) => {
252+
const { url, stderr, kill } = await serverEndpoint({ args: ['--shared-browser-context'] });
253+
254+
// Create first client and navigate
255+
const transport1 = new StreamableHTTPClientTransport(new URL('/mcp', url));
256+
const client1 = new Client({ name: 'test1', version: '1.0.0' });
257+
await client1.connect(transport1);
258+
await client1.callTool({
259+
name: 'browser_navigate',
260+
arguments: { url: server.HELLO_WORLD },
261+
});
262+
263+
// Create second client - should reuse the same browser context
264+
const transport2 = new StreamableHTTPClientTransport(new URL('/mcp', url));
265+
const client2 = new Client({ name: 'test2', version: '1.0.0' });
266+
await client2.connect(transport2);
267+
268+
// Get tabs from second client - should see the tab created by first client
269+
const tabsResult = await client2.callTool({
270+
name: 'browser_tabs',
271+
arguments: { action: 'list' },
272+
});
273+
274+
// Should have at least one tab (the one created by client1)
275+
expect(tabsResult.content[0]?.text).toContain('tabs');
276+
277+
await transport1.terminateSession();
278+
await client1.close();
279+
280+
// Second client should still work since context is shared
281+
await client2.callTool({
282+
name: 'browser_snapshot',
283+
arguments: {},
284+
});
285+
286+
await transport2.terminateSession();
287+
await client2.close();
288+
289+
await expect(async () => {
290+
const lines = stderr().split('\n');
291+
expect(lines.filter(line => line.match(/create http session/)).length).toBe(2);
292+
expect(lines.filter(line => line.match(/delete http session/)).length).toBe(2);
293+
294+
// Should have only one context creation since it's shared
295+
expect(lines.filter(line => line.match(/create shared browser context/)).length).toBe(1);
296+
297+
// Should see client connect/disconnect messages
298+
expect(lines.filter(line => line.match(/shared context client connected/)).length).toBe(2);
299+
expect(lines.filter(line => line.match(/shared context client disconnected/)).length).toBe(2);
300+
expect(lines.filter(line => line.match(/create context/)).length).toBe(2);
301+
expect(lines.filter(line => line.match(/close context/)).length).toBe(2);
302+
303+
// Context should only close when the server shuts down.
304+
expect(lines.filter(line => line.match(/close browser context complete \(persistent\)/)).length).toBe(0);
305+
}).toPass();
306+
307+
kill();
308+
309+
if (process.platform !== 'win32') {
310+
await expect(async () => {
311+
const lines = stderr().split('\n');
312+
// Context should only close when the server shuts down.
313+
expect(lines.filter(line => line.match(/close browser context complete \(persistent\)/)).length).toBe(1);
314+
}).toPass();
315+
}
316+
});
317+
248318
test('http transport (default)', async ({ serverEndpoint }) => {
249319
const { url } = await serverEndpoint();
250320
const transport = new StreamableHTTPClientTransport(url);

tests/mcp/sse.spec.ts

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import { test as baseTest, expect, mcpServerPath } from './fixtures';
2323

2424
import type { Config } from '../../packages/playwright/src/mcp/config';
2525

26-
const test = baseTest.extend<{ serverEndpoint: (options?: { args?: string[], noPort?: boolean }) => Promise<{ url: URL, stderr: () => string }> }>({
26+
const test = baseTest.extend<{ serverEndpoint: (options?: { args?: string[], noPort?: boolean }) => Promise<{ url: URL, stderr: () => string, kill: () => void }> }>({
2727
serverEndpoint: async ({ mcpHeadless }, use, testInfo) => {
2828
let cp: ChildProcess | undefined;
2929
const userDataDir = testInfo.outputPath('user-data-dir');
@@ -54,7 +54,10 @@ const test = baseTest.extend<{ serverEndpoint: (options?: { args?: string[], noP
5454
resolve(match[1]);
5555
}));
5656

57-
return { url: new URL(url), stderr: () => stderr };
57+
return { url: new URL(url), stderr: () => stderr, kill: () => {
58+
cp?.kill('SIGTERM');
59+
cp = undefined;
60+
} };
5861
});
5962
cp?.kill('SIGTERM');
6063
},
@@ -229,3 +232,68 @@ test('sse transport browser lifecycle (persistent, multiclient)', async ({ serve
229232
await client1.close();
230233
await client2.close();
231234
});
235+
236+
test('sse transport shared context', async ({ serverEndpoint, server }) => {
237+
const { url, stderr, kill } = await serverEndpoint({ args: ['--shared-browser-context'] });
238+
239+
// Create first client and navigate
240+
const transport1 = new SSEClientTransport(new URL('/sse', url));
241+
const client1 = new Client({ name: 'test1', version: '1.0.0' });
242+
await client1.connect(transport1);
243+
await client1.callTool({
244+
name: 'browser_navigate',
245+
arguments: { url: server.HELLO_WORLD },
246+
});
247+
248+
// Create second client - should reuse the same browser context
249+
const transport2 = new SSEClientTransport(new URL('/sse', url));
250+
const client2 = new Client({ name: 'test2', version: '1.0.0' });
251+
await client2.connect(transport2);
252+
253+
// Get tabs from second client - should see the tab created by first client
254+
const tabsResult = await client2.callTool({
255+
name: 'browser_tabs',
256+
arguments: { action: 'list' },
257+
});
258+
259+
// Should have at least one tab (the one created by client1)
260+
expect(tabsResult.content[0]?.text).toContain('tabs');
261+
262+
await client1.close();
263+
264+
// Second client should still work since context is shared
265+
await client2.callTool({
266+
name: 'browser_snapshot',
267+
arguments: {},
268+
});
269+
270+
await client2.close();
271+
272+
await expect(async () => {
273+
const lines = stderr().split('\n');
274+
expect(lines.filter(line => line.match(/create SSE session/)).length).toBe(2);
275+
expect(lines.filter(line => line.match(/delete SSE session/)).length).toBe(2);
276+
277+
// Should have only one context creation since it's shared
278+
expect(lines.filter(line => line.match(/create shared browser context/)).length).toBe(1);
279+
280+
// Should see client connect/disconnect messages
281+
expect(lines.filter(line => line.match(/shared context client connected/)).length).toBe(2);
282+
expect(lines.filter(line => line.match(/shared context client disconnected/)).length).toBe(2);
283+
expect(lines.filter(line => line.match(/create context/)).length).toBe(2);
284+
expect(lines.filter(line => line.match(/close context/)).length).toBe(2);
285+
286+
// Context should only close when the server shuts down.
287+
expect(lines.filter(line => line.match(/close browser context complete \(persistent\)/)).length).toBe(0);
288+
}).toPass();
289+
290+
kill();
291+
292+
if (process.platform !== 'win32') {
293+
await expect(async () => {
294+
const lines = stderr().split('\n');
295+
// Context should only close when the server shuts down.
296+
expect(lines.filter(line => line.match(/close browser context complete \(persistent\)/)).length).toBe(1);
297+
}).toPass();
298+
}
299+
});

0 commit comments

Comments
 (0)