chore: do not wrap mcp in proxy by default, drive-by deps fix (#909)

This commit is contained in:
Pavel Feldman
2025-08-16 19:39:49 -07:00
committed by GitHub
parent d5d810f896
commit 865eac2fee
14 changed files with 161 additions and 167 deletions

View File

@@ -42,27 +42,23 @@ export function contextFactory(config: FullConfig): BrowserContextFactory {
export type ClientInfo = { name?: string, version?: string, rootPath?: string }; export type ClientInfo = { name?: string, version?: string, rootPath?: string };
export interface BrowserContextFactory { export interface BrowserContextFactory {
readonly name: string;
readonly description: string;
createContext(clientInfo: ClientInfo, abortSignal: AbortSignal): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }>; createContext(clientInfo: ClientInfo, abortSignal: AbortSignal): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }>;
} }
class BaseContextFactory implements BrowserContextFactory { class BaseContextFactory implements BrowserContextFactory {
readonly name: string;
readonly description: string;
readonly config: FullConfig; readonly config: FullConfig;
private _logName: string;
protected _browserPromise: Promise<playwright.Browser> | undefined; protected _browserPromise: Promise<playwright.Browser> | undefined;
constructor(name: string, description: string, config: FullConfig) { constructor(name: string, config: FullConfig) {
this.name = name; this._logName = name;
this.description = description;
this.config = config; this.config = config;
} }
protected async _obtainBrowser(clientInfo: ClientInfo): Promise<playwright.Browser> { protected async _obtainBrowser(clientInfo: ClientInfo): Promise<playwright.Browser> {
if (this._browserPromise) if (this._browserPromise)
return this._browserPromise; return this._browserPromise;
testDebug(`obtain browser (${this.name})`); testDebug(`obtain browser (${this._logName})`);
this._browserPromise = this._doObtainBrowser(clientInfo); this._browserPromise = this._doObtainBrowser(clientInfo);
void this._browserPromise.then(browser => { void this._browserPromise.then(browser => {
browser.on('disconnected', () => { browser.on('disconnected', () => {
@@ -79,7 +75,7 @@ class BaseContextFactory implements BrowserContextFactory {
} }
async createContext(clientInfo: ClientInfo): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> { async createContext(clientInfo: ClientInfo): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
testDebug(`create browser context (${this.name})`); testDebug(`create browser context (${this._logName})`);
const browser = await this._obtainBrowser(clientInfo); const browser = await this._obtainBrowser(clientInfo);
const browserContext = await this._doCreateContext(browser); const browserContext = await this._doCreateContext(browser);
return { browserContext, close: () => this._closeBrowserContext(browserContext, browser) }; return { browserContext, close: () => this._closeBrowserContext(browserContext, browser) };
@@ -90,12 +86,12 @@ class BaseContextFactory implements BrowserContextFactory {
} }
private async _closeBrowserContext(browserContext: playwright.BrowserContext, browser: playwright.Browser) { private async _closeBrowserContext(browserContext: playwright.BrowserContext, browser: playwright.Browser) {
testDebug(`close browser context (${this.name})`); testDebug(`close browser context (${this._logName})`);
if (browser.contexts().length === 1) if (browser.contexts().length === 1)
this._browserPromise = undefined; this._browserPromise = undefined;
await browserContext.close().catch(logUnhandledError); await browserContext.close().catch(logUnhandledError);
if (browser.contexts().length === 0) { if (browser.contexts().length === 0) {
testDebug(`close browser (${this.name})`); testDebug(`close browser (${this._logName})`);
await browser.close().catch(logUnhandledError); await browser.close().catch(logUnhandledError);
} }
} }
@@ -103,7 +99,7 @@ class BaseContextFactory implements BrowserContextFactory {
class IsolatedContextFactory extends BaseContextFactory { class IsolatedContextFactory extends BaseContextFactory {
constructor(config: FullConfig) { constructor(config: FullConfig) {
super('isolated', 'Create a new isolated browser context', config); super('isolated', config);
} }
protected override async _doObtainBrowser(clientInfo: ClientInfo): Promise<playwright.Browser> { protected override async _doObtainBrowser(clientInfo: ClientInfo): Promise<playwright.Browser> {
@@ -128,7 +124,7 @@ class IsolatedContextFactory extends BaseContextFactory {
class CdpContextFactory extends BaseContextFactory { class CdpContextFactory extends BaseContextFactory {
constructor(config: FullConfig) { constructor(config: FullConfig) {
super('cdp', 'Connect to a browser over CDP', config); super('cdp', config);
} }
protected override async _doObtainBrowser(): Promise<playwright.Browser> { protected override async _doObtainBrowser(): Promise<playwright.Browser> {
@@ -142,7 +138,7 @@ class CdpContextFactory extends BaseContextFactory {
class RemoteContextFactory extends BaseContextFactory { class RemoteContextFactory extends BaseContextFactory {
constructor(config: FullConfig) { constructor(config: FullConfig) {
super('remote', 'Connect to a browser using a remote endpoint', config); super('remote', config);
} }
protected override async _doObtainBrowser(): Promise<playwright.Browser> { protected override async _doObtainBrowser(): Promise<playwright.Browser> {

View File

@@ -21,7 +21,6 @@ import { logUnhandledError } from './utils/log.js';
import { Response } from './response.js'; import { Response } from './response.js';
import { SessionLog } from './sessionLog.js'; import { SessionLog } from './sessionLog.js';
import { filteredTools } from './tools.js'; import { filteredTools } from './tools.js';
import { packageJSON } from './utils/package.js';
import { toMcpTool } from './mcp/tool.js'; import { toMcpTool } from './mcp/tool.js';
import type { Tool } from './tools/tool.js'; import type { Tool } from './tools/tool.js';
@@ -30,9 +29,6 @@ import type * as mcpServer from './mcp/server.js';
import type { ServerBackend } from './mcp/server.js'; import type { ServerBackend } from './mcp/server.js';
export class BrowserServerBackend implements ServerBackend { export class BrowserServerBackend implements ServerBackend {
name = 'Playwright';
version = packageJSON.version;
private _tools: Tool[]; private _tools: Tool[];
private _context: Context | undefined; private _context: Context | undefined;
private _sessionLog: SessionLog | undefined; private _sessionLog: SessionLog | undefined;

View File

@@ -26,7 +26,7 @@ import { spawn } from 'child_process';
import http from 'http'; import http from 'http';
import debug from 'debug'; import debug from 'debug';
import { WebSocket, WebSocketServer } from 'ws'; import { WebSocket, WebSocketServer } from 'ws';
import { httpAddressToString } from '../utils/httpServer.js'; import { httpAddressToString } from '../mcp/http.js';
import { logUnhandledError } from '../utils/log.js'; import { logUnhandledError } from '../utils/log.js';
import { ManualPromise } from '../utils/manualPromise.js'; import { ManualPromise } from '../utils/manualPromise.js';
import type websocket from 'ws'; import type websocket from 'ws';

View File

@@ -16,7 +16,7 @@
import debug from 'debug'; import debug from 'debug';
import * as playwright from 'playwright'; import * as playwright from 'playwright';
import { startHttpServer } from '../utils/httpServer.js'; import { startHttpServer } from '../mcp/http.js';
import { CDPRelayServer } from './cdpRelay.js'; import { CDPRelayServer } from './cdpRelay.js';
import type { BrowserContextFactory, ClientInfo } from '../browserContextFactory.js'; import type { BrowserContextFactory, ClientInfo } from '../browserContextFactory.js';
@@ -24,9 +24,6 @@ import type { BrowserContextFactory, ClientInfo } from '../browserContextFactory
const debugLogger = debug('pw:mcp:relay'); const debugLogger = debug('pw:mcp:relay');
export class ExtensionContextFactory implements BrowserContextFactory { export class ExtensionContextFactory implements BrowserContextFactory {
name = 'extension';
description = 'Connect to a browser using the Playwright MCP extension';
private _browserChannel: string; private _browserChannel: string;
private _userDataDir?: string; private _userDataDir?: string;

View File

@@ -18,6 +18,7 @@ import { BrowserServerBackend } from './browserServerBackend.js';
import { resolveConfig } from './config.js'; import { resolveConfig } from './config.js';
import { contextFactory } from './browserContextFactory.js'; import { contextFactory } from './browserContextFactory.js';
import * as mcpServer from './mcp/server.js'; import * as mcpServer from './mcp/server.js';
import { packageJSON } from './utils/package.js';
import type { Config } from '../config.js'; import type { Config } from '../config.js';
import type { BrowserContext } from 'playwright'; import type { BrowserContext } from 'playwright';
@@ -27,7 +28,7 @@ import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
export async function createConnection(userConfig: Config = {}, contextGetter?: () => Promise<BrowserContext>): Promise<Server> { export async function createConnection(userConfig: Config = {}, contextGetter?: () => Promise<BrowserContext>): Promise<Server> {
const config = await resolveConfig(userConfig); const config = await resolveConfig(userConfig);
const factory = contextGetter ? new SimpleBrowserContextFactory(contextGetter) : contextFactory(config); const factory = contextGetter ? new SimpleBrowserContextFactory(contextGetter) : contextFactory(config);
return mcpServer.createServer(new BrowserServerBackend(config, factory), false); return mcpServer.createServer('Playwright', packageJSON.version, new BrowserServerBackend(config, factory), false);
} }
class SimpleBrowserContextFactory implements BrowserContextFactory { class SimpleBrowserContextFactory implements BrowserContextFactory {

View File

@@ -23,6 +23,7 @@ import { OpenAIDelegate } from '../loop/loopOpenAI.js';
import { ClaudeDelegate } from '../loop/loopClaude.js'; import { ClaudeDelegate } from '../loop/loopClaude.js';
import { InProcessTransport } from '../mcp/inProcessTransport.js'; import { InProcessTransport } from '../mcp/inProcessTransport.js';
import * as mcpServer from '../mcp/server.js'; import * as mcpServer from '../mcp/server.js';
import { packageJSON } from '../utils/package.js';
import type { LLMDelegate } from '../loop/loop.js'; import type { LLMDelegate } from '../loop/loop.js';
import type { FullConfig } from '../config.js'; import type { FullConfig } from '../config.js';
@@ -44,9 +45,9 @@ export class Context {
} }
static async create(config: FullConfig) { static async create(config: FullConfig) {
const client = new Client({ name: 'Playwright Proxy', version: '1.0.0' }); const client = new Client({ name: 'Playwright Proxy', version: packageJSON.version });
const browserContextFactory = contextFactory(config); const browserContextFactory = contextFactory(config);
const server = mcpServer.createServer(new BrowserServerBackend(config, browserContextFactory), false); const server = mcpServer.createServer('Playwright Subagent', packageJSON.version, new BrowserServerBackend(config, browserContextFactory), false);
await client.connect(new InProcessTransport(server)); await client.connect(new InProcessTransport(server));
await client.ping(); await client.ping();
return new Context(config, client); return new Context(config, client);

View File

@@ -17,7 +17,6 @@
import dotenv from 'dotenv'; import dotenv from 'dotenv';
import * as mcpServer from '../mcp/server.js'; import * as mcpServer from '../mcp/server.js';
import * as mcpTransport from '../mcp/transport.js';
import { packageJSON } from '../utils/package.js'; import { packageJSON } from '../utils/package.js';
import { Context } from './context.js'; import { Context } from './context.js';
import { perform } from './perform.js'; import { perform } from './perform.js';
@@ -30,13 +29,16 @@ import type { Tool } from './tool.js';
export async function runLoopTools(config: FullConfig) { export async function runLoopTools(config: FullConfig) {
dotenv.config(); dotenv.config();
const serverBackendFactory = () => new LoopToolsServerBackend(config); const serverBackendFactory = {
await mcpTransport.start(serverBackendFactory, config.server); name: 'Playwright',
nameInConfig: 'playwright-loop',
version: packageJSON.version,
create: () => new LoopToolsServerBackend(config)
};
await mcpServer.start(serverBackendFactory, config.server);
} }
class LoopToolsServerBackend implements ServerBackend { class LoopToolsServerBackend implements ServerBackend {
readonly name = 'Playwright';
readonly version = packageJSON.version;
private _config: FullConfig; private _config: FullConfig;
private _context: Context | undefined; private _context: Context | undefined;
private _tools: Tool<any>[] = [perform, snapshot]; private _tools: Tool<any>[] = [perform, snapshot];

View File

@@ -1,2 +1 @@
[*] [*]
../utils/

View File

@@ -1 +1 @@
- Generic MCP utils, no dependencies on Playwright here. - Generic MCP utils, no dependencies on anything.

View File

@@ -14,33 +14,61 @@
* limitations under the License. * limitations under the License.
*/ */
import assert from 'assert';
import net from 'net';
import http from 'http'; import http from 'http';
import crypto from 'crypto'; import crypto from 'crypto';
import debug from 'debug'; import debug from 'debug';
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'; import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { httpAddressToString, startHttpServer } from '../utils/httpServer.js';
import * as mcpServer from './server.js'; import * as mcpServer from './server.js';
import type { ServerBackendFactory } from './server.js'; import type { ServerBackendFactory } from './server.js';
export async function start(serverBackendFactory: ServerBackendFactory, options: { host?: string; port?: number }) {
if (options.port !== undefined) {
const httpServer = await startHttpServer(options);
startHttpTransport(httpServer, serverBackendFactory);
} else {
await startStdioTransport(serverBackendFactory);
}
}
async function startStdioTransport(serverBackendFactory: ServerBackendFactory) {
await mcpServer.connect(serverBackendFactory, new StdioServerTransport(), false);
}
const testDebug = debug('pw:mcp:test'); const testDebug = debug('pw:mcp:test');
export async function startHttpServer(config: { host?: string, port?: number }, abortSignal?: AbortSignal): Promise<http.Server> {
const { host, port } = config;
const httpServer = http.createServer();
await new Promise<void>((resolve, reject) => {
httpServer.on('error', reject);
abortSignal?.addEventListener('abort', () => {
httpServer.close();
reject(new Error('Aborted'));
});
httpServer.listen(port, host, () => {
resolve();
httpServer.removeListener('error', reject);
});
});
return httpServer;
}
export function httpAddressToString(address: string | net.AddressInfo | null): string {
assert(address, 'Could not bind server socket');
if (typeof address === 'string')
return address;
const resolvedPort = address.port;
let resolvedHost = address.family === 'IPv4' ? address.address : `[${address.address}]`;
if (resolvedHost === '0.0.0.0' || resolvedHost === '[::]')
resolvedHost = 'localhost';
return `http://${resolvedHost}:${resolvedPort}`;
}
export async function installHttpTransport(httpServer: http.Server, serverBackendFactory: ServerBackendFactory) {
const sseSessions = new Map();
const streamableSessions = new Map();
httpServer.on('request', async (req, res) => {
const url = new URL(`http://localhost${req.url}`);
if (url.pathname.startsWith('/sse'))
await handleSSE(serverBackendFactory, req, res, url, sseSessions);
else
await handleStreamable(serverBackendFactory, req, res, streamableSessions);
});
}
async function handleSSE(serverBackendFactory: ServerBackendFactory, req: http.IncomingMessage, res: http.ServerResponse, url: URL, sessions: Map<string, SSEServerTransport>) { async function handleSSE(serverBackendFactory: ServerBackendFactory, req: http.IncomingMessage, res: http.ServerResponse, url: URL, sessions: Map<string, SSEServerTransport>) {
if (req.method === 'POST') { if (req.method === 'POST') {
const sessionId = url.searchParams.get('sessionId'); const sessionId = url.searchParams.get('sessionId');
@@ -108,30 +136,3 @@ async function handleStreamable(serverBackendFactory: ServerBackendFactory, req:
res.statusCode = 400; res.statusCode = 400;
res.end('Invalid request'); res.end('Invalid request');
} }
function startHttpTransport(httpServer: http.Server, serverBackendFactory: ServerBackendFactory) {
const sseSessions = new Map();
const streamableSessions = new Map();
httpServer.on('request', async (req, res) => {
const url = new URL(`http://localhost${req.url}`);
if (url.pathname.startsWith('/sse'))
await handleSSE(serverBackendFactory, req, res, url, sseSessions);
else
await handleStreamable(serverBackendFactory, req, res, streamableSessions);
});
const url = httpAddressToString(httpServer.address());
const message = [
`Listening on ${url}`,
'Put this in your client config:',
JSON.stringify({
'mcpServers': {
'playwright': {
'url': `${url}/mcp`
}
}
}, undefined, 2),
'For legacy SSE transport support, you can use the /sse endpoint instead.',
].join('\n');
// eslint-disable-next-line no-console
console.error(message);
}

View File

@@ -14,14 +14,12 @@
* limitations under the License. * limitations under the License.
*/ */
import debug from 'debug';
import { z } from 'zod'; import { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema'; import { zodToJsonSchema } from 'zod-to-json-schema';
import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { ListRootsRequestSchema, PingRequestSchema } from '@modelcontextprotocol/sdk/types.js'; import { ListRootsRequestSchema, PingRequestSchema } from '@modelcontextprotocol/sdk/types.js';
import { logUnhandledError } from '../utils/log.js';
import { packageJSON } from '../utils/package.js';
import type { ServerBackend, ClientVersion, Root } from './server.js'; import type { ServerBackend, ClientVersion, Root } from './server.js';
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
@@ -33,10 +31,9 @@ export type MCPProvider = {
connect(): Promise<Transport>; connect(): Promise<Transport>;
}; };
export class ProxyBackend implements ServerBackend { const errorsDebug = debug('pw:mcp:errors');
name = 'Playwright MCP Client Switcher';
version = packageJSON.version;
export class ProxyBackend implements ServerBackend {
private _mcpProviders: MCPProvider[]; private _mcpProviders: MCPProvider[];
private _currentClient: Client | undefined; private _currentClient: Client | undefined;
private _contextSwitchTool: Tool; private _contextSwitchTool: Tool;
@@ -72,7 +69,7 @@ export class ProxyBackend implements ServerBackend {
} }
serverClosed?(): void { serverClosed?(): void {
void this._currentClient?.close().catch(logUnhandledError); void this._currentClient?.close().catch(errorsDebug);
} }
private async _callContextSwitchTool(params: any): Promise<CallToolResult> { private async _callContextSwitchTool(params: any): Promise<CallToolResult> {
@@ -115,7 +112,7 @@ export class ProxyBackend implements ServerBackend {
await this._currentClient?.close(); await this._currentClient?.close();
this._currentClient = undefined; this._currentClient = undefined;
const client = new Client({ name: 'Playwright MCP Proxy', version: packageJSON.version }); const client = new Client({ name: 'Playwright MCP Proxy', version: '0.0.0' });
client.registerCapabilities({ client.registerCapabilities({
roots: { roots: {
listRoots: true, listRoots: true,

View File

@@ -15,10 +15,12 @@
*/ */
import debug from 'debug'; import debug from 'debug';
import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'; import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
import { ManualPromise } from '../utils/manualPromise.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { logUnhandledError } from '../utils/log.js'; import { httpAddressToString, installHttpTransport, startHttpServer } from './http.js';
import { InProcessTransport } from './inProcessTransport.js';
import type { Tool, CallToolResult, CallToolRequest, Root } from '@modelcontextprotocol/sdk/types.js'; import type { Tool, CallToolResult, CallToolRequest, Root } from '@modelcontextprotocol/sdk/types.js';
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
@@ -26,28 +28,37 @@ export type { Server } from '@modelcontextprotocol/sdk/server/index.js';
export type { Tool, CallToolResult, CallToolRequest, Root } from '@modelcontextprotocol/sdk/types.js'; export type { Tool, CallToolResult, CallToolRequest, Root } from '@modelcontextprotocol/sdk/types.js';
const serverDebug = debug('pw:mcp:server'); const serverDebug = debug('pw:mcp:server');
const errorsDebug = debug('pw:mcp:errors');
export type ClientVersion = { name: string, version: string }; export type ClientVersion = { name: string, version: string };
export interface ServerBackend { export interface ServerBackend {
name: string;
version: string;
initialize?(clientVersion: ClientVersion, roots: Root[]): Promise<void>; initialize?(clientVersion: ClientVersion, roots: Root[]): Promise<void>;
listTools(): Promise<Tool[]>; listTools(): Promise<Tool[]>;
callTool(name: string, args: CallToolRequest['params']['arguments']): Promise<CallToolResult>; callTool(name: string, args: CallToolRequest['params']['arguments']): Promise<CallToolResult>;
serverClosed?(): void; serverClosed?(): void;
} }
export type ServerBackendFactory = () => ServerBackend; export type ServerBackendFactory = {
name: string;
nameInConfig: string;
version: string;
create: () => ServerBackend;
};
export async function connect(serverBackendFactory: ServerBackendFactory, transport: Transport, runHeartbeat: boolean) { export async function connect(factory: ServerBackendFactory, transport: Transport, runHeartbeat: boolean) {
const backend = serverBackendFactory(); const server = createServer(factory.name, factory.version, factory.create(), runHeartbeat);
const server = createServer(backend, runHeartbeat);
await server.connect(transport); await server.connect(transport);
} }
export function createServer(backend: ServerBackend, runHeartbeat: boolean): Server { export async function wrapInProcess(backend: ServerBackend): Promise<Transport> {
const initializedPromise = new ManualPromise<void>(); const server = createServer('Internal', '0.0.0', backend, false);
const server = new Server({ name: backend.name, version: backend.version }, { return new InProcessTransport(server);
}
export function createServer(name: string, version: string, backend: ServerBackend, runHeartbeat: boolean): Server {
let initializedPromiseResolve = () => {};
const initializedPromise = new Promise<void>(resolve => initializedPromiseResolve = resolve);
const server = new Server({ name, version }, {
capabilities: { capabilities: {
tools: {}, tools: {},
} }
@@ -89,9 +100,9 @@ export function createServer(backend: ServerBackend, runHeartbeat: boolean): Ser
} }
const clientVersion = server.getClientVersion() ?? { name: 'unknown', version: 'unknown' }; const clientVersion = server.getClientVersion() ?? { name: 'unknown', version: 'unknown' };
await backend.initialize?.(clientVersion, clientRoots); await backend.initialize?.(clientVersion, clientRoots);
initializedPromise.resolve(); initializedPromiseResolve();
} catch (e) { } catch (e) {
logUnhandledError(e); errorsDebug(e);
} }
}); });
addServerListener(server, 'close', () => backend.serverClosed?.()); addServerListener(server, 'close', () => backend.serverClosed?.());
@@ -120,3 +131,27 @@ function addServerListener(server: Server, event: 'close' | 'initialized', liste
listener(); listener();
}; };
} }
export async function start(serverBackendFactory: ServerBackendFactory, options: { host?: string; port?: number }) {
if (options.port === undefined) {
await connect(serverBackendFactory, new StdioServerTransport(), false);
return;
}
const httpServer = await startHttpServer(options);
await installHttpTransport(httpServer, serverBackendFactory);
const url = httpAddressToString(httpServer.address());
const mcpConfig: any = { mcpServers: { } };
mcpConfig.mcpServers[serverBackendFactory.nameInConfig] = {
url: `${url}/mcp`
};
const message = [
`Listening on ${url}`,
'Put this in your client config:',
JSON.stringify(mcpConfig, undefined, 2),
'For legacy SSE transport support, you can use the /sse endpoint instead.',
].join('\n');
// eslint-disable-next-line no-console
console.error(message);
}

View File

@@ -16,7 +16,6 @@
import { program, Option } from 'commander'; import { program, Option } from 'commander';
import * as mcpServer from './mcp/server.js'; import * as mcpServer from './mcp/server.js';
import * as mcpTransport from './mcp/transport.js';
import { commaSeparatedList, resolveCLIConfig, semicolonSeparatedList } from './config.js'; import { commaSeparatedList, resolveCLIConfig, semicolonSeparatedList } from './config.js';
import { packageJSON } from './utils/package.js'; import { packageJSON } from './utils/package.js';
import { Context } from './context.js'; import { Context } from './context.js';
@@ -25,11 +24,8 @@ import { runLoopTools } from './loopTools/main.js';
import { ProxyBackend } from './mcp/proxyBackend.js'; import { ProxyBackend } from './mcp/proxyBackend.js';
import { BrowserServerBackend } from './browserServerBackend.js'; import { BrowserServerBackend } from './browserServerBackend.js';
import { ExtensionContextFactory } from './extension/extensionContextFactory.js'; import { ExtensionContextFactory } from './extension/extensionContextFactory.js';
import { InProcessTransport } from './mcp/inProcessTransport.js';
import type { MCPProvider } from './mcp/proxyBackend.js'; import type { MCPProvider } from './mcp/proxyBackend.js';
import type { FullConfig } from './config.js';
import type { BrowserContextFactory } from './browserContextFactory.js';
program program
.version('Version ' + packageJSON.version) .version('Version ' + packageJSON.version)
@@ -71,12 +67,19 @@ program
console.error('The --vision option is deprecated, use --caps=vision instead'); console.error('The --vision option is deprecated, use --caps=vision instead');
options.caps = 'vision'; options.caps = 'vision';
} }
const config = await resolveCLIConfig(options); const config = await resolveCLIConfig(options);
const browserContextFactory = contextFactory(config);
const extensionContextFactory = new ExtensionContextFactory(config.browser.launchOptions.channel || 'chrome', config.browser.userDataDir);
if (options.extension) { if (options.extension) {
const contextFactory = createExtensionContextFactory(config); const serverBackendFactory: mcpServer.ServerBackendFactory = {
const serverBackendFactory = () => new BrowserServerBackend(config, contextFactory); name: 'Playwright w/ extension',
await mcpTransport.start(serverBackendFactory, config.server); nameInConfig: 'playwright-extension',
version: packageJSON.version,
create: () => new BrowserServerBackend(config, extensionContextFactory)
};
await mcpServer.start(serverBackendFactory, config.server);
return; return;
} }
@@ -85,11 +88,36 @@ program
return; return;
} }
const browserContextFactory = contextFactory(config); if (options.connectTool) {
const providers: MCPProvider[] = [mcpProviderForBrowserContextFactory(config, browserContextFactory)]; const providers: MCPProvider[] = [
if (options.connectTool) {
providers.push(mcpProviderForBrowserContextFactory(config, createExtensionContextFactory(config))); name: 'default',
await mcpTransport.start(() => new ProxyBackend(providers), config.server); description: 'Starts standalone browser',
connect: () => mcpServer.wrapInProcess(new BrowserServerBackend(config, browserContextFactory)),
},
{
name: 'extension',
description: 'Connect to a browser using the Playwright MCP extension',
connect: () => mcpServer.wrapInProcess(new BrowserServerBackend(config, extensionContextFactory)),
},
];
const factory: mcpServer.ServerBackendFactory = {
name: 'Playwright w/ switch',
nameInConfig: 'playwright-switch',
version: packageJSON.version,
create: () => new ProxyBackend(providers),
};
await mcpServer.start(factory, config.server);
return;
}
const factory: mcpServer.ServerBackendFactory = {
name: 'Playwright',
nameInConfig: 'playwright',
version: packageJSON.version,
create: () => new BrowserServerBackend(config, browserContextFactory)
};
await mcpServer.start(factory, config.server);
}); });
function setupExitWatchdog() { function setupExitWatchdog() {
@@ -108,19 +136,4 @@ function setupExitWatchdog() {
process.on('SIGTERM', handleExit); process.on('SIGTERM', handleExit);
} }
function createExtensionContextFactory(config: FullConfig) {
return new ExtensionContextFactory(config.browser.launchOptions.channel || 'chrome', config.browser.userDataDir);
}
function mcpProviderForBrowserContextFactory(config: FullConfig, browserContextFactory: BrowserContextFactory) {
return {
name: browserContextFactory.name,
description: browserContextFactory.description,
connect: async () => {
const server = mcpServer.createServer(new BrowserServerBackend(config, browserContextFactory), false);
return new InProcessTransport(server);
},
};
}
void program.parseAsync(process.argv); void program.parseAsync(process.argv);

View File

@@ -1,44 +0,0 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import assert from 'assert';
import http from 'http';
import type * as net from 'net';
export async function startHttpServer(config: { host?: string, port?: number }): Promise<http.Server> {
const { host, port } = config;
const httpServer = http.createServer();
await new Promise<void>((resolve, reject) => {
httpServer.on('error', reject);
httpServer.listen(port, host, () => {
resolve();
httpServer.removeListener('error', reject);
});
});
return httpServer;
}
export function httpAddressToString(address: string | net.AddressInfo | null): string {
assert(address, 'Could not bind server socket');
if (typeof address === 'string')
return address;
const resolvedPort = address.port;
let resolvedHost = address.family === 'IPv4' ? address.address : `[${address.address}]`;
if (resolvedHost === '0.0.0.0' || resolvedHost === '[::]')
resolvedHost = 'localhost';
return `http://${resolvedHost}:${resolvedPort}`;
}