Skip to content

Commit 2ae7800

Browse files
authored
chore(vscode): add vscode mcp factory (#868)
1 parent f6862a3 commit 2ae7800

File tree

5 files changed

+291
-0
lines changed

5 files changed

+291
-0
lines changed

src/program.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { ProxyBackend } from './mcp/proxyBackend.js';
2525
import { BrowserServerBackend } from './browserServerBackend.js';
2626
import { ExtensionContextFactory } from './extension/extensionContextFactory.js';
2727

28+
import { runVSCodeTools } from './vscode/host.js';
2829
import type { MCPProvider } from './mcp/proxyBackend.js';
2930

3031
program
@@ -57,6 +58,7 @@ program
5758
.option('--user-data-dir <path>', 'path to the user data directory. If not specified, a temporary directory will be created.')
5859
.option('--viewport-size <size>', 'specify browser viewport size in pixels, for example "1280, 720"')
5960
.addOption(new Option('--connect-tool', 'Allow to switch between different browser connection methods.').hideHelp())
61+
.addOption(new Option('--vscode', 'VS Code tools.').hideHelp())
6062
.addOption(new Option('--loop-tools', 'Run loop tools').hideHelp())
6163
.addOption(new Option('--vision', 'Legacy option, use --caps=vision instead').hideHelp())
6264
.action(async options => {
@@ -83,6 +85,11 @@ program
8385
return;
8486
}
8587

88+
if (options.vscode) {
89+
await runVSCodeTools(config);
90+
return;
91+
}
92+
8693
if (options.loopTools) {
8794
await runLoopTools(config);
8895
return;

src/vscode/DEPS.list

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
[*]
2+
../mcp/
3+
../utils/
4+
../config.js
5+
../browserServerBackend.js
6+
../browserContextFactory.js

src/vscode/host.ts

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
/**
2+
* Copyright (c) Microsoft Corporation.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { fileURLToPath } from 'url';
18+
import path from 'path';
19+
import { z } from 'zod';
20+
import { zodToJsonSchema } from 'zod-to-json-schema';
21+
22+
23+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
24+
import { ListRootsRequestSchema, PingRequestSchema } from '@modelcontextprotocol/sdk/types.js';
25+
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
26+
import * as mcpServer from '../mcp/server.js';
27+
import { logUnhandledError } from '../utils/log.js';
28+
import { packageJSON } from '../utils/package.js';
29+
30+
import { FullConfig } from '../config.js';
31+
import { BrowserServerBackend } from '../browserServerBackend.js';
32+
import { contextFactory } from '../browserContextFactory.js';
33+
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
34+
import type { ClientVersion, ServerBackend } from '../mcp/server.js';
35+
import type { Root, Tool, CallToolResult, CallToolRequest } from '@modelcontextprotocol/sdk/types.js';
36+
37+
const contextSwitchOptions = z.object({
38+
connectionString: z.string().optional().describe('The connection string to use to connect to the browser'),
39+
lib: z.string().optional().describe('The library to use for the connection'),
40+
});
41+
42+
class VSCodeProxyBackend implements ServerBackend {
43+
name = 'Playwright MCP Client Switcher';
44+
version = packageJSON.version;
45+
46+
private _currentClient: Client | undefined;
47+
private _contextSwitchTool: Tool;
48+
private _roots: Root[] = [];
49+
private _clientVersion?: ClientVersion;
50+
51+
constructor(private readonly _config: FullConfig, private readonly _defaultTransportFactory: () => Promise<Transport>) {
52+
this._contextSwitchTool = this._defineContextSwitchTool();
53+
}
54+
55+
async initialize(clientVersion: ClientVersion, roots: Root[]): Promise<void> {
56+
this._clientVersion = clientVersion;
57+
this._roots = roots;
58+
const transport = await this._defaultTransportFactory();
59+
await this._setCurrentClient(transport);
60+
}
61+
62+
async listTools(): Promise<Tool[]> {
63+
const response = await this._currentClient!.listTools();
64+
return [
65+
...response.tools,
66+
this._contextSwitchTool,
67+
];
68+
}
69+
70+
async callTool(name: string, args: CallToolRequest['params']['arguments']): Promise<CallToolResult> {
71+
if (name === this._contextSwitchTool.name)
72+
return this._callContextSwitchTool(args as any);
73+
return await this._currentClient!.callTool({
74+
name,
75+
arguments: args,
76+
}) as CallToolResult;
77+
}
78+
79+
serverClosed?(): void {
80+
void this._currentClient?.close().catch(logUnhandledError);
81+
}
82+
83+
private async _callContextSwitchTool(params: z.infer<typeof contextSwitchOptions>): Promise<CallToolResult> {
84+
if (!params.connectionString || !params.lib) {
85+
const transport = await this._defaultTransportFactory();
86+
await this._setCurrentClient(transport);
87+
return {
88+
content: [{ type: 'text', text: '### Result\nSuccessfully disconnected.\n' }],
89+
};
90+
}
91+
92+
await this._setCurrentClient(
93+
new StdioClientTransport({
94+
command: process.execPath,
95+
cwd: process.cwd(),
96+
args: [
97+
path.join(fileURLToPath(import.meta.url), '..', 'main.js'),
98+
JSON.stringify(this._config),
99+
params.connectionString,
100+
params.lib,
101+
],
102+
})
103+
);
104+
return {
105+
content: [{ type: 'text', text: '### Result\nSuccessfully connected.\n' }],
106+
};
107+
}
108+
109+
private _defineContextSwitchTool(): Tool {
110+
return {
111+
name: 'browser_connect',
112+
description: 'Do not call, this tool is used in the integration with the Playwright VS Code Extension and meant for programmatic usage only.',
113+
inputSchema: zodToJsonSchema(contextSwitchOptions, { strictUnions: true }) as Tool['inputSchema'],
114+
annotations: {
115+
title: 'Connect to a browser running in VS Code.',
116+
readOnlyHint: true,
117+
openWorldHint: false,
118+
},
119+
};
120+
}
121+
122+
private async _setCurrentClient(transport: Transport) {
123+
await this._currentClient?.close();
124+
this._currentClient = undefined;
125+
126+
const client = new Client(this._clientVersion!);
127+
client.registerCapabilities({
128+
roots: {
129+
listRoots: true,
130+
},
131+
});
132+
client.setRequestHandler(ListRootsRequestSchema, () => ({ roots: this._roots }));
133+
client.setRequestHandler(PingRequestSchema, () => ({}));
134+
135+
await client.connect(transport);
136+
this._currentClient = client;
137+
}
138+
}
139+
140+
export async function runVSCodeTools(config: FullConfig) {
141+
const serverBackendFactory: mcpServer.ServerBackendFactory = {
142+
name: 'Playwright w/ vscode',
143+
nameInConfig: 'playwright-vscode',
144+
version: packageJSON.version,
145+
create: () => new VSCodeProxyBackend(config, () => mcpServer.wrapInProcess(new BrowserServerBackend(config, contextFactory(config))))
146+
};
147+
await mcpServer.start(serverBackendFactory, config.server);
148+
return;
149+
}

src/vscode/main.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
/**
2+
* Copyright (c) Microsoft Corporation.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
18+
import * as mcpServer from '../mcp/server.js';
19+
import { BrowserServerBackend } from '../browserServerBackend.js';
20+
import { BrowserContextFactory, ClientInfo } from '../browserContextFactory.js';
21+
import type { FullConfig } from '../config.js';
22+
import type { BrowserContext } from 'playwright-core';
23+
24+
class VSCodeBrowserContextFactory implements BrowserContextFactory {
25+
name = 'vscode';
26+
description = 'Connect to a browser running in the Playwright VS Code extension';
27+
28+
constructor(private _config: FullConfig, private _playwright: typeof import('playwright'), private _connectionString: string) {}
29+
30+
async createContext(clientInfo: ClientInfo, abortSignal: AbortSignal): Promise<{ browserContext: BrowserContext; close: () => Promise<void>; }> {
31+
let launchOptions: any = this._config.browser.launchOptions;
32+
if (this._config.browser.userDataDir) {
33+
launchOptions = {
34+
...launchOptions,
35+
...this._config.browser.contextOptions,
36+
userDataDir: this._config.browser.userDataDir,
37+
};
38+
}
39+
const connectionString = new URL(this._connectionString);
40+
connectionString.searchParams.set('launch-options', JSON.stringify(launchOptions));
41+
42+
const browserType = this._playwright.chromium; // it could also be firefox or webkit, we just need some browser type to call `connect` on
43+
const browser = await browserType.connect(connectionString.toString());
44+
45+
const context = browser.contexts()[0] ?? await browser.newContext(this._config.browser.contextOptions);
46+
47+
return {
48+
browserContext: context,
49+
close: async () => {
50+
await browser.close();
51+
}
52+
};
53+
}
54+
}
55+
56+
async function main(config: FullConfig, connectionString: string, lib: string) {
57+
const playwright = await import(lib).then(mod => mod.default ?? mod);
58+
const factory = new VSCodeBrowserContextFactory(config, playwright, connectionString);
59+
await mcpServer.connect(
60+
{
61+
name: 'Playwright MCP',
62+
nameInConfig: 'playwright-vscode',
63+
create: () => new BrowserServerBackend(config, factory),
64+
version: 'unused'
65+
},
66+
new StdioServerTransport(),
67+
false
68+
);
69+
}
70+
71+
await main(
72+
JSON.parse(process.argv[2]),
73+
process.argv[3],
74+
process.argv[4]
75+
);

tests/vscode.spec.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/**
2+
* Copyright (c) Microsoft Corporation.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { test, expect } from './fixtures.js';
18+
19+
test('browser_connect(vscode) works', async ({ startClient, playwright, browserName }) => {
20+
const { client } = await startClient({
21+
args: ['--vscode'],
22+
});
23+
24+
const server = await playwright[browserName].launchServer();
25+
26+
expect(await client.callTool({
27+
name: 'browser_connect',
28+
arguments: {
29+
connectionString: server.wsEndpoint(),
30+
lib: import.meta.resolve('playwright'),
31+
}
32+
})).toHaveResponse({
33+
result: 'Successfully connected.'
34+
});
35+
36+
expect(await client.callTool({
37+
name: 'browser_navigate',
38+
arguments: {
39+
url: 'data:text/html,foo'
40+
}
41+
})).toHaveResponse({
42+
pageState: expect.stringContaining('foo'),
43+
});
44+
45+
await server.close();
46+
47+
expect(await client.callTool({
48+
name: 'browser_snapshot',
49+
arguments: {}
50+
}), 'it actually used the server').toHaveResponse({
51+
isError: true,
52+
result: expect.stringContaining('ECONNREFUSED')
53+
});
54+
});

0 commit comments

Comments
 (0)