chore: do not wrap mcp in proxy by default, drive-by deps fix (#909)
This commit is contained in:
@@ -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> {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|||||||
@@ -1,2 +1 @@
|
|||||||
[*]
|
[*]
|
||||||
../utils/
|
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
- Generic MCP utils, no dependencies on Playwright here.
|
- Generic MCP utils, no dependencies on anything.
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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}`;
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user