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: 10 additions & 2 deletions packages/playwright/src/mcp/browser/browserContextFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ class IsolatedContextFactory extends BaseContextFactory {
handleSIGTERM: false,
}).catch(error => {
if (error.message.includes('Executable doesn\'t exist'))
throw new Error(`Browser specified in your config is not installed. Either install it (likely) or change the config.`);
throwBrowserIsNotInstalledError(this.config);
throw error;
});
}
Expand Down Expand Up @@ -227,7 +227,7 @@ class PersistentContextFactory implements BrowserContextFactory {
return { browserContext, close };
} catch (error: any) {
if (error.message.includes('Executable doesn\'t exist'))
throw new Error(`Browser specified in your config is not installed. Either install it (likely) or change the config.`);
throwBrowserIsNotInstalledError(this.config);
if (error.message.includes('cannot open shared object file: No such file or directory')) {
const browserName = launchOptions.channel ?? this.config.browser.browserName;
throw new Error(`Missing system dependencies required to run browser ${browserName}. Install them with: sudo npx playwright install-deps ${browserName}`);
Expand Down Expand Up @@ -368,3 +368,11 @@ async function browserContextOptionsFromConfig(config: FullConfig, clientInfo: C
}
return result;
}

function throwBrowserIsNotInstalledError(config: FullConfig): never {
const channel = config.browser.launchOptions?.channel ?? config.browser.browserName;
if (config.skillMode)
throw new Error(`Browser "${channel}" is not installed. Run \`playwright-cli install-browser ${channel}\` to install`);
else
throw new Error(`Browser "${channel}" is not installed. Either install it (likely) or change the config.`);
}
5 changes: 0 additions & 5 deletions packages/playwright/src/mcp/browser/browserServerBackend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,6 @@ export class BrowserServerBackend implements ServerBackend {
private _config: FullConfig;
private _browserContextFactory: BrowserContextFactory;

onBrowserContextClosed: (() => void) | undefined;
onBrowserLaunchFailed: ((error: Error) => void) | undefined;

constructor(config: FullConfig, factory: BrowserContextFactory, options: { allTools?: boolean, structuredOutput?: boolean } = {}) {
this._config = config;
this._browserContextFactory = factory;
Expand All @@ -51,8 +48,6 @@ export class BrowserServerBackend implements ServerBackend {
sessionLog: this._sessionLog,
clientInfo,
});
this._context.onBrowserContextClosed = () => this.onBrowserContextClosed?.();
this._context.onBrowserLaunchFailed = error => this.onBrowserLaunchFailed?.(error);
}

async listTools(): Promise<mcpServer.Tool[]> {
Expand Down
7 changes: 1 addition & 6 deletions packages/playwright/src/mcp/browser/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,9 +80,6 @@ export class Context {
private _runningToolName: string | undefined;
private _abortController = new AbortController();

onBrowserContextClosed: (() => void) | undefined;
onBrowserLaunchFailed: ((error: Error) => void) | undefined;

constructor(options: ContextOptions) {
this.config = options.config;
this.sessionLog = options.sessionLog;
Expand Down Expand Up @@ -289,9 +286,8 @@ export class Context {
return this._browserContextPromise;

this._browserContextPromise = this._setupBrowserContext();
this._browserContextPromise.catch(error => {
this._browserContextPromise.catch(() => {
this._browserContextPromise = undefined;
this.onBrowserLaunchFailed?.(error);
});
return this._browserContextPromise;
}
Expand All @@ -313,7 +309,6 @@ export class Context {
for (const page of browserContext.pages())
this._onPageCreated(page);
browserContext.on('page', page => this._onPageCreated(page));
browserContext.on('close', () => this.onBrowserContextClosed?.());
if (this.config.saveTrace) {
await (browserContext.tracing as Tracing).start({
name: 'trace-' + Date.now(),
Expand Down
12 changes: 6 additions & 6 deletions packages/playwright/src/mcp/extension/cdpRelay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,23 +99,23 @@ export class CDPRelayServer {
return `${this._wsHost}${this._extensionPath}`;
}

async ensureExtensionConnectionForMCPContext(clientInfo: ClientInfo, abortSignal: AbortSignal, toolName: string | undefined) {
async ensureExtensionConnectionForMCPContext(clientInfo: ClientInfo, abortSignal: AbortSignal, forceNewTab: boolean) {
debugLogger('Ensuring extension connection for MCP context');
if (this._extensionConnection)
return;
this._connectBrowser(clientInfo, toolName);
this._connectBrowser(clientInfo, forceNewTab);
debugLogger('Waiting for incoming extension connection');
await Promise.race([
this._extensionConnectionPromise,
new Promise((_, reject) => setTimeout(() => {
reject(new Error(`Extension connection timeout. Make sure the "Playwright MCP Bridge" extension is installed. See https://github.com/microsoft/playwright-mcp/blob/main/extension/README.md for installation instructions.`));
reject(new Error(`Extension connection timeout. Make sure the "Playwright MCP Bridge" extension is installed. See https://github.com/microsoft/playwright-mcp/blob/main/packages/extension/README.md for installation instructions.`));
}, process.env.PWMCP_TEST_CONNECTION_TIMEOUT ? parseInt(process.env.PWMCP_TEST_CONNECTION_TIMEOUT, 10) : 5_000)),
new Promise((_, reject) => abortSignal.addEventListener('abort', reject))
]);
debugLogger('Extension connection established');
}

private _connectBrowser(clientInfo: ClientInfo, toolName: string | undefined) {
private _connectBrowser(clientInfo: ClientInfo, forceNewTab: boolean) {
const mcpRelayEndpoint = `${this._wsHost}${this._extensionPath}`;
// Need to specify "key" in the manifest.json to make the id stable when loading from file.
const url = new URL('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html');
Expand All @@ -126,8 +126,8 @@ export class CDPRelayServer {
};
url.searchParams.set('client', JSON.stringify(client));
url.searchParams.set('protocolVersion', process.env.PWMCP_TEST_PROTOCOL_VERSION ?? protocol.VERSION.toString());
if (toolName)
url.searchParams.set('newTab', String(toolName === 'browser_navigate'));
if (forceNewTab)
url.searchParams.set('newTab', 'true');
const token = process.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN;
if (token)
url.searchParams.set('token', token);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,13 @@ export class ExtensionContextFactory implements BrowserContextFactory {
private _browserChannel: string;
private _userDataDir?: string;
private _executablePath?: string;
private _forceNewTab: boolean;

constructor(browserChannel: string, userDataDir: string | undefined, executablePath: string | undefined) {
constructor(browserChannel: string, userDataDir: string | undefined, executablePath: string | undefined, forceNewTab: boolean) {
this._browserChannel = browserChannel;
this._userDataDir = userDataDir;
this._executablePath = executablePath;
this._forceNewTab = forceNewTab;
}

async createContext(clientInfo: ClientInfo, abortSignal: AbortSignal, options: { toolName?: string }): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
Expand All @@ -49,7 +51,8 @@ export class ExtensionContextFactory implements BrowserContextFactory {

private async _obtainBrowser(clientInfo: ClientInfo, abortSignal: AbortSignal, toolName: string | undefined): Promise<playwright.Browser> {
const relay = await this._startRelay(abortSignal);
await relay.ensureExtensionConnectionForMCPContext(clientInfo, abortSignal, toolName);
const forceNewTab = this._forceNewTab || toolName === 'browser_navigate';
await relay.ensureExtensionConnectionForMCPContext(clientInfo, abortSignal, forceNewTab);
return await playwright.chromium.connectOverCDP(relay.cdpEndpoint(), { isLocal: true });
}

Expand Down
28 changes: 15 additions & 13 deletions packages/playwright/src/mcp/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,22 +106,24 @@ export function decorateCommand(command: Command, version: string) {
}

const browserContextFactory = contextFactory(config);
const extensionContextFactory = new ExtensionContextFactory(config.browser.launchOptions.channel || 'chrome', config.browser.userDataDir, config.browser.launchOptions.executablePath);
// Always force new tab in cli mode as the first command is always navigation.
const forceNewTab = !!config.sessionConfig;
const extensionContextFactory = new ExtensionContextFactory(config.browser.launchOptions.channel || 'chrome', config.browser.userDataDir, config.browser.launchOptions.executablePath, forceNewTab);

if (config.sessionConfig) {
const contextFactory = config.extension ? extensionContextFactory : browserContextFactory;
const serverBackendFactory: mcpServer.ServerBackendFactory = {
name: 'Playwright',
nameInConfig: 'playwright-daemon',
version,
create: () => new BrowserServerBackend(config, contextFactory, { allTools: true })
};
console.error(`### Config`);
console.error('```json');
console.error(JSON.stringify(config, null, 2));
console.error('```');
const socketPath = await startMcpDaemonServer(config.sessionConfig, serverBackendFactory);
console.error(`Daemon server listening on ${socketPath}`);
console.log(`### Config`);
console.log('```json');
console.log(JSON.stringify(config, null, 2));
console.log('```');
try {
const socketPath = await startMcpDaemonServer(config, contextFactory);
console.log(`### Success\nDaemon listening on ${socketPath}`);
console.log('<EOF>');
} catch (error) {
console.log(`### Error\n${error.message}`);
console.log('<EOF>');
}
return;
}

Expand Down
2 changes: 0 additions & 2 deletions packages/playwright/src/mcp/sdk/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,6 @@ export interface ServerBackend {
listTools(): Promise<Tool[]>;
callTool(name: string, args: CallToolRequest['params']['arguments'], progress: ProgressCallback): Promise<CallToolResult>;
serverClosed?(server: Server): void;
onBrowserContextClosed?: (() => void) | undefined;
onBrowserLaunchFailed?: ((error: Error) => void) | undefined;
}

export type ServerBackendFactory = {
Expand Down
1 change: 1 addition & 0 deletions packages/playwright/src/mcp/terminal/DEPS.list
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
[daemon.ts]
../browser/browserServerBackend.ts
../browser/tools
../cli/socketConnection.ts

Expand Down
43 changes: 20 additions & 23 deletions packages/playwright/src/mcp/terminal/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,14 @@ import url from 'url';
import { debug } from 'playwright-core/lib/utilsBundle';
import { gracefullyProcessExitDoNotHang } from 'playwright-core/lib/utils';

import { BrowserServerBackend } from '../browser/browserServerBackend';
import { SocketConnection } from './socketConnection';
import { commands } from './commands';
import { parseCommand } from './command';

import type { ServerBackendFactory } from '../sdk/server';
import type * as mcp from '../sdk/exports';
import type { SessionConfig } from './program';
import type { BrowserContextFactory } from '../browser/browserContextFactory';
import type { FullConfig } from '../browser/config';

const daemonDebug = debug('pw:daemon');

Expand All @@ -44,9 +45,10 @@ async function socketExists(socketPath: string): Promise<boolean> {
}

export async function startMcpDaemonServer(
sessionConfig: SessionConfig,
serverBackendFactory: ServerBackendFactory,
config: FullConfig,
contextFactory: BrowserContextFactory,
): Promise<string> {
const sessionConfig = config.sessionConfig!;
const { socketPath, version } = sessionConfig;
// Clean up existing socket file on Unix
if (os.platform() !== 'win32' && await socketExists(socketPath)) {
Expand All @@ -59,21 +61,30 @@ export async function startMcpDaemonServer(
}
}

const backend = serverBackendFactory.create();
const cwd = url.pathToFileURL(process.cwd()).href;
await backend.initialize?.({
const clientInfo = {
name: 'playwright-cli',
version: sessionConfig.version,
roots: [{
uri: cwd,
name: 'cwd'
}],
timestamp: Date.now(),
};

const { browserContext, close } = await contextFactory.createContext(clientInfo, new AbortController().signal, {});
browserContext.on('close', () => {
daemonDebug('browser closed, shutting down daemon');
shutdown(0);
});

await fs.mkdir(path.dirname(socketPath), { recursive: true });
const existingContextFactory = {
createContext: () => Promise.resolve({ browserContext, close }),
};
const backend = new BrowserServerBackend(config, existingContextFactory, { allTools: true });
await backend.initialize?.(clientInfo);

let shutdownPending = false;
await fs.mkdir(path.dirname(socketPath), { recursive: true });

const shutdown = (exitCode: number) => {
daemonDebug(`shutting down daemon with exit code ${exitCode}`);
Expand Down Expand Up @@ -101,32 +112,18 @@ export async function startMcpDaemonServer(
const { toolName, toolParams } = parseCliCommand(params.args);
if (params.cwd)
toolParams._meta = { cwd: params.cwd };
const response = await backend.callTool(toolName, toolParams, () => {});
const response = await backend.callTool(toolName, toolParams);
await connection.send({ id, result: formatResult(response) });
if (shutdownPending)
shutdown(1);
} else {
throw new Error(`Unknown method: ${method}`);
}
} catch (e) {
daemonDebug('command failed', e);
await connection.send({ id, error: (e as Error).message });
if (shutdownPending)
shutdown(1);
}
};
});

backend.onBrowserContextClosed = () => {
daemonDebug('browser closed, shutting down daemon');
shutdown(0);
};

backend.onBrowserLaunchFailed = error => {
daemonDebug('browser launch failed, will shut down after response', error);
shutdownPending = true;
};

return new Promise((resolve, reject) => {
server.on('error', (error: NodeJS.ErrnoException) => {
daemonDebug(`server error: ${error.message}`);
Expand Down
2 changes: 1 addition & 1 deletion packages/playwright/src/mcp/terminal/helpGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ const categories: { name: Category, title: string }[] = [
export function generateHelp() {
const lines: string[] = [];
lines.push('Usage: playwright-cli <command> [args] [options]');
lines.push('Usage: playwright-cli -b=<session> <command> [args] [options]');
lines.push('Usage: playwright-cli -s=<session> <command> [args] [options]');

const commandsByCategory = new Map<string, AnyCommandSchema[]>();
for (const c of categories)
Expand Down
Loading
Loading