From 9957360b6d031c7f9e8e89785af3287183389fa6 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Fri, 24 Jul 2026 11:16:37 -0700 Subject: [PATCH 1/3] chore(mcp): replace BackendManager with 'disposed' event on BrowserBackend BrowserBackend now emits 'disposed' at the end of dispose(), and the browser cleanup logic is attached as a listener right where the backend is created. This removes the BackendManager indirection and the ServerBackendFactory.disposed method. --- .../src/tools/backend/browserBackend.ts | 20 ++++++++---- .../playwright-core/src/tools/mcp/index.ts | 1 - .../playwright-core/src/tools/mcp/program.ts | 32 +++++++++---------- .../src/tools/utils/mcp/server.ts | 30 +++-------------- packages/playwright/src/program.ts | 1 - 5 files changed, 34 insertions(+), 50 deletions(-) diff --git a/packages/playwright-core/src/tools/backend/browserBackend.ts b/packages/playwright-core/src/tools/backend/browserBackend.ts index b8c8fb55d78f7..b49e5c50b6354 100644 --- a/packages/playwright-core/src/tools/backend/browserBackend.ts +++ b/packages/playwright-core/src/tools/backend/browserBackend.ts @@ -14,6 +14,8 @@ * limitations under the License. */ +import { EventEmitter } from 'events'; + import * as z from 'zod'; import debug from 'debug'; import { Context } from './context'; @@ -25,26 +27,28 @@ import type { Tool } from './tool'; import type * as mcpServer from '../utils/mcp/server'; import type { ClientInfo, ServerBackend } from '../utils/mcp/server'; -export class BrowserBackend implements ServerBackend { +export class BrowserBackend extends EventEmitter<{ disposed: [] }> implements ServerBackend { private _tools: Tool[]; private _context: Context | undefined; private _sessionLog: SessionLog | undefined; private _config: ContextConfig; private _disconnected = false; - readonly browserContext: playwright.BrowserContext; + private _disposed = false; + private _browserContext: playwright.BrowserContext; constructor(config: ContextConfig, browserContext: playwright.BrowserContext, tools: Tool[]) { + super(); this._config = config; this._tools = tools; - this.browserContext = browserContext; + this._browserContext = browserContext; const markDisconnected = () => { this._disconnected = true; }; - this.browserContext.once('close', markDisconnected); - this.browserContext.browser()?.once('disconnected', markDisconnected); + this._browserContext.once('close', markDisconnected); + this._browserContext.browser()?.once('disconnected', markDisconnected); } async initialize(clientInfo: ClientInfo): Promise { this._sessionLog = this._config.saveSession ? await SessionLog.create(this._config, clientInfo.cwd) : undefined; - this._context = new Context(this.browserContext, { + this._context = new Context(this._browserContext, { config: this._config, sessionLog: this._sessionLog, cwd: clientInfo.cwd, @@ -52,7 +56,11 @@ export class BrowserBackend implements ServerBackend { } async dispose() { + if (this._disposed) + return; + this._disposed = true; await this._context?.dispose().catch(e => debug('pw:tools:error')(e)); + this.emit('disposed'); } async callTool(name: string, rawArguments: mcpServer.CallToolRequest['params']['arguments'] & { _meta?: Record } = {}, signal?: AbortSignal): Promise { diff --git a/packages/playwright-core/src/tools/mcp/index.ts b/packages/playwright-core/src/tools/mcp/index.ts index a1a1c8c4b5a0c..d720e1c93890a 100644 --- a/packages/playwright-core/src/tools/mcp/index.ts +++ b/packages/playwright-core/src/tools/mcp/index.ts @@ -41,7 +41,6 @@ export async function createConnection(userConfig: Config = {}, contextGetter?: const context = config.browser.isolated ? await browser.newContext(config.browser.contextOptions) : browser.contexts()[0]; return new BrowserBackend(config, context, tools); }, - disposed: async () => { } }; return createServer('api', packageJSON.version, backendFactory, Promise.resolve(), false); } diff --git a/packages/playwright-core/src/tools/mcp/program.ts b/packages/playwright-core/src/tools/mcp/program.ts index 2643131dd085d..745c00710641c 100644 --- a/packages/playwright-core/src/tools/mcp/program.ts +++ b/packages/playwright-core/src/tools/mcp/program.ts @@ -129,25 +129,25 @@ export function decorateMCPCommand(command: Command) { await browser.bind(sessionName, { workspaceDir: clientInfo.cwd }); } const browserContext = config.browser.isolated ? await browser.newContext(config.browser.contextOptions) : browser.contexts()[0]; - return new BrowserBackend(config, browserContext, tools); - }, - disposed: async backend => { - clientCount--; - const browserContext = (backend as BrowserBackend).browserContext; + const backend = new BrowserBackend(config, browserContext, tools); + backend.once('disposed', async () => { + clientCount--; - if (sharedBrowserPromise && clientCount > 0) { - if (config.browser.isolated) { - testDebug('close context'); - await browserContext.close().catch(() => { }); + if (sharedBrowserPromise && clientCount > 0) { + if (config.browser.isolated) { + testDebug('close context'); + await browserContext.close().catch(() => { }); + } + return; } - return; - } - testDebug('close browser'); - sharedBrowserPromise = undefined; - await browserContext.close().catch(() => { }); - await browserContext.browser()?.close().catch(() => { }); - } + testDebug('close browser'); + sharedBrowserPromise = undefined; + await browserContext.close().catch(() => { }); + await browserContext.browser()?.close().catch(() => { }); + }); + return backend; + }, }; await mcpServer.start(factory, config.server); }); diff --git a/packages/playwright-core/src/tools/utils/mcp/server.ts b/packages/playwright-core/src/tools/utils/mcp/server.ts index 15cf6ebe1b5ed..201a9b01719a9 100644 --- a/packages/playwright-core/src/tools/utils/mcp/server.ts +++ b/packages/playwright-core/src/tools/utils/mcp/server.ts @@ -38,28 +38,6 @@ export type ClientInfo = { clientName: string; }; -class BackendManager { - private _backends = new Map(); - - async createBackend(factory: ServerBackendFactory, clientInfo: ClientInfo): Promise { - const backend = await factory.create(clientInfo); - await backend.initialize?.(clientInfo); - this._backends.set(backend, factory); - return backend; - } - - async disposeBackend(backend: ServerBackend) { - const factory = this._backends.get(backend); - if (!factory) - return; - await backend.dispose?.(); - await factory.disposed(backend).catch(serverDebug); - this._backends.delete(backend); - } -} - -const backendManager = new BackendManager(); - export interface ServerBackend { initialize?(clientInfo: ClientInfo): Promise; callTool(name: string, args: CallToolRequest['params']['arguments'], signal: AbortSignal): Promise; @@ -72,7 +50,6 @@ export type ServerBackendFactory = { version: string; toolSchemas: ToolSchema[]; create: (clientInfo: ClientInfo) => Promise; - disposed: (backend: ServerBackend) => Promise; }; export async function connect(factory: ServerBackendFactory, transport: Transport, transportInitialized: Promise, runHeartbeat: boolean) { @@ -94,7 +71,7 @@ export function createServer(name: string, version: string, factory: ServerBacke let backendPromise: Promise | undefined; - const onClose = () => backendPromise?.then(b => backendManager.disposeBackend(b)).catch(serverDebug); + const onClose = () => backendPromise?.then(b => b.dispose?.()).catch(serverDebug); addServerListener(server, 'close', onClose); server.setRequestHandler(CallToolRequestSchema, async (request, extra) => { @@ -111,7 +88,7 @@ export function createServer(name: string, version: string, factory: ServerBacke const backend = await backendPromise; const toolResult = await backend.callTool(request.params.name, request.params.arguments || {}, extra.signal); if (toolResult.isClose) { - await backendManager.disposeBackend(backend).catch(serverDebug); + await backend.dispose?.().catch(serverDebug); backendPromise = undefined; delete toolResult.isClose; } @@ -146,7 +123,8 @@ const initializeServer = async (server: ServerType, factory: ServerBackendFactor clientName: server.getClientVersion()?.name ?? 'Playwright MCP', }; - const backend = await backendManager.createBackend(factory, clientInfo); + const backend = await factory.create(clientInfo); + await backend.initialize?.(clientInfo); if (runHeartbeat) startHeartbeat(server); return backend; diff --git a/packages/playwright/src/program.ts b/packages/playwright/src/program.ts index eea299a9db04f..2384f49a8852e 100644 --- a/packages/playwright/src/program.ts +++ b/packages/playwright/src/program.ts @@ -152,7 +152,6 @@ function addTestMCPServerCommand(program: Command) { version: packageJSON.version, toolSchemas: testServerBackendTools.map(tool => tool.schema), create: async () => new TestServerBackend(options.config, { muteConsole: options.port === undefined, headless: options.headless }), - disposed: async () => { } }; // TODO: add all options from mcp.startHttpServer. await tools.start(factory, { port: options.port === undefined ? undefined : +options.port, host: options.host }); From 72db48a47cc9cc7baae0510446f2dbab9eadcd27 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Fri, 24 Jul 2026 12:50:26 -0700 Subject: [PATCH 2/3] chore(mcp): move backend dispose into BrowserBackend.callTool BrowserBackend now takes a dispose callback in the constructor and awaits it in dispose(), disposes itself when the tool response is marked as close or the browser is disconnected, and emits 'disposed' so the server can reset its backend promise. The isClose flag no longer crosses the ServerBackend.callTool boundary. --- .../src/tools/backend/browserBackend.ts | 13 +++++++++---- packages/playwright-core/src/tools/mcp/program.ts | 4 +--- .../playwright-core/src/tools/utils/mcp/server.ts | 14 ++++++-------- 3 files changed, 16 insertions(+), 15 deletions(-) diff --git a/packages/playwright-core/src/tools/backend/browserBackend.ts b/packages/playwright-core/src/tools/backend/browserBackend.ts index b49e5c50b6354..6cf63cdb62d80 100644 --- a/packages/playwright-core/src/tools/backend/browserBackend.ts +++ b/packages/playwright-core/src/tools/backend/browserBackend.ts @@ -35,12 +35,14 @@ export class BrowserBackend extends EventEmitter<{ disposed: [] }> implements Se private _disconnected = false; private _disposed = false; private _browserContext: playwright.BrowserContext; + private _disposeCallback: (() => Promise) | undefined; - constructor(config: ContextConfig, browserContext: playwright.BrowserContext, tools: Tool[]) { + constructor(config: ContextConfig, browserContext: playwright.BrowserContext, tools: Tool[], disposeCallback?: () => Promise) { super(); this._config = config; this._tools = tools; this._browserContext = browserContext; + this._disposeCallback = disposeCallback; const markDisconnected = () => { this._disconnected = true; }; this._browserContext.once('close', markDisconnected); this._browserContext.browser()?.once('disconnected', markDisconnected); @@ -60,10 +62,11 @@ export class BrowserBackend extends EventEmitter<{ disposed: [] }> implements Se return; this._disposed = true; await this._context?.dispose().catch(e => debug('pw:tools:error')(e)); + await this._disposeCallback?.().catch(e => debug('pw:tools:error')(e)); this.emit('disposed'); } - 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}` }], @@ -98,8 +101,10 @@ export class BrowserBackend extends EventEmitter<{ disposed: [] }> implements Se } finally { context.setRunningTool(undefined); } - if (this._disconnected) - responseObject.isClose = true; + if (this._disconnected || responseObject.isClose) { + delete responseObject.isClose; + await this.dispose(); + } return responseObject; } } diff --git a/packages/playwright-core/src/tools/mcp/program.ts b/packages/playwright-core/src/tools/mcp/program.ts index 745c00710641c..2c748848b31c7 100644 --- a/packages/playwright-core/src/tools/mcp/program.ts +++ b/packages/playwright-core/src/tools/mcp/program.ts @@ -129,8 +129,7 @@ export function decorateMCPCommand(command: Command) { await browser.bind(sessionName, { workspaceDir: clientInfo.cwd }); } const browserContext = config.browser.isolated ? await browser.newContext(config.browser.contextOptions) : browser.contexts()[0]; - const backend = new BrowserBackend(config, browserContext, tools); - backend.once('disposed', async () => { + return new BrowserBackend(config, browserContext, tools, async () => { clientCount--; if (sharedBrowserPromise && clientCount > 0) { @@ -146,7 +145,6 @@ export function decorateMCPCommand(command: Command) { await browserContext.close().catch(() => { }); await browserContext.browser()?.close().catch(() => { }); }); - return backend; }, }; await mcpServer.start(factory, config.server); diff --git a/packages/playwright-core/src/tools/utils/mcp/server.ts b/packages/playwright-core/src/tools/utils/mcp/server.ts index 201a9b01719a9..ecdd1ae6ca11b 100644 --- a/packages/playwright-core/src/tools/utils/mcp/server.ts +++ b/packages/playwright-core/src/tools/utils/mcp/server.ts @@ -40,8 +40,9 @@ export type ClientInfo = { export interface ServerBackend { initialize?(clientInfo: ClientInfo): Promise; - callTool(name: string, args: CallToolRequest['params']['arguments'], signal: AbortSignal): Promise; + callTool(name: string, args: CallToolRequest['params']['arguments'], signal: AbortSignal): Promise; dispose?(): Promise; + once(event: 'disposed', listener: () => void): void; } export type ServerBackendFactory = { @@ -79,7 +80,10 @@ export function createServer(name: string, version: string, factory: ServerBacke try { if (!backendPromise) { - backendPromise = initializeServer(server, factory, transportInitialized, runHeartbeat).catch(e => { + backendPromise = initializeServer(server, factory, transportInitialized, runHeartbeat).then(backend => { + backend.once('disposed', () => { backendPromise = undefined; }); + return backend; + }).catch(e => { backendPromise = undefined; throw e; }); @@ -87,12 +91,6 @@ export function createServer(name: string, version: string, factory: ServerBacke const backend = await backendPromise; const toolResult = await backend.callTool(request.params.name, request.params.arguments || {}, extra.signal); - if (toolResult.isClose) { - await backend.dispose?.().catch(serverDebug); - backendPromise = undefined; - delete toolResult.isClose; - } - const mergedResult = mergeTextParts(toolResult); serverDebugResponse('callResult', mergedResult); return mergedResult; From d40bf7e9be603da06d7d14dc0745460cc272d328 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Fri, 24 Jul 2026 14:00:45 -0700 Subject: [PATCH 3/3] chore(mcp): emit 'disconnected' event when browser context closes --- .../playwright-core/src/tools/backend/browserBackend.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/playwright-core/src/tools/backend/browserBackend.ts b/packages/playwright-core/src/tools/backend/browserBackend.ts index 6cf63cdb62d80..2fc6d82944af2 100644 --- a/packages/playwright-core/src/tools/backend/browserBackend.ts +++ b/packages/playwright-core/src/tools/backend/browserBackend.ts @@ -27,7 +27,7 @@ import type { Tool } from './tool'; import type * as mcpServer from '../utils/mcp/server'; import type { ClientInfo, ServerBackend } from '../utils/mcp/server'; -export class BrowserBackend extends EventEmitter<{ disposed: [] }> implements ServerBackend { +export class BrowserBackend extends EventEmitter<{ disconnected: [], disposed: [] }> implements ServerBackend { private _tools: Tool[]; private _context: Context | undefined; private _sessionLog: SessionLog | undefined; @@ -43,7 +43,12 @@ export class BrowserBackend extends EventEmitter<{ disposed: [] }> implements Se this._tools = tools; this._browserContext = browserContext; this._disposeCallback = disposeCallback; - const markDisconnected = () => { this._disconnected = true; }; + const markDisconnected = () => { + if (this._disconnected) + return; + this._disconnected = true; + this.emit('disconnected'); + }; this._browserContext.once('close', markDisconnected); this._browserContext.browser()?.once('disconnected', markDisconnected); }