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
40 changes: 29 additions & 11 deletions packages/playwright-core/src/tools/backend/browserBackend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -25,37 +27,51 @@ 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<{ disconnected: [], 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;
private _disposeCallback: (() => Promise<void>) | undefined;

constructor(config: ContextConfig, browserContext: playwright.BrowserContext, tools: Tool[]) {
constructor(config: ContextConfig, browserContext: playwright.BrowserContext, tools: Tool[], disposeCallback?: () => Promise<void>) {
super();
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);
this._browserContext = browserContext;
this._disposeCallback = disposeCallback;
const markDisconnected = () => {
if (this._disconnected)
return;
this._disconnected = true;
this.emit('disconnected');
};
this._browserContext.once('close', markDisconnected);
this._browserContext.browser()?.once('disconnected', markDisconnected);
}

async initialize(clientInfo: ClientInfo): Promise<void> {
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,
});
}

async dispose() {
if (this._disposed)
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<string, any> } = {}, signal?: AbortSignal): Promise<mcpServer.CallToolResult & { isClose?: boolean }> {
async callTool(name: string, rawArguments: mcpServer.CallToolRequest['params']['arguments'] & { _meta?: Record<string, any> } = {}, signal?: AbortSignal): Promise<mcpServer.CallToolResult> {
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 Down Expand Up @@ -90,8 +106,10 @@ export class BrowserBackend implements ServerBackend {
} finally {
context.setRunningTool(undefined);
}
if (this._disconnected)
responseObject.isClose = true;
if (this._disconnected || responseObject.isClose) {
delete responseObject.isClose;
await this.dispose();
}
return responseObject;
}
}
Expand Down
1 change: 0 additions & 1 deletion packages/playwright-core/src/tools/mcp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
30 changes: 14 additions & 16 deletions packages/playwright-core/src/tools/mcp/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,25 +129,23 @@ 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;
return new BrowserBackend(config, browserContext, tools, 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(() => { });
});
},
};
await mcpServer.start(factory, config.server);
});
Expand Down
42 changes: 9 additions & 33 deletions packages/playwright-core/src/tools/utils/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,32 +38,11 @@ export type ClientInfo = {
clientName: string;
};

class BackendManager {
private _backends = new Map<ServerBackend, ServerBackendFactory>();

async createBackend(factory: ServerBackendFactory, clientInfo: ClientInfo): Promise<ServerBackend> {
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<void>;
callTool(name: string, args: CallToolRequest['params']['arguments'], signal: AbortSignal): Promise<CallToolResult & { isClose?: boolean }>;
callTool(name: string, args: CallToolRequest['params']['arguments'], signal: AbortSignal): Promise<CallToolResult>;
dispose?(): Promise<void>;
once(event: 'disposed', listener: () => void): void;
}

export type ServerBackendFactory = {
Expand All @@ -72,7 +51,6 @@ export type ServerBackendFactory = {
version: string;
toolSchemas: ToolSchema<any>[];
create: (clientInfo: ClientInfo) => Promise<ServerBackend>;
disposed: (backend: ServerBackend) => Promise<void>;
};

export async function connect(factory: ServerBackendFactory, transport: Transport, transportInitialized: Promise<void>, runHeartbeat: boolean) {
Expand All @@ -94,28 +72,25 @@ export function createServer(name: string, version: string, factory: ServerBacke

let backendPromise: Promise<ServerBackend> | 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) => {
serverDebug('callTool', request);

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;
});
}

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);
backendPromise = undefined;
delete toolResult.isClose;
}

const mergedResult = mergeTextParts(toolResult);
serverDebugResponse('callResult', mergedResult);
return mergedResult;
Expand Down Expand Up @@ -146,7 +121,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;
Expand Down
1 change: 0 additions & 1 deletion packages/playwright/src/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
Loading