-
Notifications
You must be signed in to change notification settings - Fork 975
feat: add MCP Chrome extension #325
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
93cbaf5
feat: add MCP Chrome extension
mxschmitt f280546
Report parse errors, forward protocol errors from the browser
yury-s fbfca58
Assign sessionId in the extension and pass it to the relay
yury-s f7421cd
Support OOPIFs
yury-s 6679996
rebase
yury-s 9e5fadd
cleanups
mxschmitt 4349cfe
Add license, formatting
yury-s 7281c15
Simplify fixtures
yury-s 1d5019c
Fix lint, docker, pass relayServerURL explicitely
yury-s File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,344 @@ | ||
/** | ||
* 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(); |
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,40 @@ | ||
{ | ||
"manifest_version": 3, | ||
"name": "Playwright MCP Bridge", | ||
"version": "1.0.0", | ||
"description": "Share browser tabs with Playwright MCP server through CDP bridge", | ||
|
||
"permissions": [ | ||
"debugger", | ||
"activeTab", | ||
"tabs", | ||
"storage" | ||
], | ||
|
||
"host_permissions": [ | ||
"<all_urls>" | ||
], | ||
|
||
"background": { | ||
"service_worker": "background.js", | ||
"type": "module" | ||
}, | ||
|
||
"action": { | ||
"default_title": "Share tab with Playwright MCP", | ||
"default_popup": "popup.html", | ||
"default_icon": { | ||
"16": "icons/icon-16.png", | ||
"32": "icons/icon-32.png", | ||
"48": "icons/icon-48.png", | ||
"128": "icons/icon-128.png" | ||
} | ||
}, | ||
|
||
"icons": { | ||
"16": "icons/icon-16.png", | ||
"32": "icons/icon-32.png", | ||
"48": "icons/icon-48.png", | ||
"128": "icons/icon-128.png" | ||
} | ||
} |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.