Skip to content

Commit 8890b76

Browse files
committed
feat: add MCP Chrome extension
1 parent 7dae68d commit 8890b76

29 files changed

+1443
-66
lines changed

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,9 @@ Playwright MCP server supports following arguments. They can be provided in the
188188
example "1280, 720"
189189
--vision Run server that uses screenshots (Aria snapshots
190190
are used by default)
191+
--extension Allow connecting to a running browser instance
192+
(Edge/Chrome only). Requires the 'Playwright MCP'
193+
browser extension to be installed.
191194
```
192195

193196
<!--- End of options generated section -->

config.d.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,11 @@ export type Config = {
104104
*/
105105
saveTrace?: boolean;
106106

107+
/**
108+
* Run server that is able to connect to the 'Playwright MCP' Chrome extension.
109+
*/
110+
extension?: boolean;
111+
107112
/**
108113
* The directory to save output files.
109114
*/

extension/background.js

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