From c92aefdc1268be377693d1f5223ac7807d96e2ef Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Thu, 14 Aug 2025 10:57:07 -0700 Subject: [PATCH 01/15] chore: close all clients in fixture (#878) --- tests/fixtures.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/fixtures.ts b/tests/fixtures.ts index c02d5c4..ef686b8 100644 --- a/tests/fixtures.ts +++ b/tests/fixtures.ts @@ -69,7 +69,7 @@ export const test = baseTest.extend( startClient: async ({ mcpHeadless, mcpBrowser, mcpMode }, use, testInfo) => { const configDir = path.dirname(test.info().config.configFile!); - let client: Client | undefined; + const clients: Client[] = []; await use(async options => { const args: string[] = []; @@ -87,7 +87,7 @@ export const test = baseTest.extend( args.push(`--config=${path.relative(configDir, configFile)}`); } - client = new Client({ name: options?.clientName ?? 'test', version: '1.0.0' }, options?.roots ? { capabilities: { roots: {} } } : undefined); + const client = new Client({ name: options?.clientName ?? 'test', version: '1.0.0' }, options?.roots ? { capabilities: { roots: {} } } : undefined); if (options?.roots) { client.setRequestHandler(ListRootsRequestSchema, async request => { if (options.rootsResponseDelay) @@ -104,12 +104,13 @@ export const test = baseTest.extend( process.stderr.write(data); stderrBuffer += data.toString(); }); + clients.push(client); await client.connect(transport); await client.ping(); return { client, stderr: () => stderrBuffer }; }); - await client?.close(); + await Promise.all(clients.map(client => client.close())); }, wsEndpoint: async ({ }, use) => { @@ -126,6 +127,8 @@ export const test = baseTest.extend( await use({ endpoint: `http://localhost:${port}`, start: async () => { + if (browserContext) + throw new Error('CDP server already exists'); browserContext = await chromium.launchPersistentContext(testInfo.outputPath('cdp-user-data-dir'), { channel: mcpBrowser, headless: true, From 3f148a4005f89f34c5710f87d56b7ac1531427c6 Mon Sep 17 00:00:00 2001 From: Adam Tarantino Date: Thu, 14 Aug 2025 15:41:46 -0700 Subject: [PATCH 02/15] docs: add opencode installation instructions (#895) --- README.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/README.md b/README.md index dd6cf78..57d80fc 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,29 @@ Go to `Advanced settings` -> `Extensions` -> `Add custom extension`. Name to you Go to `Program` in the right sidebar -> `Install` -> `Edit mcp.json`. Use the standard config above. +
+opencode + +Follow the MCP Servers [documentation](https://opencode.ai/docs/mcp-servers/). For example in `~/.config/opencode/opencode.json`: + +```json +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "playwright": { + "type": "local", + "command": [ + "npx", + "@playwright/mcp@latest" + ], + "enabled": true + } + } +} + +``` +
+
Qodo Gen From 2fc4e880488408778e98645cc09644be1da28b40 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Thu, 14 Aug 2025 16:01:14 -0700 Subject: [PATCH 03/15] chore(extension): add readme file, recommend --extension option (#894) --- README.md | 9 ++++++++- extension/README.md | 48 +++++++++++++++++++++++++++++++++++++++++++++ src/program.ts | 2 +- 3 files changed, 57 insertions(+), 2 deletions(-) create mode 100644 extension/README.md diff --git a/README.md b/README.md index 57d80fc..975d762 100644 --- a/README.md +++ b/README.md @@ -181,6 +181,9 @@ Playwright MCP server supports following arguments. They can be provided in the --config path to the configuration file. --device device to emulate, for example: "iPhone 15" --executable-path path to the browser executable. + --extension Connect to a running browser instance + (Edge/Chrome only). Requires the "Playwright MCP + Bridge" browser extension to be installed. --headless run browser in headless mode, headed by default --host host to bind server to. Default is localhost. Use 0.0.0.0 to bind to all interfaces. @@ -214,7 +217,7 @@ Playwright MCP server supports following arguments. They can be provided in the ### User profile -You can run Playwright MCP with persistent profile like a regular browser (default), or in the isolated contexts for the testing sessions. +You can run Playwright MCP with persistent profile like a regular browser (default), in isolated contexts for testing sessions, or connect to your existing browser using the browser extension. **Persistent profile** @@ -254,6 +257,10 @@ state [here](https://playwright.dev/docs/auth). } ``` +**Browser Extension** + +The Playwright MCP Chrome Extension allows you to connect to existing browser tabs and leverage your logged-in sessions and browser state. See [extension/README.md](extension/README.md) for installation and setup instructions. + ### Configuration file The Playwright MCP server can be configured using a JSON configuration file. You can specify the configuration file diff --git a/extension/README.md b/extension/README.md new file mode 100644 index 0000000..6421798 --- /dev/null +++ b/extension/README.md @@ -0,0 +1,48 @@ +# Playwright MCP Chrome Extension + +## Introduction + +The Playwright MCP Chrome Extension allows you to connect to pages in your existing browser and leverage the state of your default user profile. This means the AI assistant can interact with websites where you're already logged in, using your existing cookies, sessions, and browser state, providing a seamless experience without requiring separate authentication or setup. + +## Prerequisites + +- Chrome/Edge/Chromium browser + +## Installation Steps + +### Download the Extension + +Download the latest Chrome extension from GitHub: +- **Download link**: https://github.com/microsoft/playwright-mcp/releases + +### Load Chrome Extension + +1. Open Chrome and navigate to `chrome://extensions/` +2. Enable "Developer mode" (toggle in the top right corner) +3. Click "Load unpacked" and select the extension directory + +### Configure Playwright MCP server + +Configure Playwright MCP server to connect to the browser using the extension by passing the `--extension` option when running the MCP server: + +```json +{ + "mcpServers": { + "playwright-extension": { + "command": "npx", + "args": [ + "@playwright/mcp@latest", + "--extension" + ] + } + } +} +``` + +## Usage + +### Browser Tab Selection + +When the LLM interacts with the browser for the first time, it will load a page where you can select which browser tab the LLM will connect to. This allows you to control which specific page the AI assistant will interact with during the session. + + diff --git a/src/program.ts b/src/program.ts index ee0e64b..bc313c2 100644 --- a/src/program.ts +++ b/src/program.ts @@ -43,6 +43,7 @@ program .option('--config ', 'path to the configuration file.') .option('--device ', 'device to emulate, for example: "iPhone 15"') .option('--executable-path ', 'path to the browser executable.') + .option('--extension', 'Connect to a running browser instance (Edge/Chrome only). Requires the "Playwright MCP Bridge" browser extension to be installed.') .option('--headless', 'run browser in headless mode, headed by default') .option('--host ', 'host to bind server to. Default is localhost. Use 0.0.0.0 to bind to all interfaces.') .option('--ignore-https-errors', 'ignore https errors') @@ -59,7 +60,6 @@ program .option('--user-agent ', 'specify user agent string') .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()) From ba726fb44a3bd5c39cf2d59d60b6f05c867c5495 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Fri, 15 Aug 2025 09:09:35 -0700 Subject: [PATCH 04/15] chore(extension): connection timeout when extension not installed (#896) --- extension/tests/extension.spec.ts | 143 +++++++++++++++++++----------- src/extension/cdpRelay.ts | 3 + tests/fixtures.ts | 17 ++-- 3 files changed, 102 insertions(+), 61 deletions(-) diff --git a/extension/tests/extension.spec.ts b/extension/tests/extension.spec.ts index 5de2c19..6ec47bf 100644 --- a/extension/tests/extension.spec.ts +++ b/extension/tests/extension.spec.ts @@ -19,10 +19,12 @@ import { chromium } from 'playwright'; 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 { StartClient } from '../../tests/fixtures.js'; type BrowserWithExtension = { userDataDir: string; - launch: () => Promise; + launch: (mode?: 'disable-extension') => Promise; }; const test = base.extend<{ browserWithExtension: BrowserWithExtension }>({ @@ -37,14 +39,14 @@ const test = base.extend<{ browserWithExtension: BrowserWithExtension }>({ const userDataDir = testInfo.outputPath('extension-user-data-dir'); await use({ userDataDir, - launch: async () => { + launch: async (mode?: 'disable-extension') => { browserContext = await chromium.launchPersistentContext(userDataDir, { channel: mcpBrowser, // Opening the browser singleton only works in headed. headless: false, // Automation disables singleton browser process behavior, which is necessary for the extension. ignoreDefaultArgs: ['--enable-automation'], - args: [ + args: mode === 'disable-extension' ? [] : [ `--disable-extensions-except=${pathToExtension}`, `--load-extension=${pathToExtension}`, ], @@ -63,9 +65,7 @@ const test = base.extend<{ browserWithExtension: BrowserWithExtension }>({ }, }); -test('navigate with extension', async ({ browserWithExtension, startClient, server }) => { - const browserContext = await browserWithExtension.launch(); - +async function startAndCallConnectTool(browserWithExtension: BrowserWithExtension, startClient: StartClient): Promise { const { client } = await startClient({ args: [`--connect-tool`], config: { @@ -84,69 +84,104 @@ test('navigate with extension', async ({ browserWithExtension, startClient, serv result: 'Successfully changed connection method.', }); - 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 selectorPage = await confirmationPagePromise; - await selectorPage.locator('.tab-item', { hasText: 'Playwright MCP Extension' }).getByRole('button', { name: 'Connect' }).click(); - - expect(await navigateResponse).toHaveResponse({ - pageState: expect.stringContaining(`- generic [active] [ref=e1]: Hello, world!`), - }); -}); - -test('snapshot of an existing page', async ({ browserWithExtension, startClient, server }) => { - const browserContext = await browserWithExtension.launch(); - - const page = await browserContext.newPage(); - await page.goto(server.HELLO_WORLD); - - // Another empty page. - await browserContext.newPage(); - expect(browserContext.pages()).toHaveLength(3); + return client; +} +async function startWithExtensionFlag(browserWithExtension: BrowserWithExtension, startClient: StartClient): Promise { const { client } = await startClient({ - args: [`--connect-tool`], + args: [`--extension`], config: { browser: { userDataDir: browserWithExtension.userDataDir, } }, }); + return client; +} - expect(await client.callTool({ - name: 'browser_connect', - arguments: { - name: 'extension' - } - })).toHaveResponse({ - result: 'Successfully changed connection method.', - }); - expect(browserContext.pages()).toHaveLength(3); +for (const [mode, startClientMethod] of [ + ['connect-tool', startAndCallConnectTool], + ['extension-flag', startWithExtensionFlag], +] as const) { - const confirmationPagePromise = browserContext.waitForEvent('page', page => { - return page.url().startsWith('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html'); + test(`navigate with extension (${mode})`, async ({ browserWithExtension, startClient, server }) => { + 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 selectorPage = await confirmationPagePromise; + await selectorPage.locator('.tab-item', { hasText: 'Playwright MCP Extension' }).getByRole('button', { name: 'Connect' }).click(); + + expect(await navigateResponse).toHaveResponse({ + pageState: expect.stringContaining(`- generic [active] [ref=e1]: Hello, world!`), + }); }); - const navigateResponse = client.callTool({ - name: 'browser_snapshot', - arguments: { }, + test(`snapshot of an existing page (${mode})`, async ({ browserWithExtension, startClient, server }) => { + const browserContext = await browserWithExtension.launch(); + + const page = await browserContext.newPage(); + await page.goto(server.HELLO_WORLD); + + // Another empty page. + await browserContext.newPage(); + expect(browserContext.pages()).toHaveLength(3); + + const client = await startClientMethod(browserWithExtension, startClient); + expect(browserContext.pages()).toHaveLength(3); + + const confirmationPagePromise = browserContext.waitForEvent('page', page => { + return page.url().startsWith('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html'); + }); + + const navigateResponse = client.callTool({ + name: 'browser_snapshot', + arguments: { }, + }); + + const selectorPage = await confirmationPagePromise; + expect(browserContext.pages()).toHaveLength(4); + + await selectorPage.locator('.tab-item', { hasText: 'Title' }).getByRole('button', { name: 'Connect' }).click(); + + expect(await navigateResponse).toHaveResponse({ + pageState: expect.stringContaining(`- generic [active] [ref=e1]: Hello, world!`), + }); + + expect(browserContext.pages()).toHaveLength(4); }); - const selectorPage = await confirmationPagePromise; - expect(browserContext.pages()).toHaveLength(4); + test(`extension not installed timeout (${mode})`, async ({ browserWithExtension, startClient, server }) => { + process.env.PWMCP_TEST_CONNECTION_TIMEOUT = '100'; - await selectorPage.locator('.tab-item', { hasText: 'Title' }).getByRole('button', { name: 'Connect' }).click(); + const browserContext = await browserWithExtension.launch(); - expect(await navigateResponse).toHaveResponse({ - pageState: expect.stringContaining(`- generic [active] [ref=e1]: Hello, world!`), + const client = await startClientMethod(browserWithExtension, startClient); + + const confirmationPagePromise = browserContext.waitForEvent('page', page => { + return page.url().startsWith('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html'); + }); + + expect(await client.callTool({ + name: 'browser_navigate', + arguments: { url: server.HELLO_WORLD }, + })).toHaveResponse({ + result: expect.stringContaining('Extension connection timeout. Make sure the "Playwright MCP Bridge" extension is installed.'), + isError: true, + }); + + await confirmationPagePromise; + + process.env.PWMCP_TEST_CONNECTION_TIMEOUT = undefined; }); - expect(browserContext.pages()).toHaveLength(4); -}); +} diff --git a/src/extension/cdpRelay.ts b/src/extension/cdpRelay.ts index 53e639e..c042b0f 100644 --- a/src/extension/cdpRelay.ts +++ b/src/extension/cdpRelay.ts @@ -100,6 +100,9 @@ export class CDPRelayServer { debugLogger('Waiting for incoming extension connection'); await Promise.race([ this._extensionConnectionPromise, + new Promise((_, reject) => setTimeout(() => { + reject(new Error(`Extension connection timeout. Make sure the "Playwright MCP Bridge" extension is installed. See https://github.com/microsoft/playwright-mcp/blob/main/extension/README.md for installation instructions.`)); + }, process.env.PWMCP_TEST_CONNECTION_TIMEOUT ? parseInt(process.env.PWMCP_TEST_CONNECTION_TIMEOUT, 10) : 5_000)), new Promise((_, reject) => abortSignal.addEventListener('abort', reject)) ]); debugLogger('Extension connection established'); diff --git a/tests/fixtures.ts b/tests/fixtures.ts index ef686b8..966dc9d 100644 --- a/tests/fixtures.ts +++ b/tests/fixtures.ts @@ -40,15 +40,18 @@ type CDPServer = { start: () => Promise; }; +export type StartClient = (options?: { + clientName?: string, + args?: string[], + config?: Config, + roots?: { name: string, uri: string }[], + rootsResponseDelay?: number, +}) => Promise<{ client: Client, stderr: () => string }>; + + type TestFixtures = { client: Client; - startClient: (options?: { - clientName?: string, - args?: string[], - config?: Config, - roots?: { name: string, uri: string }[], - rootsResponseDelay?: number, - }) => Promise<{ client: Client, stderr: () => string }>; + startClient: StartClient; wsEndpoint: string; cdpServer: CDPServer; server: TestServer; From 4370f2cdf2a7492e145fdfb306852af08146c239 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Fri, 15 Aug 2025 10:19:52 -0700 Subject: [PATCH 05/15] chore: try macos15 runners (#892) --- .github/workflows/ci.yml | 2 +- playwright.config.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dca6c39..b695863 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,7 +28,7 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, macos-latest, windows-latest] + os: [ubuntu-latest, macos-15, windows-latest] runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 diff --git a/playwright.config.ts b/playwright.config.ts index 792c3e3..8486de1 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -26,7 +26,6 @@ export default defineConfig({ reporter: 'list', projects: [ { name: 'chrome' }, - { name: 'msedge', use: { mcpBrowser: 'msedge' } }, { name: 'chromium', use: { mcpBrowser: 'chromium' } }, ...process.env.MCP_IN_DOCKER ? [{ name: 'chromium-docker', @@ -38,5 +37,6 @@ export default defineConfig({ }] : [], { name: 'firefox', use: { mcpBrowser: 'firefox' } }, { name: 'webkit', use: { mcpBrowser: 'webkit' } }, + ... process.platform === 'win32' ? [{ name: 'msedge', use: { mcpBrowser: 'msedge' } }] : [], ], }); From 92554abfd1e5e007b55ab17064a43dd7802afa7a Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Fri, 15 Aug 2025 10:25:46 -0700 Subject: [PATCH 06/15] devops: extension publishing job (#888) --- .github/workflows/publish.yml | 28 ++++++++++++++++++++++++++++ extension/manifest.json | 2 +- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index f581c2c..ed23287 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -68,3 +68,31 @@ jobs: for tag in $(echo ${{ steps.build-push.outputs.metadata['image.name'] }} | tr ',' '\n'); do attach_eol_manifest $tag done + + package-extension: + runs-on: ubuntu-latest + permissions: + contents: write # Needed to upload release assets + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: 'npm' + - name: Install extension dependencies + working-directory: ./extension + run: npm ci + - name: Build extension + working-directory: ./extension + run: npm run build + - name: Package extension + working-directory: ./extension + run: | + cd dist + zip -r ../playwright-mcp-extension-${{ github.event.release.tag_name }}.zip . + cd .. + - name: Upload extension to release + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + gh release upload ${{github.event.release.tag_name}} ./extension/playwright-mcp-extension-${{ github.event.release.tag_name }}.zip diff --git a/extension/manifest.json b/extension/manifest.json index d39c7b0..fdcf584 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Playwright MCP Bridge", - "version": "1.0.0", + "version": "0.0.33", "description": "Share browser tabs with Playwright MCP server", "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA9nMS2b0WCohjVHPGb8D9qAdkbIngDqoAjTeSccHJijgcONejge+OJxOQOMLu7b0ovt1c9BiEJa5JcpM+EHFVGL1vluBxK71zmBy1m2f9vZF3HG0LSCp7YRkum9rAIEthDwbkxx6XTvpmAY5rjFa/NON6b9Hlbo+8peUSkoOK7HTwYnnI36asZ9eUTiveIf+DMPLojW2UX33vDWG2UKvMVDewzclb4+uLxAYshY7Mx8we/b44xu+Anb/EBLKjOPk9Yh541xJ5Ozc8EiP/5yxOp9c/lRiYUHaRW+4r0HKZyFt0eZ52ti2iM4Nfk7jRXR7an3JPsUIf5deC/1cVM/+1ZQIDAQAB", From 91d5d24cab55669e4e03a1415f426b689777cfed Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Fri, 15 Aug 2025 11:23:59 -0700 Subject: [PATCH 07/15] chore: handle list roots in the server, with timeout (#898) --- src/browserServerBackend.ts | 8 +-- src/mcp/proxyBackend.ts | 17 ++---- src/mcp/server.ts | 23 ++++++-- tests/roots.spec.ts | 114 +++++++++++++++++------------------- 4 files changed, 78 insertions(+), 84 deletions(-) diff --git a/src/browserServerBackend.ts b/src/browserServerBackend.ts index e71a15d..1170b31 100644 --- a/src/browserServerBackend.ts +++ b/src/browserServerBackend.ts @@ -45,11 +45,9 @@ export class BrowserServerBackend implements ServerBackend { this._tools = filteredTools(config); } - async initialize(server: mcpServer.Server): Promise { - const capabilities = server.getClientCapabilities(); + async initialize(clientVersion: mcpServer.ClientVersion, roots: mcpServer.Root[]): Promise { let rootPath: string | undefined; - if (capabilities?.roots) { - const { roots } = await server.listRoots(); + if (roots.length > 0) { const firstRootUri = roots[0]?.uri; const url = firstRootUri ? new URL(firstRootUri) : undefined; rootPath = url ? fileURLToPath(url) : undefined; @@ -60,7 +58,7 @@ export class BrowserServerBackend implements ServerBackend { config: this._config, browserContextFactory: this._browserContextFactory, sessionLog: this._sessionLog, - clientInfo: { ...server.getClientVersion(), rootPath }, + clientInfo: { ...clientVersion, rootPath }, }); } diff --git a/src/mcp/proxyBackend.ts b/src/mcp/proxyBackend.ts index e4083b5..c639fd5 100644 --- a/src/mcp/proxyBackend.ts +++ b/src/mcp/proxyBackend.ts @@ -23,10 +23,9 @@ import { logUnhandledError } from '../utils/log.js'; import { packageJSON } from '../utils/package.js'; -import type { Server } from '@modelcontextprotocol/sdk/server/index.js'; -import type { ServerBackend } from './server.js'; +import type { ServerBackend, ClientVersion, Root } from './server.js'; import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; -import type { Root, Tool, CallToolResult, CallToolRequest } from '@modelcontextprotocol/sdk/types.js'; +import type { Tool, CallToolResult, CallToolRequest } from '@modelcontextprotocol/sdk/types.js'; export type MCPProvider = { name: string; @@ -48,14 +47,8 @@ export class ProxyBackend implements ServerBackend { 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; - } - + async initialize(clientVersion: ClientVersion, roots: Root[]): Promise { + this._roots = roots; await this._setCurrentClient(this._mcpProviders[0]); } @@ -136,5 +129,3 @@ export class ProxyBackend implements ServerBackend { this._currentClient = client; } } - -const clientsWithRoots = ['Visual Studio Code', 'Visual Studio Code - Insiders']; diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 3ac389e..80c1461 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -20,17 +20,18 @@ import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprot import { ManualPromise } from '../utils/manualPromise.js'; import { logUnhandledError } from '../utils/log.js'; -import type { Tool, CallToolResult, CallToolRequest } from '@modelcontextprotocol/sdk/types.js'; +import type { Tool, CallToolResult, CallToolRequest, Root } from '@modelcontextprotocol/sdk/types.js'; import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; export type { Server } from '@modelcontextprotocol/sdk/server/index.js'; -export type { Tool, CallToolResult, CallToolRequest } from '@modelcontextprotocol/sdk/types.js'; +export type { Tool, CallToolResult, CallToolRequest, Root } from '@modelcontextprotocol/sdk/types.js'; const serverDebug = debug('pw:mcp:server'); +export type ClientVersion = { name: string, version: string }; export interface ServerBackend { name: string; version: string; - initialize?(server: Server): Promise; + initialize?(clientVersion: ClientVersion, roots: Root[]): Promise; listTools(): Promise; callTool(name: string, args: CallToolRequest['params']['arguments']): Promise; serverClosed?(): void; @@ -78,8 +79,20 @@ export function createServer(backend: ServerBackend, runHeartbeat: boolean): Ser }; } }); - addServerListener(server, 'initialized', () => { - backend.initialize?.(server).then(() => initializedPromise.resolve()).catch(logUnhandledError); + addServerListener(server, 'initialized', async () => { + try { + const capabilities = server.getClientCapabilities(); + let clientRoots: Root[] = []; + if (capabilities?.roots) { + const { roots } = await server.listRoots(undefined, { timeout: 2_000 }).catch(() => ({ roots: [] })); + clientRoots = roots; + } + const clientVersion = server.getClientVersion() ?? { name: 'unknown', version: 'unknown' }; + await backend.initialize?.(clientVersion, clientRoots); + initializedPromise.resolve(); + } catch (e) { + logUnhandledError(e); + } }); addServerListener(server, 'close', () => backend.serverClosed?.()); return server; diff --git a/tests/roots.spec.ts b/tests/roots.spec.ts index a94191e..ffcd8b5 100644 --- a/tests/roots.spec.ts +++ b/tests/roots.spec.ts @@ -23,65 +23,57 @@ import { createHash } from '../src/utils/guid.js'; const p = process.platform === 'win32' ? 'c:\\non\\existent\\folder' : '/non/existent/folder'; -for (const mode of ['default', 'proxy']) { - const extraArgs = mode === 'proxy' ? ['--connect-tool'] : []; - - test.describe(`${mode} mode`, () => { - test('should use separate user data by root path', async ({ startClient, server }, testInfo) => { - const { client } = await startClient({ - args: extraArgs, - clientName: 'Visual Studio Code', // Simulate VS Code client, roots only work with it - roots: [ - { - name: 'test', - uri: 'file://' + p.replace(/\\/g, '/'), - } - ], - }); - - await client.callTool({ - name: 'browser_navigate', - arguments: { url: server.HELLO_WORLD }, - }); - - const hash = createHash(p); - const [file] = await fs.promises.readdir(testInfo.outputPath('ms-playwright')); - expect(file).toContain(hash); - }); - - - test('check that trace is saved in workspace', async ({ startClient, server }, testInfo) => { - const rootPath = testInfo.outputPath('workspace'); - const { client } = await startClient({ - args: ['--save-trace', ...extraArgs], - clientName: 'Visual Studio Code - Insiders', // Simulate VS Code client, roots only work with it - roots: [ - { - name: 'workspace', - uri: pathToFileURL(rootPath).toString(), - }, - ], - }); - - expect(await client.callTool({ - name: 'browser_navigate', - arguments: { url: server.HELLO_WORLD }, - })).toHaveResponse({ - code: expect.stringContaining(`page.goto('http://localhost`), - }); - - const [file] = await fs.promises.readdir(path.join(rootPath, '.playwright-mcp')); - expect(file).toContain('traces'); - }); - - test('should list all tools when listRoots is slow', async ({ startClient, server }, testInfo) => { - const { client } = await startClient({ - clientName: 'Visual Studio Code', // Simulate VS Code client, roots only work with it - roots: [], - rootsResponseDelay: 1000, - }); - const tools = await client.listTools(); - expect(tools.tools.length).toBeGreaterThan(20); - }); +test('should use separate user data by root path', async ({ startClient, server }, testInfo) => { + const { client } = await startClient({ + clientName: 'Visual Studio Code', + roots: [ + { + name: 'test', + uri: 'file://' + p.replace(/\\/g, '/'), + } + ], }); -} + + await client.callTool({ + name: 'browser_navigate', + arguments: { url: server.HELLO_WORLD }, + }); + + const hash = createHash(p); + const [file] = await fs.promises.readdir(testInfo.outputPath('ms-playwright')); + expect(file).toContain(hash); +}); + +test('check that trace is saved in workspace', async ({ startClient, server }, testInfo) => { + const rootPath = testInfo.outputPath('workspace'); + const { client } = await startClient({ + args: ['--save-trace'], + clientName: 'My client', + roots: [ + { + name: 'workspace', + uri: pathToFileURL(rootPath).toString(), + }, + ], + }); + + expect(await client.callTool({ + name: 'browser_navigate', + arguments: { url: server.HELLO_WORLD }, + })).toHaveResponse({ + code: expect.stringContaining(`page.goto('http://localhost`), + }); + + const [file] = await fs.promises.readdir(path.join(rootPath, '.playwright-mcp')); + expect(file).toContain('traces'); +}); + +test('should list all tools when listRoots is slow', async ({ startClient, server }, testInfo) => { + const { client } = await startClient({ + clientName: 'Another custom client', + roots: [], + rootsResponseDelay: 1000, + }); + const tools = await client.listTools(); + expect(tools.tools.length).toBeGreaterThan(20); +}); From c559243ef62956ee48512d482b88b18f18f0ba5d Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Fri, 15 Aug 2025 13:44:17 -0700 Subject: [PATCH 08/15] chore(extension): connected badge while loading (#899) --- extension/src/background.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extension/src/background.ts b/extension/src/background.ts index b54bb69..8c6b49e 100644 --- a/extension/src/background.ts +++ b/extension/src/background.ts @@ -193,7 +193,7 @@ class TabShareExtension { } private _onTabUpdated(tabId: number, changeInfo: chrome.tabs.TabChangeInfo, tab: chrome.tabs.Tab) { - if (changeInfo.status === 'complete' && this._connectedTabId === tabId) + if (this._connectedTabId === tabId) void this._setConnectedTabId(tabId); } From 25f15e7f5e4c57c96fe3f389c68c4de191ddf3be Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Fri, 15 Aug 2025 15:28:59 -0700 Subject: [PATCH 09/15] devops: set-version.js script (#902) --- utils/set-version.js | 60 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 utils/set-version.js diff --git a/utils/set-version.js b/utils/set-version.js new file mode 100644 index 0000000..d671be2 --- /dev/null +++ b/utils/set-version.js @@ -0,0 +1,60 @@ +#!/usr/bin/env node +/** + * Copyright 2019 Google Inc. All rights reserved. + * Modifications 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. + */ + +// @ts-check + +import fs from 'fs'; +import path from 'path'; +import child_process from 'child_process'; +import { argv } from 'process'; + +const __dirname = path.dirname(new URL(import.meta.url).pathname); + +const readJSON = async (filePath) => JSON.parse(await fs.promises.readFile(filePath, 'utf8')); +const writeJSON = async (filePath, json) => { + await fs.promises.writeFile(filePath, JSON.stringify(json, null, 2) + '\n'); +} + +async function updatePackageJSON(dir, version) { + const packageJSONPath = path.join(dir, 'package.json'); + const packageJSON = await readJSON(packageJSONPath); + console.log(`Updating ${packageJSONPath} to version ${version}`); + packageJSON.version = version; + await writeJSON(packageJSONPath, packageJSON); + + // Run npm i to update package-lock.json + child_process.execSync('npm i', { + cwd: dir + }); +} + +async function setVersion(version) { + if (version.startsWith('v')) + throw new Error('version must not start with "v"'); + + const packageRoot = path.join(__dirname, '..'); + await updatePackageJSON(packageRoot, version) + await updatePackageJSON(path.join(packageRoot, 'extension'), version) +} + +if (argv.length !== 3) { + console.error('Usage: set-version '); + process.exit(1); +} + +setVersion(argv[2]); \ No newline at end of file From 1d1db1e287a6838c9de6b215aecc24ca37b866e6 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Fri, 15 Aug 2025 15:36:12 -0700 Subject: [PATCH 10/15] chore: fix copyright (#903) --- utils/set-version.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/utils/set-version.js b/utils/set-version.js index d671be2..f2daaa8 100644 --- a/utils/set-version.js +++ b/utils/set-version.js @@ -1,13 +1,12 @@ #!/usr/bin/env node /** - * Copyright 2019 Google Inc. All rights reserved. - * Modifications copyright (c) Microsoft Corporation. + * 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 + * 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, From 1efd3b55e55b7b14e5e8c636f49b34f79c4f5f92 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Fri, 15 Aug 2025 16:10:49 -0700 Subject: [PATCH 11/15] devops: update extension manifest version (#904) --- utils/set-version.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/utils/set-version.js b/utils/set-version.js index f2daaa8..b0d7b34 100644 --- a/utils/set-version.js +++ b/utils/set-version.js @@ -42,6 +42,14 @@ async function updatePackageJSON(dir, version) { }); } +async function updateExtensionManifest(dir, version) { + const manifestPath = path.join(dir, 'manifest.json'); + const manifest = await readJSON(manifestPath); + console.log(`Updating ${manifestPath} to version ${version}`); + manifest.version = version; + await writeJSON(manifestPath, manifest); +} + async function setVersion(version) { if (version.startsWith('v')) throw new Error('version must not start with "v"'); @@ -49,6 +57,7 @@ async function setVersion(version) { const packageRoot = path.join(__dirname, '..'); await updatePackageJSON(packageRoot, version) await updatePackageJSON(path.join(packageRoot, 'extension'), version) + await updateExtensionManifest(path.join(packageRoot, 'extension'), version) } if (argv.length !== 3) { From d5d810f8966731744627cba0ac3d49be4d0c6fe5 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Fri, 15 Aug 2025 17:38:58 -0700 Subject: [PATCH 12/15] chore: mark 0.0.34 (#901) --- extension/manifest.json | 2 +- extension/package-lock.json | 4 ++-- extension/package.json | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/extension/manifest.json b/extension/manifest.json index fdcf584..e83a841 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Playwright MCP Bridge", - "version": "0.0.33", + "version": "0.0.34", "description": "Share browser tabs with Playwright MCP server", "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA9nMS2b0WCohjVHPGb8D9qAdkbIngDqoAjTeSccHJijgcONejge+OJxOQOMLu7b0ovt1c9BiEJa5JcpM+EHFVGL1vluBxK71zmBy1m2f9vZF3HG0LSCp7YRkum9rAIEthDwbkxx6XTvpmAY5rjFa/NON6b9Hlbo+8peUSkoOK7HTwYnnI36asZ9eUTiveIf+DMPLojW2UX33vDWG2UKvMVDewzclb4+uLxAYshY7Mx8we/b44xu+Anb/EBLKjOPk9Yh541xJ5Ozc8EiP/5yxOp9c/lRiYUHaRW+4r0HKZyFt0eZ52ti2iM4Nfk7jRXR7an3JPsUIf5deC/1cVM/+1ZQIDAQAB", diff --git a/extension/package-lock.json b/extension/package-lock.json index d63af82..443c468 100644 --- a/extension/package-lock.json +++ b/extension/package-lock.json @@ -1,12 +1,12 @@ { "name": "@playwright/mcp-extension", - "version": "0.0.32", + "version": "0.0.34", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@playwright/mcp-extension", - "version": "0.0.32", + "version": "0.0.34", "license": "Apache-2.0", "devDependencies": { "@types/chrome": "^0.0.315", diff --git a/extension/package.json b/extension/package.json index f2bc50b..1e3ac76 100644 --- a/extension/package.json +++ b/extension/package.json @@ -1,6 +1,6 @@ { "name": "@playwright/mcp-extension", - "version": "0.0.32", + "version": "0.0.34", "description": "Playwright MCP Browser Extension", "type": "module", "private": true, diff --git a/package-lock.json b/package-lock.json index 3f31246..1e8a01e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@playwright/mcp", - "version": "0.0.33", + "version": "0.0.34", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@playwright/mcp", - "version": "0.0.33", + "version": "0.0.34", "license": "Apache-2.0", "dependencies": { "@modelcontextprotocol/sdk": "^1.16.0", diff --git a/package.json b/package.json index 0ed3bb1..6429094 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@playwright/mcp", - "version": "0.0.33", + "version": "0.0.34", "description": "Playwright Tools for MCP", "type": "module", "repository": { From 865eac2fee761535722c2a20734304bdda0518cf Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Sat, 16 Aug 2025 19:39:49 -0700 Subject: [PATCH 13/15] chore: do not wrap mcp in proxy by default, drive-by deps fix (#909) --- src/browserContextFactory.ts | 24 +++---- src/browserServerBackend.ts | 4 -- src/extension/cdpRelay.ts | 2 +- src/extension/extensionContextFactory.ts | 5 +- src/index.ts | 3 +- src/loopTools/context.ts | 5 +- src/loopTools/main.ts | 12 ++-- src/mcp/DEPS.list | 1 - src/mcp/README.md | 2 +- src/mcp/{transport.ts => http.ts} | 85 ++++++++++++------------ src/mcp/proxyBackend.ts | 13 ++-- src/mcp/server.ts | 61 +++++++++++++---- src/program.ts | 67 +++++++++++-------- src/utils/httpServer.ts | 44 ------------ 14 files changed, 161 insertions(+), 167 deletions(-) rename src/mcp/{transport.ts => http.ts} (75%) delete mode 100644 src/utils/httpServer.ts diff --git a/src/browserContextFactory.ts b/src/browserContextFactory.ts index ecc835d..4d39562 100644 --- a/src/browserContextFactory.ts +++ b/src/browserContextFactory.ts @@ -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 }>; } class BaseContextFactory implements BrowserContextFactory { - readonly name: string; - readonly description: string; readonly config: FullConfig; + private _logName: string; protected _browserPromise: Promise | 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 { 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 }> { - 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 { @@ -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 { @@ -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 { diff --git a/src/browserServerBackend.ts b/src/browserServerBackend.ts index 1170b31..5286e03 100644 --- a/src/browserServerBackend.ts +++ b/src/browserServerBackend.ts @@ -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; diff --git a/src/extension/cdpRelay.ts b/src/extension/cdpRelay.ts index c042b0f..2c4287b 100644 --- a/src/extension/cdpRelay.ts +++ b/src/extension/cdpRelay.ts @@ -26,7 +26,7 @@ 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 type websocket from 'ws'; diff --git a/src/extension/extensionContextFactory.ts b/src/extension/extensionContextFactory.ts index 7bdfaa0..0346419 100644 --- a/src/extension/extensionContextFactory.ts +++ b/src/extension/extensionContextFactory.ts @@ -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; diff --git a/src/index.ts b/src/index.ts index 86a9280..6809cd9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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): 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('Playwright', packageJSON.version, new BrowserServerBackend(config, factory), false); } class SimpleBrowserContextFactory implements BrowserContextFactory { diff --git a/src/loopTools/context.ts b/src/loopTools/context.ts index 777ba61..dadb6b9 100644 --- a/src/loopTools/context.ts +++ b/src/loopTools/context.ts @@ -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); diff --git a/src/loopTools/main.ts b/src/loopTools/main.ts index a8ea803..2d017ba 100644 --- a/src/loopTools/main.ts +++ b/src/loopTools/main.ts @@ -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[] = [perform, snapshot]; diff --git a/src/mcp/DEPS.list b/src/mcp/DEPS.list index 5870e2d..e43dcb5 100644 --- a/src/mcp/DEPS.list +++ b/src/mcp/DEPS.list @@ -1,2 +1 @@ [*] -../utils/ diff --git a/src/mcp/README.md b/src/mcp/README.md index 64edb62..b8b280e 100644 --- a/src/mcp/README.md +++ b/src/mcp/README.md @@ -1 +1 @@ -- Generic MCP utils, no dependencies on Playwright here. +- Generic MCP utils, no dependencies on anything. diff --git a/src/mcp/transport.ts b/src/mcp/http.ts similarity index 75% rename from src/mcp/transport.ts rename to src/mcp/http.ts index 06965b7..6890ddc 100644 --- a/src/mcp/transport.ts +++ b/src/mcp/http.ts @@ -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 { + const { host, port } = config; + const httpServer = http.createServer(); + await new Promise((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) { 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); -} diff --git a/src/mcp/proxyBackend.ts b/src/mcp/proxyBackend.ts index c639fd5..da186c4 100644 --- a/src/mcp/proxyBackend.ts +++ b/src/mcp/proxyBackend.ts @@ -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; }; -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 { @@ -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, diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 80c1461..e9b4944 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -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; listTools(): Promise; callTool(name: string, args: CallToolRequest['params']['arguments']): Promise; 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(); - const server = new Server({ name: backend.name, version: backend.version }, { +export async function wrapInProcess(backend: ServerBackend): Promise { + 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(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); +} diff --git a/src/program.ts b/src/program.ts index bc313c2..210ae10 100644 --- a/src/program.ts +++ b/src/program.ts @@ -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,8 @@ 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 type { MCPProvider } from './mcp/proxyBackend.js'; -import type { FullConfig } from './config.js'; -import type { BrowserContextFactory } from './browserContextFactory.js'; program .version('Version ' + packageJSON.version) @@ -71,12 +67,19 @@ 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; } @@ -85,11 +88,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 +136,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); diff --git a/src/utils/httpServer.ts b/src/utils/httpServer.ts deleted file mode 100644 index 3102bd5..0000000 --- a/src/utils/httpServer.ts +++ /dev/null @@ -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 { - const { host, port } = config; - const httpServer = http.createServer(); - await new Promise((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}`; -} From e664e0460c7a121e0eb4baa3bf265caad42d45a1 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Mon, 18 Aug 2025 13:28:13 -0700 Subject: [PATCH 14/15] chore: check extension version on connect (#907) --- extension/src/background.ts | 14 ++++-- extension/src/ui/connect.tsx | 28 ++++++------ extension/tests/extension.spec.ts | 75 +++++++++++++++++++++++++++---- src/extension/cdpRelay.ts | 9 +++- 4 files changed, 98 insertions(+), 28 deletions(-) diff --git a/extension/src/background.ts b/extension/src/background.ts index 8c6b49e..67b81b8 100644 --- a/extension/src/background.ts +++ b/extension/src/background.ts @@ -19,6 +19,7 @@ import { RelayConnection, debugLog } from './relayConnection.js'; type PageMessage = { type: 'connectToMCPRelay'; mcpRelayUrl: string; + pwMcpVersion: string | null; } | { type: 'getTabs'; } | { @@ -49,7 +50,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, message.pwMcpVersion).then( () => sendResponse({ success: true }), (error: any) => sendResponse({ success: false, error: error.message })); return true; @@ -77,7 +78,11 @@ class TabShareExtension { return false; } - private async _connectToRelay(selectorTabId: number, mcpRelayUrl: string): Promise { + private async _connectToRelay(selectorTabId: number, mcpRelayUrl: string, pwMcpVersion: string | null): Promise { + const version = chrome.runtime.getManifest().version; + if (pwMcpVersion !== version) + throw new Error(`Incompatible Playwright MCP version: ${pwMcpVersion} (extension version: ${version}). Please install the latest version of the extension.`); + try { debugLog(`Connecting to relay at ${mcpRelayUrl}`); const socket = new WebSocket(mcpRelayUrl); @@ -96,8 +101,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); } } diff --git a/extension/src/ui/connect.tsx b/extension/src/ui/connect.tsx index c9a8180..956a4be 100644 --- a/extension/src/ui/connect.tsx +++ b/extension/src/ui/connect.tsx @@ -54,16 +54,22 @@ const ConnectApp: React.FC = () => { return; } - void connectToMCPRelay(relayUrl); + void connectToMCPRelay(relayUrl, params.get('pwMcpVersion')); void loadTabs(); }, []); - 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 }); + const handleReject = useCallback((message: string) => { + setShowButtons(false); + setShowTabList(false); + setStatus({ type: 'error', message }); }, []); + const connectToMCPRelay = useCallback(async (mcpRelayUrl: string, pwMcpVersion: string | null) => { + const response = await chrome.runtime.sendMessage({ type: 'connectToMCPRelay', mcpRelayUrl, pwMcpVersion }); + if (!response.success) + handleReject(response.error); + }, [handleReject]); + const loadTabs = useCallback(async () => { const response = await chrome.runtime.sendMessage({ type: 'getTabs' }); if (response.success) @@ -100,22 +106,16 @@ 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 (
@@ -124,7 +124,7 @@ const ConnectApp: React.FC = () => {
{showButtons && ( - )} diff --git a/extension/tests/extension.spec.ts b/extension/tests/extension.spec.ts index 6ec47bf..a79ce73 100644 --- a/extension/tests/extension.spec.ts +++ b/extension/tests/extension.spec.ts @@ -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; }; -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({ + 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 { @@ -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.`); + + expect(await navigateResponse).toHaveResponse({ + result: expect.stringContaining('Extension connection timeout.'), + isError: true, + }); }); } diff --git a/src/extension/cdpRelay.ts b/src/extension/cdpRelay.ts index 2c4287b..c06a94b 100644 --- a/src/extension/cdpRelay.ts +++ b/src/extension/cdpRelay.ts @@ -29,6 +29,8 @@ import { WebSocket, WebSocketServer } from 'ws'; 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) From f6862a39c39cc9782dc875e91edccade17e8cdd8 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Tue, 19 Aug 2025 17:39:58 -0700 Subject: [PATCH 15/15] chore: check version in page, link to instructions (#918) --- extension/src/background.ts | 9 ++--- extension/src/ui/connect.tsx | 55 ++++++++++++++++++++++++++----- extension/tests/extension.spec.ts | 2 +- 3 files changed, 50 insertions(+), 16 deletions(-) diff --git a/extension/src/background.ts b/extension/src/background.ts index 67b81b8..34623fa 100644 --- a/extension/src/background.ts +++ b/extension/src/background.ts @@ -19,7 +19,6 @@ import { RelayConnection, debugLog } from './relayConnection.js'; type PageMessage = { type: 'connectToMCPRelay'; mcpRelayUrl: string; - pwMcpVersion: string | null; } | { type: 'getTabs'; } | { @@ -50,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, message.pwMcpVersion).then( + this._connectToRelay(sender.tab!.id!, message.mcpRelayUrl).then( () => sendResponse({ success: true }), (error: any) => sendResponse({ success: false, error: error.message })); return true; @@ -78,11 +77,7 @@ class TabShareExtension { return false; } - private async _connectToRelay(selectorTabId: number, mcpRelayUrl: string, pwMcpVersion: string | null): Promise { - const version = chrome.runtime.getManifest().version; - if (pwMcpVersion !== version) - throw new Error(`Incompatible Playwright MCP version: ${pwMcpVersion} (extension version: ${version}). Please install the latest version of the extension.`); - + private async _connectToRelay(selectorTabId: number, mcpRelayUrl: string): Promise { try { debugLog(`Connecting to relay at ${mcpRelayUrl}`); const socket = new WebSocket(mcpRelayUrl); diff --git a/extension/src/ui/connect.tsx b/extension/src/ui/connect.tsx index 956a4be..e8c7812 100644 --- a/extension/src/ui/connect.tsx +++ b/extension/src/ui/connect.tsx @@ -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([]); - const [status, setStatus] = useState<{ type: StatusType; message: string } | null>(null); + const [status, setStatus] = useState(null); const [showButtons, setShowButtons] = useState(true); const [showTabList, setShowTabList] = useState(true); const [clientInfo, setClientInfo] = useState('unknown'); @@ -54,7 +58,22 @@ const ConnectApp: React.FC = () => { return; } - void connectToMCPRelay(relayUrl, params.get('pwMcpVersion')); + 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(); }, []); @@ -64,8 +83,9 @@ const ConnectApp: React.FC = () => { setStatus({ type: 'error', message }); }, []); - const connectToMCPRelay = useCallback(async (mcpRelayUrl: string, pwMcpVersion: string | null) => { - const response = await chrome.runtime.sendMessage({ type: 'connectToMCPRelay', mcpRelayUrl, pwMcpVersion }); + const connectToMCPRelay = useCallback(async (mcpRelayUrl: string) => { + + const response = await chrome.runtime.sendMessage({ type: 'connectToMCPRelay', mcpRelayUrl }); if (!response.success) handleReject(response.error); }, [handleReject]); @@ -122,7 +142,7 @@ const ConnectApp: React.FC = () => {
{status && (
- + {showButtons && (