Skip to content

Commit dcce7ae

Browse files
committed
feat: add MCP Chrome extension
1 parent a15f0f3 commit dcce7ae

31 files changed

+1184
-17
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
lib/
22
node_modules/
33
test-results/
4+
playwright-report/
45
.vscode/mcp.json
56

67
.idea

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -485,4 +485,10 @@ X Y coordinate space, based on the provided screenshot.
485485
- `accept` (boolean): Whether to accept the dialog.
486486
- `promptText` (string, optional): The text of the prompt in case of a prompt dialog.
487487

488+
<!-- NOTE: This has been generated via update-readme.js -->
489+
490+
- **browser_connect**
491+
- Description: If the user explicitly asks to connect to a running browser, use this tool to initiate the connection.
492+
- Parameters: None
493+
488494
<!--- End of generated section -->

config.d.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,11 @@ export type Config = {
8989
*/
9090
vision?: boolean;
9191

92+
/**
93+
* Run server that is able to connect to the 'Playwright MCP' Chrome extension.
94+
*/
95+
extension?: boolean;
96+
9297
/**
9398
* The directory to save output files.
9499
*/

extension/background.js

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
// @ts-check
2+
3+
/**
4+
* @typedef {{tabId: number}} DebuggerTarget
5+
*/
6+
7+
function debugLog(...args) {
8+
const enabled = false;
9+
if (enabled) {
10+
console.log(...args);
11+
}
12+
}
13+
14+
class Extension {
15+
constructor() {
16+
this._socket = null;
17+
this._attachedTabId = null;
18+
chrome.debugger.onEvent.addListener((this.onDebuggerEvent.bind(this)));
19+
chrome.debugger.onDetach.addListener(this.onDebuggerDetach.bind(this));
20+
chrome.tabs.onUpdated.addListener((this.onTabsUpdated.bind(this)));
21+
}
22+
23+
/**
24+
* @param {chrome.debugger.DebuggerSession} source
25+
* @param {string} method
26+
* @param {Object} params
27+
*/
28+
onDebuggerEvent(source, method, params) {
29+
// Only handle events coming from the attached tab.
30+
if (source.tabId !== this._attachedTabId) return;
31+
debugLog("CDP event:", method, params);
32+
if (this._socket && this._socket.readyState === WebSocket.OPEN) {
33+
let eventData = {
34+
method: method,
35+
params: params,
36+
sessionId: 'dummy-session-id', // Use a dummy session ID for now
37+
};
38+
this._socket.send(JSON.stringify(eventData));
39+
}
40+
}
41+
42+
/**
43+
*
44+
* @param {WebSocket} socket
45+
* @param {DebuggerTarget} debuggee
46+
* @param {string} targetId
47+
* @param {string} browserContextId
48+
* @param {object} event
49+
* @returns
50+
*/
51+
async onBrowserMessage(socket, debuggee, targetId, browserContextId, event) {
52+
try {
53+
let message = JSON.parse(new TextDecoder().decode(await event.data.arrayBuffer()));
54+
if (message.method === 'Browser.getVersion') {
55+
// Handle the Browser.getVersion command
56+
let versionInfo = {
57+
protocolVersion: "1.3",
58+
userAgent: navigator.userAgent,
59+
product: "Chrome"
60+
};
61+
socket.send(JSON.stringify({ id: message.id, result: versionInfo }));
62+
return;
63+
}
64+
if (message.method === 'Target.setAutoAttach' && !message.sessionId) {
65+
socket.send(JSON.stringify({
66+
method: "Target.attachedToTarget",
67+
params: {
68+
sessionId: "dummy-session-id",
69+
targetInfo: {
70+
targetId,
71+
browserContextId,
72+
type: "page",
73+
title: "",
74+
url: "data:text/html,",
75+
attached: true,
76+
canAccessOpener: false,
77+
},
78+
waitingForDebugger: false
79+
}
80+
}))
81+
socket.send(JSON.stringify({
82+
id: message.id,
83+
result: {},
84+
}));
85+
return;
86+
}
87+
if (message.method === 'Browser.setDownloadBehavior') {
88+
socket.send(JSON.stringify({
89+
id: message.id,
90+
result: {},
91+
}));
92+
return;
93+
}
94+
if (message.method) {
95+
debugLog("Received command from WebSocket:", message);
96+
chrome.debugger.sendCommand(debuggee, message.method, message.params || {}, (response) => {
97+
// Send back the response to the WebSocket server.
98+
let reply = {
99+
id: message.id, // echo back the command id if provided
100+
result: response,
101+
error: chrome.runtime.lastError ? chrome.runtime.lastError.message : null,
102+
sessionId: message.sessionId
103+
};
104+
if (socket && socket.readyState === WebSocket.OPEN) {
105+
socket.send(JSON.stringify(reply));
106+
}
107+
});
108+
}
109+
} catch (e) {
110+
console.error("Error processing WebSocket message:", e);
111+
}
112+
}
113+
114+
/**
115+
* @param {chrome.debugger.Debuggee} source
116+
* @param {chrome.debugger.DetachReason} reason
117+
*/
118+
onDebuggerDetach(source, reason) {
119+
if (source.tabId === this._attachedTabId) {
120+
debugLog("Debugger detached from tab:", this._attachedTabId, "Reason:", reason);
121+
this._attachedTabId = null;
122+
if (this._socket && this._socket.readyState === WebSocket.OPEN) {
123+
this._socket.close();
124+
}
125+
}
126+
}
127+
128+
/**
129+
* @param {number} tabId
130+
* @param {chrome.tabs.TabChangeInfo} changeInfo
131+
* @param {chrome.tabs.Tab} tab
132+
*/
133+
onTabsUpdated(tabId, changeInfo, tab) {
134+
if (changeInfo.status !== 'complete' || !tab.url)
135+
return;
136+
const url = new URL(tab.url);
137+
if (url.hostname !== 'demo.playwright.dev' || url.pathname !== '/mcp.html')
138+
return;
139+
const params = new URLSearchParams(url.search);
140+
const proxyURL = params.get('connectionURL');
141+
if (!proxyURL)
142+
return;
143+
if (this._attachedTabId !== null) {
144+
debugLog("Already attached to tab:", this._attachedTabId);
145+
return;
146+
}
147+
this._attachedTabId = tabId;
148+
debugLog("Attaching debugger to tab:", this._attachedTabId);
149+
// Initialize the proxy connection
150+
this.initializeProxy({ tabId }, proxyURL).catch((error) => {
151+
console.error("Error initializing proxy:", error);
152+
});
153+
}
154+
155+
/**
156+
* @param {DebuggerTarget} debuggee
157+
* @param {string} proxyURL
158+
*/
159+
async initializeProxy(debuggee, proxyURL) {
160+
await chrome.tabs.update(debuggee.tabId, { url: chrome.runtime.getURL('prompt.html') });
161+
await new Promise((resolve) => {
162+
const listener = (message, foo) => {
163+
if (foo.tab.id === debuggee.tabId && message.action === 'approve') {
164+
chrome.runtime.onMessage.removeListener(listener);
165+
resolve(undefined);
166+
}
167+
};
168+
chrome.runtime.onMessage.addListener(listener);
169+
});
170+
await chrome.debugger.attach(debuggee, "1.3")
171+
if (chrome.runtime.lastError) {
172+
console.error("Failed to attach debugger:", chrome.runtime.lastError.message);
173+
return;
174+
}
175+
debugLog("Debugger attached to tab:", debuggee.tabId);
176+
const { targetId, browserContextId } = (/** @type{any} */ (await chrome.debugger.sendCommand(debuggee, "Target.getTargetInfo"))).targetInfo;
177+
const socket = this._socket = new WebSocket(proxyURL);
178+
socket.addEventListener('open', () => {
179+
chrome.tabs.update(debuggee.tabId, { url: chrome.runtime.getURL('success.html') });
180+
});
181+
182+
socket.addEventListener('message', (e) => this.onBrowserMessage(socket, debuggee, targetId, browserContextId, e));
183+
184+
socket.addEventListener('error', (event) => {
185+
console.error("WebSocket error:", event);
186+
});
187+
188+
// On WebSocket close, detach the debugger.
189+
socket.addEventListener('close', async (event) => {
190+
debugLog("WebSocket connection closed:", event);
191+
if (!this._attachedTabId)
192+
return;
193+
await chrome.debugger.detach({ tabId: this._attachedTabId });
194+
debugLog("Debugger detached from tab:", this._attachedTabId);
195+
this._attachedTabId = null;
196+
});
197+
}
198+
}
199+
200+
new Extension();

extension/icon-128.png

6.2 KB
Loading

extension/icon-16.png

571 Bytes
Loading

extension/icon-32.png

1.23 KB
Loading

extension/icon-48.png

2 KB
Loading

extension/manifest.json

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
{
2+
"manifest_version": 3,
3+
"name": "Playwright MCP",
4+
"version": "0.1",
5+
"description": "Allows Playwright MCP to connect to your browser.",
6+
"permissions": [
7+
"debugger",
8+
"tabs"
9+
],
10+
"background": {
11+
"service_worker": "background.js"
12+
},
13+
"icons": {
14+
"16": "icon-16.png",
15+
"32": "icon-32.png",
16+
"48": "icon-48.png",
17+
"128": "icon-128.png"
18+
}
19+
}

0 commit comments

Comments
 (0)