From bd34e9d7e9898144a8b3f1495eef3432697f3957 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Thu, 24 Jul 2025 12:01:35 -0700 Subject: [PATCH] chore(extension): page selector for MCP (#750) --- extension/connect.html | 8 +- extension/src/background.ts | 35 ++++--- extension/src/connect.ts | 197 +++++++++++++++++++++++++++--------- 3 files changed, 176 insertions(+), 64 deletions(-) diff --git a/extension/connect.html b/extension/connect.html index e8ab1b6..f8aa868 100644 --- a/extension/connect.html +++ b/extension/connect.html @@ -19,14 +19,16 @@ Playwright MCP extension -
-

Playwright MCP extension

-
+

Playwright MCP extension

+
+

Select page to expose to MCP server:

+
+
\ No newline at end of file diff --git a/extension/src/background.ts b/extension/src/background.ts index 205d847..1a3870f 100644 --- a/extension/src/background.ts +++ b/extension/src/background.ts @@ -19,6 +19,10 @@ import { RelayConnection, debugLog } from './relayConnection.js'; type PageMessage = { type: 'connectToMCPRelay'; mcpRelayUrl: string; + tabId: number; + windowId: number; +} | { + type: 'getTabs'; }; class TabShareExtension { @@ -35,22 +39,22 @@ class TabShareExtension { private _onMessage(message: PageMessage, sender: chrome.runtime.MessageSender, sendResponse: (response: any) => void) { switch (message.type) { case 'connectToMCPRelay': - const tabId = sender.tab?.id; - if (!tabId) { - sendResponse({ success: false, error: 'No tab id' }); - return true; - } - this._connectTab(sender.tab!, message.mcpRelayUrl!).then( + this._connectTab(message.tabId, message.windowId, message.mcpRelayUrl!).then( () => sendResponse({ success: true }), (error: any) => sendResponse({ success: false, error: error.message })); return true; // Return true to indicate that the response will be sent asynchronously + case 'getTabs': + this._getTabs().then( + tabs => sendResponse({ success: true, tabs, currentTabId: sender.tab?.id }), + (error: any) => sendResponse({ success: false, error: error.message })); + return true; } return false; } - private async _connectTab(tab: chrome.tabs.Tab, mcpRelayUrl: string): Promise { + private async _connectTab(tabId: number, windowId: number, mcpRelayUrl: string): Promise { try { - debugLog(`Connecting tab ${tab.id} to bridge at ${mcpRelayUrl}`); + debugLog(`Connecting tab ${tabId} to bridge at ${mcpRelayUrl}`); const socket = new WebSocket(mcpRelayUrl); await new Promise((resolve, reject) => { socket.onopen = () => resolve(); @@ -58,7 +62,7 @@ class TabShareExtension { setTimeout(() => reject(new Error('Connection timeout')), 5000); }); - const connection = new RelayConnection(socket, tab.id!); + const connection = new RelayConnection(socket, tabId); const connectionClosed = (m: string) => { debugLog(m); if (this._activeConnection === connection) { @@ -71,14 +75,14 @@ class TabShareExtension { this._activeConnection = connection; await Promise.all([ - this._setConnectedTabId(tab.id!), - chrome.tabs.update(tab.id!, { active: true }), - chrome.windows.update(tab.windowId, { focused: true }), + this._setConnectedTabId(tabId), + chrome.tabs.update(tabId, { active: true }), + chrome.windows.update(windowId, { focused: true }), ]); debugLog(`Connected to MCP bridge`); } catch (error: any) { await this._setConnectedTabId(null); - debugLog(`Failed to connect tab ${tab.id}:`, error.message); + debugLog(`Failed to connect tab ${tabId}:`, error.message); throw error; } } @@ -110,6 +114,11 @@ class TabShareExtension { if (changeInfo.status === 'complete' && this._connectedTabId === tabId) await this._setConnectedTabId(tabId); } + + private async _getTabs(): Promise { + const tabs = await chrome.tabs.query({}); + return tabs; + } } new TabShareExtension(); diff --git a/extension/src/connect.ts b/extension/src/connect.ts index bd748a2..1bbdd4c 100644 --- a/extension/src/connect.ts +++ b/extension/src/connect.ts @@ -14,57 +14,158 @@ * limitations under the License. */ -document.addEventListener('DOMContentLoaded', async () => { - const statusContainer = document.getElementById('status-container') as HTMLElement; - const continueBtn = document.getElementById('continue-btn') as HTMLButtonElement; - const rejectBtn = document.getElementById('reject-btn') as HTMLButtonElement; - const buttonRow = document.querySelector('.button-row') as HTMLElement; +interface TabInfo { + id: number; + windowId: number; + title: string; + url: string; + favIconUrl?: string; +} - function showStatus(type: 'connected' | 'error' | 'connecting', message: string) { +class ConnectPage { + private _tabList: HTMLElement; + private _tabListContainer: HTMLElement; + private _statusContainer: HTMLElement; + private _selectedTab: TabInfo | undefined; + + constructor() { + this._tabList = document.getElementById('tab-list')!; + this._tabListContainer = document.getElementById('tab-list-container')!; + this._statusContainer = document.getElementById('status-container') as HTMLElement; + this._addButtonHandlers(); + void this._loadTabs(); + } + + private _addButtonHandlers() { + const continueBtn = document.getElementById('continue-btn') as HTMLButtonElement; + const rejectBtn = document.getElementById('reject-btn') as HTMLButtonElement; + const buttonRow = document.querySelector('.button-row') as HTMLElement; + + const params = new URLSearchParams(window.location.search); + const mcpRelayUrl = params.get('mcpRelayUrl'); + + if (!mcpRelayUrl) { + buttonRow.style.display = 'none'; + this._showStatus('error', 'Missing mcpRelayUrl parameter in URL.'); + return; + } + + let clientInfo = 'unknown'; + try { + const client = JSON.parse(params.get('client') || '{}'); + clientInfo = `${client.name}/${client.version}`; + } catch (e) { + this._showStatus('error', 'Failed to parse client version.'); + return; + } + + this._showStatus('connecting', `MCP client "${clientInfo}" is trying to connect. Do you want to continue?`); + + rejectBtn.addEventListener('click', async () => { + buttonRow.style.display = 'none'; + this._tabListContainer.style.display = 'none'; + this._showStatus('error', 'Connection rejected. This tab can be closed.'); + }); + + continueBtn.addEventListener('click', async () => { + buttonRow.style.display = 'none'; + try { + const selectedTab = this._selectedTab; + if (!selectedTab) { + this._showStatus('error', 'Tab not selected.'); + return; + } + const response = await chrome.runtime.sendMessage({ + type: 'connectToMCPRelay', + mcpRelayUrl, + tabId: selectedTab.id, + windowId: selectedTab.windowId, + }); + if (response?.success) + this._showStatus('connected', `MCP client "${clientInfo}" connected.`); + else + this._showStatus('error', response?.error || `MCP client "${clientInfo}" failed to connect.`); + } catch (e) { + this._showStatus('error', `MCP client "${clientInfo}" failed to connect: ${e}`); + } + }); + } + + private async _loadTabs(): Promise { + try { + const response = await chrome.runtime.sendMessage({ type: 'getTabs' }); + if (response.success) + this._populateTabList(response.tabs, response.currentTabId); + else + this._showStatus('error', 'Failed to load tabs: ' + response.error); + } catch (error) { + this._showStatus('error', 'Failed to communicate with background script: ' + error); + } + } + + private _populateTabList(tabs: TabInfo[], currentTabId: number): void { + this._tabList.replaceChildren(); + this._selectedTab = tabs.find(tab => tab.id === currentTabId); + + tabs.forEach((tab, index) => { + const tabElement = this._createTabElement(tab); + this._tabList.appendChild(tabElement); + }); + } + + private _createTabElement(tab: TabInfo): HTMLElement { + const disabled = tab.url.startsWith('chrome://'); + + const tabInfoDiv = document.createElement('div'); + tabInfoDiv.className = 'tab-info'; + tabInfoDiv.style.padding = '5px'; + if (disabled) + tabInfoDiv.style.opacity = '0.5'; + + const radioButton = document.createElement('input'); + radioButton.type = 'radio'; + radioButton.name = 'tab-selection'; + radioButton.checked = tab.id === this._selectedTab?.id; + radioButton.id = `tab-${tab.id}`; + radioButton.addEventListener('change', e => { + if (radioButton.checked) + this._selectedTab = tab; + }); + if (disabled) + radioButton.disabled = true; + + const favicon = document.createElement('img'); + favicon.className = 'tab-favicon'; + if (tab.favIconUrl) + favicon.src = tab.favIconUrl; + favicon.alt = ''; + favicon.style.height = '16px'; + favicon.style.width = '16px'; + + const title = document.createElement('span'); + title.style.paddingLeft = '5px'; + title.className = 'tab-title'; + title.textContent = tab.title || 'Untitled'; + + const url = document.createElement('span'); + url.style.paddingLeft = '5px'; + url.className = 'tab-url'; + url.textContent = tab.url; + + tabInfoDiv.appendChild(radioButton); + tabInfoDiv.appendChild(favicon); + tabInfoDiv.appendChild(title); + tabInfoDiv.appendChild(url); + + return tabInfoDiv; + } + + private _showStatus(type: 'connected' | 'error' | 'connecting', message: string) { const div = document.createElement('div'); div.className = `status ${type}`; div.textContent = message; - statusContainer.replaceChildren(div); + this._statusContainer.replaceChildren(div); } +} - const params = new URLSearchParams(window.location.search); - const mcpRelayUrl = params.get('mcpRelayUrl'); - - if (!mcpRelayUrl) { - buttonRow.style.display = 'none'; - showStatus('error', 'Missing mcpRelayUrl parameter in URL.'); - return; - } - - let clientInfo = 'unknown'; - try { - const client = JSON.parse(params.get('client') || '{}'); - clientInfo = `${client.name}/${client.version}`; - } catch (e) { - showStatus('error', 'Failed to parse client version.'); - return; - } - - showStatus('connecting', `MCP client "${clientInfo}" is trying to connect. Do you want to continue?`); - - rejectBtn.addEventListener('click', async () => { - buttonRow.style.display = 'none'; - showStatus('error', 'Connection rejected. This tab can be closed.'); - }); - - continueBtn.addEventListener('click', async () => { - buttonRow.style.display = 'none'; - try { - const response = await chrome.runtime.sendMessage({ - type: 'connectToMCPRelay', - mcpRelayUrl - }); - if (response?.success) - showStatus('connected', `MCP client "${clientInfo}" connected.`); - else - showStatus('error', response?.error || `MCP client "${clientInfo}" failed to connect.`); - } catch (e) { - showStatus('error', `MCP client "${clientInfo}" failed to connect: ${e}`); - } - }); -}); +new ConnectPage();