diff --git a/packages/playwright/src/mcp/browser/browserContextFactory.ts b/packages/playwright/src/mcp/browser/browserContextFactory.ts index f6b7a55e12f42..97cf9f12201af 100644 --- a/packages/playwright/src/mcp/browser/browserContextFactory.ts +++ b/packages/playwright/src/mcp/browser/browserContextFactory.ts @@ -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) @@ -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 }> | 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 }> { + 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(); + } +} diff --git a/packages/playwright/src/mcp/browser/config.ts b/packages/playwright/src/mcp/browser/config.ts index 4e7f6e9d9679b..b41d07f585452 100644 --- a/packages/playwright/src/mcp/browser/config.ts +++ b/packages/playwright/src/mcp/browser/config.ts @@ -52,6 +52,7 @@ export type CLIOptions = { saveSession?: boolean; saveTrace?: boolean; secrets?: Record; + sharedBrowserContext?: boolean; storageState?: string; timeoutAction?: number; timeoutNavigation?: number; @@ -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: { diff --git a/packages/playwright/src/mcp/browser/watchdog.ts b/packages/playwright/src/mcp/browser/watchdog.ts index c342e039f505b..a437accab7ce6 100644 --- a/packages/playwright/src/mcp/browser/watchdog.ts +++ b/packages/playwright/src/mcp/browser/watchdog.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { SharedContextFactory } from './browserContextFactory'; import { Context } from './context'; export function setupExitWatchdog() { @@ -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); }; diff --git a/packages/playwright/src/mcp/config.d.ts b/packages/playwright/src/mcp/config.d.ts index 845f0ede6cb47..aed8c4745b8ec 100644 --- a/packages/playwright/src/mcp/config.d.ts +++ b/packages/playwright/src/mcp/config.d.ts @@ -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. diff --git a/packages/playwright/src/mcp/program.ts b/packages/playwright/src/mcp/program.ts index acc349ba00756..852f4b5a75851 100644 --- a/packages/playwright/src/mcp/program.ts +++ b/packages/playwright/src/mcp/program.ts @@ -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 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 to the storage state file for isolated sessions.') .option('--timeout-action ', 'specify action timeout in milliseconds, defaults to 5000ms', numberParser) .option('--timeout-navigation ', 'specify navigation timeout in milliseconds, defaults to 60000ms', numberParser) diff --git a/tests/mcp/http.spec.ts b/tests/mcp/http.spec.ts index 4a498072b12d5..458b2e6c6e0ef 100644 --- a/tests/mcp/http.spec.ts +++ b/tests/mcp/http.spec.ts @@ -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'); @@ -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'); }, @@ -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); diff --git a/tests/mcp/sse.spec.ts b/tests/mcp/sse.spec.ts index d67a24a98b2ac..c214e44157724 100644 --- a/tests/mcp/sse.spec.ts +++ b/tests/mcp/sse.spec.ts @@ -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'); @@ -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'); }, @@ -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(); + } +});