From 9f084555fd82dead5faab0b80f18c18bf9ff156e Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Tue, 5 May 2026 16:36:23 -0700 Subject: [PATCH] fix(mcp): auto-recover when remote browser disconnects mid-session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When connecting via --cdp-endpoint to a remote browser service that enforces session timeouts (e.g. Browserless), the remote can kill the CDP session at any moment. Today, the next tool call fails with "Target page, context or browser has been closed" — and so do all subsequent calls, because BrowserBackend.callTool returns the error without signalling isClose, leaving the server holding a dead backend. The only escape was a manual browser_close -> browser_navigate sequence, which AI agents rarely discover on their own. Listen for the browserContext 'close' and browser 'disconnected' events on initialize(). When either fires, mark the backend as disconnected and stamp isClose: true on the next tool result. The existing server.ts path then disposes the backend and clears backendPromise, so the following tool call transparently establishes a fresh CDP connection — no manual browser_close needed. Adds tests/mcp/cdp.spec.ts coverage that drops the CDP endpoint mid-session and verifies the next call recovers automatically. The cdpServer fixture is taught to accept a restart after disconnect. Fixes: https://github.com/microsoft/playwright-mcp/issues/1588 --- .../src/tools/backend/browserBackend.ts | 12 +++++-- tests/mcp/cdp.spec.ts | 34 +++++++++++++++++++ tests/mcp/fixtures.ts | 4 +-- 3 files changed, 45 insertions(+), 5 deletions(-) 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) => {