From 372395666a0e6f895c8f5ad97ff56738dd3221d6 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Fri, 1 Aug 2025 17:34:28 -0700 Subject: [PATCH] chore: allow to switch between browser connection methods (#815) --- src/browserContextFactory.ts | 17 ++++--- src/browserServerBackend.ts | 56 ++++++++++++++++++++++-- src/context.ts | 2 + src/extension/extensionContextFactory.ts | 3 ++ src/extension/main.ts | 6 ++- src/index.ts | 5 ++- src/loopTools/context.ts | 2 +- src/program.ts | 10 +++-- 8 files changed, 87 insertions(+), 14 deletions(-) diff --git a/src/browserContextFactory.ts b/src/browserContextFactory.ts index 5f52ec3..2d3fcff 100644 --- a/src/browserContextFactory.ts +++ b/src/browserContextFactory.ts @@ -40,17 +40,21 @@ export function contextFactory(config: FullConfig): BrowserContextFactory { export type ClientInfo = { name?: string, version?: string, rootPath?: string }; export interface BrowserContextFactory { + readonly name: string; + readonly description: string; createContext(clientInfo: ClientInfo, abortSignal: AbortSignal): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise }>; } class BaseContextFactory implements BrowserContextFactory { + readonly name: string; + readonly description: string; readonly config: FullConfig; protected _browserPromise: Promise | undefined; protected _tracesDir: string | undefined; - readonly name: string; - constructor(name: string, config: FullConfig) { + constructor(name: string, description: string, config: FullConfig) { this.name = name; + this.description = description; this.config = config; } @@ -101,7 +105,7 @@ class BaseContextFactory implements BrowserContextFactory { class IsolatedContextFactory extends BaseContextFactory { constructor(config: FullConfig) { - super('isolated', config); + super('isolated', 'Create a new isolated browser context', config); } protected override async _doObtainBrowser(): Promise { @@ -126,7 +130,7 @@ class IsolatedContextFactory extends BaseContextFactory { class CdpContextFactory extends BaseContextFactory { constructor(config: FullConfig) { - super('cdp', config); + super('cdp', 'Connect to a browser over CDP', config); } protected override async _doObtainBrowser(): Promise { @@ -140,7 +144,7 @@ class CdpContextFactory extends BaseContextFactory { class RemoteContextFactory extends BaseContextFactory { constructor(config: FullConfig) { - super('remote', config); + super('remote', 'Connect to a browser using a remote endpoint', config); } protected override async _doObtainBrowser(): Promise { @@ -158,6 +162,9 @@ class RemoteContextFactory extends BaseContextFactory { class PersistentContextFactory implements BrowserContextFactory { readonly config: FullConfig; + readonly name = 'persistent'; + readonly description = 'Create a new persistent browser context'; + private _userDataDirs = new Set(); constructor(config: FullConfig) { diff --git a/src/browserServerBackend.ts b/src/browserServerBackend.ts index bdf0173..95b60e3 100644 --- a/src/browserServerBackend.ts +++ b/src/browserServerBackend.ts @@ -15,6 +15,7 @@ */ import { fileURLToPath } from 'url'; +import { z } from 'zod'; import { FullConfig } from './config.js'; import { Context } from './context.js'; import { logUnhandledError } from './log.js'; @@ -22,11 +23,16 @@ import { Response } from './response.js'; import { SessionLog } from './sessionLog.js'; import { filteredTools } from './tools.js'; import { packageJSON } from './package.js'; +import { defineTool } from './tools/tool.js'; +import type { Tool } from './tools/tool.js'; import type { BrowserContextFactory } from './browserContextFactory.js'; import type * as mcpServer from './mcp/server.js'; import type { ServerBackend } from './mcp/server.js'; -import type { Tool } from './tools/tool.js'; + +type NonEmptyArray = [T, ...T[]]; + +export type FactoryList = NonEmptyArray; export class BrowserServerBackend implements ServerBackend { name = 'Playwright'; @@ -38,10 +44,12 @@ export class BrowserServerBackend implements ServerBackend { private _config: FullConfig; private _browserContextFactory: BrowserContextFactory; - constructor(config: FullConfig, browserContextFactory: BrowserContextFactory) { + constructor(config: FullConfig, factories: FactoryList) { this._config = config; - this._browserContextFactory = browserContextFactory; + this._browserContextFactory = factories[0]; this._tools = filteredTools(config); + if (factories.length > 1) + this._tools.push(this._defineContextSwitchTool(factories)); } async initialize(server: mcpServer.Server): Promise { @@ -87,4 +95,46 @@ export class BrowserServerBackend implements ServerBackend { serverClosed() { void this._context!.dispose().catch(logUnhandledError); } + + private _defineContextSwitchTool(factories: FactoryList): Tool { + const self = this; + return defineTool({ + capability: 'core', + + schema: { + name: 'browser_connect', + title: 'Connect to a browser context', + description: [ + 'Connect to a browser using one of the available methods:', + ...factories.map(factory => `- "${factory.name}": ${factory.description}`), + ].join('\n'), + inputSchema: z.object({ + method: z.enum(factories.map(factory => factory.name) as [string, ...string[]]).default(factories[0].name).describe('The method to use to connect to the browser'), + }), + type: 'readOnly', + }, + + async handle(context, params, response) { + const factory = factories.find(factory => factory.name === params.method); + if (!factory) { + response.addError('Unknown connection method: ' + params.method); + return; + } + await self._setContextFactory(factory); + response.addResult('Successfully changed connection method.'); + } + }); + } + + private async _setContextFactory(newFactory: BrowserContextFactory) { + if (this._context) { + const options = { + ...this._context.options, + browserContextFactory: newFactory, + }; + await this._context.dispose(); + this._context = new Context(options); + } + this._browserContextFactory = newFactory; + } } diff --git a/src/context.ts b/src/context.ts index 9b5dc49..e84356d 100644 --- a/src/context.ts +++ b/src/context.ts @@ -41,6 +41,7 @@ export class Context { readonly tools: Tool[]; readonly config: FullConfig; readonly sessionLog: SessionLog | undefined; + readonly options: ContextOptions; private _browserContextPromise: Promise<{ browserContext: playwright.BrowserContext, close: () => Promise }> | undefined; private _browserContextFactory: BrowserContextFactory; private _tabs: Tab[] = []; @@ -56,6 +57,7 @@ export class Context { this.tools = options.tools; this.config = options.config; this.sessionLog = options.sessionLog; + this.options = options; this._browserContextFactory = options.browserContextFactory; this._clientInfo = options.clientInfo; testDebug('create context'); diff --git a/src/extension/extensionContextFactory.ts b/src/extension/extensionContextFactory.ts index bb23605..da04d44 100644 --- a/src/extension/extensionContextFactory.ts +++ b/src/extension/extensionContextFactory.ts @@ -24,6 +24,9 @@ import type { BrowserContextFactory, ClientInfo } from '../browserContextFactory const debugLogger = debug('pw:mcp:relay'); export class ExtensionContextFactory implements BrowserContextFactory { + name = 'extension'; + description = 'Connect to a browser using the Playwright MCP extension'; + private _browserChannel: string; private _relayPromise: Promise | undefined; private _browserPromise: Promise | undefined; diff --git a/src/extension/main.ts b/src/extension/main.ts index 50c2e3a..bb3a197 100644 --- a/src/extension/main.ts +++ b/src/extension/main.ts @@ -22,6 +22,10 @@ import type { FullConfig } from '../config.js'; export async function runWithExtension(config: FullConfig) { const contextFactory = new ExtensionContextFactory(config.browser.launchOptions.channel || 'chrome'); - const serverBackendFactory = () => new BrowserServerBackend(config, contextFactory); + const serverBackendFactory = () => new BrowserServerBackend(config, [contextFactory]); await mcpTransport.start(serverBackendFactory, config.server); } + +export function createExtensionContextFactory(config: FullConfig) { + return new ExtensionContextFactory(config.browser.launchOptions.channel || 'chrome'); +} diff --git a/src/index.ts b/src/index.ts index d180b7f..2d181b1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -27,10 +27,13 @@ import type { Server } from '@modelcontextprotocol/sdk/server/index.js'; export async function createConnection(userConfig: Config = {}, contextGetter?: () => Promise): Promise { const config = await resolveConfig(userConfig); const factory = contextGetter ? new SimpleBrowserContextFactory(contextGetter) : contextFactory(config); - return mcpServer.createServer(new BrowserServerBackend(config, factory), false); + return mcpServer.createServer(new BrowserServerBackend(config, [factory]), false); } class SimpleBrowserContextFactory implements BrowserContextFactory { + name = 'custom'; + description = 'Connect to a browser using a custom context getter'; + private readonly _contextGetter: () => Promise; constructor(contextGetter: () => Promise) { diff --git a/src/loopTools/context.ts b/src/loopTools/context.ts index 732af07..9e52577 100644 --- a/src/loopTools/context.ts +++ b/src/loopTools/context.ts @@ -46,7 +46,7 @@ export class Context { static async create(config: FullConfig) { const client = new Client({ name: 'Playwright Proxy', version: '1.0.0' }); const browserContextFactory = contextFactory(config); - const server = mcpServer.createServer(new BrowserServerBackend(config, browserContextFactory), false); + const server = mcpServer.createServer(new BrowserServerBackend(config, [browserContextFactory]), false); await client.connect(new InProcessTransport(server)); await client.ping(); return new Context(config, client); diff --git a/src/program.ts b/src/program.ts index 51eb116..ae1f1d9 100644 --- a/src/program.ts +++ b/src/program.ts @@ -21,8 +21,8 @@ import { startTraceViewerServer } from 'playwright-core/lib/server'; import * as mcpTransport from './mcp/transport.js'; import { commaSeparatedList, resolveCLIConfig, semicolonSeparatedList } from './config.js'; import { packageJSON } from './package.js'; -import { runWithExtension } from './extension/main.js'; -import { BrowserServerBackend } from './browserServerBackend.js'; +import { createExtensionContextFactory, runWithExtension } from './extension/main.js'; +import { BrowserServerBackend, FactoryList } from './browserServerBackend.js'; import { Context } from './context.js'; import { contextFactory } from './browserContextFactory.js'; import { runLoopTools } from './loopTools/main.js'; @@ -56,6 +56,7 @@ program .option('--user-data-dir ', 'path to the user data directory. If not specified, a temporary directory will be created.') .option('--viewport-size ', 'specify browser viewport size in pixels, for example "1280, 720"') .addOption(new Option('--extension', 'Connect to a running browser instance (Edge/Chrome only). Requires the "Playwright MCP Bridge" browser extension to be installed.').hideHelp()) + .addOption(new Option('--connect-tool', 'Allow to switch between different browser connection methods.').hideHelp()) .addOption(new Option('--loop-tools', 'Run loop tools').hideHelp()) .addOption(new Option('--vision', 'Legacy option, use --caps=vision instead').hideHelp()) .action(async options => { @@ -78,7 +79,10 @@ program } const browserContextFactory = contextFactory(config); - const serverBackendFactory = () => new BrowserServerBackend(config, browserContextFactory); + const factories: FactoryList = [browserContextFactory]; + if (options.connectTool) + factories.push(createExtensionContextFactory(config)); + const serverBackendFactory = () => new BrowserServerBackend(config, factories); await mcpTransport.start(serverBackendFactory, config.server); if (config.saveTrace) {