28 Commits

Author SHA1 Message Date
Simon Knott
76ba7f7bb6 windows shenanigans 2025-08-21 13:17:32 +02:00
Simon Knott
dc149c19c0 change description 2025-08-21 12:48:15 +02:00
Simon Knott
74e3ab5267 move entrypoint to host.ts 2025-08-21 11:17:53 +02:00
Simon Knott
d12b5aab18 lint 2025-08-21 11:12:29 +02:00
Simon Knott
922002e435 make test pass 2025-08-20 18:44:45 +02:00
Simon Knott
21e03968c5 simplify 2025-08-20 18:38:19 +02:00
Simon Knott
ee59735f42 reset main 2025-08-20 15:55:30 +02:00
Simon Knott
da5b0c6fdd Merge branch 'main' into vscode-client-factory 2025-08-20 15:54:40 +02:00
Simon Knott
35c464ef5b more 2025-08-20 15:52:59 +02:00
Yury Semikhatsky
f6862a39c3 chore: check version in page, link to instructions (#918) 2025-08-19 17:39:58 -07:00
Yury Semikhatsky
e664e0460c chore: check extension version on connect (#907) 2025-08-18 13:28:13 -07:00
Pavel Feldman
865eac2fee chore: do not wrap mcp in proxy by default, drive-by deps fix (#909) 2025-08-16 19:39:49 -07:00
Simon Knott
fcd953c097 Merge branch 'main' into vscode-client-factory 2025-08-14 12:59:01 +02:00
Simon Knott
14b931d25d lint 2025-08-13 13:25:17 +02:00
Simon Knott
bcbc2fecb8 add basic test 2025-08-13 13:22:14 +02:00
Simon Knott
5a0cfb9e65 address 2025-08-13 11:27:01 +02:00
Simon Knott
1ff80f8761 comment 2025-08-13 11:20:28 +02:00
Simon Knott
98fef06b3b options arg 2025-08-13 11:19:59 +02:00
Simon Knott
affe1d7ed9 Merge branch 'main' into vscode-client-factory 2025-08-13 11:18:58 +02:00
Simon Knott
cc61b67c14 Merge branch 'main' into vscode-client-factory 2025-08-13 11:17:37 +02:00
Simon Knott
7a814d5cd4 allow closing before init 2025-08-12 15:26:38 +02:00
Simon Knott
39c384850f add newlines 2025-08-12 15:24:25 +02:00
Simon Knott
f8a61de332 add todo 2025-08-12 10:22:05 +02:00
Simon Knott
9d17572403 merge 2025-08-12 10:20:41 +02:00
Simon Knott
0741b8bee8 Merge branch 'main' into vscode-client-factory 2025-08-12 10:19:31 +02:00
Simon Knott
0d0783be07 name & desc 2025-08-12 10:14:48 +02:00
Simon Knott
001fa6f2fb forward clientFactory 2025-08-12 10:12:49 +02:00
Simon Knott
e884b3aacb get started 2025-08-12 10:00:58 +02:00
21 changed files with 587 additions and 198 deletions

View File

@@ -49,7 +49,7 @@ class TabShareExtension {
private _onMessage(message: PageMessage, sender: chrome.runtime.MessageSender, sendResponse: (response: any) => void) {
switch (message.type) {
case 'connectToMCPRelay':
this._connectToRelay(sender.tab!.id!, message.mcpRelayUrl!).then(
this._connectToRelay(sender.tab!.id!, message.mcpRelayUrl).then(
() => sendResponse({ success: true }),
(error: any) => sendResponse({ success: false, error: error.message }));
return true;
@@ -96,8 +96,9 @@ class TabShareExtension {
this._pendingTabSelection.set(selectorTabId, { connection });
debugLog(`Connected to MCP relay`);
} catch (error: any) {
debugLog(`Failed to connect to MCP relay:`, error.message);
throw error;
const message = `Failed to connect to MCP relay: ${error.message}`;
debugLog(message);
throw new Error(message);
}
}

View File

@@ -19,11 +19,15 @@ import { createRoot } from 'react-dom/client';
import { Button, TabItem } from './tabItem.js';
import type { TabInfo } from './tabItem.js';
type StatusType = 'connected' | 'error' | 'connecting';
type Status =
| { type: 'connecting'; message: string }
| { type: 'connected'; message: string }
| { type: 'error'; message: string }
| { type: 'error'; versionMismatch: { pwMcpVersion: string; extensionVersion: string } };
const ConnectApp: React.FC = () => {
const [tabs, setTabs] = useState<TabInfo[]>([]);
const [status, setStatus] = useState<{ type: StatusType; message: string } | null>(null);
const [status, setStatus] = useState<Status | null>(null);
const [showButtons, setShowButtons] = useState(true);
const [showTabList, setShowTabList] = useState(true);
const [clientInfo, setClientInfo] = useState('unknown');
@@ -54,15 +58,37 @@ const ConnectApp: React.FC = () => {
return;
}
const pwMcpVersion = params.get('pwMcpVersion');
const extensionVersion = chrome.runtime.getManifest().version;
if (pwMcpVersion !== extensionVersion) {
setShowButtons(false);
setShowTabList(false);
setStatus({
type: 'error',
versionMismatch: {
pwMcpVersion: pwMcpVersion || 'unknown',
extensionVersion
}
});
return;
}
void connectToMCPRelay(relayUrl);
void loadTabs();
}, []);
const handleReject = useCallback((message: string) => {
setShowButtons(false);
setShowTabList(false);
setStatus({ type: 'error', message });
}, []);
const connectToMCPRelay = useCallback(async (mcpRelayUrl: string) => {
const response = await chrome.runtime.sendMessage({ type: 'connectToMCPRelay', mcpRelayUrl });
if (!response.success)
setStatus({ type: 'error', message: 'Failed to connect to MCP relay: ' + response.error });
}, []);
handleReject(response.error);
}, [handleReject]);
const loadTabs = useCallback(async () => {
const response = await chrome.runtime.sendMessage({ type: 'getTabs' });
@@ -100,31 +126,25 @@ const ConnectApp: React.FC = () => {
}
}, [clientInfo, mcpRelayUrl]);
const handleReject = useCallback(() => {
setShowButtons(false);
setShowTabList(false);
setStatus({ type: 'error', message: 'Connection rejected. This tab can be closed.' });
}, []);
useEffect(() => {
const listener = (message: any) => {
if (message.type === 'connectionTimeout')
handleReject();
handleReject('Connection timed out.');
};
chrome.runtime.onMessage.addListener(listener);
return () => {
chrome.runtime.onMessage.removeListener(listener);
};
}, []);
}, [handleReject]);
return (
<div className='app-container'>
<div className='content-wrapper'>
{status && (
<div className='status-container'>
<StatusBanner type={status.type} message={status.message} />
<StatusBanner status={status} />
{showButtons && (
<Button variant='reject' onClick={handleReject}>
<Button variant='reject' onClick={() => handleReject('Connection rejected. This tab can be closed.')}>
Reject
</Button>
)}
@@ -156,8 +176,27 @@ const ConnectApp: React.FC = () => {
);
};
const StatusBanner: React.FC<{ type: StatusType; message: string }> = ({ type, message }) => {
return <div className={`status-banner ${type}`}>{message}</div>;
const VersionMismatchError: React.FC<{ pwMcpVersion: string; extensionVersion: string }> = ({ pwMcpVersion, extensionVersion }) => {
const readmeUrl = 'https://github.com/microsoft/playwright-mcp/blob/main/extension/README.md';
return (
<div>
Incompatible Playwright MCP version: {pwMcpVersion} (extension version: {extensionVersion}).
Please install the latest version of the extension.{' '}
See <a href={readmeUrl} target='_blank' rel='noopener noreferrer'>installation instructions</a>.
</div>
);
};
const StatusBanner: React.FC<{ status: Status }> = ({ status }) => {
return (
<div className={`status-banner ${status.type}`}>
{'versionMismatch' in status ? (
<VersionMismatchError pwMcpVersion={status.versionMismatch.pwMcpVersion} extensionVersion={status.versionMismatch.extensionVersion} />
) : (
status.message
)}
</div>
);
};
// Initialize the React app

View File

@@ -14,12 +14,15 @@
* limitations under the License.
*/
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { chromium } from 'playwright';
import packageJSON from '../../package.json' assert { type: 'json' };
import { test as base, expect } from '../../tests/fixtures.js';
import type { BrowserContext } from 'playwright';
import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
import type { BrowserContext } from 'playwright';
import type { StartClient } from '../../tests/fixtures.js';
type BrowserWithExtension = {
@@ -27,14 +30,22 @@ type BrowserWithExtension = {
launch: (mode?: 'disable-extension') => Promise<BrowserContext>;
};
const test = base.extend<{ browserWithExtension: BrowserWithExtension }>({
browserWithExtension: async ({ mcpBrowser }, use, testInfo) => {
type TestFixtures = {
browserWithExtension: BrowserWithExtension,
pathToExtension: string,
useShortConnectionTimeout: (timeoutMs: number) => void
};
const test = base.extend<TestFixtures>({
pathToExtension: async ({}, use) => {
await use(fileURLToPath(new URL('../dist', import.meta.url)));
},
browserWithExtension: async ({ mcpBrowser, pathToExtension }, use, testInfo) => {
// The flags no longer work in Chrome since
// https://chromium.googlesource.com/chromium/src/+/290ed8046692651ce76088914750cb659b65fb17%5E%21/chrome/browser/extensions/extension_service.cc?pli=1#
test.skip('chromium' !== mcpBrowser, '--load-extension is not supported for official builds of Chromium');
const pathToExtension = fileURLToPath(new URL('../dist', import.meta.url));
let browserContext: BrowserContext | undefined;
const userDataDir = testInfo.outputPath('extension-user-data-dir');
await use({
@@ -60,9 +71,16 @@ const test = base.extend<{ browserWithExtension: BrowserWithExtension }>({
return browserContext;
}
});
await browserContext?.close();
},
useShortConnectionTimeout: async ({}, use) => {
await use((timeoutMs: number) => {
process.env.PWMCP_TEST_CONNECTION_TIMEOUT = timeoutMs.toString();
});
process.env.PWMCP_TEST_CONNECTION_TIMEOUT = undefined;
},
});
async function startAndCallConnectTool(browserWithExtension: BrowserWithExtension, startClient: StartClient): Promise<Client> {
@@ -99,6 +117,21 @@ async function startWithExtensionFlag(browserWithExtension: BrowserWithExtension
return client;
}
const testWithOldVersion = test.extend({
pathToExtension: async ({}, use, testInfo) => {
const extensionDir = testInfo.outputPath('extension');
const oldPath = fileURLToPath(new URL('../dist', import.meta.url));
await fs.promises.cp(oldPath, extensionDir, { recursive: true });
const manifestPath = path.join(extensionDir, 'manifest.json');
const manifest = JSON.parse(await fs.promises.readFile(manifestPath, 'utf8'));
manifest.version = '0.0.1';
await fs.promises.writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
await use(extensionDir);
},
});
for (const [mode, startClientMethod] of [
['connect-tool', startAndCallConnectTool],
['extension-flag', startWithExtensionFlag],
@@ -160,8 +193,8 @@ for (const [mode, startClientMethod] of [
expect(browserContext.pages()).toHaveLength(4);
});
test(`extension not installed timeout (${mode})`, async ({ browserWithExtension, startClient, server }) => {
process.env.PWMCP_TEST_CONNECTION_TIMEOUT = '100';
test(`extension not installed timeout (${mode})`, async ({ browserWithExtension, startClient, server, useShortConnectionTimeout }) => {
useShortConnectionTimeout(100);
const browserContext = await browserWithExtension.launch();
@@ -180,8 +213,32 @@ for (const [mode, startClientMethod] of [
});
await confirmationPagePromise;
});
process.env.PWMCP_TEST_CONNECTION_TIMEOUT = undefined;
testWithOldVersion(`extension version mismatch (${mode})`, async ({ browserWithExtension, startClient, server, useShortConnectionTimeout }) => {
useShortConnectionTimeout(500);
// Prelaunch the browser, so that it is properly closed after the test.
const browserContext = await browserWithExtension.launch();
const client = await startClientMethod(browserWithExtension, startClient);
const confirmationPagePromise = browserContext.waitForEvent('page', page => {
return page.url().startsWith('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html');
});
const navigateResponse = client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
});
const confirmationPage = await confirmationPagePromise;
await expect(confirmationPage.locator('.status-banner')).toHaveText(`Incompatible Playwright MCP version: ${packageJSON.version} (extension version: 0.0.1). Please install the latest version of the extension. See installation instructions.`);
expect(await navigateResponse).toHaveResponse({
result: expect.stringContaining('Extension connection timeout.'),
isError: true,
});
});
}

View File

@@ -42,27 +42,23 @@ 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<void> }>;
}
class BaseContextFactory implements BrowserContextFactory {
readonly name: string;
readonly description: string;
readonly config: FullConfig;
private _logName: string;
protected _browserPromise: Promise<playwright.Browser> | undefined;
constructor(name: string, description: string, config: FullConfig) {
this.name = name;
this.description = description;
constructor(name: string, config: FullConfig) {
this._logName = name;
this.config = config;
}
protected async _obtainBrowser(clientInfo: ClientInfo): Promise<playwright.Browser> {
if (this._browserPromise)
return this._browserPromise;
testDebug(`obtain browser (${this.name})`);
testDebug(`obtain browser (${this._logName})`);
this._browserPromise = this._doObtainBrowser(clientInfo);
void this._browserPromise.then(browser => {
browser.on('disconnected', () => {
@@ -79,7 +75,7 @@ class BaseContextFactory implements BrowserContextFactory {
}
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 browserContext = await this._doCreateContext(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) {
testDebug(`close browser context (${this.name})`);
testDebug(`close browser context (${this._logName})`);
if (browser.contexts().length === 1)
this._browserPromise = undefined;
await browserContext.close().catch(logUnhandledError);
if (browser.contexts().length === 0) {
testDebug(`close browser (${this.name})`);
testDebug(`close browser (${this._logName})`);
await browser.close().catch(logUnhandledError);
}
}
@@ -103,7 +99,7 @@ class BaseContextFactory implements BrowserContextFactory {
class IsolatedContextFactory extends BaseContextFactory {
constructor(config: FullConfig) {
super('isolated', 'Create a new isolated browser context', config);
super('isolated', config);
}
protected override async _doObtainBrowser(clientInfo: ClientInfo): Promise<playwright.Browser> {
@@ -128,7 +124,7 @@ class IsolatedContextFactory extends BaseContextFactory {
class CdpContextFactory extends BaseContextFactory {
constructor(config: FullConfig) {
super('cdp', 'Connect to a browser over CDP', config);
super('cdp', config);
}
protected override async _doObtainBrowser(): Promise<playwright.Browser> {
@@ -142,7 +138,7 @@ class CdpContextFactory extends BaseContextFactory {
class RemoteContextFactory extends BaseContextFactory {
constructor(config: FullConfig) {
super('remote', 'Connect to a browser using a remote endpoint', config);
super('remote', config);
}
protected override async _doObtainBrowser(): Promise<playwright.Browser> {

View File

@@ -21,7 +21,6 @@ import { logUnhandledError } from './utils/log.js';
import { Response } from './response.js';
import { SessionLog } from './sessionLog.js';
import { filteredTools } from './tools.js';
import { packageJSON } from './utils/package.js';
import { toMcpTool } from './mcp/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';
export class BrowserServerBackend implements ServerBackend {
name = 'Playwright';
version = packageJSON.version;
private _tools: Tool[];
private _context: Context | undefined;
private _sessionLog: SessionLog | undefined;

View File

@@ -26,9 +26,11 @@ import { spawn } from 'child_process';
import http from 'http';
import debug from 'debug';
import { WebSocket, WebSocketServer } from 'ws';
import { httpAddressToString } from '../utils/httpServer.js';
import { httpAddressToString } from '../mcp/http.js';
import { logUnhandledError } from '../utils/log.js';
import { ManualPromise } from '../utils/manualPromise.js';
import { packageJSON } from '../utils/package.js';
import type websocket from 'ws';
import type { ClientInfo } from '../browserContextFactory.js';
@@ -113,7 +115,12 @@ export class CDPRelayServer {
// Need to specify "key" in the manifest.json to make the id stable when loading from file.
const url = new URL('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html');
url.searchParams.set('mcpRelayUrl', mcpRelayEndpoint);
url.searchParams.set('client', JSON.stringify(clientInfo));
const client = {
name: clientInfo.name,
version: clientInfo.version,
};
url.searchParams.set('client', JSON.stringify(client));
url.searchParams.set('pwMcpVersion', packageJSON.version);
const href = url.toString();
const executableInfo = registry.findExecutable(this._browserChannel);
if (!executableInfo)

View File

@@ -16,7 +16,7 @@
import debug from 'debug';
import * as playwright from 'playwright';
import { startHttpServer } from '../utils/httpServer.js';
import { startHttpServer } from '../mcp/http.js';
import { CDPRelayServer } from './cdpRelay.js';
import type { BrowserContextFactory, ClientInfo } from '../browserContextFactory.js';
@@ -24,9 +24,6 @@ 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 _userDataDir?: string;

View File

@@ -18,6 +18,7 @@ import { BrowserServerBackend } from './browserServerBackend.js';
import { resolveConfig } from './config.js';
import { contextFactory } from './browserContextFactory.js';
import * as mcpServer from './mcp/server.js';
import { packageJSON } from './utils/package.js';
import type { Config } from '../config.js';
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> {
const config = await resolveConfig(userConfig);
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 {

View File

@@ -23,6 +23,7 @@ import { OpenAIDelegate } from '../loop/loopOpenAI.js';
import { ClaudeDelegate } from '../loop/loopClaude.js';
import { InProcessTransport } from '../mcp/inProcessTransport.js';
import * as mcpServer from '../mcp/server.js';
import { packageJSON } from '../utils/package.js';
import type { LLMDelegate } from '../loop/loop.js';
import type { FullConfig } from '../config.js';
@@ -44,9 +45,9 @@ export class Context {
}
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 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.ping();
return new Context(config, client);

View File

@@ -17,7 +17,6 @@
import dotenv from 'dotenv';
import * as mcpServer from '../mcp/server.js';
import * as mcpTransport from '../mcp/transport.js';
import { packageJSON } from '../utils/package.js';
import { Context } from './context.js';
import { perform } from './perform.js';
@@ -30,13 +29,16 @@ import type { Tool } from './tool.js';
export async function runLoopTools(config: FullConfig) {
dotenv.config();
const serverBackendFactory = () => new LoopToolsServerBackend(config);
await mcpTransport.start(serverBackendFactory, config.server);
const serverBackendFactory = {
name: 'Playwright',
nameInConfig: 'playwright-loop',
version: packageJSON.version,
create: () => new LoopToolsServerBackend(config)
};
await mcpServer.start(serverBackendFactory, config.server);
}
class LoopToolsServerBackend implements ServerBackend {
readonly name = 'Playwright';
readonly version = packageJSON.version;
private _config: FullConfig;
private _context: Context | undefined;
private _tools: Tool<any>[] = [perform, snapshot];

View File

@@ -1,2 +1 @@
[*]
../utils/

View File

@@ -1 +1 @@
- Generic MCP utils, no dependencies on Playwright here.
- Generic MCP utils, no dependencies on anything.

View File

@@ -14,33 +14,61 @@
* limitations under the License.
*/
import assert from 'assert';
import net from 'net';
import http from 'http';
import crypto from 'crypto';
import debug from 'debug';
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.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 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');
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>) {
if (req.method === 'POST') {
const sessionId = url.searchParams.get('sessionId');
@@ -108,30 +136,3 @@ async function handleStreamable(serverBackendFactory: ServerBackendFactory, req:
res.statusCode = 400;
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);
}

View File

@@ -14,14 +14,12 @@
* limitations under the License.
*/
import debug from 'debug';
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 type { ServerBackend, ClientVersion, Root } from './server.js';
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
@@ -33,10 +31,9 @@ export type MCPProvider = {
connect(): Promise<Transport>;
};
export class ProxyBackend implements ServerBackend {
name = 'Playwright MCP Client Switcher';
version = packageJSON.version;
const errorsDebug = debug('pw:mcp:errors');
export class ProxyBackend implements ServerBackend {
private _mcpProviders: MCPProvider[];
private _currentClient: Client | undefined;
private _contextSwitchTool: Tool;
@@ -72,7 +69,7 @@ export class ProxyBackend implements ServerBackend {
}
serverClosed?(): void {
void this._currentClient?.close().catch(logUnhandledError);
void this._currentClient?.close().catch(errorsDebug);
}
private async _callContextSwitchTool(params: any): Promise<CallToolResult> {
@@ -115,7 +112,7 @@ export class ProxyBackend implements ServerBackend {
await this._currentClient?.close();
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({
roots: {
listRoots: true,

View File

@@ -15,10 +15,12 @@
*/
import debug from 'debug';
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
import { ManualPromise } from '../utils/manualPromise.js';
import { logUnhandledError } from '../utils/log.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.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 { 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';
const serverDebug = debug('pw:mcp:server');
const errorsDebug = debug('pw:mcp:errors');
export type ClientVersion = { name: string, version: string };
export interface ServerBackend {
name: string;
version: string;
initialize?(clientVersion: ClientVersion, roots: Root[]): Promise<void>;
listTools(): Promise<Tool[]>;
callTool(name: string, args: CallToolRequest['params']['arguments']): Promise<CallToolResult>;
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) {
const backend = serverBackendFactory();
const server = createServer(backend, runHeartbeat);
export async function connect(factory: ServerBackendFactory, transport: Transport, runHeartbeat: boolean) {
const server = createServer(factory.name, factory.version, factory.create(), runHeartbeat);
await server.connect(transport);
}
export function createServer(backend: ServerBackend, runHeartbeat: boolean): Server {
const initializedPromise = new ManualPromise<void>();
const server = new Server({ name: backend.name, version: backend.version }, {
export async function wrapInProcess(backend: ServerBackend): Promise<Transport> {
const server = createServer('Internal', '0.0.0', backend, false);
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: {
tools: {},
}
@@ -89,9 +100,9 @@ export function createServer(backend: ServerBackend, runHeartbeat: boolean): Ser
}
const clientVersion = server.getClientVersion() ?? { name: 'unknown', version: 'unknown' };
await backend.initialize?.(clientVersion, clientRoots);
initializedPromise.resolve();
initializedPromiseResolve();
} catch (e) {
logUnhandledError(e);
errorsDebug(e);
}
});
addServerListener(server, 'close', () => backend.serverClosed?.());
@@ -120,3 +131,27 @@ function addServerListener(server: Server, event: 'close' | 'initialized', liste
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);
}

View File

@@ -16,7 +16,6 @@
import { program, Option } from 'commander';
import * as mcpServer from './mcp/server.js';
import * as mcpTransport from './mcp/transport.js';
import { commaSeparatedList, resolveCLIConfig, semicolonSeparatedList } from './config.js';
import { packageJSON } from './utils/package.js';
import { Context } from './context.js';
@@ -25,11 +24,9 @@ import { runLoopTools } from './loopTools/main.js';
import { ProxyBackend } from './mcp/proxyBackend.js';
import { BrowserServerBackend } from './browserServerBackend.js';
import { ExtensionContextFactory } from './extension/extensionContextFactory.js';
import { InProcessTransport } from './mcp/inProcessTransport.js';
import { runVSCodeTools } from './vscode/host.js';
import type { MCPProvider } from './mcp/proxyBackend.js';
import type { FullConfig } from './config.js';
import type { BrowserContextFactory } from './browserContextFactory.js';
program
.version('Version ' + packageJSON.version)
@@ -61,6 +58,7 @@ program
.option('--user-data-dir <path>', 'path to the user data directory. If not specified, a temporary directory will be created.')
.option('--viewport-size <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 => {
@@ -71,12 +69,24 @@ program
console.error('The --vision option is deprecated, use --caps=vision instead');
options.caps = 'vision';
}
const config = await resolveCLIConfig(options);
const browserContextFactory = contextFactory(config);
const extensionContextFactory = new ExtensionContextFactory(config.browser.launchOptions.channel || 'chrome', config.browser.userDataDir);
if (options.extension) {
const contextFactory = createExtensionContextFactory(config);
const serverBackendFactory = () => new BrowserServerBackend(config, contextFactory);
await mcpTransport.start(serverBackendFactory, config.server);
const serverBackendFactory: mcpServer.ServerBackendFactory = {
name: 'Playwright w/ extension',
nameInConfig: 'playwright-extension',
version: packageJSON.version,
create: () => new BrowserServerBackend(config, extensionContextFactory)
};
await mcpServer.start(serverBackendFactory, config.server);
return;
}
if (options.vscode) {
await runVSCodeTools(config);
return;
}
@@ -85,11 +95,36 @@ program
return;
}
const browserContextFactory = contextFactory(config);
const providers: MCPProvider[] = [mcpProviderForBrowserContextFactory(config, browserContextFactory)];
if (options.connectTool)
providers.push(mcpProviderForBrowserContextFactory(config, createExtensionContextFactory(config)));
await mcpTransport.start(() => new ProxyBackend(providers), config.server);
if (options.connectTool) {
const providers: MCPProvider[] = [
{
name: 'default',
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() {
@@ -108,19 +143,4 @@ function setupExitWatchdog() {
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);

View File

@@ -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}`;
}

6
src/vscode/DEPS.list Normal file
View File

@@ -0,0 +1,6 @@
[*]
../mcp/
../utils/
../config.js
../browserServerBackend.js
../browserContextFactory.js

149
src/vscode/host.ts Normal file
View File

@@ -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 { 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';
import { fileURLToPath } from 'url';
import path from 'path';
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<Transport>) {
this._contextSwitchTool = this._defineContextSwitchTool();
}
async initialize(clientVersion: ClientVersion, roots: Root[]): Promise<void> {
this._clientVersion = clientVersion;
this._roots = roots;
const transport = await this._defaultTransportFactory();
await this._setCurrentClient(transport);
}
async listTools(): Promise<Tool[]> {
const response = await this._currentClient!.listTools();
return [
...response.tools,
this._contextSwitchTool,
];
}
async callTool(name: string, args: CallToolRequest['params']['arguments']): Promise<CallToolResult> {
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<typeof contextSwitchOptions>): Promise<CallToolResult> {
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;
}

75
src/vscode/main.ts Normal file
View File

@@ -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<void>; }> {
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]
);

54
tests/vscode.spec.ts Normal file
View File

@@ -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')
});
});