chore: add mcp chrome extension (#710)

This commit is contained in:
Yury Semikhatsky 2025-07-18 17:12:44 -07:00 committed by GitHub
parent 1eee30fd45
commit d3867affed
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 880 additions and 5 deletions

32
extension/connect.html Normal file
View File

@ -0,0 +1,32 @@
<!--
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.
-->
<!DOCTYPE html>
<html>
<head>
<title>Playwright MCP extension</title>
</head>
<body>
<div class="header">
<h3>Playwright MCP extension</h3>
</div>
<div id="status-container"></div>
<div class="button-row">
<button id="continue-btn">Continue</button>
<button id="reject-btn">Reject</button>
</div>
<script src="lib/connect.js"></script>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

BIN
extension/icons/icon-16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 571 B

BIN
extension/icons/icon-32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
extension/icons/icon-48.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

40
extension/manifest.json Normal file
View File

@ -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": [
"<all_urls>"
],
"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"
}
}

109
extension/src/background.ts Normal file
View File

@ -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<void> {
try {
debugLog(`Connecting tab ${tabId} to bridge at ${mcpRelayUrl}`);
const socket = new WebSocket(mcpRelayUrl);
await new Promise<void>((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<void> {
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<void> {
await chrome.action.setBadgeText({ tabId, text });
if (color)
await chrome.action.setBadgeBackgroundColor({ tabId, color });
}
private async _onTabRemoved(tabId: number): Promise<void> {
if (this._connectedTabId === tabId)
this._activeConnection!.setConnectedTabId(null);
}
private async _onTabUpdated(tabId: number, changeInfo: chrome.tabs.TabChangeInfo, tab: chrome.tabs.Tab): Promise<void> {
if (changeInfo.status === 'complete' && this._connectedTabId === tabId)
await this._setConnectedTabId(tabId);
}
}
new TabShareExtension();

70
extension/src/connect.ts Normal file
View File

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

View File

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

15
extension/tsconfig.json Normal file
View File

@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ESNext",
"esModuleInterop": true,
"moduleResolution": "node",
"strict": true,
"module": "ESNext",
"rootDir": "src",
"outDir": "./lib",
"resolveJsonModule": true,
},
"include": [
"src",
],
}

View File

@ -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": {

385
src/extension/cdpRelay.ts Normal file
View File

@ -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<void>;
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<void> {
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<boolean> {
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<void> {
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<number, { resolve: (o: any) => 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<any> {
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 ws> Closing websocket due to malformed JSON. eventData=${eventData} e=${e?.message}`);
this._ws.close();
return;
}
try {
this._handleParsedMessage(parsedJson);
} catch (e: any) {
debugLogger(`<closing ws> 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(`<ws closed> code=${event.code} reason=${event.reason}`);
this._dispose();
}
private _onError(event: websocket.ErrorEvent) {
debugLogger(`<ws error> 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();
}
}

38
src/extension/main.ts Normal file
View File

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

View File

@ -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 <ua string>', 'specify user agent string')
.option('--user-data-dir <path>', 'path to the user data directory. If not specified, a temporary directory will be created.')
.option('--viewport-size <size>', 'specify browser viewport size in pixels, for example "1280, 720"')
.addOption(new Option('--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();

View File

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