From d3867affedcec472abd53af6122ec116b24d20e5 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Fri, 18 Jul 2025 17:12:44 -0700 Subject: [PATCH] chore: add mcp chrome extension (#710) --- extension/connect.html | 32 +++ extension/icons/icon-128.png | Bin 0 -> 6352 bytes extension/icons/icon-16.png | Bin 0 -> 571 bytes extension/icons/icon-32.png | Bin 0 -> 1258 bytes extension/icons/icon-48.png | Bin 0 -> 2043 bytes extension/manifest.json | 40 ++++ extension/src/background.ts | 109 +++++++++ extension/src/connect.ts | 70 ++++++ extension/src/relayConnection.ts | 176 ++++++++++++++ extension/tsconfig.json | 15 ++ package.json | 4 +- src/extension/cdpRelay.ts | 385 +++++++++++++++++++++++++++++++ src/extension/main.ts | 38 +++ src/program.ts | 14 +- src/transport.ts | 2 +- 15 files changed, 880 insertions(+), 5 deletions(-) create mode 100644 extension/connect.html create mode 100644 extension/icons/icon-128.png create mode 100644 extension/icons/icon-16.png create mode 100644 extension/icons/icon-32.png create mode 100644 extension/icons/icon-48.png create mode 100644 extension/manifest.json create mode 100644 extension/src/background.ts create mode 100644 extension/src/connect.ts create mode 100644 extension/src/relayConnection.ts create mode 100644 extension/tsconfig.json create mode 100644 src/extension/cdpRelay.ts create mode 100644 src/extension/main.ts diff --git a/extension/connect.html b/extension/connect.html new file mode 100644 index 0000000..e8ab1b6 --- /dev/null +++ b/extension/connect.html @@ -0,0 +1,32 @@ + + + + + Playwright MCP extension + + +
+

Playwright MCP extension

+
+
+
+ + +
+ + + \ No newline at end of file diff --git a/extension/icons/icon-128.png b/extension/icons/icon-128.png new file mode 100644 index 0000000000000000000000000000000000000000..c4bc8b02508b40cec8a0d73465324a9612744172 GIT binary patch literal 6352 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 JPDHLkV1gAA4qyNP literal 0 HcmV?d00001 diff --git a/extension/icons/icon-32.png b/extension/icons/icon-32.png new file mode 100644 index 0000000000000000000000000000000000000000..1f9a8ccb89bdbccd6bdc1c7f945f72eed9b94017 GIT binary patch literal 1258 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 Z{soKhKGhw*_GbV9002ovPDHLkV1gda&R_rl literal 0 HcmV?d00001 diff --git a/extension/manifest.json b/extension/manifest.json new file mode 100644 index 0000000..d39c7b0 --- /dev/null +++ b/extension/manifest.json @@ -0,0 +1,40 @@ +{ + "manifest_version": 3, + "name": "Playwright MCP Bridge", + "version": "1.0.0", + "description": "Share browser tabs with Playwright MCP server", + "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA9nMS2b0WCohjVHPGb8D9qAdkbIngDqoAjTeSccHJijgcONejge+OJxOQOMLu7b0ovt1c9BiEJa5JcpM+EHFVGL1vluBxK71zmBy1m2f9vZF3HG0LSCp7YRkum9rAIEthDwbkxx6XTvpmAY5rjFa/NON6b9Hlbo+8peUSkoOK7HTwYnnI36asZ9eUTiveIf+DMPLojW2UX33vDWG2UKvMVDewzclb4+uLxAYshY7Mx8we/b44xu+Anb/EBLKjOPk9Yh541xJ5Ozc8EiP/5yxOp9c/lRiYUHaRW+4r0HKZyFt0eZ52ti2iM4Nfk7jRXR7an3JPsUIf5deC/1cVM/+1ZQIDAQAB", + + "permissions": [ + "debugger", + "activeTab", + "tabs", + "storage" + ], + + "host_permissions": [ + "" + ], + + "background": { + "service_worker": "lib/background.js", + "type": "module" + }, + + "action": { + "default_title": "Playwright MCP Bridge", + "default_icon": { + "16": "icons/icon-16.png", + "32": "icons/icon-32.png", + "48": "icons/icon-48.png", + "128": "icons/icon-128.png" + } + }, + + "icons": { + "16": "icons/icon-16.png", + "32": "icons/icon-32.png", + "48": "icons/icon-48.png", + "128": "icons/icon-128.png" + } +} diff --git a/extension/src/background.ts b/extension/src/background.ts new file mode 100644 index 0000000..9a7063a --- /dev/null +++ b/extension/src/background.ts @@ -0,0 +1,109 @@ +/** + * 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 { RelayConnection, debugLog } from './relayConnection.js'; + +type PageMessage = { + type: 'connectToMCPRelay'; + mcpRelayUrl: string; +}; + +class TabShareExtension { + private _activeConnection: RelayConnection | undefined; + private _connectedTabId: number | null = null; + + constructor() { + chrome.tabs.onRemoved.addListener(this._onTabRemoved.bind(this)); + chrome.tabs.onUpdated.addListener(this._onTabUpdated.bind(this)); + chrome.runtime.onMessage.addListener(this._onMessage.bind(this)); + } + + // Promise-based message handling is not supported in Chrome: https://issues.chromium.org/issues/40753031 + private _onMessage(message: PageMessage, sender: chrome.runtime.MessageSender, sendResponse: (response: any) => void) { + switch (message.type) { + case 'connectToMCPRelay': + const tabId = sender.tab?.id; + if (!tabId) { + sendResponse({ success: false, error: 'No tab id' }); + return true; + } + this._connectTab(tabId, message.mcpRelayUrl!).then( + () => sendResponse({ success: true }), + (error: any) => sendResponse({ success: false, error: error.message })); + return true; // Return true to indicate that the response will be sent asynchronously + } + return false; + } + + private async _connectTab(tabId: number, mcpRelayUrl: string): Promise { + try { + debugLog(`Connecting tab ${tabId} to bridge at ${mcpRelayUrl}`); + const socket = new WebSocket(mcpRelayUrl); + await new Promise((resolve, reject) => { + socket.onopen = () => resolve(); + socket.onerror = () => reject(new Error('WebSocket error')); + setTimeout(() => reject(new Error('Connection timeout')), 5000); + }); + + const connection = new RelayConnection(socket); + connection.setConnectedTabId(tabId); + const connectionClosed = (m: string) => { + debugLog(m); + if (this._activeConnection === connection) { + this._activeConnection = undefined; + void this._setConnectedTabId(null); + } + }; + socket.onclose = () => connectionClosed('WebSocket closed'); + socket.onerror = error => connectionClosed(`WebSocket error: ${error}`); + this._activeConnection = connection; + + await this._setConnectedTabId(tabId); + debugLog(`Tab ${tabId} connected successfully`); + } catch (error: any) { + debugLog(`Failed to connect tab ${tabId}:`, error.message); + await this._setConnectedTabId(null); + throw error; + } + } + + private async _setConnectedTabId(tabId: number | null): Promise { + const oldTabId = this._connectedTabId; + this._connectedTabId = tabId; + if (oldTabId && oldTabId !== tabId) + await this._updateBadge(oldTabId, { text: '', color: null }); + if (tabId) + await this._updateBadge(tabId, { text: '●', color: '#4CAF50' }); + } + + private async _updateBadge(tabId: number, { text, color }: { text: string; color: string | null }): Promise { + await chrome.action.setBadgeText({ tabId, text }); + if (color) + await chrome.action.setBadgeBackgroundColor({ tabId, color }); + } + + private async _onTabRemoved(tabId: number): Promise { + if (this._connectedTabId === tabId) + this._activeConnection!.setConnectedTabId(null); + } + + private async _onTabUpdated(tabId: number, changeInfo: chrome.tabs.TabChangeInfo, tab: chrome.tabs.Tab): Promise { + if (changeInfo.status === 'complete' && this._connectedTabId === tabId) + await this._setConnectedTabId(tabId); + } +} + +new TabShareExtension(); diff --git a/extension/src/connect.ts b/extension/src/connect.ts new file mode 100644 index 0000000..bd748a2 --- /dev/null +++ b/extension/src/connect.ts @@ -0,0 +1,70 @@ +/** + * 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. + */ + +document.addEventListener('DOMContentLoaded', async () => { + const statusContainer = document.getElementById('status-container') as HTMLElement; + const continueBtn = document.getElementById('continue-btn') as HTMLButtonElement; + const rejectBtn = document.getElementById('reject-btn') as HTMLButtonElement; + const buttonRow = document.querySelector('.button-row') as HTMLElement; + + function showStatus(type: 'connected' | 'error' | 'connecting', message: string) { + const div = document.createElement('div'); + div.className = `status ${type}`; + div.textContent = message; + statusContainer.replaceChildren(div); + } + + const params = new URLSearchParams(window.location.search); + const mcpRelayUrl = params.get('mcpRelayUrl'); + + if (!mcpRelayUrl) { + buttonRow.style.display = 'none'; + showStatus('error', 'Missing mcpRelayUrl parameter in URL.'); + return; + } + + let clientInfo = 'unknown'; + try { + const client = JSON.parse(params.get('client') || '{}'); + clientInfo = `${client.name}/${client.version}`; + } catch (e) { + showStatus('error', 'Failed to parse client version.'); + return; + } + + showStatus('connecting', `MCP client "${clientInfo}" is trying to connect. Do you want to continue?`); + + rejectBtn.addEventListener('click', async () => { + buttonRow.style.display = 'none'; + showStatus('error', 'Connection rejected. This tab can be closed.'); + }); + + continueBtn.addEventListener('click', async () => { + buttonRow.style.display = 'none'; + try { + const response = await chrome.runtime.sendMessage({ + type: 'connectToMCPRelay', + mcpRelayUrl + }); + if (response?.success) + showStatus('connected', `MCP client "${clientInfo}" connected.`); + else + showStatus('error', response?.error || `MCP client "${clientInfo}" failed to connect.`); + } catch (e) { + showStatus('error', `MCP client "${clientInfo}" failed to connect: ${e}`); + } + }); +}); diff --git a/extension/src/relayConnection.ts b/extension/src/relayConnection.ts new file mode 100644 index 0000000..75b2881 --- /dev/null +++ b/extension/src/relayConnection.ts @@ -0,0 +1,176 @@ +/** + * 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. + */ + +export function debugLog(...args: unknown[]): void { + const enabled = true; + if (enabled) { + // eslint-disable-next-line no-console + console.log('[Extension]', ...args); + } +} + +type ProtocolCommand = { + id: number; + method: string; + params?: any; +}; + +type ProtocolResponse = { + id?: number; + method?: string; + params?: any; + result?: any; + error?: string; +}; + +export class RelayConnection { + private _debuggee: chrome.debugger.Debuggee = {}; + private _rootSessionId = ''; + private _ws: WebSocket; + private _eventListener: (source: chrome.debugger.DebuggerSession, method: string, params: any) => void; + private _detachListener: (source: chrome.debugger.Debuggee, reason: string) => void; + + constructor(ws: WebSocket) { + this._ws = ws; + this._ws.onmessage = this._onMessage.bind(this); + // Store listeners for cleanup + this._eventListener = this._onDebuggerEvent.bind(this); + this._detachListener = this._onDebuggerDetach.bind(this); + chrome.debugger.onEvent.addListener(this._eventListener); + chrome.debugger.onDetach.addListener(this._detachListener); + } + + setConnectedTabId(tabId: number | null): void { + if (!tabId) { + this._debuggee = { }; + this._rootSessionId = ''; + return; + } + this._debuggee = { tabId }; + this._rootSessionId = `pw-tab-${tabId}`; + } + + close(message?: string): void { + chrome.debugger.onEvent.removeListener(this._eventListener); + chrome.debugger.onDetach.removeListener(this._detachListener); + this._ws.close(1000, message || 'Connection closed'); + } + + private async _detachDebugger(): Promise { + await chrome.debugger.detach(this._debuggee); + } + + private _onDebuggerEvent(source: chrome.debugger.DebuggerSession, method: string, params: any): void { + if (source.tabId !== this._debuggee.tabId) + return; + debugLog('Forwarding CDP event:', method, params); + const sessionId = source.sessionId || this._rootSessionId; + this._sendMessage({ + method: 'forwardCDPEvent', + params: { + sessionId, + method, + params, + }, + }); + } + + private _onDebuggerDetach(source: chrome.debugger.Debuggee, reason: string): void { + if (source.tabId !== this._debuggee.tabId) + return; + this._sendMessage({ + method: 'detachedFromTab', + params: { + tabId: this._debuggee.tabId, + reason, + }, + }); + } + + private _onMessage(event: MessageEvent): void { + this._onMessageAsync(event).catch(e => debugLog('Error handling message:', e)); + } + + private async _onMessageAsync(event: MessageEvent): Promise { + let message: ProtocolCommand; + try { + message = JSON.parse(event.data); + } catch (error: any) { + debugLog('Error parsing message:', error); + this._sendError(-32700, `Error parsing message: ${error.message}`); + return; + } + + debugLog('Received message:', message); + + const response: ProtocolResponse = { + id: message.id, + }; + try { + response.result = await this._handleCommand(message); + } catch (error: any) { + debugLog('Error handling command:', error); + response.error = error.message; + } + debugLog('Sending response:', response); + this._sendMessage(response); + } + + private async _handleCommand(message: ProtocolCommand): Promise { + if (!this._debuggee.tabId) + throw new Error('No tab is connected. Please go to the Playwright MCP extension and select the tab you want to connect to.'); + if (message.method === 'attachToTab') { + debugLog('Attaching debugger to tab:', this._debuggee); + await chrome.debugger.attach(this._debuggee, '1.3'); + const result: any = await chrome.debugger.sendCommand(this._debuggee, 'Target.getTargetInfo'); + return { + sessionId: this._rootSessionId, + targetInfo: result?.targetInfo, + }; + } + if (message.method === 'detachFromTab') { + debugLog('Detaching debugger from tab:', this._debuggee); + return await this._detachDebugger(); + } + if (message.method === 'forwardCDPCommand') { + const { sessionId, method, params } = message.params; + debugLog('CDP command:', method, params); + const debuggerSession: chrome.debugger.DebuggerSession = { ...this._debuggee }; + // Pass session id, unless it's the root session. + if (sessionId && sessionId !== this._rootSessionId) + debuggerSession.sessionId = sessionId; + // Forward CDP command to chrome.debugger + return await chrome.debugger.sendCommand( + debuggerSession, + method, + params + ); + } + } + + private _sendError(code: number, message: string): void { + this._sendMessage({ + error: { + code, + message, + }, + }); + } + + private _sendMessage(message: any): void { + this._ws.send(JSON.stringify(message)); + } +} diff --git a/extension/tsconfig.json b/extension/tsconfig.json new file mode 100644 index 0000000..9fcde29 --- /dev/null +++ b/extension/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ESNext", + "esModuleInterop": true, + "moduleResolution": "node", + "strict": true, + "module": "ESNext", + "rootDir": "src", + "outDir": "./lib", + "resolveJsonModule": true, + }, + "include": [ + "src", + ], +} diff --git a/package.json b/package.json index 39c3c50..8ca2237 100644 --- a/package.json +++ b/package.json @@ -17,15 +17,17 @@ "license": "Apache-2.0", "scripts": { "build": "tsc", + "build:extension": "tsc --project extension", "lint": "npm run update-readme && eslint . && tsc --noEmit", "update-readme": "node utils/update-readme.js", "watch": "tsc --watch", + "watch:extension": "tsc --watch --project extension", "test": "playwright test", "ctest": "playwright test --project=chrome", "ftest": "playwright test --project=firefox", "wtest": "playwright test --project=webkit", "run-server": "node lib/browserServer.js", - "clean": "rm -rf lib", + "clean": "rm -rf lib extension/lib", "npm-publish": "npm run clean && npm run build && npm run test && npm publish" }, "exports": { diff --git a/src/extension/cdpRelay.ts b/src/extension/cdpRelay.ts new file mode 100644 index 0000000..e775909 --- /dev/null +++ b/src/extension/cdpRelay.ts @@ -0,0 +1,385 @@ +/** + * 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. + */ + +/** + * WebSocket server that bridges Playwright MCP and Chrome Extension + * + * Endpoints: + * - /cdp/guid - Full CDP interface for Playwright MCP + * - /extension/guid - Extension connection for chrome.debugger forwarding + */ + +import { WebSocket, WebSocketServer } from 'ws'; +import type websocket from 'ws'; +import http from 'node:http'; +import debug from 'debug'; +import { promisify } from 'node:util'; +import { exec } from 'node:child_process'; +import { httpAddressToString, startHttpServer } from '../transport.js'; + +const debugLogger = debug('pw:mcp:relay'); + +type CDPCommand = { + id: number; + sessionId?: string; + method: string; + params?: any; +}; + +type CDPResponse = { + id?: number; + sessionId?: string; + method?: string; + params?: any; + result?: any; + error?: { code?: number; message: string }; +}; + +export class CDPRelayServer { + private _wsHost: string; + private _getClientInfo: () => { name: string, version: string }; + private _cdpPath: string; + private _extensionPath: string; + private _wss: WebSocketServer; + private _playwrightConnection: WebSocket | null = null; + private _extensionConnection: ExtensionConnection | null = null; + private _connectedTabInfo: { + targetInfo: any; + // Page sessionId that should be used by this connection. + sessionId: string; + } | undefined; + private _extensionConnectionPromise: Promise; + private _extensionConnectionResolve: (() => void) | null = null; + + constructor(server: http.Server, getClientInfo: () => { name: string, version: string }) { + this._getClientInfo = getClientInfo; + this._wsHost = httpAddressToString(server.address()).replace(/^http/, 'ws'); + + const uuid = crypto.randomUUID(); + this._cdpPath = `/cdp/${uuid}`; + this._extensionPath = `/extension/${uuid}`; + + this._extensionConnectionPromise = new Promise(resolve => { + this._extensionConnectionResolve = resolve; + }); + this._wss = new WebSocketServer({ server, verifyClient: this._verifyClient.bind(this) }); + this._wss.on('connection', this._onConnection.bind(this)); + } + + cdpEndpoint() { + return `${this._wsHost}${this._cdpPath}`; + } + + extensionEndpoint() { + return `${this._wsHost}${this._extensionPath}`; + } + + private async _verifyClient(info: { origin: string, req: http.IncomingMessage }, callback: (result: boolean, code?: number, message?: string) => void) { + if (info.req.url?.startsWith(this._cdpPath)) { + if (this._playwrightConnection) { + callback(false, 500, 'Another Playwright connection already established'); + return; + } + await this._connectBrowser(); + await this._extensionConnectionPromise; + callback(!!this._extensionConnection); + return; + } + callback(true); + } + + private async _connectBrowser() { + const mcpRelayEndpoint = `${this._wsHost}${this._extensionPath}`; + // 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(this._getClientInfo())); + const href = url.toString(); + const command = `'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' '${href}'`; + try { + await promisify(exec)(command); + } catch (err) { + debugLogger('Failed to run command:', err); + } + } + + stop(): void { + this._playwrightConnection?.close(); + this._extensionConnection?.close(); + } + + private _onConnection(ws: WebSocket, request: http.IncomingMessage): void { + const url = new URL(`http://localhost${request.url}`); + debugLogger(`New connection to ${url.pathname}`); + if (url.pathname === this._cdpPath) { + this._handlePlaywrightConnection(ws); + } else if (url.pathname === this._extensionPath) { + this._handleExtensionConnection(ws); + } else { + debugLogger(`Invalid path: ${url.pathname}`); + ws.close(4004, 'Invalid path'); + } + } + + private _handlePlaywrightConnection(ws: WebSocket): void { + this._playwrightConnection = ws; + ws.on('message', async data => { + try { + const message = JSON.parse(data.toString()); + await this._handlePlaywrightMessage(message); + } catch (error) { + debugLogger('Error parsing Playwright message:', error); + } + }); + ws.on('close', () => { + if (this._playwrightConnection === ws) { + this._playwrightConnection = null; + this._closeExtensionConnection(); + debugLogger('Playwright MCP disconnected'); + } + }); + ws.on('error', error => { + debugLogger('Playwright WebSocket error:', error); + }); + debugLogger('Playwright MCP connected'); + } + + private _closeExtensionConnection() { + this._connectedTabInfo = undefined; + this._extensionConnection?.close(); + this._extensionConnection = null; + this._extensionConnectionPromise = new Promise(resolve => { + this._extensionConnectionResolve = resolve; + }); + } + + private _handleExtensionConnection(ws: WebSocket): void { + if (this._extensionConnection) { + ws.close(1000, 'Another extension connection already established'); + return; + } + this._extensionConnection = new ExtensionConnection(ws); + this._extensionConnection.onclose = c => { + if (this._extensionConnection === c) + this._extensionConnection = null; + }; + this._extensionConnection.onmessage = this._handleExtensionMessage.bind(this); + this._extensionConnectionResolve?.(); + } + + private _handleExtensionMessage(method: string, params: any) { + switch (method) { + case 'forwardCDPEvent': + this._sendToPlaywright({ + sessionId: params.sessionId, + method: params.method, + params: params.params + }); + break; + case 'detachedFromTab': + debugLogger('← Debugger detached from tab:', params); + this._connectedTabInfo = undefined; + break; + } + } + + private async _handlePlaywrightMessage(message: CDPCommand): Promise { + debugLogger('← Playwright:', `${message.method} (id=${message.id})`); + if (!this._extensionConnection) { + debugLogger('Extension not connected, sending error to Playwright'); + this._sendToPlaywright({ + id: message.id, + error: { message: 'Extension not connected' } + }); + return; + } + if (await this._interceptCDPCommand(message)) + return; + await this._forwardToExtension(message); + } + + private async _interceptCDPCommand(message: CDPCommand): Promise { + switch (message.method) { + case 'Browser.getVersion': { + this._sendToPlaywright({ + id: message.id, + result: { + protocolVersion: '1.3', + product: 'Chrome/Extension-Bridge', + userAgent: 'CDP-Bridge-Server/1.0.0', + } + }); + return true; + } + case 'Browser.setDownloadBehavior': { + this._sendToPlaywright({ + id: message.id + }); + return true; + } + case 'Target.setAutoAttach': { + // Simulate auto-attach behavior with real target info + if (!message.sessionId) { + this._connectedTabInfo = await this._extensionConnection!.send('attachToTab'); + debugLogger('Simulating auto-attach for target:', message); + this._sendToPlaywright({ + method: 'Target.attachedToTarget', + params: { + sessionId: this._connectedTabInfo!.sessionId, + targetInfo: { + ...this._connectedTabInfo!.targetInfo, + attached: true, + }, + waitingForDebugger: false + } + }); + this._sendToPlaywright({ + id: message.id + }); + } else { + await this._forwardToExtension(message); + } + return true; + } + case 'Target.getTargetInfo': { + debugLogger('Target.getTargetInfo', message); + this._sendToPlaywright({ + id: message.id, + result: this._connectedTabInfo?.targetInfo + }); + return true; + } + } + return false; + } + + private async _forwardToExtension(message: CDPCommand): Promise { + try { + if (!this._extensionConnection) + throw new Error('Extension not connected'); + const { id, sessionId, method, params } = message; + const result = await this._extensionConnection.send('forwardCDPCommand', { sessionId, method, params }); + this._sendToPlaywright({ id, sessionId, result }); + } catch (e) { + debugLogger('Error in the extension:', e); + this._sendToPlaywright({ + id: message.id, + sessionId: message.sessionId, + error: { message: (e as Error).message } + }); + } + } + + private _sendToPlaywright(message: CDPResponse): void { + debugLogger('→ Playwright:', `${message.method ?? `response(id=${message.id})`}`); + this._playwrightConnection?.send(JSON.stringify(message)); + } +} + +export async function startCDPRelayServer({ + getClientInfo, + port, +}: { + getClientInfo: () => { name: string, version: string }; + port: number; +}) { + const httpServer = await startHttpServer({ port }); + const cdpRelayServer = new CDPRelayServer(httpServer, getClientInfo); + process.on('exit', () => cdpRelayServer.stop()); + debugLogger(`CDP relay server started, extension endpoint: ${cdpRelayServer.extensionEndpoint()}.`); + return cdpRelayServer.cdpEndpoint(); +} + +class ExtensionConnection { + private readonly _ws: WebSocket; + private readonly _callbacks = new Map void, reject: (e: Error) => void }>(); + private _lastId = 0; + + onmessage?: (method: string, params: any) => void; + onclose?: (self: ExtensionConnection) => void; + + constructor(ws: WebSocket) { + this._ws = ws; + this._ws.on('message', this._onMessage.bind(this)); + this._ws.on('close', this._onClose.bind(this)); + this._ws.on('error', this._onError.bind(this)); + } + + async send(method: string, params?: any, sessionId?: string): Promise { + if (this._ws.readyState !== WebSocket.OPEN) + throw new Error('WebSocket closed'); + const id = ++this._lastId; + this._ws.send(JSON.stringify({ id, method, params, sessionId })); + return new Promise((resolve, reject) => { + this._callbacks.set(id, { resolve, reject }); + }); + } + + close(message?: string) { + debugLogger('closing extension connection:', message); + this._ws.close(1000, message ?? 'Connection closed'); + this.onclose?.(this); + } + + private _onMessage(event: websocket.RawData) { + const eventData = event.toString(); + let parsedJson; + try { + parsedJson = JSON.parse(eventData); + } catch (e: any) { + debugLogger(` Closing websocket due to malformed JSON. eventData=${eventData} e=${e?.message}`); + this._ws.close(); + return; + } + try { + this._handleParsedMessage(parsedJson); + } catch (e: any) { + debugLogger(` Closing websocket due to failed onmessage callback. eventData=${eventData} e=${e?.message}`); + this._ws.close(); + } + } + + private _handleParsedMessage(object: any) { + if (object.id && this._callbacks.has(object.id)) { + const callback = this._callbacks.get(object.id)!; + this._callbacks.delete(object.id); + if (object.error) + callback.reject(new Error(object.error.message)); + else + callback.resolve(object.result); + } else if (object.id) { + debugLogger('← Extension: unexpected response', object); + } else { + this.onmessage?.(object.method, object.params); + } + } + + private _onClose(event: websocket.CloseEvent) { + debugLogger(` code=${event.code} reason=${event.reason}`); + this._dispose(); + } + + private _onError(event: websocket.ErrorEvent) { + debugLogger(` message=${event.message} type=${event.type} target=${event.target}`); + this._dispose(); + } + + private _dispose() { + for (const callback of this._callbacks.values()) + callback.reject(new Error('WebSocket closed')); + this._callbacks.clear(); + } +} diff --git a/src/extension/main.ts b/src/extension/main.ts new file mode 100644 index 0000000..f6c8651 --- /dev/null +++ b/src/extension/main.ts @@ -0,0 +1,38 @@ +/** + * 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 { resolveCLIConfig } from '../config.js'; +import { Connection } from '../connection.js'; +import { startStdioTransport } from '../transport.js'; +import { Server } from '../server.js'; +import { startCDPRelayServer } from './cdpRelay.js'; + +export async function runWithExtension(options: any) { + const config = await resolveCLIConfig({ }); + + let connection: Connection | null = null; + const cdpEndpoint = await startCDPRelayServer({ + getClientInfo: () => connection!.server.getClientVersion()!, + port: 9225, + }); + // Point CDP endpoint to the relay server. + config.browser.cdpEndpoint = cdpEndpoint; + + const server = new Server(config); + server.setupExitWatchdog(); + + connection = await startStdioTransport(server); +} diff --git a/src/program.ts b/src/program.ts index 3035b28..cfe0d5b 100644 --- a/src/program.ts +++ b/src/program.ts @@ -22,6 +22,7 @@ import { startHttpServer, startHttpTransport, startStdioTransport } from './tran import { commaSeparatedList, resolveCLIConfig, semicolonSeparatedList } from './config.js'; import { Server } from './server.js'; import { packageJSON } from './package.js'; +import { runWithExtension } from './extension/main.js'; program .version('Version ' + packageJSON.version) @@ -50,23 +51,30 @@ 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('--vision', 'Legacy option, use --caps=vision instead').hideHelp()) .action(async options => { + if (options.extension) { + await runWithExtension(options); + return; + } + if (options.vision) { // eslint-disable-next-line no-console console.error('The --vision option is deprecated, use --caps=vision instead'); options.caps = 'vision'; } const config = await resolveCLIConfig(options); - const httpServer = config.server.port !== undefined ? await startHttpServer(config.server) : undefined; const server = new Server(config); server.setupExitWatchdog(); - if (httpServer) + if (config.server.port !== undefined) { + const httpServer = await startHttpServer(config.server); startHttpTransport(httpServer, server); - else + } else { await startStdioTransport(server); + } if (config.saveTrace) { const server = await startTraceViewerServer(); diff --git a/src/transport.ts b/src/transport.ts index 2342fe9..b645a1f 100644 --- a/src/transport.ts +++ b/src/transport.ts @@ -27,7 +27,7 @@ import type { AddressInfo } from 'node:net'; import type { Server } from './server.js'; export async function startStdioTransport(server: Server) { - await server.createConnection(new StdioServerTransport()); + return await server.createConnection(new StdioServerTransport()); } const testDebug = debug('pw:mcp:test');