Skip to content

Commit 0698d71

Browse files
committed
feat: add MCP Chrome extension
1 parent 7dc689e commit 0698d71

29 files changed

+1256
-72
lines changed

README.md

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

490+
<!-- NOTE: This has been generated via update-readme.js -->
491+
492+
- **browser_connect**
493+
- Description: If the user explicitly asks to connect to a running browser, use this tool to initiate the connection.
494+
- Parameters: None
495+
490496
### Testing
491497

492498
<!-- NOTE: This has been generated via update-readme.js -->

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