diff --git a/src/program.ts b/src/program.ts index cfb924a..e8a9232 100644 --- a/src/program.ts +++ b/src/program.ts @@ -28,6 +28,7 @@ import { ExtensionContextFactory } from './extension/extensionContextFactory.js' import { InProcessTransport } from './mcp/inProcessTransport.js'; import { VSCodeMCPFactory } from './vscode/host.js'; +import { VSCodeProxyBackend } from './vscode/proxyBackend.js'; import type { MCPProvider } from './mcp/proxyBackend.js'; import type { FullConfig } from './config.js'; import type { BrowserContextFactory } from './browserContextFactory.js'; @@ -62,6 +63,7 @@ program .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('--vscode', 'VS Code tools.').hideHelp()) .addOption(new Option('--loop-tools', 'Run loop tools').hideHelp()) .addOption(new Option('--vision', 'Legacy option, use --caps=vision instead').hideHelp()) .action(async options => { @@ -86,6 +88,13 @@ program return; } + if (options.vscode) { + const browserContextFactory = contextFactory(config); + const vscodeBackendFactory = () => new VSCodeProxyBackend(config, browserContextFactory); + await mcpTransport.start(vscodeBackendFactory, config.server); + return; + } + const browserContextFactory = contextFactory(config); const providers: MCPProvider[] = [mcpProviderForBrowserContextFactory(config, browserContextFactory)]; if (options.connectTool) { diff --git a/src/vscode/host.ts b/src/vscode/host.ts index bc6c223..1774cb4 100644 --- a/src/vscode/host.ts +++ b/src/vscode/host.ts @@ -16,28 +16,23 @@ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; import type { FullConfig } from '../config.js'; -import type { MCPProvider } from '../mcp/proxyBackend.js'; +import type { MCPProvider } from './proxyBackend.js'; export class VSCodeMCPFactory implements MCPProvider { name = 'vscode'; description = 'Connect to a browser running in the Playwright VS Code extension'; - constructor(private readonly _config: FullConfig) {} - - async connect(options: any): Promise { - if (typeof options.connectionString !== 'string') - throw new Error('Missing options.connectionString'); - if (typeof options.lib !== 'string') - throw new Error('Missing options.lib'); + constructor(private readonly _config: FullConfig, private readonly _connectionString: string, private readonly _lib: string) {} + async connect(): Promise { return new StdioClientTransport({ command: process.execPath, cwd: process.cwd(), args: [ new URL('./main.js', import.meta.url).pathname, JSON.stringify(this._config), - options.connectionString, - options.lib, + this._connectionString, + this._lib, ], }); } diff --git a/src/vscode/proxyBackend.ts b/src/vscode/proxyBackend.ts new file mode 100644 index 0000000..fe63136 --- /dev/null +++ b/src/vscode/proxyBackend.ts @@ -0,0 +1,139 @@ +/** + * 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 { z } from 'zod'; +import { zodToJsonSchema } from 'zod-to-json-schema'; + +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { ListRootsRequestSchema, PingRequestSchema } from '@modelcontextprotocol/sdk/types.js'; +import { logUnhandledError } from '../utils/log.js'; +import { packageJSON } from '../utils/package.js'; + + +import { VSCodeMCPFactory } from './host.js'; +import { FullConfig } from '../config.js'; +import type { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import type { ServerBackend } from '../mcp/server.js'; +import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; +import type { Root, Tool, CallToolResult, CallToolRequest } from '@modelcontextprotocol/sdk/types.js'; + +export type MCPProvider = { + name: string; + description: string; + connect(): Promise; +}; + +const contextSwitchOptions = z.object({ + connectionString: z.string().optional().describe('The connection string to use to connect to the browser'), + lib: z.string().optional().describe('The library to use for the connection'), +}); + +export class VSCodeProxyBackend implements ServerBackend { + name = 'Playwright MCP Client Switcher'; + version = packageJSON.version; + + private _currentClient: Client | undefined; + private _contextSwitchTool: Tool; + private _roots: Root[] = []; + + constructor(private readonly _config: FullConfig, private readonly _defaultProvider: MCPProvider) { + this._contextSwitchTool = this._defineContextSwitchTool(); + } + + async initialize(server: Server): Promise { + const version = server.getClientVersion(); + const capabilities = server.getClientCapabilities(); + if (capabilities?.roots && version && clientsWithRoots.includes(version.name)) { + const { roots } = await server.listRoots(); + this._roots = roots; + } + + await this._setCurrentClient(this._defaultProvider); + } + + async listTools(): Promise { + const response = await this._currentClient!.listTools(); + return [ + ...response.tools, + this._contextSwitchTool, + ]; + } + + async callTool(name: string, args: CallToolRequest['params']['arguments']): Promise { + if (name === this._contextSwitchTool.name) + return this._callContextSwitchTool(args as any); + return await this._currentClient!.callTool({ + name, + arguments: args, + }) as CallToolResult; + } + + serverClosed?(): void { + void this._currentClient?.close().catch(logUnhandledError); + } + + private async _callContextSwitchTool(params: z.infer): Promise { + if (!params.connectionString || !params.lib) { + await this._setCurrentClient(this._defaultProvider); + return { + content: [{ type: 'text', text: '### Result\nSuccessfully disconnected.\n' }], + }; + } + + await this._setCurrentClient(new VSCodeMCPFactory(this._config, params.connectionString, params.lib)); + return { + content: [{ type: 'text', text: '### Result\nSuccessfully connected.\n' }], + }; + } + + private _defineContextSwitchTool(): Tool { + return { + name: 'browser_connect', + description: 'Connect to a browser running in VS Code.', + inputSchema: zodToJsonSchema(contextSwitchOptions, { strictUnions: true }) as Tool['inputSchema'], + annotations: { + title: 'Connect to a browser running in VS Code.', + readOnlyHint: true, + openWorldHint: false, + }, + }; + } + + private async _setCurrentClient(factory: MCPProvider) { + await this._currentClient?.close(); + this._currentClient = undefined; + + const client = new Client({ name: 'Playwright MCP Proxy', version: packageJSON.version }); + client.registerCapabilities({ + roots: { + listRoots: true, + }, + }); + client.setRequestHandler(ListRootsRequestSchema, () => ({ roots: this._roots })); + client.setRequestHandler(PingRequestSchema, () => ({})); + + const transport = await factory.connect(); + await client.connect(transport); + this._currentClient = client; + } +} + +const clientsWithRoots = ['Visual Studio Code', 'Visual Studio Code - Insiders']; + +export async function runLoopTools(config: FullConfig) { + const serverBackendFactory = () => new LoopToolsServerBackend(config); + await mcpTransport.start(serverBackendFactory, config.server); +}