mirror of
https://github.com/microsoft/playwright-mcp.git
synced 2025-07-27 00:52:27 +08:00
chore(extension): page selector for MCP (#750)
This commit is contained in:
parent
c72d0320f4
commit
bd34e9d7e9
@ -19,14 +19,16 @@
|
|||||||
<title>Playwright MCP extension</title>
|
<title>Playwright MCP extension</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="header">
|
<h3>Playwright MCP extension</h3>
|
||||||
<h3>Playwright MCP extension</h3>
|
|
||||||
</div>
|
|
||||||
<div id="status-container"></div>
|
<div id="status-container"></div>
|
||||||
<div class="button-row">
|
<div class="button-row">
|
||||||
<button id="continue-btn">Continue</button>
|
<button id="continue-btn">Continue</button>
|
||||||
<button id="reject-btn">Reject</button>
|
<button id="reject-btn">Reject</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="tab-list-container">
|
||||||
|
<h4>Select page to expose to MCP server:</h4>
|
||||||
|
<div id="tab-list"></div>
|
||||||
|
</div>
|
||||||
<script src="lib/connect.js"></script>
|
<script src="lib/connect.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
@ -19,6 +19,10 @@ import { RelayConnection, debugLog } from './relayConnection.js';
|
|||||||
type PageMessage = {
|
type PageMessage = {
|
||||||
type: 'connectToMCPRelay';
|
type: 'connectToMCPRelay';
|
||||||
mcpRelayUrl: string;
|
mcpRelayUrl: string;
|
||||||
|
tabId: number;
|
||||||
|
windowId: number;
|
||||||
|
} | {
|
||||||
|
type: 'getTabs';
|
||||||
};
|
};
|
||||||
|
|
||||||
class TabShareExtension {
|
class TabShareExtension {
|
||||||
@ -35,22 +39,22 @@ class TabShareExtension {
|
|||||||
private _onMessage(message: PageMessage, sender: chrome.runtime.MessageSender, sendResponse: (response: any) => void) {
|
private _onMessage(message: PageMessage, sender: chrome.runtime.MessageSender, sendResponse: (response: any) => void) {
|
||||||
switch (message.type) {
|
switch (message.type) {
|
||||||
case 'connectToMCPRelay':
|
case 'connectToMCPRelay':
|
||||||
const tabId = sender.tab?.id;
|
this._connectTab(message.tabId, message.windowId, message.mcpRelayUrl!).then(
|
||||||
if (!tabId) {
|
|
||||||
sendResponse({ success: false, error: 'No tab id' });
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
this._connectTab(sender.tab!, message.mcpRelayUrl!).then(
|
|
||||||
() => sendResponse({ success: true }),
|
() => sendResponse({ success: true }),
|
||||||
(error: any) => sendResponse({ success: false, error: error.message }));
|
(error: any) => sendResponse({ success: false, error: error.message }));
|
||||||
return true; // Return true to indicate that the response will be sent asynchronously
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _connectTab(tab: chrome.tabs.Tab, mcpRelayUrl: string): Promise<void> {
|
private async _connectTab(tabId: number, windowId: number, mcpRelayUrl: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
debugLog(`Connecting tab ${tab.id} to bridge at ${mcpRelayUrl}`);
|
debugLog(`Connecting tab ${tabId} to bridge at ${mcpRelayUrl}`);
|
||||||
const socket = new WebSocket(mcpRelayUrl);
|
const socket = new WebSocket(mcpRelayUrl);
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
socket.onopen = () => resolve();
|
socket.onopen = () => resolve();
|
||||||
@ -58,7 +62,7 @@ class TabShareExtension {
|
|||||||
setTimeout(() => reject(new Error('Connection timeout')), 5000);
|
setTimeout(() => reject(new Error('Connection timeout')), 5000);
|
||||||
});
|
});
|
||||||
|
|
||||||
const connection = new RelayConnection(socket, tab.id!);
|
const connection = new RelayConnection(socket, tabId);
|
||||||
const connectionClosed = (m: string) => {
|
const connectionClosed = (m: string) => {
|
||||||
debugLog(m);
|
debugLog(m);
|
||||||
if (this._activeConnection === connection) {
|
if (this._activeConnection === connection) {
|
||||||
@ -71,14 +75,14 @@ class TabShareExtension {
|
|||||||
this._activeConnection = connection;
|
this._activeConnection = connection;
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
this._setConnectedTabId(tab.id!),
|
this._setConnectedTabId(tabId),
|
||||||
chrome.tabs.update(tab.id!, { active: true }),
|
chrome.tabs.update(tabId, { active: true }),
|
||||||
chrome.windows.update(tab.windowId, { focused: true }),
|
chrome.windows.update(windowId, { focused: true }),
|
||||||
]);
|
]);
|
||||||
debugLog(`Connected to MCP bridge`);
|
debugLog(`Connected to MCP bridge`);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
await this._setConnectedTabId(null);
|
await this._setConnectedTabId(null);
|
||||||
debugLog(`Failed to connect tab ${tab.id}:`, error.message);
|
debugLog(`Failed to connect tab ${tabId}:`, error.message);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -110,6 +114,11 @@ class TabShareExtension {
|
|||||||
if (changeInfo.status === 'complete' && this._connectedTabId === tabId)
|
if (changeInfo.status === 'complete' && this._connectedTabId === tabId)
|
||||||
await this._setConnectedTabId(tabId);
|
await this._setConnectedTabId(tabId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async _getTabs(): Promise<chrome.tabs.Tab[]> {
|
||||||
|
const tabs = await chrome.tabs.query({});
|
||||||
|
return tabs;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
new TabShareExtension();
|
new TabShareExtension();
|
||||||
|
@ -14,57 +14,158 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', async () => {
|
interface TabInfo {
|
||||||
const statusContainer = document.getElementById('status-container') as HTMLElement;
|
id: number;
|
||||||
const continueBtn = document.getElementById('continue-btn') as HTMLButtonElement;
|
windowId: number;
|
||||||
const rejectBtn = document.getElementById('reject-btn') as HTMLButtonElement;
|
title: string;
|
||||||
const buttonRow = document.querySelector('.button-row') as HTMLElement;
|
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<void> {
|
||||||
|
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');
|
const div = document.createElement('div');
|
||||||
div.className = `status ${type}`;
|
div.className = `status ${type}`;
|
||||||
div.textContent = message;
|
div.textContent = message;
|
||||||
statusContainer.replaceChildren(div);
|
this._statusContainer.replaceChildren(div);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const params = new URLSearchParams(window.location.search);
|
new ConnectPage();
|
||||||
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}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user