Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions packages/playwright/src/mcp/browser/browserContextFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ import type { LaunchOptions } from '../../../../playwright-core/src/client/types
import type { ClientInfo } from '../sdk/server';

export function contextFactory(config: FullConfig): BrowserContextFactory {
if (config.sharedBrowserContext)
return SharedContextFactory.create(config);
if (config.browser.remoteEndpoint)
return new RemoteContextFactory(config);
if (config.browser.cdpEndpoint)
Expand Down Expand Up @@ -259,3 +261,51 @@ async function startTraceServer(config: FullConfig, tracesDir: string): Promise<
function createHash(data: string): string {
return crypto.createHash('sha256').update(data).digest('hex').slice(0, 7);
}

export class SharedContextFactory implements BrowserContextFactory {
private _contextPromise: Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> | undefined;
private _baseFactory: BrowserContextFactory;
private static _instance: SharedContextFactory | undefined;

static create(config: FullConfig) {
if (SharedContextFactory._instance)
throw new Error('SharedContextFactory already exists');
const baseConfig = { ...config, sharedBrowserContext: false };
const baseFactory = contextFactory(baseConfig);
SharedContextFactory._instance = new SharedContextFactory(baseFactory);
return SharedContextFactory._instance;
}

private constructor(baseFactory: BrowserContextFactory) {
this._baseFactory = baseFactory;
}

async createContext(clientInfo: ClientInfo, abortSignal: AbortSignal, toolName: string | undefined): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
if (!this._contextPromise) {
testDebug('create shared browser context');
this._contextPromise = this._baseFactory.createContext(clientInfo, abortSignal, toolName);
}

const { browserContext } = await this._contextPromise;
testDebug(`shared context client connected`);
return {
browserContext,
close: async () => {
testDebug(`shared context client disconnected`);
},
};
}

static async dispose() {
await SharedContextFactory._instance?._dispose();
}

private async _dispose() {
const contextPromise = this._contextPromise;
this._contextPromise = undefined;
if (!contextPromise)
return;
const { close } = await contextPromise;
await close();
}
}
2 changes: 2 additions & 0 deletions packages/playwright/src/mcp/browser/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export type CLIOptions = {
saveSession?: boolean;
saveTrace?: boolean;
secrets?: Record<string, string>;
sharedBrowserContext?: boolean;
storageState?: string;
timeoutAction?: number;
timeoutNavigation?: number;
Expand Down Expand Up @@ -212,6 +213,7 @@ export function configFromCLIOptions(cliOptions: CLIOptions): Config {
saveSession: cliOptions.saveSession,
saveTrace: cliOptions.saveTrace,
secrets: cliOptions.secrets,
sharedBrowserContext: cliOptions.sharedBrowserContext,
outputDir: cliOptions.outputDir,
imageResponses: cliOptions.imageResponses,
timeouts: {
Expand Down
2 changes: 2 additions & 0 deletions packages/playwright/src/mcp/browser/watchdog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
* limitations under the License.
*/

import { SharedContextFactory } from './browserContextFactory';
import { Context } from './context';

export function setupExitWatchdog() {
Expand All @@ -25,6 +26,7 @@ export function setupExitWatchdog() {
// eslint-disable-next-line no-restricted-properties
setTimeout(() => process.exit(0), 15000);
await Context.disposeAll();
await SharedContextFactory.dispose();
// eslint-disable-next-line no-restricted-properties
process.exit(0);
};
Expand Down
5 changes: 5 additions & 0 deletions packages/playwright/src/mcp/config.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,11 @@ export type Config = {
*/
saveTrace?: boolean;

/**
* Reuse the same browser context between all connected HTTP clients.
*/
sharedBrowserContext?: boolean;

/**
* Secrets are used to prevent LLM from getting sensitive data while
* automating scenarios such as authentication.
Expand Down
1 change: 1 addition & 0 deletions packages/playwright/src/mcp/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export function decorateCommand(command: Command, version: string) {
.option('--save-session', 'Whether to save the Playwright MCP session into the output directory.')
.option('--save-trace', 'Whether to save the Playwright Trace of the session into the output directory.')
.option('--secrets <path>', 'path to a file containing secrets in the dotenv format', dotenvFileLoader)
.option('--shared-browser-context', 'reuse the same browser context between all connected HTTP clients.')
.option('--storage-state <path>', 'path to the storage state file for isolated sessions.')
.option('--timeout-action <timeout>', 'specify action timeout in milliseconds, defaults to 5000ms', numberParser)
.option('--timeout-navigation <timeout>', 'specify navigation timeout in milliseconds, defaults to 60000ms', numberParser)
Expand Down
74 changes: 72 additions & 2 deletions tests/mcp/http.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import { test as baseTest, expect, mcpServerPath } from './fixtures';
import type { Config } from '../../packages/playwright/src/mcp/config';
import { ListRootsRequestSchema } from 'packages/playwright/lib/mcp/sdk/bundle';

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

return { url: new URL(url), stderr: () => stderr };
return { url: new URL(url), stderr: () => stderr, kill: () => {
cp?.kill('SIGTERM');
cp = undefined;
} };
});
cp?.kill('SIGTERM');
},
Expand Down Expand Up @@ -245,6 +248,73 @@ test('http transport browser lifecycle (persistent, multiclient)', async ({ serv
await client2.close();
});

test('http transport shared context', async ({ serverEndpoint, server }) => {
const { url, stderr, kill } = await serverEndpoint({ args: ['--shared-browser-context'] });

// Create first client and navigate
const transport1 = new StreamableHTTPClientTransport(new URL('/mcp', url));
const client1 = new Client({ name: 'test1', version: '1.0.0' });
await client1.connect(transport1);
await client1.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
});

// Create second client - should reuse the same browser context
const transport2 = new StreamableHTTPClientTransport(new URL('/mcp', url));
const client2 = new Client({ name: 'test2', version: '1.0.0' });
await client2.connect(transport2);

// Get tabs from second client - should see the tab created by first client
const tabsResult = await client2.callTool({
name: 'browser_tabs',
arguments: { action: 'list' },
});

// Should have at least one tab (the one created by client1)
expect(tabsResult.content[0]?.text).toContain('tabs');

await transport1.terminateSession();
await client1.close();

// Second client should still work since context is shared
await client2.callTool({
name: 'browser_snapshot',
arguments: {},
});

await transport2.terminateSession();
await client2.close();

await expect(async () => {
const lines = stderr().split('\n');
expect(lines.filter(line => line.match(/create http session/)).length).toBe(2);
expect(lines.filter(line => line.match(/delete http session/)).length).toBe(2);

// Should have only one context creation since it's shared
expect(lines.filter(line => line.match(/create shared browser context/)).length).toBe(1);

// Should see client connect/disconnect messages
expect(lines.filter(line => line.match(/shared context client connected/)).length).toBe(2);
expect(lines.filter(line => line.match(/shared context client disconnected/)).length).toBe(2);
expect(lines.filter(line => line.match(/create context/)).length).toBe(2);
expect(lines.filter(line => line.match(/close context/)).length).toBe(2);

// Context should only close when the server shuts down.
expect(lines.filter(line => line.match(/close browser context complete \(persistent\)/)).length).toBe(0);
}).toPass();

kill();

if (process.platform !== 'win32') {
await expect(async () => {
const lines = stderr().split('\n');
// Context should only close when the server shuts down.
expect(lines.filter(line => line.match(/close browser context complete \(persistent\)/)).length).toBe(1);
}).toPass();
}
});

test('http transport (default)', async ({ serverEndpoint }) => {
const { url } = await serverEndpoint();
const transport = new StreamableHTTPClientTransport(url);
Expand Down
72 changes: 70 additions & 2 deletions tests/mcp/sse.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import { test as baseTest, expect, mcpServerPath } from './fixtures';

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

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

return { url: new URL(url), stderr: () => stderr };
return { url: new URL(url), stderr: () => stderr, kill: () => {
cp?.kill('SIGTERM');
cp = undefined;
} };
});
cp?.kill('SIGTERM');
},
Expand Down Expand Up @@ -229,3 +232,68 @@ test('sse transport browser lifecycle (persistent, multiclient)', async ({ serve
await client1.close();
await client2.close();
});

test('sse transport shared context', async ({ serverEndpoint, server }) => {
const { url, stderr, kill } = await serverEndpoint({ args: ['--shared-browser-context'] });

// Create first client and navigate
const transport1 = new SSEClientTransport(new URL('/sse', url));
const client1 = new Client({ name: 'test1', version: '1.0.0' });
await client1.connect(transport1);
await client1.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
});

// Create second client - should reuse the same browser context
const transport2 = new SSEClientTransport(new URL('/sse', url));
const client2 = new Client({ name: 'test2', version: '1.0.0' });
await client2.connect(transport2);

// Get tabs from second client - should see the tab created by first client
const tabsResult = await client2.callTool({
name: 'browser_tabs',
arguments: { action: 'list' },
});

// Should have at least one tab (the one created by client1)
expect(tabsResult.content[0]?.text).toContain('tabs');

await client1.close();

// Second client should still work since context is shared
await client2.callTool({
name: 'browser_snapshot',
arguments: {},
});

await client2.close();

await expect(async () => {
const lines = stderr().split('\n');
expect(lines.filter(line => line.match(/create SSE session/)).length).toBe(2);
expect(lines.filter(line => line.match(/delete SSE session/)).length).toBe(2);

// Should have only one context creation since it's shared
expect(lines.filter(line => line.match(/create shared browser context/)).length).toBe(1);

// Should see client connect/disconnect messages
expect(lines.filter(line => line.match(/shared context client connected/)).length).toBe(2);
expect(lines.filter(line => line.match(/shared context client disconnected/)).length).toBe(2);
expect(lines.filter(line => line.match(/create context/)).length).toBe(2);
expect(lines.filter(line => line.match(/close context/)).length).toBe(2);

// Context should only close when the server shuts down.
expect(lines.filter(line => line.match(/close browser context complete \(persistent\)/)).length).toBe(0);
}).toPass();

kill();

if (process.platform !== 'win32') {
await expect(async () => {
const lines = stderr().split('\n');
// Context should only close when the server shuts down.
expect(lines.filter(line => line.match(/close browser context complete \(persistent\)/)).length).toBe(1);
}).toPass();
}
});
Loading