diff --git a/packages/playwright-core/src/tools/backend/browserBackend.ts b/packages/playwright-core/src/tools/backend/browserBackend.ts index b769738fded92..28dea5d908cbf 100644 --- a/packages/playwright-core/src/tools/backend/browserBackend.ts +++ b/packages/playwright-core/src/tools/backend/browserBackend.ts @@ -29,12 +29,16 @@ export class BrowserBackend implements ServerBackend { private _context: Context | undefined; private _sessionLog: SessionLog | undefined; private _config: ContextConfig; + private _disconnected = false; readonly browserContext: playwright.BrowserContext; constructor(config: ContextConfig, browserContext: playwright.BrowserContext, tools: Tool[]) { this._config = config; this._tools = tools; this.browserContext = browserContext; + const markDisconnected = () => { this._disconnected = true; }; + this.browserContext.once('close', markDisconnected); + this.browserContext.browser()?.once('disconnected', markDisconnected); } async initialize(clientInfo: ClientInfo): Promise { @@ -50,7 +54,7 @@ export class BrowserBackend implements ServerBackend { await this._context?.dispose().catch(e => debug('pw:tools:error')(e)); } - async callTool(name: string, rawArguments: mcpServer.CallToolRequest['params']['arguments'] & { _meta?: Record } = {}, signal?: AbortSignal): Promise { + async callTool(name: string, rawArguments: mcpServer.CallToolRequest['params']['arguments'] & { _meta?: Record } = {}, signal?: AbortSignal): Promise { const json = !!rawArguments._meta?.json; const formatError = (message: string): mcpServer.CallToolResult => ({ content: [{ type: 'text' as const, text: json ? JSON.stringify({ isError: true, error: message }, null, 2) : `### Error\n${message}` }], @@ -66,7 +70,7 @@ export class BrowserBackend implements ServerBackend { const context = this._context!; const response = new Response(context, name, parsedArguments, { relativeTo: cwd, raw, json }); context.setRunningTool(name); - let responseObject: mcpServer.CallToolResult; + let responseObject: mcpServer.CallToolResult & { isClose?: boolean }; try { await tool.handle(context, parsedArguments, response, signal); for (const reason of context.drainPendingUnhandledRejections()) @@ -75,10 +79,12 @@ export class BrowserBackend implements ServerBackend { this._sessionLog?.logResponse(name, parsedArguments, responseObject); } catch (error: any) { const messages = [String(error), ...context.drainPendingUnhandledRejections().map(formatRejectionReason)]; - return formatError(messages.join('\n\n')); + responseObject = formatError(messages.join('\n\n')); } finally { context.setRunningTool(undefined); } + if (this._disconnected) + responseObject.isClose = true; return responseObject; } } diff --git a/tests/mcp/cdp.spec.ts b/tests/mcp/cdp.spec.ts index 0fbada3211de3..fbfe60f09134a 100644 --- a/tests/mcp/cdp.spec.ts +++ b/tests/mcp/cdp.spec.ts @@ -83,6 +83,40 @@ test('should throw connection error and allow re-connecting', async ({ cdpServer }); }); +test('auto-recover when remote browser disconnects mid-session', async ({ cdpServer, startClient, server }) => { + const browserContext = await cdpServer.start(); + const { client } = await startClient({ args: [`--cdp-endpoint=${cdpServer.endpoint}`] }); + + expect(await client.callTool({ + name: 'browser_navigate', + arguments: { url: server.HELLO_WORLD }, + })).toHaveResponse({ + snapshot: expect.stringContaining(`Hello, world!`), + }); + + // Simulate the remote browser dying mid-session (e.g. CDP endpoint session timeout). + await browserContext.close(); + + // The next call hits the dead backend; it must error and let the MCP server discard + // the backend so the next call can transparently establish a fresh connection. + expect(await client.callTool({ + name: 'browser_navigate', + arguments: { url: server.HELLO_WORLD }, + })).toHaveResponse({ + isError: true, + }); + + // Bring the CDP endpoint back. The next call should reconnect transparently — + // no manual browser_close needed (regression test for playwright-mcp#1588). + await cdpServer.start(); + expect(await client.callTool({ + name: 'browser_navigate', + arguments: { url: server.HELLO_WORLD }, + })).toHaveResponse({ + snapshot: expect.stringContaining(`Hello, world!`), + }); +}); + test('does not support --device', async () => { const result = spawnSync('node', [ ...mcpServerPath, '--device=Pixel 5', '--cdp-endpoint=http://localhost:1234', diff --git a/tests/mcp/fixtures.ts b/tests/mcp/fixtures.ts index a47f9d7e14b4b..9793b65205ade 100644 --- a/tests/mcp/fixtures.ts +++ b/tests/mcp/fixtures.ts @@ -169,7 +169,7 @@ export const test = serverTest.extend { - if (browserContext) + if (browserContext && browserContext.browser()?.isConnected()) throw new Error('CDP server already exists'); browserContext = await chromium.launchPersistentContext(testInfo.outputPath('cdp-user-data-dir'), { channel: mcpBrowser, @@ -181,7 +181,7 @@ export const test = serverTest.extend {}); }, mcpHeadless: async ({ headless }, use) => {