Skip to content

Commit 0a3f985

Browse files
yury-sEdward-Upton
authored andcommitted
chore: check extension version on connect (microsoft#907)
1 parent a2d7712 commit 0a3f985

File tree

4 files changed

+98
-28
lines changed

4 files changed

+98
-28
lines changed

extension/src/background.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { RelayConnection, debugLog } from './relayConnection.js';
1919
type PageMessage = {
2020
type: 'connectToMCPRelay';
2121
mcpRelayUrl: string;
22+
pwMcpVersion: string | null;
2223
} | {
2324
type: 'getTabs';
2425
} | {
@@ -49,7 +50,7 @@ class TabShareExtension {
4950
private _onMessage(message: PageMessage, sender: chrome.runtime.MessageSender, sendResponse: (response: any) => void) {
5051
switch (message.type) {
5152
case 'connectToMCPRelay':
52-
this._connectToRelay(sender.tab!.id!, message.mcpRelayUrl!).then(
53+
this._connectToRelay(sender.tab!.id!, message.mcpRelayUrl, message.pwMcpVersion).then(
5354
() => sendResponse({ success: true }),
5455
(error: any) => sendResponse({ success: false, error: error.message }));
5556
return true;
@@ -77,7 +78,11 @@ class TabShareExtension {
7778
return false;
7879
}
7980

80-
private async _connectToRelay(selectorTabId: number, mcpRelayUrl: string): Promise<void> {
81+
private async _connectToRelay(selectorTabId: number, mcpRelayUrl: string, pwMcpVersion: string | null): Promise<void> {
82+
const version = chrome.runtime.getManifest().version;
83+
if (pwMcpVersion !== version)
84+
throw new Error(`Incompatible Playwright MCP version: ${pwMcpVersion} (extension version: ${version}). Please install the latest version of the extension.`);
85+
8186
try {
8287
debugLog(`Connecting to relay at ${mcpRelayUrl}`);
8388
const socket = new WebSocket(mcpRelayUrl);
@@ -96,8 +101,9 @@ class TabShareExtension {
96101
this._pendingTabSelection.set(selectorTabId, { connection });
97102
debugLog(`Connected to MCP relay`);
98103
} catch (error: any) {
99-
debugLog(`Failed to connect to MCP relay:`, error.message);
100-
throw error;
104+
const message = `Failed to connect to MCP relay: ${error.message}`;
105+
debugLog(message);
106+
throw new Error(message);
101107
}
102108
}
103109

extension/src/ui/connect.tsx

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -54,16 +54,22 @@ const ConnectApp: React.FC = () => {
5454
return;
5555
}
5656

57-
void connectToMCPRelay(relayUrl);
57+
void connectToMCPRelay(relayUrl, params.get('pwMcpVersion'));
5858
void loadTabs();
5959
}, []);
6060

61-
const connectToMCPRelay = useCallback(async (mcpRelayUrl: string) => {
62-
const response = await chrome.runtime.sendMessage({ type: 'connectToMCPRelay', mcpRelayUrl });
63-
if (!response.success)
64-
setStatus({ type: 'error', message: 'Failed to connect to MCP relay: ' + response.error });
61+
const handleReject = useCallback((message: string) => {
62+
setShowButtons(false);
63+
setShowTabList(false);
64+
setStatus({ type: 'error', message });
6565
}, []);
6666

67+
const connectToMCPRelay = useCallback(async (mcpRelayUrl: string, pwMcpVersion: string | null) => {
68+
const response = await chrome.runtime.sendMessage({ type: 'connectToMCPRelay', mcpRelayUrl, pwMcpVersion });
69+
if (!response.success)
70+
handleReject(response.error);
71+
}, [handleReject]);
72+
6773
const loadTabs = useCallback(async () => {
6874
const response = await chrome.runtime.sendMessage({ type: 'getTabs' });
6975
if (response.success)
@@ -100,22 +106,16 @@ const ConnectApp: React.FC = () => {
100106
}
101107
}, [clientInfo, mcpRelayUrl]);
102108

103-
const handleReject = useCallback(() => {
104-
setShowButtons(false);
105-
setShowTabList(false);
106-
setStatus({ type: 'error', message: 'Connection rejected. This tab can be closed.' });
107-
}, []);
108-
109109
useEffect(() => {
110110
const listener = (message: any) => {
111111
if (message.type === 'connectionTimeout')
112-
handleReject();
112+
handleReject('Connection timed out.');
113113
};
114114
chrome.runtime.onMessage.addListener(listener);
115115
return () => {
116116
chrome.runtime.onMessage.removeListener(listener);
117117
};
118-
}, []);
118+
}, [handleReject]);
119119

120120
return (
121121
<div className='app-container'>
@@ -124,7 +124,7 @@ const ConnectApp: React.FC = () => {
124124
<div className='status-container'>
125125
<StatusBanner type={status.type} message={status.message} />
126126
{showButtons && (
127-
<Button variant='reject' onClick={handleReject}>
127+
<Button variant='reject' onClick={() => handleReject('Connection rejected. This tab can be closed.')}>
128128
Reject
129129
</Button>
130130
)}

extension/tests/extension.spec.ts

Lines changed: 66 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,27 +14,38 @@
1414
* limitations under the License.
1515
*/
1616

17+
import fs from 'fs';
18+
import path from 'path';
1719
import { fileURLToPath } from 'url';
1820
import { chromium } from 'playwright';
21+
import packageJSON from '../../package.json' assert { type: 'json' };
1922
import { test as base, expect } from '../../tests/fixtures.js';
2023

21-
import type { BrowserContext } from 'playwright';
2224
import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
25+
import type { BrowserContext } from 'playwright';
2326
import type { StartClient } from '../../tests/fixtures.js';
2427

2528
type BrowserWithExtension = {
2629
userDataDir: string;
2730
launch: (mode?: 'disable-extension') => Promise<BrowserContext>;
2831
};
2932

30-
const test = base.extend<{ browserWithExtension: BrowserWithExtension }>({
31-
browserWithExtension: async ({ mcpBrowser }, use, testInfo) => {
33+
type TestFixtures = {
34+
browserWithExtension: BrowserWithExtension,
35+
pathToExtension: string,
36+
useShortConnectionTimeout: (timeoutMs: number) => void
37+
};
38+
39+
const test = base.extend<TestFixtures>({
40+
pathToExtension: async ({}, use) => {
41+
await use(fileURLToPath(new URL('../dist', import.meta.url)));
42+
},
43+
44+
browserWithExtension: async ({ mcpBrowser, pathToExtension }, use, testInfo) => {
3245
// The flags no longer work in Chrome since
3346
// https://chromium.googlesource.com/chromium/src/+/290ed8046692651ce76088914750cb659b65fb17%5E%21/chrome/browser/extensions/extension_service.cc?pli=1#
3447
test.skip('chromium' !== mcpBrowser, '--load-extension is not supported for official builds of Chromium');
3548

36-
const pathToExtension = fileURLToPath(new URL('../dist', import.meta.url));
37-
3849
let browserContext: BrowserContext | undefined;
3950
const userDataDir = testInfo.outputPath('extension-user-data-dir');
4051
await use({
@@ -60,9 +71,16 @@ const test = base.extend<{ browserWithExtension: BrowserWithExtension }>({
6071
return browserContext;
6172
}
6273
});
63-
6474
await browserContext?.close();
6575
},
76+
77+
useShortConnectionTimeout: async ({}, use) => {
78+
await use((timeoutMs: number) => {
79+
process.env.PWMCP_TEST_CONNECTION_TIMEOUT = timeoutMs.toString();
80+
});
81+
process.env.PWMCP_TEST_CONNECTION_TIMEOUT = undefined;
82+
},
83+
6684
});
6785

6886
async function startAndCallConnectTool(browserWithExtension: BrowserWithExtension, startClient: StartClient): Promise<Client> {
@@ -99,6 +117,21 @@ async function startWithExtensionFlag(browserWithExtension: BrowserWithExtension
99117
return client;
100118
}
101119

120+
const testWithOldVersion = test.extend({
121+
pathToExtension: async ({}, use, testInfo) => {
122+
const extensionDir = testInfo.outputPath('extension');
123+
const oldPath = fileURLToPath(new URL('../dist', import.meta.url));
124+
125+
await fs.promises.cp(oldPath, extensionDir, { recursive: true });
126+
const manifestPath = path.join(extensionDir, 'manifest.json');
127+
const manifest = JSON.parse(await fs.promises.readFile(manifestPath, 'utf8'));
128+
manifest.version = '0.0.1';
129+
await fs.promises.writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
130+
131+
await use(extensionDir);
132+
},
133+
});
134+
102135
for (const [mode, startClientMethod] of [
103136
['connect-tool', startAndCallConnectTool],
104137
['extension-flag', startWithExtensionFlag],
@@ -160,8 +193,8 @@ for (const [mode, startClientMethod] of [
160193
expect(browserContext.pages()).toHaveLength(4);
161194
});
162195

163-
test(`extension not installed timeout (${mode})`, async ({ browserWithExtension, startClient, server }) => {
164-
process.env.PWMCP_TEST_CONNECTION_TIMEOUT = '100';
196+
test(`extension not installed timeout (${mode})`, async ({ browserWithExtension, startClient, server, useShortConnectionTimeout }) => {
197+
useShortConnectionTimeout(100);
165198

166199
const browserContext = await browserWithExtension.launch();
167200

@@ -180,8 +213,32 @@ for (const [mode, startClientMethod] of [
180213
});
181214

182215
await confirmationPagePromise;
216+
});
183217

184-
process.env.PWMCP_TEST_CONNECTION_TIMEOUT = undefined;
218+
testWithOldVersion(`extension version mismatch (${mode})`, async ({ browserWithExtension, startClient, server, useShortConnectionTimeout }) => {
219+
useShortConnectionTimeout(500);
220+
221+
// Prelaunch the browser, so that it is properly closed after the test.
222+
const browserContext = await browserWithExtension.launch();
223+
224+
const client = await startClientMethod(browserWithExtension, startClient);
225+
226+
const confirmationPagePromise = browserContext.waitForEvent('page', page => {
227+
return page.url().startsWith('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html');
228+
});
229+
230+
const navigateResponse = client.callTool({
231+
name: 'browser_navigate',
232+
arguments: { url: server.HELLO_WORLD },
233+
});
234+
235+
const confirmationPage = await confirmationPagePromise;
236+
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.`);
237+
238+
expect(await navigateResponse).toHaveResponse({
239+
result: expect.stringContaining('Extension connection timeout.'),
240+
isError: true,
241+
});
185242
});
186243

187244
}

src/extension/cdpRelay.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ import { WebSocket, WebSocketServer } from 'ws';
2929
import { httpAddressToString } from '../mcp/http.js';
3030
import { logUnhandledError } from '../utils/log.js';
3131
import { ManualPromise } from '../utils/manualPromise.js';
32+
import { packageJSON } from '../utils/package.js';
33+
3234
import type websocket from 'ws';
3335
import type { ClientInfo } from '../browserContextFactory.js';
3436

@@ -113,7 +115,12 @@ export class CDPRelayServer {
113115
// Need to specify "key" in the manifest.json to make the id stable when loading from file.
114116
const url = new URL('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html');
115117
url.searchParams.set('mcpRelayUrl', mcpRelayEndpoint);
116-
url.searchParams.set('client', JSON.stringify(clientInfo));
118+
const client = {
119+
name: clientInfo.name,
120+
version: clientInfo.version,
121+
};
122+
url.searchParams.set('client', JSON.stringify(client));
123+
url.searchParams.set('pwMcpVersion', packageJSON.version);
117124
const href = url.toString();
118125
const executableInfo = registry.findExecutable(this._browserChannel);
119126
if (!executableInfo)

0 commit comments

Comments
 (0)