diff --git a/apps/playwright-browser-tunnel/docs/storage-state-plan.md b/apps/playwright-browser-tunnel/docs/storage-state-plan.md new file mode 100644 index 0000000000..07c93c904f --- /dev/null +++ b/apps/playwright-browser-tunnel/docs/storage-state-plan.md @@ -0,0 +1,251 @@ +# Plan: Enable Storage State Reading in PlaywrightBrowserTunnel + +## Background + +Currently, `PlaywrightTunnel` launches a bare browser **server** via `browserType.launchServer(options)` and proxies WebSocket traffic to the test runner (codespace). The test runner client (via `playwright[browser].connect(wsEndpoint)`) then creates its own contexts. There is no mechanism to inject a pre-saved Playwright **storage state** (cookies, localStorage, sessionStorage) into the browser context. + +Playwright's `storageState` is a property of `browser.newContext(options)`, not of `launchServer()`. In `remoteEndpoint` mode the codespace side cannot correctly create a context with storage state. Therefore, this must be done **on the browser host** inside `PlaywrightBrowserTunnel`, where the actual browser process runs. + +**Scope:** Only `PlaywrightBrowserTunnel.ts` is modified. No changes to TunneledBrowser, TunneledBrowserConnection, or the handshake protocol. + +--- + +## Approach + +After `launchServer()` creates the browser process, the tunnel will use Playwright's `connect()` API to obtain a local `Browser` handle to the same process, then call `browser.newContext({ storageState })` to pre-seed a context with cookies/localStorage/sessionStorage. This context lives in the browser process and is available to the remote client that connects through the tunnel. + +--- + +## Changes (all in `PlaywrightBrowserTunnel.ts`) + +### 1. Import `Browser` and `BrowserContext` types + +**Location:** Line 7 + +Add `Browser` and `BrowserContext` to the existing `playwright-core` type import: + +```ts +// Before +import type { BrowserServer, BrowserType, LaunchOptions } from 'playwright-core'; + +// After +import type { Browser, BrowserContext, BrowserServer, BrowserType, LaunchOptions } from 'playwright-core'; +``` + +### 2. Add `storageStatePath` to `IPlaywrightTunnelOptions` + +**Location:** ~Line 62 (inside the options type) + +Add an optional property for the path to a saved storage state JSON file: + +```ts +export type IPlaywrightTunnelOptions = { + terminal: ITerminal; + onStatusChange: (status: TunnelStatus) => void; + playwrightInstallPath: string; + onBeforeLaunch?: (handshake: IHandshake) => Promise | boolean; + /** + * Optional path to a saved Playwright storage state JSON file. + * If provided, a browser context will be created with the storage state + * applied when the browser server is launched. The file should contain + * a JSON object with `cookies` and/or `origins` arrays as produced + * by `browserContext.storageState()`. + */ + storageStatePath?: string; +} & ( ... ); +``` + +### 3. Store the path as a private field + +**Location:** ~Line 97 (class fields) and ~Line 108 (constructor) + +Add a new private readonly field and assign it in the constructor: + +```ts +// Field declaration +private readonly _storageStatePath: string | undefined; + +// In constructor, destructure and assign +const { mode, terminal, onStatusChange, playwrightInstallPath, onBeforeLaunch } = options; +this._storageStatePath = options.storageStatePath; +``` + +### 4. Expand `IBrowserServerProxy` interface + +**Location:** ~Line 81 + +Add an optional reference to the locally-connected `Browser` so it can be cleaned up on close: + +```ts +interface IBrowserServerProxy { + browserServer: BrowserServer; + client: WebSocket; + /** + * Local browser connection used to seed the storage state context. + * Must be closed when the tunnel shuts down. + */ + localBrowser?: Browser; +} +``` + +### 5. Read & apply storage state in `_getPlaywrightBrowserServerProxyAsync` + +**Location:** ~Line 396 (after `launchServer()` call, before returning) + +After launching the browser server, if `_storageStatePath` is set, connect locally and create a context with the storage state: + +```ts +const browserServer: BrowserServer = await browsers[browserName].launchServer(safeOptions); + +if (!browserServer) { + throw new Error( + `Failed to launch browser server for ${browserName} with options: ${JSON.stringify(safeOptions)}` + ); +} + +terminal.writeLine(`Launched ${browserName} browser server`); + +// Apply saved storage state if configured +let localBrowser: Browser | undefined; +if (this._storageStatePath) { + terminal.writeLine(`Reading storage state from: ${this._storageStatePath}`); + try { + const storageStateContent: string = await FileSystem.readFileAsync(this._storageStatePath); + const storageState: object = JSON.parse(storageStateContent); + + // Connect locally to seed the browser process with a storage-state context + localBrowser = await browsers[browserName].connect(browserServer.wsEndpoint()); + const _context: BrowserContext = await localBrowser.newContext({ + storageState: storageState as any + }); + terminal.writeLine('Browser context created with storage state successfully'); + } catch (error) { + terminal.writeWarningLine( + `Failed to apply storage state: ${getNormalizedErrorString(error)}. Continuing without it.` + ); + } +} + +const client: WebSocket = new WebSocket(browserServer.wsEndpoint()); + +return { + browserServer, + client, + localBrowser +}; +``` + +This is **non-fatal** — if the file is missing, unreadable, or has invalid JSON, a warning is logged and the tunnel proceeds normally without storage state. + +### 6. Clean up the local browser connection on close + +**Location:** ~Line 522-555 (`_initPlaywrightBrowserTunnelAsync`) + +Track the `localBrowser` reference and close it during the WebSocket `close` handler: + +```ts +// Add alongside existing variables at the top of the method +let localBrowser: Browser | undefined = undefined; + +// After getting browserServerProxy (in the message handler): +client = browserServerProxy.client; +browserServer = browserServerProxy.browserServer; +localBrowser = browserServerProxy.localBrowser; + +// In the ws 'close' handler, close localBrowser before browserServer: +if (localBrowser) { + this._terminal.writeLine(' Closing local browser connection...'); + await localBrowser.close(); + this._terminal.writeLine(' Local browser connection closed'); +} +if (browserServer) { + this._terminal.writeLine(' Closing browser server...'); + await browserServer.close(); + this._terminal.writeLine(' Browser server closed'); +} +``` + +--- + +## Files Changed + +| File | Change | +|------|--------| +| `PlaywrightBrowserTunnel.ts` | Import `Browser`/`BrowserContext`, add `storageStatePath?` to options, store as field, expand `IBrowserServerProxy`, read file + create context in `_getPlaywrightBrowserServerProxyAsync`, clean up local browser on close | + +**No changes** to TunneledBrowser, TunneledBrowserConnection, ITunneledBrowserConnection, ITunneledBrowser, or any handshake protocol types. + +--- + +## ⚠️ Critical Issue: Local Browser Context Is Not Shared + +**The approach of connecting locally and creating a context with storage state does not work with Playwright's connection model.** + +In Playwright's architecture: +- Each `browser.connect()` call creates an **independent client session** +- Contexts created by one client are **invisible** to other clients connecting to the same `BrowserServer` +- When a client connection closes, **all contexts it created are destroyed** + +This means: +1. If `localBrowser` is closed immediately after seeding → the context is destroyed +2. Even if `localBrowser` stays open → the remote client connecting through the tunnel gets a fresh session with **no access** to the locally-created context + +### Viable Alternatives + +#### Alternative A: Intercept CDP Messages (tunnel-side only) + +The tunnel already forwards all WebSocket messages between the remote client and browser server. It could: +- Watch for `Browser.newContext` CDP calls in the forwarded traffic +- Inject or merge the `storageState` parameter into the call before forwarding it to the browser server +- Read the storage state file once at startup and hold it in memory + +**Pros:** No changes to TunneledBrowser; fully transparent to the remote client +**Cons:** Fragile — depends on Playwright's internal CDP protocol structure, which is not a public API and may change between versions + +#### Alternative B: Send Storage State in Handshake Ack (requires TunneledBrowser change) + +- Read the storage state file on the browser host +- Include its contents in the `handshakeAck` message +- The codespace side applies it when calling `newContext()` + +**Pros:** Clean, uses Playwright's public API +**Cons:** Requires modifying TunneledBrowser (ruled out per constraints) + +#### ~~Alternative C: Use `--user-data-dir` Launch Option~~ ❌ NOT VIABLE + +~~Pre-seed cookies/localStorage into a Chromium user data directory and pass `--user-data-dir=` via `launchOptions.args`.~~ + +**Why it doesn't work:** Playwright explicitly rejects `--user-data-dir` in `launch()` and `launchServer()` args, throwing an error that says to use `browserType.launchPersistentContext()` instead. However, `launchPersistentContext()` returns a `BrowserContext` — not a `BrowserServer` — so it is incompatible with the tunnel's server model that requires `launchServer()` → `BrowserServer` → `wsEndpoint()`. + +#### Alternative D: Expose Storage State via a Sideband HTTP Endpoint + +- The browser host reads the storage state file and serves its contents over a simple HTTP endpoint (or includes it as metadata in a new tunnel protocol message type) +- The test code on the codespace side fetches the storage state and passes it to `newContext()` explicitly + +**Pros:** No changes to the core TunneledBrowser connection code; clean separation +**Cons:** Requires test code to be aware of the sideband endpoint; not fully transparent + +### Recommendation + +**Alternative A (CDP interception)** is the most viable approach that keeps TunneledBrowser untouched and is transparent to test code. However, it couples the implementation to Playwright's internal protocol. + +If the constraint against modifying TunneledBrowser can be relaxed, **Alternative B** is the cleanest solution. + +--- + +## Design Decisions + +### Why `IPlaywrightTunnelOptions` (not the handshake)? + +The `storageStatePath` is a **local file** on the browser host machine. It's a trusted configuration value set by the extension, not something received from the remote test runner. This avoids path traversal security concerns and keeps the handshake protocol unchanged. + +### Graceful degradation + +If the storage state file doesn't exist, is malformed, or context creation fails: +- A warning is logged +- The tunnel proceeds normally without storage state +- No error is thrown + +### Storage state file format + +Playwright's `storageState` JSON has the shape `{ cookies: [...], origins: [...] }` as produced by `browserContext.storageState()`. Playwright itself validates the structure when passed to `newContext()`, so no additional schema validation is needed — any errors are caught by the try/catch. diff --git a/vscode-extensions/playwright-local-browser-server-vscode-extension/README.md b/vscode-extensions/playwright-local-browser-server-vscode-extension/README.md index 4968cb7f8e..2f4cff2000 100644 --- a/vscode-extensions/playwright-local-browser-server-vscode-extension/README.md +++ b/vscode-extensions/playwright-local-browser-server-vscode-extension/README.md @@ -154,12 +154,99 @@ sequenceDiagram Note over PT,LB: 🎉 Profit! Local browser available to remote tests transparently ``` +## MCP Tunnel Mode (Alternative) + +As an alternative to the browser tunnel, this extension also supports **MCP Tunnel mode**. Instead of launching a browser server and forwarding WebSocket traffic, it runs a local MCP server (e.g. `@playwright/mcp`) on your machine and proxies MCP protocol messages to/from the codespace. + +### Architecture + +``` +[MCP Client (Cursor/Claude Code)] --> stdio --> [mcp-codespace-proxy.mjs] --> TCP :56768 + (codespace) (codespace) | + VS Code port forwarding + | +[McpTunnel in extension] --> TCP :56768 --> [@playwright/mcp process] --> local browser + (local machine) (local machine) +``` + +### Setup + +**1. Start MCP Tunnel in VS Code** + +Open the Command Palette and run **"Playwright: Start MCP Tunnel"**, or use the status bar menu. + +**2. Copy the proxy script to your codespace** + +Copy `mcp-codespace-proxy.mjs` (included with this extension) to your codespace. This is a standalone Node.js script with zero external dependencies — it only uses built-in `net` and `readline` modules. + +**3. Configure your MCP client on the codespace** + +Point your MCP client to the proxy script. For example, in Claude Code (`.claude/settings.json`): + +```json +{ + "mcpServers": { + "playwright": { + "command": "node", + "args": ["/path/to/mcp-codespace-proxy.mjs", "56768"] + } + } +} +``` + +The port must match the `playwright-local-browser-server.mcpTunnelPort` setting (default: `56768`). + +**4. Use MCP tools from your codespace** + +The MCP client will communicate through the proxy to the locally-running `@playwright/mcp` server, which drives a real browser on your local machine. + +### MCP Tunnel Sequence Diagram + +```mermaid +sequenceDiagram + participant MC as MCP Client (Codespace) + participant MP as mcp-codespace-proxy.mjs + participant PF as VS Code Port Forwarding + participant EXT as VS Code Extension (Local) + participant MCP as @playwright/mcp (Local) + participant LB as Local Browser + + Note over MC,LB: MCP Tunnel Mode: Local MCP server proxied to codespace + + MP->>MP: Listen on TCP port (56768) + + loop Polling + EXT->>PF: Connect to localhost:56768 + PF->>MP: Forward TCP connection + end + + MP-->>EXT: TCP connection established + + EXT->>MCP: Spawn @playwright/mcp (stdio) + + rect rgb(200, 230, 200) + Note over MC,LB: Transparent bidirectional MCP communication + MC->>MP: MCP request (stdin) + MP->>PF: Forward via TCP + PF->>EXT: Forward to extension + EXT->>MCP: Write to MCP stdin + MCP->>LB: Execute browser action + LB-->>MCP: Result + MCP-->>EXT: MCP response (stdout) + EXT-->>PF: Forward via TCP + PF-->>MP: Forward to proxy + MP-->>MC: MCP response (stdout) + end +``` + ## Commands This extension contributes the following commands: - **Playwright: Start Playwright Browser Tunnel** (`playwright-local-browser-server.start`) - **Playwright: Stop Playwright Browser Tunnel** (`playwright-local-browser-server.stop`) +- **Playwright: Start MCP Tunnel** (`playwright-local-browser-server.startMcp`) +- **Playwright: Stop MCP Tunnel** (`playwright-local-browser-server.stopMcp`) - **Playwright Local Browser Server: Manage Launch Options Allowlist** (`playwright-local-browser-server.manageAllowlist`) - **Playwright Local Browser Server: Show Log** (`playwright-local-browser-server.showLog`) - **Playwright Local Browser Server: Show Settings** (`playwright-local-browser-server.showSettings`) @@ -170,8 +257,11 @@ This extension contributes the following commands: - `playwright-local-browser-server.autoStart` (default: `false`) — automatically starts the tunnel when the extension activates. - `playwright-local-browser-server.promptBeforeLaunch` (default: `true`) — show a confirmation prompt before launching the browser server with the requested launch options. This helps protect against potentially malicious launch options from compromised environments. - `playwright-local-browser-server.tunnelPort` (default: `56767`) — port used by the remote tunnel server. +- `playwright-local-browser-server.mcpTunnelPort` (default: `56768`) — port for the MCP tunnel. Must match the port used by `mcp-codespace-proxy.mjs` on the codespace side. +- `playwright-local-browser-server.mcpCommand` (default: `npx @playwright/mcp`) — command to start the local MCP server. The MCP server must use stdio transport. ## Notes -- The extension currently connects to `ws://127.0.0.1:56767` on the local machine. In Codespaces, make sure the remote port is forwarded so it is reachable as `localhost` from your VS Code UI environment. +- The browser tunnel connects to `ws://127.0.0.1:56767` on the local machine. In Codespaces, make sure the remote port is forwarded so it is reachable as `localhost` from your VS Code UI environment. +- The MCP tunnel connects to `127.0.0.1:56768` by default. The codespace proxy listens on this port and VS Code automatically forwards it. - For the underlying API and examples, see [`@rushstack/playwright-browser-tunnel`](../../apps/playwright-browser-tunnel). diff --git a/vscode-extensions/playwright-local-browser-server-vscode-extension/mcp-codespace-proxy.mjs b/vscode-extensions/playwright-local-browser-server-vscode-extension/mcp-codespace-proxy.mjs new file mode 100644 index 0000000000..a843523fe3 --- /dev/null +++ b/vscode-extensions/playwright-local-browser-server-vscode-extension/mcp-codespace-proxy.mjs @@ -0,0 +1,108 @@ +#!/usr/bin/env node + +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +// MCP Codespace Proxy +// +// This script runs on the codespace side. It is configured as an MCP server +// in the MCP client's config (e.g., Claude Code, Cursor, etc.) using stdio transport. +// +// It opens a TCP port that the local VS Code extension connects to via port forwarding. +// Once the extension connects, it bridges the MCP client's stdin/stdout to the TCP +// connection, which the extension bridges to a locally-running @playwright/mcp server. +// +// Architecture: +// [MCP Client] → stdio → [this proxy] → TCP port ↕ (VS Code port forwarding) ↕ [Extension] → [local MCP server] +// +// Usage: +// node mcp-codespace-proxy.mjs [port] +// Default port: 56768 +// +// MCP client configuration example (e.g., in .claude/settings.json or similar): +// { +// "mcpServers": { +// "playwright": { +// "command": "node", +// "args": ["/path/to/mcp-codespace-proxy.mjs", "56768"] +// } +// } +// } + +import * as net from 'node:net'; +import * as readline from 'node:readline'; + +const port = parseInt(process.argv[2] || '56768', 10); + +if (isNaN(port) || port < 1 || port > 65535) { + process.stderr.write('Error: Invalid port number. Must be between 1 and 65535.\n'); + process.exit(1); +} + +/** @type {net.Socket | null} */ +let extensionSocket = null; + +/** @type {string[]} */ +const bufferedMessages = []; + +const server = net.createServer((socket) => { + if (extensionSocket) { + process.stderr.write('MCP proxy: Replacing existing extension connection\n'); + extensionSocket.destroy(); + extensionSocket = null; + } + + extensionSocket = socket; + process.stderr.write('MCP proxy: Extension connected\n'); + + // Flush any buffered messages from the MCP client + for (const msg of bufferedMessages) { + socket.write(msg + '\n'); + } + bufferedMessages.length = 0; + + // Extension → stdout (MCP responses back to the MCP client) + const socketReader = readline.createInterface({ input: socket }); + socketReader.on('line', (line) => { + process.stdout.write(line + '\n'); + }); + + socket.on('close', () => { + process.stderr.write('MCP proxy: Extension disconnected\n'); + extensionSocket = null; + }); + + socket.on('error', (err) => { + process.stderr.write(`MCP proxy: Socket error: ${err.message}\n`); + extensionSocket = null; + }); +}); + +server.listen(port, '0.0.0.0', () => { + process.stderr.write(`MCP proxy: Listening on port ${port}\n`); +}); + +// stdin → extension socket (MCP requests from the MCP client) +const stdinReader = readline.createInterface({ input: process.stdin }); +stdinReader.on('line', (line) => { + if (extensionSocket && !extensionSocket.destroyed) { + extensionSocket.write(line + '\n'); + } else { + // Buffer messages until the extension connects + bufferedMessages.push(line); + } +}); + +stdinReader.on('close', () => { + // MCP client closed stdin, shut down + process.stderr.write('MCP proxy: stdin closed, shutting down\n'); + if (extensionSocket) { + extensionSocket.destroy(); + } + server.close(); +}); + +server.on('error', (err) => { + process.stderr.write(`MCP proxy: Server error: ${err.message}\n`); + process.exit(1); +}); diff --git a/vscode-extensions/playwright-local-browser-server-vscode-extension/package.json b/vscode-extensions/playwright-local-browser-server-vscode-extension/package.json index 1ee9159796..a3e168b3a8 100644 --- a/vscode-extensions/playwright-local-browser-server-vscode-extension/package.json +++ b/vscode-extensions/playwright-local-browser-server-vscode-extension/package.json @@ -1,6 +1,6 @@ { "name": "playwright-local-browser-server", - "version": "0.1.3", + "version": "0.1.10", "repository": { "type": "git", "url": "https://github.com/microsoft/rushstack.git", @@ -68,6 +68,16 @@ "command": "playwright-local-browser-server.showMenu", "title": "Show Tunnel Menu", "category": "Playwright Local Browser Server" + }, + { + "command": "playwright-local-browser-server.startMcp", + "title": "Start MCP Tunnel", + "category": "Playwright" + }, + { + "command": "playwright-local-browser-server.stopMcp", + "title": "Stop MCP Tunnel", + "category": "Playwright" } ], "configuration": { @@ -87,6 +97,26 @@ "type": "number", "default": 56767, "description": "Port for the browser tunnel server" + }, + "playwright-local-browser-server.mcpTunnelPort": { + "type": "number", + "default": 56768, + "description": "Port for the MCP tunnel. This should match the port used by mcp-codespace-proxy.mjs on the codespace side." + }, + "playwright-local-browser-server.persistentSession": { + "type": "boolean", + "default": false, + "description": "Enable a persistent, authenticated browser session. When enabled, the pre-launch command runs on the codespace to produce a Playwright storage state file, which is then copied locally and passed to the MCP server." + }, + "playwright-local-browser-server.preLaunchCommand": { + "type": "string", + "default": "", + "description": "Command to execute on the remote codespace before launching the MCP server. This command should produce a Playwright storage state JSON file at the path specified by 'storageStatePath'. Only used when 'persistentSession' is enabled." + }, + "playwright-local-browser-server.storageStatePath": { + "type": "string", + "default": "/tmp/storage-state.json", + "description": "Path on the remote codespace where the pre-launch command writes the Playwright storage state JSON file. The extension reads this file from the codespace and copies it locally. Only used when 'persistentSession' is enabled." } } } diff --git a/vscode-extensions/playwright-local-browser-server-vscode-extension/src/McpTunnel.ts b/vscode-extensions/playwright-local-browser-server-vscode-extension/src/McpTunnel.ts new file mode 100644 index 0000000000..ed8f4f4cf6 --- /dev/null +++ b/vscode-extensions/playwright-local-browser-server-vscode-extension/src/McpTunnel.ts @@ -0,0 +1,183 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as child_process from 'node:child_process'; +import * as net from 'node:net'; +import * as readline from 'node:readline'; + +import type { ITerminal } from '@rushstack/terminal'; +import type { TunnelStatus } from '@rushstack/playwright-browser-tunnel'; + +const POLL_INTERVAL_MS: number = 2000; + +export interface IMcpTunnelOptions { + port: number; + mcpCommand: string; + terminal: ITerminal; + onStatusChange: (status: TunnelStatus) => void; +} + +/** + * MCP Tunnel mode: starts a local MCP server (e.g. @playwright/mcp) and bridges + * it to a TCP proxy running on the codespace side via VS Code port forwarding. + * + * Architecture: + * [MCP Client on codespace] → stdio → [mcp-codespace-proxy.mjs] → TCP port + * ↕ (VS Code port forwarding) + * [McpTunnel in extension] → TCP → [spawned @playwright/mcp process] → stdio + */ +export class McpTunnel { + private _options: IMcpTunnelOptions; + private _mcpProcess: child_process.ChildProcess | undefined; + private _socket: net.Socket | undefined; + private _stopped: boolean = false; + private _pollTimer: ReturnType | undefined; + + public constructor(options: IMcpTunnelOptions) { + this._options = options; + } + + public async startAsync(): Promise { + this._stopped = false; + this._options.onStatusChange('waiting-for-connection'); + this._options.terminal.writeLine( + `MCP Tunnel: Polling for codespace proxy connection on 127.0.0.1:${this._options.port}...` + ); + + this._pollForConnection(); + } + + private _pollForConnection(): void { + if (this._stopped) { + return; + } + + const socket: net.Socket = net.createConnection({ host: '127.0.0.1', port: this._options.port }, () => { + this._socket = socket; + this._options.terminal.writeLine('MCP Tunnel: Connected to codespace proxy'); + this._options.onStatusChange('setting-up-browser-server'); + this._bridge(socket); + }); + + socket.on('error', () => { + // Connection failed, retry after delay + socket.destroy(); + if (!this._stopped) { + this._pollTimer = setTimeout(() => this._pollForConnection(), POLL_INTERVAL_MS); + } + }); + } + + private _bridge(socket: net.Socket): void { + const { mcpCommand, terminal, onStatusChange } = this._options; + + terminal.writeLine(`MCP Tunnel: Spawning MCP server: ${mcpCommand}`); + + // On Windows, pass the entire command as a single string with shell: true + // so that cmd.exe handles parsing. Splitting on whitespace would mangle + // file paths containing spaces or backslashes. + const mcpProcess: child_process.ChildProcess = child_process.spawn(mcpCommand, { + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true, + shell: true + }); + + this._mcpProcess = mcpProcess; + + let cleanedUp: boolean = false; + const cleanup: () => void = () => { + if (cleanedUp) { + return; + } + cleanedUp = true; + + if (!socket.destroyed) { + socket.destroy(); + } + if (mcpProcess.exitCode === null && mcpProcess.signalCode === null) { + mcpProcess.kill(); + } + + this._mcpProcess = undefined; + this._socket = undefined; + + if (!this._stopped) { + terminal.writeLine('MCP Tunnel: Connection lost, will reconnect...'); + onStatusChange('waiting-for-connection'); + this._pollTimer = setTimeout(() => this._pollForConnection(), POLL_INTERVAL_MS); + } + }; + + mcpProcess.on('error', (err: Error) => { + terminal.writeErrorLine(`MCP Tunnel: MCP process error: ${err.message}`); + onStatusChange('error'); + cleanup(); + }); + + mcpProcess.on('exit', (code: number | null) => { + terminal.writeLine(`MCP Tunnel: MCP process exited with code ${code}`); + cleanup(); + }); + + socket.on('close', () => { + terminal.writeLine('MCP Tunnel: Proxy socket closed'); + cleanup(); + }); + + socket.on('error', (err: Error) => { + terminal.writeErrorLine(`MCP Tunnel: Socket error: ${err.message}`); + cleanup(); + }); + + // Bridge: proxy socket → MCP process stdin + const socketReader: readline.Interface = readline.createInterface({ input: socket }); + socketReader.on('line', (line: string) => { + if (mcpProcess.stdin && !mcpProcess.stdin.destroyed) { + mcpProcess.stdin.write(line + '\n'); + } + }); + + // Bridge: MCP process stdout → proxy socket + if (mcpProcess.stdout) { + const stdoutReader: readline.Interface = readline.createInterface({ input: mcpProcess.stdout }); + stdoutReader.on('line', (line: string) => { + if (!socket.destroyed) { + socket.write(line + '\n'); + } + }); + } + + // Log MCP process stderr + if (mcpProcess.stderr) { + const stderrReader: readline.Interface = readline.createInterface({ input: mcpProcess.stderr }); + stderrReader.on('line', (line: string) => { + terminal.writeLine(`MCP server: ${line}`); + }); + } + + onStatusChange('browser-server-running'); + terminal.writeLine('MCP Tunnel: Bridge established, MCP server is running'); + } + + public async stopAsync(): Promise { + this._stopped = true; + + if (this._pollTimer) { + clearTimeout(this._pollTimer); + this._pollTimer = undefined; + } + + if (this._mcpProcess && this._mcpProcess.exitCode === null && this._mcpProcess.signalCode === null) { + this._mcpProcess.kill(); + this._mcpProcess = undefined; + } + + if (this._socket && !this._socket.destroyed) { + this._socket.destroy(); + this._socket = undefined; + } + + this._options.terminal.writeLine('MCP Tunnel: Stopped'); + this._options.onStatusChange('stopped'); + } +} diff --git a/vscode-extensions/playwright-local-browser-server-vscode-extension/src/extension.ts b/vscode-extensions/playwright-local-browser-server-vscode-extension/src/extension.ts index 6701d15d09..3db9b2f027 100644 --- a/vscode-extensions/playwright-local-browser-server-vscode-extension/src/extension.ts +++ b/vscode-extensions/playwright-local-browser-server-vscode-extension/src/extension.ts @@ -26,6 +26,8 @@ import { runWorkspaceCommandAsync } from '@rushstack/vscode-shared/lib/runWorksp import { VScodeOutputChannelTerminalProvider } from '@rushstack/vscode-shared/lib/VScodeOutputChannelTerminalProvider'; import packageJson from '../package.json'; +import { McpTunnel } from './McpTunnel'; + const EXTENSION_DISPLAY_NAME: string = 'Playwright Local Browser Server'; const COMMAND_SHOW_LOG: string = 'playwright-local-browser-server.showLog'; const COMMAND_SHOW_SETTINGS: string = 'playwright-local-browser-server.showSettings'; @@ -33,6 +35,8 @@ const COMMAND_START_TUNNEL: string = 'playwright-local-browser-server.start'; const COMMAND_STOP_TUNNEL: string = 'playwright-local-browser-server.stop'; const COMMAND_SHOW_MENU: string = 'playwright-local-browser-server.showMenu'; const COMMAND_MANAGE_ALLOWLIST: string = 'playwright-local-browser-server.manageAllowlist'; +const COMMAND_START_MCP_TUNNEL: string = 'playwright-local-browser-server.startMcp'; +const COMMAND_STOP_MCP_TUNNEL: string = 'playwright-local-browser-server.stopMcp'; const VSCODE_COMMAND_WORKSPACE_OPEN_SETTINGS: string = 'workbench.action.openSettings'; const EXTENSION_ID: string = `${packageJson.publisher}.${packageJson.name}`; const VSCODE_SETTINGS_EXTENSION_FILTER: string = `@ext:${EXTENSION_ID}`; @@ -155,8 +159,9 @@ export async function activate(context: vscode.ExtensionContext): Promise updateStatusBar('stopped'); statusBarItem.show(); - // Tunnel instance + // Tunnel instances let tunnel: PlaywrightTunnel | undefined; + let mcpTunnel: McpTunnel | undefined; function getTmpPath(): string { return path.join(os.tmpdir(), 'playwright-browser-tunnel'); @@ -462,22 +467,183 @@ export async function activate(context: vscode.ExtensionContext): Promise } } + async function readFileFromCodespaceAsync(remotePath: string): Promise { + const workspaceFolder: vscode.WorkspaceFolder | undefined = vscode.workspace.workspaceFolders?.[0]; + let fileUri: vscode.Uri; + if (workspaceFolder) { + fileUri = vscode.Uri.from({ + scheme: workspaceFolder.uri.scheme, + authority: workspaceFolder.uri.authority, + path: remotePath + }); + } else { + fileUri = vscode.Uri.parse(`vscode-remote://${vscode.env.remoteName}${remotePath}`); + } + terminal.writeLine(`Reading remote file: ${fileUri.toString()}`); + return await vscode.workspace.fs.readFile(fileUri); + } + + async function handleStartMcpTunnelAsync(): Promise { + if (mcpTunnel) { + outputChannel.appendLine('MCP tunnel is already running.'); + void vscode.window.showInformationMessage('MCP tunnel is already running.'); + return; + } + + // Stop browser tunnel if running + if (tunnel) { + outputChannel.appendLine('Stopping browser tunnel before starting MCP tunnel...'); + await handleStopTunnelAsync(); + } + + const config: vscode.WorkspaceConfiguration = vscode.workspace.getConfiguration( + 'playwright-local-browser-server' + ); + const mcpPort: number = config.get('mcpTunnelPort', 56768); + const persistentSession: boolean = config.get('persistentSession', false); + + try { + // Build the MCP command + const mcpArgs: string[] = ['npx', '@playwright/mcp@latest', '--browser', 'msedge']; + + if (persistentSession) { + const preLaunchCommand: string = config.get('preLaunchCommand', ''); + const remoteStorageStatePath: string = config.get( + 'storageStatePath', + '/tmp/storage-state.json' + ); + + if (!preLaunchCommand) { + void vscode.window.showErrorMessage( + 'Persistent session is enabled but no pre-launch command is configured. ' + + 'Set "playwright-local-browser-server.preLaunchCommand" in settings.' + ); + return; + } + + // Run the pre-launch command on the codespace to generate the storage state file + outputChannel.appendLine(`Running pre-launch command on codespace: ${preLaunchCommand}`); + try { + const preLaunchOutput: string = await runWorkspaceCommandAsync({ + terminalOptions: { name: 'playwright-pre-launch', hideFromUser: true }, + commandLine: preLaunchCommand, + terminal + }); + outputChannel.appendLine(`Pre-launch command output: ${preLaunchOutput}`); + } catch (error) { + const errorMessage: string = getNormalizedErrorString(error); + outputChannel.appendLine(`Pre-launch command failed: ${errorMessage}`); + void vscode.window.showErrorMessage( + `Pre-launch command failed: ${errorMessage}. MCP tunnel will not start.` + ); + return; + } + + // Read the storage state file from the codespace + outputChannel.appendLine(`Reading storage state from codespace: ${remoteStorageStatePath}`); + let storageStateContents: Uint8Array; + try { + storageStateContents = await readFileFromCodespaceAsync(remoteStorageStatePath); + } catch (error) { + const errorMessage: string = getNormalizedErrorString(error); + outputChannel.appendLine(`Failed to read storage state from codespace: ${errorMessage}`); + void vscode.window.showErrorMessage( + `Failed to read storage state file from codespace at ${remoteStorageStatePath}: ${errorMessage}` + ); + return; + } + + // Write the storage state file locally + const localStorageStatePath: string = path.join(os.tmpdir(), 'playwright-storage-state.json'); + const fs: typeof import('node:fs') = await import('node:fs'); + fs.writeFileSync(localStorageStatePath, storageStateContents); + outputChannel.appendLine(`Storage state written locally to: ${localStorageStatePath}`); + + // --isolated is required so @playwright/mcp uses browser.newContext() instead of + // launchPersistentContext(), which correctly applies the storage state cookies. + mcpArgs.push('--isolated', `--storage-state=${localStorageStatePath}`); + } + + const mcpCommand: string = mcpArgs.join(' '); + outputChannel.appendLine(`Starting MCP tunnel on port ${mcpPort} with command: ${mcpCommand}`); + + const newMcpTunnel: McpTunnel = new McpTunnel({ + port: mcpPort, + mcpCommand, + terminal, + onStatusChange: (status) => { + outputChannel.appendLine(`MCP tunnel status changed: ${status}`); + updateStatusBar(status); + } + }); + + void newMcpTunnel.startAsync().catch((error: Error) => { + outputChannel.appendLine(`MCP tunnel error: ${getNormalizedErrorString(error)}`); + updateStatusBar('error'); + void vscode.window.showErrorMessage(`MCP tunnel error: ${getNormalizedErrorString(error)}`); + }); + + // eslint-disable-next-line require-atomic-updates + mcpTunnel = newMcpTunnel; + + outputChannel.appendLine('MCP tunnel start initiated.'); + } catch (error) { + const errorMessage: string = getNormalizedErrorString(error); + outputChannel.appendLine(`Failed to start MCP tunnel: ${errorMessage}`); + updateStatusBar('error'); + void vscode.window.showErrorMessage(`Failed to start MCP tunnel: ${errorMessage}`); + } + } + + async function handleStopMcpTunnelAsync(): Promise { + const currentMcpTunnel: McpTunnel | undefined = mcpTunnel; + if (!currentMcpTunnel) { + outputChannel.appendLine('No MCP tunnel instance to stop.'); + void vscode.window.showInformationMessage('MCP tunnel is not running.'); + return; + } + + mcpTunnel = undefined; + + try { + outputChannel.appendLine('Stopping MCP tunnel...'); + await currentMcpTunnel.stopAsync(); + updateStatusBar('stopped'); + outputChannel.appendLine('MCP tunnel stopped.'); + void vscode.window.showInformationMessage('MCP tunnel stopped.'); + } catch (error) { + const errorMessage: string = getNormalizedErrorString(error); + outputChannel.appendLine(`Failed to stop MCP tunnel: ${errorMessage}`); + void vscode.window.showErrorMessage(`Failed to stop MCP tunnel: ${errorMessage}`); + } + } + async function handleShowMenu(): Promise { interface IQuickPickItem extends vscode.QuickPickItem { - action: 'start' | 'stop' | 'showLog' | 'manageAllowlist'; + action: 'start' | 'stop' | 'startMcp' | 'stopMcp' | 'showLog' | 'manageAllowlist'; } const items: IQuickPickItem[] = [ { - label: '$(play) Start Tunnel', - description: 'Start the Playwright browser tunnel', + label: '$(play) Start Browser Tunnel', + description: 'Start the Playwright browser tunnel (browser server mode)', action: 'start' }, { - label: '$(debug-stop) Stop Tunnel', + label: '$(debug-stop) Stop Browser Tunnel', description: 'Stop the Playwright browser tunnel', action: 'stop' }, + { + label: '$(play) Start MCP Tunnel', + description: 'Start the MCP tunnel (runs @playwright/mcp locally, proxied to codespace)', + action: 'startMcp' + }, + { + label: '$(debug-stop) Stop MCP Tunnel', + description: 'Stop the MCP tunnel', + action: 'stopMcp' + }, { label: '$(shield) Manage Allowlist', description: 'Configure allowed launch options', @@ -502,6 +668,12 @@ export async function activate(context: vscode.ExtensionContext): Promise case 'stop': await handleStopTunnelAsync(); break; + case 'startMcp': + await handleStartMcpTunnelAsync(); + break; + case 'stopMcp': + await handleStopMcpTunnelAsync(); + break; case 'manageAllowlist': await handleManageAllowlist(); break; @@ -521,16 +693,25 @@ export async function activate(context: vscode.ExtensionContext): Promise vscode.commands.registerCommand(COMMAND_STOP_TUNNEL, handleStopTunnelAsync), vscode.commands.registerCommand(COMMAND_SHOW_MENU, handleShowMenu), vscode.commands.registerCommand(COMMAND_MANAGE_ALLOWLIST, handleManageAllowlist), - // Cleanup tunnel on deactivate + vscode.commands.registerCommand(COMMAND_START_MCP_TUNNEL, handleStartMcpTunnelAsync), + vscode.commands.registerCommand(COMMAND_STOP_MCP_TUNNEL, handleStopMcpTunnelAsync), + // Cleanup tunnels on deactivate { dispose: () => { const currentTunnel: PlaywrightTunnel | undefined = tunnel; if (currentTunnel) { - outputChannel.appendLine('Extension deactivating, stopping tunnel...'); + outputChannel.appendLine('Extension deactivating, stopping browser tunnel...'); void currentTunnel.stopAsync().then(() => { tunnel = undefined; }); } + const currentMcpTunnel: McpTunnel | undefined = mcpTunnel; + if (currentMcpTunnel) { + outputChannel.appendLine('Extension deactivating, stopping MCP tunnel...'); + void currentMcpTunnel.stopAsync().then(() => { + mcpTunnel = undefined; + }); + } } } );