mirror of
https://github.com/microsoft/playwright-mcp.git
synced 2025-07-26 08:32:26 +08:00

Instructions: 1. `git clone https://github.com/mxschmitt/playwright-mcp && git checkout extension-drafft` 2. `npm ci && npm run build` 3. `chrome://extensions` in your normal Chrome, "load unpacked" and select the extension folder. 4. `node cli.js --port=4242 --extension` - The URL it prints at the end you can put into the extension popup. 5. Put either this into Claude Desktop (it does not support SSE yet hence wrapping it or just put the URL into Cursor/VSCode) ```json { "mcpServers": { "playwright": { "command": "bash", "args": [ "-c", "source $HOME/.nvm/nvm.sh && nvm use --silent 22 && npx supergateway --streamableHttp http://127.0.0.1:4242/mcp" ] } } } ``` Things like `Take a snapshot of my browser.` should now work in your Prompt Chat. ---- - SSE only for now, since we already have a http server with a port there - Upstream "page tests" can be executed over this CDP relay via https://github.com/microsoft/playwright/pull/36286 - Limitations for now are everything what happens outside of the tab its session is shared with -> `window.open` / `target=_blank`. --------- Co-authored-by: Yury Semikhatsky <yurys@chromium.org>
345 lines
9.6 KiB
JavaScript
345 lines
9.6 KiB
JavaScript
/**
|
|
* 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.
|
|
*/
|
|
|
|
/**
|
|
* Simple Chrome Extension that pumps CDP messages between chrome.debugger and WebSocket
|
|
*/
|
|
|
|
// @ts-check
|
|
|
|
function debugLog(...args) {
|
|
const enabled = false;
|
|
if (enabled) {
|
|
console.log('[Extension]', ...args);
|
|
}
|
|
}
|
|
|
|
class TabShareExtension {
|
|
constructor() {
|
|
this.activeConnections = new Map(); // tabId -> connection info
|
|
|
|
// Remove page action click handler since we now use popup
|
|
chrome.tabs.onRemoved.addListener(this.onTabRemoved.bind(this));
|
|
|
|
// Handle messages from popup
|
|
chrome.runtime.onMessage.addListener(this.onMessage.bind(this));
|
|
}
|
|
|
|
/**
|
|
* Handle messages from popup
|
|
* @param {any} message
|
|
* @param {chrome.runtime.MessageSender} sender
|
|
* @param {Function} sendResponse
|
|
*/
|
|
onMessage(message, sender, sendResponse) {
|
|
switch (message.type) {
|
|
case 'getStatus':
|
|
this.getStatus(message.tabId, sendResponse);
|
|
return true; // Will respond asynchronously
|
|
|
|
case 'connect':
|
|
this.connectTab(message.tabId, message.bridgeUrl).then(
|
|
() => sendResponse({ success: true }),
|
|
(error) => sendResponse({ success: false, error: error.message })
|
|
);
|
|
return true; // Will respond asynchronously
|
|
|
|
case 'disconnect':
|
|
this.disconnectTab(message.tabId).then(
|
|
() => sendResponse({ success: true }),
|
|
(error) => sendResponse({ success: false, error: error.message })
|
|
);
|
|
return true; // Will respond asynchronously
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Get connection status for popup
|
|
* @param {number} requestedTabId
|
|
* @param {Function} sendResponse
|
|
*/
|
|
getStatus(requestedTabId, sendResponse) {
|
|
const isConnected = this.activeConnections.size > 0;
|
|
let activeTabId = null;
|
|
let activeTabInfo = null;
|
|
|
|
if (isConnected) {
|
|
const [tabId, connection] = this.activeConnections.entries().next().value;
|
|
activeTabId = tabId;
|
|
|
|
// Get tab info
|
|
chrome.tabs.get(tabId, (tab) => {
|
|
if (chrome.runtime.lastError) {
|
|
sendResponse({
|
|
isConnected: false,
|
|
error: 'Active tab not found'
|
|
});
|
|
} else {
|
|
sendResponse({
|
|
isConnected: true,
|
|
activeTabId,
|
|
activeTabInfo: {
|
|
title: tab.title,
|
|
url: tab.url
|
|
}
|
|
});
|
|
}
|
|
});
|
|
} else {
|
|
sendResponse({
|
|
isConnected: false,
|
|
activeTabId: null,
|
|
activeTabInfo: null
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Connect a tab to the bridge server
|
|
* @param {number} tabId
|
|
* @param {string} bridgeUrl
|
|
*/
|
|
async connectTab(tabId, bridgeUrl) {
|
|
try {
|
|
debugLog(`Connecting tab ${tabId} to bridge at ${bridgeUrl}`);
|
|
|
|
// Attach chrome debugger
|
|
const debuggee = { tabId };
|
|
await chrome.debugger.attach(debuggee, '1.3');
|
|
|
|
if (chrome.runtime.lastError)
|
|
throw new Error(chrome.runtime.lastError.message);
|
|
const targetInfo = /** @type {any} */ (await chrome.debugger.sendCommand(debuggee, 'Target.getTargetInfo'));
|
|
debugLog('Target info:', targetInfo);
|
|
|
|
// Connect to bridge server
|
|
const socket = new WebSocket(bridgeUrl);
|
|
|
|
const connection = {
|
|
debuggee,
|
|
socket,
|
|
tabId,
|
|
sessionId: `pw-tab-${tabId}`
|
|
};
|
|
|
|
await new Promise((resolve, reject) => {
|
|
socket.onopen = () => {
|
|
debugLog(`WebSocket connected for tab ${tabId}`);
|
|
// Send initial connection info to bridge
|
|
socket.send(JSON.stringify({
|
|
type: 'connection_info',
|
|
sessionId: connection.sessionId,
|
|
targetInfo: targetInfo?.targetInfo
|
|
}));
|
|
resolve(undefined);
|
|
};
|
|
socket.onerror = reject;
|
|
setTimeout(() => reject(new Error('Connection timeout')), 5000);
|
|
});
|
|
|
|
// Set up message handling
|
|
this.setupMessageHandling(connection);
|
|
|
|
// Store connection
|
|
this.activeConnections.set(tabId, connection);
|
|
|
|
// Update UI
|
|
chrome.action.setBadgeText({ tabId, text: '●' });
|
|
chrome.action.setBadgeBackgroundColor({ tabId, color: '#4CAF50' });
|
|
chrome.action.setTitle({ tabId, title: 'Disconnect from Playwright MCP' });
|
|
|
|
debugLog(`Tab ${tabId} connected successfully`);
|
|
|
|
} catch (error) {
|
|
debugLog(`Failed to connect tab ${tabId}:`, error.message);
|
|
await this.cleanupConnection(tabId);
|
|
|
|
// Show error to user
|
|
chrome.action.setBadgeText({ tabId, text: '!' });
|
|
chrome.action.setBadgeBackgroundColor({ tabId, color: '#F44336' });
|
|
chrome.action.setTitle({ tabId, title: `Connection failed: ${error.message}` });
|
|
|
|
throw error; // Re-throw for popup to handle
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Set up bidirectional message handling between debugger and WebSocket
|
|
* @param {Object} connection
|
|
*/
|
|
setupMessageHandling(connection) {
|
|
const { debuggee, socket, tabId, sessionId: rootSessionId } = connection;
|
|
|
|
// WebSocket -> chrome.debugger
|
|
socket.onmessage = async (event) => {
|
|
let message;
|
|
try {
|
|
message = JSON.parse(event.data);
|
|
} catch (error) {
|
|
debugLog('Error parsing message:', error);
|
|
socket.send(JSON.stringify({
|
|
error: {
|
|
code: -32700,
|
|
message: `Error parsing message: ${error.message}`
|
|
}
|
|
}));
|
|
return;
|
|
}
|
|
|
|
try {
|
|
debugLog('Received from bridge:', message);
|
|
|
|
const debuggerSession = { ...debuggee };
|
|
const sessionId = message.sessionId;
|
|
// Pass session id, unless it's the root session.
|
|
if (sessionId && sessionId !== rootSessionId)
|
|
debuggerSession.sessionId = sessionId;
|
|
|
|
// Forward CDP command to chrome.debugger
|
|
const result = await chrome.debugger.sendCommand(
|
|
debuggerSession,
|
|
message.method,
|
|
message.params || {}
|
|
);
|
|
|
|
// Send response back to bridge
|
|
const response = {
|
|
id: message.id,
|
|
sessionId,
|
|
result
|
|
};
|
|
|
|
if (chrome.runtime.lastError) {
|
|
response.error = {
|
|
code: -32000,
|
|
message: chrome.runtime.lastError.message,
|
|
};
|
|
}
|
|
|
|
socket.send(JSON.stringify(response));
|
|
} catch (error) {
|
|
debugLog('Error processing WebSocket message:', error);
|
|
const response = {
|
|
id: message.id,
|
|
sessionId: message.sessionId,
|
|
error: {
|
|
code: -32000,
|
|
message: error.message,
|
|
},
|
|
};
|
|
socket.send(JSON.stringify(response));
|
|
}
|
|
};
|
|
|
|
// chrome.debugger events -> WebSocket
|
|
const eventListener = (source, method, params) => {
|
|
if (source.tabId === tabId && socket.readyState === WebSocket.OPEN) {
|
|
// If the sessionId is not provided, use the root sessionId.
|
|
const event = {
|
|
sessionId: source.sessionId || rootSessionId,
|
|
method,
|
|
params,
|
|
};
|
|
debugLog('Forwarding CDP event:', event);
|
|
socket.send(JSON.stringify(event));
|
|
}
|
|
};
|
|
|
|
const detachListener = (source, reason) => {
|
|
if (source.tabId === tabId) {
|
|
debugLog(`Debugger detached from tab ${tabId}, reason: ${reason}`);
|
|
this.disconnectTab(tabId);
|
|
}
|
|
};
|
|
|
|
// Store listeners for cleanup
|
|
connection.eventListener = eventListener;
|
|
connection.detachListener = detachListener;
|
|
|
|
chrome.debugger.onEvent.addListener(eventListener);
|
|
chrome.debugger.onDetach.addListener(detachListener);
|
|
|
|
// Handle WebSocket close
|
|
socket.onclose = () => {
|
|
debugLog(`WebSocket closed for tab ${tabId}`);
|
|
this.disconnectTab(tabId);
|
|
};
|
|
|
|
socket.onerror = (error) => {
|
|
debugLog(`WebSocket error for tab ${tabId}:`, error);
|
|
this.disconnectTab(tabId);
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Disconnect a tab from the bridge
|
|
* @param {number} tabId
|
|
*/
|
|
async disconnectTab(tabId) {
|
|
await this.cleanupConnection(tabId);
|
|
|
|
// Update UI
|
|
chrome.action.setBadgeText({ tabId, text: '' });
|
|
chrome.action.setTitle({ tabId, title: 'Share tab with Playwright MCP' });
|
|
|
|
debugLog(`Tab ${tabId} disconnected`);
|
|
}
|
|
|
|
/**
|
|
* Clean up connection resources
|
|
* @param {number} tabId
|
|
*/
|
|
async cleanupConnection(tabId) {
|
|
const connection = this.activeConnections.get(tabId);
|
|
if (!connection) return;
|
|
|
|
// Remove listeners
|
|
if (connection.eventListener) {
|
|
chrome.debugger.onEvent.removeListener(connection.eventListener);
|
|
}
|
|
if (connection.detachListener) {
|
|
chrome.debugger.onDetach.removeListener(connection.detachListener);
|
|
}
|
|
|
|
// Close WebSocket
|
|
if (connection.socket && connection.socket.readyState === WebSocket.OPEN) {
|
|
connection.socket.close();
|
|
}
|
|
|
|
// Detach debugger
|
|
try {
|
|
await chrome.debugger.detach(connection.debuggee);
|
|
} catch (error) {
|
|
// Ignore detach errors - might already be detached
|
|
}
|
|
|
|
this.activeConnections.delete(tabId);
|
|
}
|
|
|
|
/**
|
|
* Handle tab removal
|
|
* @param {number} tabId
|
|
*/
|
|
async onTabRemoved(tabId) {
|
|
if (this.activeConnections.has(tabId)) {
|
|
await this.cleanupConnection(tabId);
|
|
}
|
|
}
|
|
}
|
|
|
|
new TabShareExtension();
|