diff --git a/src/program.ts b/src/program.ts index 210ae10c..4a1cda9a 100644 --- a/src/program.ts +++ b/src/program.ts @@ -25,6 +25,7 @@ import { ProxyBackend } from './mcp/proxyBackend.js'; import { BrowserServerBackend } from './browserServerBackend.js'; import { ExtensionContextFactory } from './extension/extensionContextFactory.js'; +import { runVSCodeTools } from './vscode/host.js'; import type { MCPProvider } from './mcp/proxyBackend.js'; program @@ -57,6 +58,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('--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 => { @@ -83,6 +85,11 @@ program return; } + if (options.vscode) { + await runVSCodeTools(config); + return; + } + if (options.loopTools) { await runLoopTools(config); return; diff --git a/src/vscode/DEPS.list b/src/vscode/DEPS.list new file mode 100644 index 00000000..29b83cd5 --- /dev/null +++ b/src/vscode/DEPS.list @@ -0,0 +1,6 @@ +[*] +../mcp/ +../utils/ +../config.js +../browserServerBackend.js +../browserContextFactory.js diff --git a/src/vscode/host.ts b/src/vscode/host.ts new file mode 100644 index 00000000..ce78cc48 --- /dev/null +++ b/src/vscode/host.ts @@ -0,0 +1,149 @@ +/** + * 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 { fileURLToPath } from 'url'; +import path from 'path'; +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 { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; +import * as mcpServer from '../mcp/server.js'; +import { logUnhandledError } from '../utils/log.js'; +import { packageJSON } from '../utils/package.js'; + +import { FullConfig } from '../config.js'; +import { BrowserServerBackend } from '../browserServerBackend.js'; +import { contextFactory } from '../browserContextFactory.js'; +import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; +import type { ClientVersion, ServerBackend } from '../mcp/server.js'; +import type { Root, Tool, CallToolResult, CallToolRequest } from '@modelcontextprotocol/sdk/types.js'; + +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'), +}); + +class VSCodeProxyBackend implements ServerBackend { + name = 'Playwright MCP Client Switcher'; + version = packageJSON.version; + + private _currentClient: Client | undefined; + private _contextSwitchTool: Tool; + private _roots: Root[] = []; + private _clientVersion?: ClientVersion; + + constructor(private readonly _config: FullConfig, private readonly _defaultTransportFactory: () => Promise) { + this._contextSwitchTool = this._defineContextSwitchTool(); + } + + async initialize(clientVersion: ClientVersion, roots: Root[]): Promise { + this._clientVersion = clientVersion; + this._roots = roots; + const transport = await this._defaultTransportFactory(); + await this._setCurrentClient(transport); + } + + 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) { + const transport = await this._defaultTransportFactory(); + await this._setCurrentClient(transport); + return { + content: [{ type: 'text', text: '### Result\nSuccessfully disconnected.\n' }], + }; + } + + await this._setCurrentClient( + new StdioClientTransport({ + command: process.execPath, + cwd: process.cwd(), + args: [ + path.join(fileURLToPath(import.meta.url), '..', 'main.js'), + JSON.stringify(this._config), + params.connectionString, + params.lib, + ], + }) + ); + return { + content: [{ type: 'text', text: '### Result\nSuccessfully connected.\n' }], + }; + } + + private _defineContextSwitchTool(): Tool { + return { + name: 'browser_connect', + description: 'Do not call, this tool is used in the integration with the Playwright VS Code Extension and meant for programmatic usage only.', + 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(transport: Transport) { + await this._currentClient?.close(); + this._currentClient = undefined; + + const client = new Client(this._clientVersion!); + client.registerCapabilities({ + roots: { + listRoots: true, + }, + }); + client.setRequestHandler(ListRootsRequestSchema, () => ({ roots: this._roots })); + client.setRequestHandler(PingRequestSchema, () => ({})); + + await client.connect(transport); + this._currentClient = client; + } +} + +export async function runVSCodeTools(config: FullConfig) { + const serverBackendFactory: mcpServer.ServerBackendFactory = { + name: 'Playwright w/ vscode', + nameInConfig: 'playwright-vscode', + version: packageJSON.version, + create: () => new VSCodeProxyBackend(config, () => mcpServer.wrapInProcess(new BrowserServerBackend(config, contextFactory(config)))) + }; + await mcpServer.start(serverBackendFactory, config.server); + return; +} diff --git a/src/vscode/main.ts b/src/vscode/main.ts new file mode 100644 index 00000000..4ed67510 --- /dev/null +++ b/src/vscode/main.ts @@ -0,0 +1,75 @@ +/** + * 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 { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import * as mcpServer from '../mcp/server.js'; +import { BrowserServerBackend } from '../browserServerBackend.js'; +import { BrowserContextFactory, ClientInfo } from '../browserContextFactory.js'; +import type { FullConfig } from '../config.js'; +import type { BrowserContext } from 'playwright-core'; + +class VSCodeBrowserContextFactory implements BrowserContextFactory { + name = 'vscode'; + description = 'Connect to a browser running in the Playwright VS Code extension'; + + constructor(private _config: FullConfig, private _playwright: typeof import('playwright'), private _connectionString: string) {} + + async createContext(clientInfo: ClientInfo, abortSignal: AbortSignal): Promise<{ browserContext: BrowserContext; close: () => Promise; }> { + let launchOptions: any = this._config.browser.launchOptions; + if (this._config.browser.userDataDir) { + launchOptions = { + ...launchOptions, + ...this._config.browser.contextOptions, + userDataDir: this._config.browser.userDataDir, + }; + } + const connectionString = new URL(this._connectionString); + connectionString.searchParams.set('launch-options', JSON.stringify(launchOptions)); + + const browserType = this._playwright.chromium; // it could also be firefox or webkit, we just need some browser type to call `connect` on + const browser = await browserType.connect(connectionString.toString()); + + const context = browser.contexts()[0] ?? await browser.newContext(this._config.browser.contextOptions); + + return { + browserContext: context, + close: async () => { + await browser.close(); + } + }; + } +} + +async function main(config: FullConfig, connectionString: string, lib: string) { + const playwright = await import(lib).then(mod => mod.default ?? mod); + const factory = new VSCodeBrowserContextFactory(config, playwright, connectionString); + await mcpServer.connect( + { + name: 'Playwright MCP', + nameInConfig: 'playwright-vscode', + create: () => new BrowserServerBackend(config, factory), + version: 'unused' + }, + new StdioServerTransport(), + false + ); +} + +await main( + JSON.parse(process.argv[2]), + process.argv[3], + process.argv[4] +); diff --git a/tests/vscode.spec.ts b/tests/vscode.spec.ts new file mode 100644 index 00000000..6aef67e5 --- /dev/null +++ b/tests/vscode.spec.ts @@ -0,0 +1,54 @@ +/** + * 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 { test, expect } from './fixtures.js'; + +test('browser_connect(vscode) works', async ({ startClient, playwright, browserName }) => { + const { client } = await startClient({ + args: ['--vscode'], + }); + + const server = await playwright[browserName].launchServer(); + + expect(await client.callTool({ + name: 'browser_connect', + arguments: { + connectionString: server.wsEndpoint(), + lib: import.meta.resolve('playwright'), + } + })).toHaveResponse({ + result: 'Successfully connected.' + }); + + expect(await client.callTool({ + name: 'browser_navigate', + arguments: { + url: 'data:text/html,foo' + } + })).toHaveResponse({ + pageState: expect.stringContaining('foo'), + }); + + await server.close(); + + expect(await client.callTool({ + name: 'browser_snapshot', + arguments: {} + }), 'it actually used the server').toHaveResponse({ + isError: true, + result: expect.stringContaining('ECONNREFUSED') + }); +});