Skip to content

Commit 23479cc

Browse files
mxschmittyury-s
andauthored
feat: add MCP Chrome extension (microsoft#325)
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 microsoft/playwright#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>
1 parent 69a804a commit 23479cc

28 files changed

+1430
-56
lines changed

extension/background.js

Lines changed: 344 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,344 @@
1+
/**
2+
* Copyright (c) Microsoft Corporation.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
/**
18+
* Simple Chrome Extension that pumps CDP messages between chrome.debugger and WebSocket
19+
*/
20+
21+
// @ts-check
22+
23+
function debugLog(...args) {
24+
const enabled = false;
25+
if (enabled) {
26+
console.log('[Extension]', ...args);
27+
}
28+
}
29+
30+
class TabShareExtension {
31+
constructor() {
32+
this.activeConnections = new Map(); // tabId -> connection info
33+
34+
// Remove page action click handler since we now use popup
35+
chrome.tabs.onRemoved.addListener(this.onTabRemoved.bind(this));
36+
37+
// Handle messages from popup
38+
chrome.runtime.onMessage.addListener(this.onMessage.bind(this));
39+
}
40+
41+
/**
42+
* Handle messages from popup
43+
* @param {any} message
44+
* @param {chrome.runtime.MessageSender} sender
45+
* @param {Function} sendResponse
46+
*/
47+
onMessage(message, sender, sendResponse) {
48+
switch (message.type) {
49+
case 'getStatus':
50+
this.getStatus(message.tabId, sendResponse);
51+
return true; // Will respond asynchronously
52+
53+
case 'connect':
54+
this.connectTab(message.tabId, message.bridgeUrl).then(
55+
() => sendResponse({ success: true }),
56+
(error) => sendResponse({ success: false, error: error.message })
57+
);
58+
return true; // Will respond asynchronously
59+
60+
case 'disconnect':
61+
this.disconnectTab(message.tabId).then(
62+
() => sendResponse({ success: true }),
63+
(error) => sendResponse({ success: false, error: error.message })
64+
);
65+
return true; // Will respond asynchronously
66+
}
67+
return false;
68+
}
69+
70+
/**
71+
* Get connection status for popup
72+
* @param {number} requestedTabId
73+
* @param {Function} sendResponse
74+
*/
75+
getStatus(requestedTabId, sendResponse) {
76+
const isConnected = this.activeConnections.size > 0;
77+
let activeTabId = null;
78+
let activeTabInfo = null;
79+
80+
if (isConnected) {
81+
const [tabId, connection] = this.activeConnections.entries().next().value;
82+
activeTabId = tabId;
83+
84+
// Get tab info
85+
chrome.tabs.get(tabId, (tab) => {
86+
if (chrome.runtime.lastError) {
87+
sendResponse({
88+
isConnected: false,
89+
error: 'Active tab not found'
90+
});
91+
} else {
92+
sendResponse({
93+
isConnected: true,
94+
activeTabId,
95+
activeTabInfo: {
96+
title: tab.title,
97+
url: tab.url
98+
}
99+
});
100+
}
101+
});
102+
} else {
103+
sendResponse({
104+
isConnected: false,
105+
activeTabId: null,
106+
activeTabInfo: null
107+
});
108+
}
109+
}
110+
111+
/**
112+
* Connect a tab to the bridge server
113+
* @param {number} tabId
114+
* @param {string} bridgeUrl
115+
*/
116+
async connectTab(tabId, bridgeUrl) {
117+
try {
118+
debugLog(`Connecting tab ${tabId} to bridge at ${bridgeUrl}`);
119+
120+
// Attach chrome debugger
121+
const debuggee = { tabId };
122+
await chrome.debugger.attach(debuggee, '1.3');
123+
124+
if (chrome.runtime.lastError)
125+
throw new Error(chrome.runtime.lastError.message);
126+
const targetInfo = /** @type {any} */ (await chrome.debugger.sendCommand(debuggee, 'Target.getTargetInfo'));
127+
debugLog('Target info:', targetInfo);
128+
129+
// Connect to bridge server
130+
const socket = new WebSocket(bridgeUrl);
131+
132+
const connection = {
133+
debuggee,
134+
socket,
135+
tabId,
136+
sessionId: `pw-tab-${tabId}`
137+
};
138+
139+
await new Promise((resolve, reject) => {
140+
socket.onopen = () => {
141+
debugLog(`WebSocket connected for tab ${tabId}`);
142+
// Send initial connection info to bridge
143+
socket.send(JSON.stringify({
144+
type: 'connection_info',
145+
sessionId: connection.sessionId,
146+
targetInfo: targetInfo?.targetInfo
147+
}));
148+
resolve(undefined);
149+
};
150+
socket.onerror = reject;
151+
setTimeout(() => reject(new Error('Connection timeout')), 5000);
152+
});
153+
154+
// Set up message handling
155+
this.setupMessageHandling(connection);
156+
157+
// Store connection
158+
this.activeConnections.set(tabId, connection);
159+
160+
// Update UI
161+
chrome.action.setBadgeText({ tabId, text: '●' });
162+
chrome.action.setBadgeBackgroundColor({ tabId, color: '#4CAF50' });
163+
chrome.action.setTitle({ tabId, title: 'Disconnect from Playwright MCP' });
164+
165+
debugLog(`Tab ${tabId} connected successfully`);
166+
167+
} catch (error) {
168+
debugLog(`Failed to connect tab ${tabId}:`, error.message);
169+
await this.cleanupConnection(tabId);
170+
171+
// Show error to user
172+
chrome.action.setBadgeText({ tabId, text: '!' });
173+
chrome.action.setBadgeBackgroundColor({ tabId, color: '#F44336' });
174+
chrome.action.setTitle({ tabId, title: `Connection failed: ${error.message}` });
175+
176+
throw error; // Re-throw for popup to handle
177+
}
178+
}
179+
180+
/**
181+
* Set up bidirectional message handling between debugger and WebSocket
182+
* @param {Object} connection
183+
*/
184+
setupMessageHandling(connection) {
185+
const { debuggee, socket, tabId, sessionId: rootSessionId } = connection;
186+
187+
// WebSocket -> chrome.debugger
188+
socket.onmessage = async (event) => {
189+
let message;
190+
try {
191+
message = JSON.parse(event.data);
192+
} catch (error) {
193+
debugLog('Error parsing message:', error);
194+
socket.send(JSON.stringify({
195+
error: {
196+
code: -32700,
197+
message: `Error parsing message: ${error.message}`
198+
}
199+
}));
200+
return;
201+
}
202+
203+
try {
204+
debugLog('Received from bridge:', message);
205+
206+
const debuggerSession = { ...debuggee };
207+
const sessionId = message.sessionId;
208+
// Pass session id, unless it's the root session.
209+
if (sessionId && sessionId !== rootSessionId)
210+
debuggerSession.sessionId = sessionId;
211+
212+
// Forward CDP command to chrome.debugger
213+
const result = await chrome.debugger.sendCommand(
214+
debuggerSession,
215+
message.method,
216+
message.params || {}
217+
);
218+
219+
// Send response back to bridge
220+
const response = {
221+
id: message.id,
222+
sessionId,
223+
result
224+
};
225+
226+
if (chrome.runtime.lastError) {
227+
response.error = {
228+
code: -32000,
229+
message: chrome.runtime.lastError.message,
230+
};
231+
}
232+
233+
socket.send(JSON.stringify(response));
234+
} catch (error) {
235+
debugLog('Error processing WebSocket message:', error);
236+
const response = {
237+
id: message.id,
238+
sessionId: message.sessionId,
239+
error: {
240+
code: -32000,
241+
message: error.message,
242+
},
243+
};
244+
socket.send(JSON.stringify(response));
245+
}
246+
};
247+
248+
// chrome.debugger events -> WebSocket
249+
const eventListener = (source, method, params) => {
250+
if (source.tabId === tabId && socket.readyState === WebSocket.OPEN) {
251+
// If the sessionId is not provided, use the root sessionId.
252+
const event = {
253+
sessionId: source.sessionId || rootSessionId,
254+
method,
255+
params,
256+
};
257+
debugLog('Forwarding CDP event:', event);
258+
socket.send(JSON.stringify(event));
259+
}
260+
};
261+
262+
const detachListener = (source, reason) => {
263+
if (source.tabId === tabId) {
264+
debugLog(`Debugger detached from tab ${tabId}, reason: ${reason}`);
265+
this.disconnectTab(tabId);
266+
}
267+
};
268+
269+
// Store listeners for cleanup
270+
connection.eventListener = eventListener;
271+
connection.detachListener = detachListener;
272+
273+
chrome.debugger.onEvent.addListener(eventListener);
274+
chrome.debugger.onDetach.addListener(detachListener);
275+
276+
// Handle WebSocket close
277+
socket.onclose = () => {
278+
debugLog(`WebSocket closed for tab ${tabId}`);
279+
this.disconnectTab(tabId);
280+
};
281+
282+
socket.onerror = (error) => {
283+
debugLog(`WebSocket error for tab ${tabId}:`, error);
284+
this.disconnectTab(tabId);
285+
};
286+
}
287+
288+
/**
289+
* Disconnect a tab from the bridge
290+
* @param {number} tabId
291+
*/
292+
async disconnectTab(tabId) {
293+
await this.cleanupConnection(tabId);
294+
295+
// Update UI
296+
chrome.action.setBadgeText({ tabId, text: '' });
297+
chrome.action.setTitle({ tabId, title: 'Share tab with Playwright MCP' });
298+
299+
debugLog(`Tab ${tabId} disconnected`);
300+
}
301+
302+
/**
303+
* Clean up connection resources
304+
* @param {number} tabId
305+
*/
306+
async cleanupConnection(tabId) {
307+
const connection = this.activeConnections.get(tabId);
308+
if (!connection) return;
309+
310+
// Remove listeners
311+
if (connection.eventListener) {
312+
chrome.debugger.onEvent.removeListener(connection.eventListener);
313+
}
314+
if (connection.detachListener) {
315+
chrome.debugger.onDetach.removeListener(connection.detachListener);
316+
}
317+
318+
// Close WebSocket
319+
if (connection.socket && connection.socket.readyState === WebSocket.OPEN) {
320+
connection.socket.close();
321+
}
322+
323+
// Detach debugger
324+
try {
325+
await chrome.debugger.detach(connection.debuggee);
326+
} catch (error) {
327+
// Ignore detach errors - might already be detached
328+
}
329+
330+
this.activeConnections.delete(tabId);
331+
}
332+
333+
/**
334+
* Handle tab removal
335+
* @param {number} tabId
336+
*/
337+
async onTabRemoved(tabId) {
338+
if (this.activeConnections.has(tabId)) {
339+
await this.cleanupConnection(tabId);
340+
}
341+
}
342+
}
343+
344+
new TabShareExtension();

extension/icons/icon-128.png

6.2 KB
Loading

extension/icons/icon-16.png

571 Bytes
Loading

extension/icons/icon-32.png

1.23 KB
Loading

extension/icons/icon-48.png

2 KB
Loading

extension/manifest.json

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
{
2+
"manifest_version": 3,
3+
"name": "Playwright MCP Bridge",
4+
"version": "1.0.0",
5+
"description": "Share browser tabs with Playwright MCP server through CDP bridge",
6+
7+
"permissions": [
8+
"debugger",
9+
"activeTab",
10+
"tabs",
11+
"storage"
12+
],
13+
14+
"host_permissions": [
15+
"<all_urls>"
16+
],
17+
18+
"background": {
19+
"service_worker": "background.js",
20+
"type": "module"
21+
},
22+
23+
"action": {
24+
"default_title": "Share tab with Playwright MCP",
25+
"default_popup": "popup.html",
26+
"default_icon": {
27+
"16": "icons/icon-16.png",
28+
"32": "icons/icon-32.png",
29+
"48": "icons/icon-48.png",
30+
"128": "icons/icon-128.png"
31+
}
32+
},
33+
34+
"icons": {
35+
"16": "icons/icon-16.png",
36+
"32": "icons/icon-32.png",
37+
"48": "icons/icon-48.png",
38+
"128": "icons/icon-128.png"
39+
}
40+
}

0 commit comments

Comments
 (0)