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 0000000..c4bc8b0 Binary files /dev/null and b/extension/icons/icon-128.png differ diff --git a/extension/icons/icon-16.png b/extension/icons/icon-16.png new file mode 100644 index 0000000..0bab712 Binary files /dev/null and b/extension/icons/icon-16.png differ diff --git a/extension/icons/icon-32.png b/extension/icons/icon-32.png new file mode 100644 index 0000000..1f9a8cc Binary files /dev/null and b/extension/icons/icon-32.png differ diff --git a/extension/icons/icon-48.png b/extension/icons/icon-48.png new file mode 100644 index 0000000..ac23ef0 Binary files /dev/null and b/extension/icons/icon-48.png differ 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');