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
12 changes: 9 additions & 3 deletions packages/playwright-core/src/tools/backend/browserBackend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
Expand All @@ -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<string, any> } = {}, signal?: AbortSignal): Promise<mcpServer.CallToolResult> {
async callTool(name: string, rawArguments: mcpServer.CallToolRequest['params']['arguments'] & { _meta?: Record<string, any> } = {}, signal?: AbortSignal): Promise<mcpServer.CallToolResult & { isClose?: boolean }> {
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}` }],
Expand All @@ -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())
Expand All @@ -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;
}
}
Expand Down
34 changes: 34 additions & 0 deletions tests/mcp/cdp.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
4 changes: 2 additions & 2 deletions tests/mcp/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ export const test = serverTest.extend<TestFixtures & TestOptions, WorkerFixtures
await use({
endpoint: `http://localhost:${port}`,
start: async () => {
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,
Expand All @@ -181,7 +181,7 @@ export const test = serverTest.extend<TestFixtures & TestOptions, WorkerFixtures
return browserContext;
}
});
await browserContext?.close();
await browserContext?.close().catch(() => {});
},

mcpHeadless: async ({ headless }, use) => {
Expand Down
Loading