chore: check extension version on connect (#907)

This commit is contained in:
Yury Semikhatsky
2025-08-18 13:28:13 -07:00
committed by GitHub
parent 865eac2fee
commit e664e0460c
4 changed files with 98 additions and 28 deletions

View File

@@ -19,6 +19,7 @@ import { RelayConnection, debugLog } from './relayConnection.js';
type PageMessage = { type PageMessage = {
type: 'connectToMCPRelay'; type: 'connectToMCPRelay';
mcpRelayUrl: string; mcpRelayUrl: string;
pwMcpVersion: string | null;
} | { } | {
type: 'getTabs'; type: 'getTabs';
} | { } | {
@@ -49,7 +50,7 @@ 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':
this._connectToRelay(sender.tab!.id!, message.mcpRelayUrl!).then( this._connectToRelay(sender.tab!.id!, message.mcpRelayUrl, message.pwMcpVersion).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;
@@ -77,7 +78,11 @@ class TabShareExtension {
return false; return false;
} }
private async _connectToRelay(selectorTabId: number, mcpRelayUrl: string): Promise<void> { private async _connectToRelay(selectorTabId: number, mcpRelayUrl: string, pwMcpVersion: string | null): Promise<void> {
const version = chrome.runtime.getManifest().version;
if (pwMcpVersion !== version)
throw new Error(`Incompatible Playwright MCP version: ${pwMcpVersion} (extension version: ${version}). Please install the latest version of the extension.`);
try { try {
debugLog(`Connecting to relay at ${mcpRelayUrl}`); debugLog(`Connecting to relay at ${mcpRelayUrl}`);
const socket = new WebSocket(mcpRelayUrl); const socket = new WebSocket(mcpRelayUrl);
@@ -96,8 +101,9 @@ class TabShareExtension {
this._pendingTabSelection.set(selectorTabId, { connection }); this._pendingTabSelection.set(selectorTabId, { connection });
debugLog(`Connected to MCP relay`); debugLog(`Connected to MCP relay`);
} catch (error: any) { } catch (error: any) {
debugLog(`Failed to connect to MCP relay:`, error.message); const message = `Failed to connect to MCP relay: ${error.message}`;
throw error; debugLog(message);
throw new Error(message);
} }
} }

View File

@@ -54,16 +54,22 @@ const ConnectApp: React.FC = () => {
return; return;
} }
void connectToMCPRelay(relayUrl); void connectToMCPRelay(relayUrl, params.get('pwMcpVersion'));
void loadTabs(); void loadTabs();
}, []); }, []);
const connectToMCPRelay = useCallback(async (mcpRelayUrl: string) => { const handleReject = useCallback((message: string) => {
const response = await chrome.runtime.sendMessage({ type: 'connectToMCPRelay', mcpRelayUrl }); setShowButtons(false);
if (!response.success) setShowTabList(false);
setStatus({ type: 'error', message: 'Failed to connect to MCP relay: ' + response.error }); setStatus({ type: 'error', message });
}, []); }, []);
const connectToMCPRelay = useCallback(async (mcpRelayUrl: string, pwMcpVersion: string | null) => {
const response = await chrome.runtime.sendMessage({ type: 'connectToMCPRelay', mcpRelayUrl, pwMcpVersion });
if (!response.success)
handleReject(response.error);
}, [handleReject]);
const loadTabs = useCallback(async () => { const loadTabs = useCallback(async () => {
const response = await chrome.runtime.sendMessage({ type: 'getTabs' }); const response = await chrome.runtime.sendMessage({ type: 'getTabs' });
if (response.success) if (response.success)
@@ -100,22 +106,16 @@ const ConnectApp: React.FC = () => {
} }
}, [clientInfo, mcpRelayUrl]); }, [clientInfo, mcpRelayUrl]);
const handleReject = useCallback(() => {
setShowButtons(false);
setShowTabList(false);
setStatus({ type: 'error', message: 'Connection rejected. This tab can be closed.' });
}, []);
useEffect(() => { useEffect(() => {
const listener = (message: any) => { const listener = (message: any) => {
if (message.type === 'connectionTimeout') if (message.type === 'connectionTimeout')
handleReject(); handleReject('Connection timed out.');
}; };
chrome.runtime.onMessage.addListener(listener); chrome.runtime.onMessage.addListener(listener);
return () => { return () => {
chrome.runtime.onMessage.removeListener(listener); chrome.runtime.onMessage.removeListener(listener);
}; };
}, []); }, [handleReject]);
return ( return (
<div className='app-container'> <div className='app-container'>
@@ -124,7 +124,7 @@ const ConnectApp: React.FC = () => {
<div className='status-container'> <div className='status-container'>
<StatusBanner type={status.type} message={status.message} /> <StatusBanner type={status.type} message={status.message} />
{showButtons && ( {showButtons && (
<Button variant='reject' onClick={handleReject}> <Button variant='reject' onClick={() => handleReject('Connection rejected. This tab can be closed.')}>
Reject Reject
</Button> </Button>
)} )}

View File

@@ -14,12 +14,15 @@
* limitations under the License. * limitations under the License.
*/ */
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { chromium } from 'playwright'; import { chromium } from 'playwright';
import packageJSON from '../../package.json' assert { type: 'json' };
import { test as base, expect } from '../../tests/fixtures.js'; import { test as base, expect } from '../../tests/fixtures.js';
import type { BrowserContext } from 'playwright';
import type { Client } from '@modelcontextprotocol/sdk/client/index.js'; import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
import type { BrowserContext } from 'playwright';
import type { StartClient } from '../../tests/fixtures.js'; import type { StartClient } from '../../tests/fixtures.js';
type BrowserWithExtension = { type BrowserWithExtension = {
@@ -27,14 +30,22 @@ type BrowserWithExtension = {
launch: (mode?: 'disable-extension') => Promise<BrowserContext>; launch: (mode?: 'disable-extension') => Promise<BrowserContext>;
}; };
const test = base.extend<{ browserWithExtension: BrowserWithExtension }>({ type TestFixtures = {
browserWithExtension: async ({ mcpBrowser }, use, testInfo) => { browserWithExtension: BrowserWithExtension,
pathToExtension: string,
useShortConnectionTimeout: (timeoutMs: number) => void
};
const test = base.extend<TestFixtures>({
pathToExtension: async ({}, use) => {
await use(fileURLToPath(new URL('../dist', import.meta.url)));
},
browserWithExtension: async ({ mcpBrowser, pathToExtension }, use, testInfo) => {
// The flags no longer work in Chrome since // The flags no longer work in Chrome since
// https://chromium.googlesource.com/chromium/src/+/290ed8046692651ce76088914750cb659b65fb17%5E%21/chrome/browser/extensions/extension_service.cc?pli=1# // https://chromium.googlesource.com/chromium/src/+/290ed8046692651ce76088914750cb659b65fb17%5E%21/chrome/browser/extensions/extension_service.cc?pli=1#
test.skip('chromium' !== mcpBrowser, '--load-extension is not supported for official builds of Chromium'); test.skip('chromium' !== mcpBrowser, '--load-extension is not supported for official builds of Chromium');
const pathToExtension = fileURLToPath(new URL('../dist', import.meta.url));
let browserContext: BrowserContext | undefined; let browserContext: BrowserContext | undefined;
const userDataDir = testInfo.outputPath('extension-user-data-dir'); const userDataDir = testInfo.outputPath('extension-user-data-dir');
await use({ await use({
@@ -60,9 +71,16 @@ const test = base.extend<{ browserWithExtension: BrowserWithExtension }>({
return browserContext; return browserContext;
} }
}); });
await browserContext?.close(); await browserContext?.close();
}, },
useShortConnectionTimeout: async ({}, use) => {
await use((timeoutMs: number) => {
process.env.PWMCP_TEST_CONNECTION_TIMEOUT = timeoutMs.toString();
});
process.env.PWMCP_TEST_CONNECTION_TIMEOUT = undefined;
},
}); });
async function startAndCallConnectTool(browserWithExtension: BrowserWithExtension, startClient: StartClient): Promise<Client> { async function startAndCallConnectTool(browserWithExtension: BrowserWithExtension, startClient: StartClient): Promise<Client> {
@@ -99,6 +117,21 @@ async function startWithExtensionFlag(browserWithExtension: BrowserWithExtension
return client; return client;
} }
const testWithOldVersion = test.extend({
pathToExtension: async ({}, use, testInfo) => {
const extensionDir = testInfo.outputPath('extension');
const oldPath = fileURLToPath(new URL('../dist', import.meta.url));
await fs.promises.cp(oldPath, extensionDir, { recursive: true });
const manifestPath = path.join(extensionDir, 'manifest.json');
const manifest = JSON.parse(await fs.promises.readFile(manifestPath, 'utf8'));
manifest.version = '0.0.1';
await fs.promises.writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
await use(extensionDir);
},
});
for (const [mode, startClientMethod] of [ for (const [mode, startClientMethod] of [
['connect-tool', startAndCallConnectTool], ['connect-tool', startAndCallConnectTool],
['extension-flag', startWithExtensionFlag], ['extension-flag', startWithExtensionFlag],
@@ -160,8 +193,8 @@ for (const [mode, startClientMethod] of [
expect(browserContext.pages()).toHaveLength(4); expect(browserContext.pages()).toHaveLength(4);
}); });
test(`extension not installed timeout (${mode})`, async ({ browserWithExtension, startClient, server }) => { test(`extension not installed timeout (${mode})`, async ({ browserWithExtension, startClient, server, useShortConnectionTimeout }) => {
process.env.PWMCP_TEST_CONNECTION_TIMEOUT = '100'; useShortConnectionTimeout(100);
const browserContext = await browserWithExtension.launch(); const browserContext = await browserWithExtension.launch();
@@ -180,8 +213,32 @@ for (const [mode, startClientMethod] of [
}); });
await confirmationPagePromise; await confirmationPagePromise;
});
process.env.PWMCP_TEST_CONNECTION_TIMEOUT = undefined; testWithOldVersion(`extension version mismatch (${mode})`, async ({ browserWithExtension, startClient, server, useShortConnectionTimeout }) => {
useShortConnectionTimeout(500);
// Prelaunch the browser, so that it is properly closed after the test.
const browserContext = await browserWithExtension.launch();
const client = await startClientMethod(browserWithExtension, startClient);
const confirmationPagePromise = browserContext.waitForEvent('page', page => {
return page.url().startsWith('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html');
});
const navigateResponse = client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
});
const confirmationPage = await confirmationPagePromise;
await expect(confirmationPage.locator('.status-banner')).toHaveText(`Incompatible Playwright MCP version: ${packageJSON.version} (extension version: 0.0.1). Please install the latest version of the extension.`);
expect(await navigateResponse).toHaveResponse({
result: expect.stringContaining('Extension connection timeout.'),
isError: true,
});
}); });
} }

View File

@@ -29,6 +29,8 @@ import { WebSocket, WebSocketServer } from 'ws';
import { httpAddressToString } from '../mcp/http.js'; import { httpAddressToString } from '../mcp/http.js';
import { logUnhandledError } from '../utils/log.js'; import { logUnhandledError } from '../utils/log.js';
import { ManualPromise } from '../utils/manualPromise.js'; import { ManualPromise } from '../utils/manualPromise.js';
import { packageJSON } from '../utils/package.js';
import type websocket from 'ws'; import type websocket from 'ws';
import type { ClientInfo } from '../browserContextFactory.js'; import type { ClientInfo } from '../browserContextFactory.js';
@@ -113,7 +115,12 @@ export class CDPRelayServer {
// Need to specify "key" in the manifest.json to make the id stable when loading from file. // Need to specify "key" in the manifest.json to make the id stable when loading from file.
const url = new URL('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html'); const url = new URL('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html');
url.searchParams.set('mcpRelayUrl', mcpRelayEndpoint); url.searchParams.set('mcpRelayUrl', mcpRelayEndpoint);
url.searchParams.set('client', JSON.stringify(clientInfo)); const client = {
name: clientInfo.name,
version: clientInfo.version,
};
url.searchParams.set('client', JSON.stringify(client));
url.searchParams.set('pwMcpVersion', packageJSON.version);
const href = url.toString(); const href = url.toString();
const executableInfo = registry.findExecutable(this._browserChannel); const executableInfo = registry.findExecutable(this._browserChannel);
if (!executableInfo) if (!executableInfo)