25 Commits

Author SHA1 Message Date
Simon Knott
76ba7f7bb6 windows shenanigans 2025-08-21 13:17:32 +02:00
Simon Knott
dc149c19c0 change description 2025-08-21 12:48:15 +02:00
Simon Knott
74e3ab5267 move entrypoint to host.ts 2025-08-21 11:17:53 +02:00
Simon Knott
d12b5aab18 lint 2025-08-21 11:12:29 +02:00
Simon Knott
922002e435 make test pass 2025-08-20 18:44:45 +02:00
Simon Knott
21e03968c5 simplify 2025-08-20 18:38:19 +02:00
Simon Knott
ee59735f42 reset main 2025-08-20 15:55:30 +02:00
Simon Knott
da5b0c6fdd Merge branch 'main' into vscode-client-factory 2025-08-20 15:54:40 +02:00
Simon Knott
35c464ef5b more 2025-08-20 15:52:59 +02:00
Simon Knott
fcd953c097 Merge branch 'main' into vscode-client-factory 2025-08-14 12:59:01 +02:00
Simon Knott
14b931d25d lint 2025-08-13 13:25:17 +02:00
Simon Knott
bcbc2fecb8 add basic test 2025-08-13 13:22:14 +02:00
Simon Knott
5a0cfb9e65 address 2025-08-13 11:27:01 +02:00
Simon Knott
1ff80f8761 comment 2025-08-13 11:20:28 +02:00
Simon Knott
98fef06b3b options arg 2025-08-13 11:19:59 +02:00
Simon Knott
affe1d7ed9 Merge branch 'main' into vscode-client-factory 2025-08-13 11:18:58 +02:00
Simon Knott
cc61b67c14 Merge branch 'main' into vscode-client-factory 2025-08-13 11:17:37 +02:00
Simon Knott
7a814d5cd4 allow closing before init 2025-08-12 15:26:38 +02:00
Simon Knott
39c384850f add newlines 2025-08-12 15:24:25 +02:00
Simon Knott
f8a61de332 add todo 2025-08-12 10:22:05 +02:00
Simon Knott
9d17572403 merge 2025-08-12 10:20:41 +02:00
Simon Knott
0741b8bee8 Merge branch 'main' into vscode-client-factory 2025-08-12 10:19:31 +02:00
Simon Knott
0d0783be07 name & desc 2025-08-12 10:14:48 +02:00
Simon Knott
001fa6f2fb forward clientFactory 2025-08-12 10:12:49 +02:00
Simon Knott
e884b3aacb get started 2025-08-12 10:00:58 +02:00
33 changed files with 232 additions and 843 deletions

View File

@@ -56,21 +56,6 @@ Follow the MCP install [guide](https://modelcontextprotocol.io/quickstart/user),
</details> </details>
<details>
<summary>Codex</summary>
Create or edit the configuration file `~/.codex/config.toml` and add:
```toml
[mcp_servers.playwright]
command = "npx"
args = ["@playwright/mcp@latest"]
```
For more information, see the [Codex MCP documentation](https://github.com/openai/codex/blob/main/codex-rs/config.md#mcp_servers).
</details>
<details> <details>
<summary>Cursor</summary> <summary>Cursor</summary>
@@ -494,15 +479,6 @@ http.createServer(async (req, res) => {
<!-- NOTE: This has been generated via update-readme.js --> <!-- NOTE: This has been generated via update-readme.js -->
- **browser_fill_form**
- Title: Fill form
- Description: Fill multiple form fields
- Parameters:
- `fields` (array): Fields to fill in
- Read-only: **false**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_handle_dialog** - **browser_handle_dialog**
- Title: Handle a dialog - Title: Handle a dialog
- Description: Handle a dialog - Description: Handle a dialog
@@ -540,6 +516,14 @@ http.createServer(async (req, res) => {
<!-- NOTE: This has been generated via update-readme.js --> <!-- NOTE: This has been generated via update-readme.js -->
- **browser_navigate_forward**
- Title: Go forward
- Description: Go forward to the next page
- Parameters: None
- Read-only: **true**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_network_requests** - **browser_network_requests**
- Title: List network requests - Title: List network requests
- Description: Returns all network requests since loading the page - Description: Returns all network requests since loading the page
@@ -628,14 +612,39 @@ http.createServer(async (req, res) => {
<!-- NOTE: This has been generated via update-readme.js --> <!-- NOTE: This has been generated via update-readme.js -->
- **browser_tabs** - **browser_tab_close**
- Title: Manage tabs - Title: Close a tab
- Description: List, create, close, or select a browser tab. - Description: Close a tab
- Parameters: - Parameters:
- `action` (string): Operation to perform - `index` (number, optional): The index of the tab to close. Closes current tab if not provided.
- `index` (number, optional): Tab index, used for close/select. If omitted for close, current tab is closed.
- Read-only: **false** - Read-only: **false**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_tab_list**
- Title: List tabs
- Description: List browser tabs
- Parameters: None
- Read-only: **true**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_tab_new**
- Title: Open a new tab
- Description: Open a new tab
- Parameters:
- `url` (string, optional): The URL to navigate to in the new tab. If not provided, the new tab will be blank.
- Read-only: **true**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_tab_select**
- Title: Select a tab
- Description: Select a tab by index
- Parameters:
- `index` (number): The index of the tab to select
- Read-only: **true**
</details> </details>
<details> <details>

View File

@@ -1,22 +1,26 @@
{ {
"manifest_version": 3, "manifest_version": 3,
"name": "Playwright MCP Bridge", "name": "Playwright MCP Bridge",
"version": "0.0.35", "version": "0.0.34",
"description": "Share browser tabs with Playwright MCP server", "description": "Share browser tabs with Playwright MCP server",
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA9nMS2b0WCohjVHPGb8D9qAdkbIngDqoAjTeSccHJijgcONejge+OJxOQOMLu7b0ovt1c9BiEJa5JcpM+EHFVGL1vluBxK71zmBy1m2f9vZF3HG0LSCp7YRkum9rAIEthDwbkxx6XTvpmAY5rjFa/NON6b9Hlbo+8peUSkoOK7HTwYnnI36asZ9eUTiveIf+DMPLojW2UX33vDWG2UKvMVDewzclb4+uLxAYshY7Mx8we/b44xu+Anb/EBLKjOPk9Yh541xJ5Ozc8EiP/5yxOp9c/lRiYUHaRW+4r0HKZyFt0eZ52ti2iM4Nfk7jRXR7an3JPsUIf5deC/1cVM/+1ZQIDAQAB", "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA9nMS2b0WCohjVHPGb8D9qAdkbIngDqoAjTeSccHJijgcONejge+OJxOQOMLu7b0ovt1c9BiEJa5JcpM+EHFVGL1vluBxK71zmBy1m2f9vZF3HG0LSCp7YRkum9rAIEthDwbkxx6XTvpmAY5rjFa/NON6b9Hlbo+8peUSkoOK7HTwYnnI36asZ9eUTiveIf+DMPLojW2UX33vDWG2UKvMVDewzclb4+uLxAYshY7Mx8we/b44xu+Anb/EBLKjOPk9Yh541xJ5Ozc8EiP/5yxOp9c/lRiYUHaRW+4r0HKZyFt0eZ52ti2iM4Nfk7jRXR7an3JPsUIf5deC/1cVM/+1ZQIDAQAB",
"permissions": [ "permissions": [
"debugger", "debugger",
"activeTab", "activeTab",
"tabs", "tabs",
"storage" "storage"
], ],
"host_permissions": [ "host_permissions": [
"<all_urls>" "<all_urls>"
], ],
"background": { "background": {
"service_worker": "lib/background.js", "service_worker": "lib/background.js",
"type": "module" "type": "module"
}, },
"action": { "action": {
"default_title": "Playwright MCP Bridge", "default_title": "Playwright MCP Bridge",
"default_icon": { "default_icon": {
@@ -26,6 +30,7 @@
"128": "icons/icon-128.png" "128": "icons/icon-128.png"
} }
}, },
"icons": { "icons": {
"16": "icons/icon-16.png", "16": "icons/icon-16.png",
"32": "icons/icon-32.png", "32": "icons/icon-32.png",

View File

@@ -1,12 +1,12 @@
{ {
"name": "@playwright/mcp-extension", "name": "@playwright/mcp-extension",
"version": "0.0.35", "version": "0.0.34",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@playwright/mcp-extension", "name": "@playwright/mcp-extension",
"version": "0.0.35", "version": "0.0.34",
"license": "Apache-2.0", "license": "Apache-2.0",
"devDependencies": { "devDependencies": {
"@types/chrome": "^0.0.315", "@types/chrome": "^0.0.315",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@playwright/mcp-extension", "name": "@playwright/mcp-extension",
"version": "0.0.35", "version": "0.0.34",
"description": "Playwright MCP Browser Extension", "description": "Playwright MCP Browser Extension",
"type": "module", "type": "module",
"private": true, "private": true,

View File

@@ -23,8 +23,8 @@ type PageMessage = {
type: 'getTabs'; type: 'getTabs';
} | { } | {
type: 'connectToTab'; type: 'connectToTab';
tabId?: number; tabId: number;
windowId?: number; windowId: number;
mcpRelayUrl: string; mcpRelayUrl: string;
} | { } | {
type: 'getConnectionStatus'; type: 'getConnectionStatus';
@@ -59,9 +59,7 @@ class TabShareExtension {
(error: any) => sendResponse({ success: false, error: error.message })); (error: any) => sendResponse({ success: false, error: error.message }));
return true; return true;
case 'connectToTab': case 'connectToTab':
const tabId = message.tabId || sender.tab?.id!; this._connectTab(sender.tab!.id!, message.tabId, message.windowId, message.mcpRelayUrl!).then(
const windowId = message.windowId || sender.tab?.windowId!;
this._connectTab(sender.tab!.id!, tabId, windowId, 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

View File

@@ -193,14 +193,3 @@ body {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
/* Link-style button */
.link-button {
background: none;
border: none;
color: #0066cc;
text-decoration: underline;
cursor: pointer;
padding: 0;
font: inherit;
}

View File

@@ -22,7 +22,8 @@ import type { TabInfo } from './tabItem.js';
type Status = type Status =
| { type: 'connecting'; message: string } | { type: 'connecting'; message: string }
| { type: 'connected'; message: string } | { type: 'connected'; message: string }
| { type: 'error'; message: string }; | { type: 'error'; message: string }
| { type: 'error'; versionMismatch: { pwMcpVersion: string; extensionVersion: string } };
const ConnectApp: React.FC = () => { const ConnectApp: React.FC = () => {
const [tabs, setTabs] = useState<TabInfo[]>([]); const [tabs, setTabs] = useState<TabInfo[]>([]);
@@ -31,7 +32,6 @@ const ConnectApp: React.FC = () => {
const [showTabList, setShowTabList] = useState(true); const [showTabList, setShowTabList] = useState(true);
const [clientInfo, setClientInfo] = useState('unknown'); const [clientInfo, setClientInfo] = useState('unknown');
const [mcpRelayUrl, setMcpRelayUrl] = useState(''); const [mcpRelayUrl, setMcpRelayUrl] = useState('');
const [newTab, setNewTab] = useState<boolean>(false);
useEffect(() => { useEffect(() => {
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
@@ -58,15 +58,23 @@ const ConnectApp: React.FC = () => {
return; return;
} }
void connectToMCPRelay(relayUrl); const pwMcpVersion = params.get('pwMcpVersion');
const extensionVersion = chrome.runtime.getManifest().version;
// If this is a browser_navigate command, hide the tab list and show simple allow/reject if (pwMcpVersion !== extensionVersion) {
if (params.get('newTab') === 'true') { setShowButtons(false);
setNewTab(true);
setShowTabList(false); setShowTabList(false);
} else { setStatus({
void loadTabs(); type: 'error',
versionMismatch: {
pwMcpVersion: pwMcpVersion || 'unknown',
extensionVersion
}
});
return;
} }
void connectToMCPRelay(relayUrl);
void loadTabs();
}, []); }, []);
const handleReject = useCallback((message: string) => { const handleReject = useCallback((message: string) => {
@@ -90,7 +98,7 @@ const ConnectApp: React.FC = () => {
setStatus({ type: 'error', message: 'Failed to load tabs: ' + response.error }); setStatus({ type: 'error', message: 'Failed to load tabs: ' + response.error });
}, []); }, []);
const handleConnectToTab = useCallback(async (tab?: TabInfo) => { const handleConnectToTab = useCallback(async (tab: TabInfo) => {
setShowButtons(false); setShowButtons(false);
setShowTabList(false); setShowTabList(false);
@@ -98,8 +106,8 @@ const ConnectApp: React.FC = () => {
const response = await chrome.runtime.sendMessage({ const response = await chrome.runtime.sendMessage({
type: 'connectToTab', type: 'connectToTab',
mcpRelayUrl, mcpRelayUrl,
tabId: tab?.id, tabId: tab.id,
windowId: tab?.windowId, windowId: tab.windowId,
}); });
if (response?.success) { if (response?.success) {
@@ -136,22 +144,9 @@ const ConnectApp: React.FC = () => {
<div className='status-container'> <div className='status-container'>
<StatusBanner status={status} /> <StatusBanner status={status} />
{showButtons && ( {showButtons && (
<div className='button-container'> <Button variant='reject' onClick={() => handleReject('Connection rejected. This tab can be closed.')}>
{newTab ? ( Reject
<> </Button>
<Button variant='primary' onClick={() => handleConnectToTab()}>
Allow
</Button>
<Button variant='reject' onClick={() => handleReject('Connection rejected. This tab can be closed.')}>
Reject
</Button>
</>
) : (
<Button variant='reject' onClick={() => handleReject('Connection rejected. This tab can be closed.')}>
Reject
</Button>
)}
</div>
)} )}
</div> </div>
)} )}
@@ -181,11 +176,25 @@ const ConnectApp: React.FC = () => {
); );
}; };
const VersionMismatchError: React.FC<{ pwMcpVersion: string; extensionVersion: string }> = ({ pwMcpVersion, extensionVersion }) => {
const readmeUrl = 'https://github.com/microsoft/playwright-mcp/blob/main/extension/README.md';
return (
<div>
Incompatible Playwright MCP version: {pwMcpVersion} (extension version: {extensionVersion}).
Please install the latest version of the extension.{' '}
See <a href={readmeUrl} target='_blank' rel='noopener noreferrer'>installation instructions</a>.
</div>
);
};
const StatusBanner: React.FC<{ status: Status }> = ({ status }) => { const StatusBanner: React.FC<{ status: Status }> = ({ status }) => {
return ( return (
<div className={`status-banner ${status.type}`}> <div className={`status-banner ${status.type}`}>
{status.message} {'versionMismatch' in status ? (
<VersionMismatchError pwMcpVersion={status.versionMismatch.pwMcpVersion} extensionVersion={status.versionMismatch.extensionVersion} />
) : (
status.message
)}
</div> </div>
); );
}; };

View File

@@ -18,6 +18,7 @@ import fs from 'fs';
import path from 'path'; 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 { Client } from '@modelcontextprotocol/sdk/client/index.js'; import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
@@ -116,7 +117,7 @@ async function startWithExtensionFlag(browserWithExtension: BrowserWithExtension
return client; return client;
} }
const testWithOldExtensionVersion = test.extend({ const testWithOldVersion = test.extend({
pathToExtension: async ({}, use, testInfo) => { pathToExtension: async ({}, use, testInfo) => {
const extensionDir = testInfo.outputPath('extension'); const extensionDir = testInfo.outputPath('extension');
const oldPath = fileURLToPath(new URL('../dist', import.meta.url)); const oldPath = fileURLToPath(new URL('../dist', import.meta.url));
@@ -151,8 +152,7 @@ for (const [mode, startClientMethod] of [
}); });
const selectorPage = await confirmationPagePromise; const selectorPage = await confirmationPagePromise;
// For browser_navigate command, the UI shows Allow/Reject buttons instead of tab selector await selectorPage.locator('.tab-item', { hasText: 'Playwright MCP Extension' }).getByRole('button', { name: 'Connect' }).click();
await selectorPage.getByRole('button', { name: 'Allow' }).click();
expect(await navigateResponse).toHaveResponse({ expect(await navigateResponse).toHaveResponse({
pageState: expect.stringContaining(`- generic [active] [ref=e1]: Hello, world!`), pageState: expect.stringContaining(`- generic [active] [ref=e1]: Hello, world!`),
@@ -215,7 +215,7 @@ for (const [mode, startClientMethod] of [
await confirmationPagePromise; await confirmationPagePromise;
}); });
testWithOldExtensionVersion(`works with old extension version (${mode})`, async ({ browserWithExtension, startClient, server, useShortConnectionTimeout }) => { testWithOldVersion(`extension version mismatch (${mode})`, async ({ browserWithExtension, startClient, server, useShortConnectionTimeout }) => {
useShortConnectionTimeout(500); useShortConnectionTimeout(500);
// Prelaunch the browser, so that it is properly closed after the test. // Prelaunch the browser, so that it is properly closed after the test.
@@ -232,12 +232,12 @@ for (const [mode, startClientMethod] of [
arguments: { url: server.HELLO_WORLD }, arguments: { url: server.HELLO_WORLD },
}); });
const selectorPage = await confirmationPagePromise; const confirmationPage = await confirmationPagePromise;
// For browser_navigate command, the UI shows Allow/Reject buttons instead of tab selector 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. See installation instructions.`);
await selectorPage.getByRole('button', { name: 'Allow' }).click();
expect(await navigateResponse).toHaveResponse({ expect(await navigateResponse).toHaveResponse({
pageState: expect.stringContaining(`- generic [active] [ref=e1]: Hello, world!`), result: expect.stringContaining('Extension connection timeout.'),
isError: true,
}); });
}); });

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "@playwright/mcp", "name": "@playwright/mcp",
"version": "0.0.35", "version": "0.0.34",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@playwright/mcp", "name": "@playwright/mcp",
"version": "0.0.35", "version": "0.0.34",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.16.0", "@modelcontextprotocol/sdk": "^1.16.0",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@playwright/mcp", "name": "@playwright/mcp",
"version": "0.0.35", "version": "0.0.34",
"description": "Playwright Tools for MCP", "description": "Playwright Tools for MCP",
"type": "module", "type": "module",
"repository": { "repository": {

View File

@@ -42,7 +42,7 @@ export function contextFactory(config: FullConfig): BrowserContextFactory {
export type ClientInfo = { name?: string, version?: string, rootPath?: string }; export type ClientInfo = { name?: string, version?: string, rootPath?: string };
export interface BrowserContextFactory { export interface BrowserContextFactory {
createContext(clientInfo: ClientInfo, abortSignal: AbortSignal, toolName: string | undefined): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }>; createContext(clientInfo: ClientInfo, abortSignal: AbortSignal): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }>;
} }
class BaseContextFactory implements BrowserContextFactory { class BaseContextFactory implements BrowserContextFactory {

View File

@@ -41,7 +41,7 @@ export class BrowserServerBackend implements ServerBackend {
this._tools = filteredTools(config); this._tools = filteredTools(config);
} }
async initialize(server: mcpServer.Server, clientVersion: mcpServer.ClientVersion, roots: mcpServer.Root[]): Promise<void> { async initialize(clientVersion: mcpServer.ClientVersion, roots: mcpServer.Root[]): Promise<void> {
let rootPath: string | undefined; let rootPath: string | undefined;
if (roots.length > 0) { if (roots.length > 0) {
const firstRootUri = roots[0]?.uri; const firstRootUri = roots[0]?.uri;
@@ -69,7 +69,7 @@ export class BrowserServerBackend implements ServerBackend {
const parsedArguments = tool.schema.inputSchema.parse(rawArguments || {}); const parsedArguments = tool.schema.inputSchema.parse(rawArguments || {});
const context = this._context!; const context = this._context!;
const response = new Response(context, name, parsedArguments); const response = new Response(context, name, parsedArguments);
context.setRunningTool(name); context.setRunningTool(true);
try { try {
await tool.handle(context, parsedArguments, response); await tool.handle(context, parsedArguments, response);
await response.finish(); await response.finish();
@@ -77,7 +77,7 @@ export class BrowserServerBackend implements ServerBackend {
} catch (error: any) { } catch (error: any) {
response.addError(String(error)); response.addError(String(error));
} finally { } finally {
context.setRunningTool(undefined); context.setRunningTool(false);
} }
return response.serialize(); return response.serialize();
} }

View File

@@ -50,7 +50,7 @@ export class Context {
private static _allContexts: Set<Context> = new Set(); private static _allContexts: Set<Context> = new Set();
private _closeBrowserContextPromise: Promise<void> | undefined; private _closeBrowserContextPromise: Promise<void> | undefined;
private _runningToolName: string | undefined; private _isRunningTool: boolean = false;
private _abortController = new AbortController(); private _abortController = new AbortController();
constructor(options: ContextOptions) { constructor(options: ContextOptions) {
@@ -145,11 +145,11 @@ export class Context {
} }
isRunningTool() { isRunningTool() {
return this._runningToolName !== undefined; return this._isRunningTool;
} }
setRunningTool(name: string | undefined) { setRunningTool(isRunningTool: boolean) {
this._runningToolName = name; this._isRunningTool = isRunningTool;
} }
private async _closeBrowserContextImpl() { private async _closeBrowserContextImpl() {
@@ -202,7 +202,7 @@ export class Context {
if (this._closeBrowserContextPromise) if (this._closeBrowserContextPromise)
throw new Error('Another browser context is being closed.'); throw new Error('Another browser context is being closed.');
// TODO: move to the browser context factory to make it based on isolation mode. // TODO: move to the browser context factory to make it based on isolation mode.
const result = await this._browserContextFactory.createContext(this._clientInfo, this._abortController.signal, this._runningToolName); const result = await this._browserContextFactory.createContext(this._clientInfo, this._abortController.signal);
const { browserContext } = result; const { browserContext } = result;
await this._setupRequestInterception(browserContext); await this._setupRequestInterception(browserContext);
if (this.sessionLog) if (this.sessionLog)

View File

@@ -28,7 +28,8 @@ import debug from 'debug';
import { WebSocket, WebSocketServer } from 'ws'; 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 '../mcp/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';
@@ -93,11 +94,11 @@ export class CDPRelayServer {
return `${this._wsHost}${this._extensionPath}`; return `${this._wsHost}${this._extensionPath}`;
} }
async ensureExtensionConnectionForMCPContext(clientInfo: ClientInfo, abortSignal: AbortSignal, toolName: string | undefined) { async ensureExtensionConnectionForMCPContext(clientInfo: ClientInfo, abortSignal: AbortSignal) {
debugLogger('Ensuring extension connection for MCP context'); debugLogger('Ensuring extension connection for MCP context');
if (this._extensionConnection) if (this._extensionConnection)
return; return;
this._connectBrowser(clientInfo, toolName); this._connectBrowser(clientInfo);
debugLogger('Waiting for incoming extension connection'); debugLogger('Waiting for incoming extension connection');
await Promise.race([ await Promise.race([
this._extensionConnectionPromise, this._extensionConnectionPromise,
@@ -109,7 +110,7 @@ export class CDPRelayServer {
debugLogger('Extension connection established'); debugLogger('Extension connection established');
} }
private _connectBrowser(clientInfo: ClientInfo, toolName: string | undefined) { private _connectBrowser(clientInfo: ClientInfo) {
const mcpRelayEndpoint = `${this._wsHost}${this._extensionPath}`; const mcpRelayEndpoint = `${this._wsHost}${this._extensionPath}`;
// 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');
@@ -119,8 +120,7 @@ export class CDPRelayServer {
version: clientInfo.version, version: clientInfo.version,
}; };
url.searchParams.set('client', JSON.stringify(client)); url.searchParams.set('client', JSON.stringify(client));
if (toolName) url.searchParams.set('pwMcpVersion', packageJSON.version);
url.searchParams.set('newTab', String(toolName === 'browser_navigate'));
const href = url.toString(); const href = url.toString();
const executableInfo = registry.findExecutable(this._browserChannel); const executableInfo = registry.findExecutable(this._browserChannel);
if (!executableInfo) if (!executableInfo)

View File

@@ -32,8 +32,8 @@ export class ExtensionContextFactory implements BrowserContextFactory {
this._userDataDir = userDataDir; this._userDataDir = userDataDir;
} }
async createContext(clientInfo: ClientInfo, abortSignal: AbortSignal, toolName: string | undefined): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> { async createContext(clientInfo: ClientInfo, abortSignal: AbortSignal): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
const browser = await this._obtainBrowser(clientInfo, abortSignal, toolName); const browser = await this._obtainBrowser(clientInfo, abortSignal);
return { return {
browserContext: browser.contexts()[0], browserContext: browser.contexts()[0],
close: async () => { close: async () => {
@@ -43,9 +43,9 @@ export class ExtensionContextFactory implements BrowserContextFactory {
}; };
} }
private async _obtainBrowser(clientInfo: ClientInfo, abortSignal: AbortSignal, toolName: string | undefined): Promise<playwright.Browser> { private async _obtainBrowser(clientInfo: ClientInfo, abortSignal: AbortSignal): Promise<playwright.Browser> {
const relay = await this._startRelay(abortSignal); const relay = await this._startRelay(abortSignal);
await relay.ensureExtensionConnectionForMCPContext(clientInfo, abortSignal, toolName); await relay.ensureExtensionConnectionForMCPContext(clientInfo, abortSignal);
return await playwright.chromium.connectOverCDP(relay.cdpEndpoint()); return await playwright.chromium.connectOverCDP(relay.cdpEndpoint());
} }

View File

@@ -32,7 +32,6 @@ const testDebug = debug('pw:mcp:test');
export async function startHttpServer(config: { host?: string, port?: number }, abortSignal?: AbortSignal): Promise<http.Server> { export async function startHttpServer(config: { host?: string, port?: number }, abortSignal?: AbortSignal): Promise<http.Server> {
const { host, port } = config; const { host, port } = config;
const httpServer = http.createServer(); const httpServer = http.createServer();
decorateServer(httpServer);
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
httpServer.on('error', reject); httpServer.on('error', reject);
abortSignal?.addEventListener('abort', () => { abortSignal?.addEventListener('abort', () => {
@@ -137,19 +136,3 @@ async function handleStreamable(serverBackendFactory: ServerBackendFactory, req:
res.statusCode = 400; res.statusCode = 400;
res.end('Invalid request'); res.end('Invalid request');
} }
function decorateServer(server: net.Server) {
const sockets = new Set<net.Socket>();
server.on('connection', socket => {
sockets.add(socket);
socket.once('close', () => sockets.delete(socket));
});
const close = server.close;
server.close = (callback?: (err?: Error) => void) => {
for (const socket of sockets)
socket.destroy();
sockets.clear();
return close.call(server, callback);
};
}

View File

@@ -1,239 +0,0 @@
/**
* 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.
*/
import debug from 'debug';
import { z } from 'zod';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { PingRequestSchema } from '@modelcontextprotocol/sdk/types.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { defineToolSchema } from './tool.js';
import * as mcpServer from './server.js';
import * as mcpHttp from './http.js';
import { wrapInProcess } from './server.js';
import { ManualPromise } from './manualPromise.js';
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
const mdbDebug = debug('pw:mcp:mdb');
const errorsDebug = debug('pw:mcp:errors');
export class MDBBackend implements mcpServer.ServerBackend {
private _stack: { client: Client, toolNames: string[], resultPromise: ManualPromise<mcpServer.CallToolResult> | undefined }[] = [];
private _interruptPromise: ManualPromise<mcpServer.CallToolResult> | undefined;
private _topLevelBackend: mcpServer.ServerBackend;
private _initialized = false;
constructor(topLevelBackend: mcpServer.ServerBackend) {
this._topLevelBackend = topLevelBackend;
}
async initialize(server: mcpServer.Server): Promise<void> {
if (this._initialized)
return;
this._initialized = true;
const transport = await wrapInProcess(this._topLevelBackend);
await this._pushClient(transport);
}
async listTools(): Promise<mcpServer.Tool[]> {
const response = await this._client().listTools();
return response.tools;
}
async callTool(name: string, args: mcpServer.CallToolRequest['params']['arguments']): Promise<mcpServer.CallToolResult> {
if (name === pushToolsSchema.name)
return await this._pushTools(pushToolsSchema.inputSchema.parse(args || {}));
const interruptPromise = new ManualPromise<mcpServer.CallToolResult>();
this._interruptPromise = interruptPromise;
let [entry] = this._stack;
// Pop the client while the tool is not found.
while (entry && !entry.toolNames.includes(name)) {
mdbDebug('popping client from stack for ', name);
this._stack.shift();
await entry.client.close();
entry = this._stack[0];
}
if (!entry)
throw new Error(`Tool ${name} not found in the tool stack`);
const resultPromise = new ManualPromise<mcpServer.CallToolResult>();
entry.resultPromise = resultPromise;
this._client().callTool({
name,
arguments: args,
}).then(result => {
resultPromise.resolve(result as mcpServer.CallToolResult);
}).catch(e => {
mdbDebug('error in client call', e);
if (this._stack.length < 2)
throw e;
this._stack.shift();
const prevEntry = this._stack[0];
void prevEntry.resultPromise!.then(result => resultPromise.resolve(result));
});
const result = await Promise.race([interruptPromise, resultPromise]);
if (interruptPromise.isDone())
mdbDebug('client call intercepted', result);
else
mdbDebug('client call result', result);
return result;
}
private _client(): Client {
const [entry] = this._stack;
if (!entry)
throw new Error('No debugging backend available');
return entry.client;
}
private async _pushTools(params: { mcpUrl: string, introMessage?: string }): Promise<mcpServer.CallToolResult> {
mdbDebug('pushing tools to the stack', params.mcpUrl);
const transport = new StreamableHTTPClientTransport(new URL(params.mcpUrl));
await this._pushClient(transport, params.introMessage);
return { content: [{ type: 'text', text: 'Tools pushed' }] };
}
private async _pushClient(transport: Transport, introMessage?: string): Promise<mcpServer.CallToolResult> {
mdbDebug('pushing client to the stack');
const client = new Client({ name: 'Internal client', version: '0.0.0' });
client.setRequestHandler(PingRequestSchema, () => ({}));
await client.connect(transport);
mdbDebug('connected to the new client');
const { tools } = await client.listTools();
this._stack.unshift({ client, toolNames: tools.map(tool => tool.name), resultPromise: undefined });
mdbDebug('new tools added to the stack:', tools.map(tool => tool.name));
mdbDebug('interrupting current call:', !!this._interruptPromise);
this._interruptPromise?.resolve({
content: [{
type: 'text',
text: introMessage || '',
}],
});
this._interruptPromise = undefined;
return { content: [{ type: 'text', text: 'Tools pushed' }] };
}
}
const pushToolsSchema = defineToolSchema({
name: 'mdb_push_tools',
title: 'Push MCP tools to the tools stack',
description: 'Push MCP tools to the tools stack',
inputSchema: z.object({
mcpUrl: z.string(),
introMessage: z.string().optional(),
}),
type: 'readOnly',
});
export type ServerBackendOnPause = mcpServer.ServerBackend & {
requestSelfDestruct?: () => void;
};
export async function runMainBackend(backendFactory: mcpServer.ServerBackendFactory, options?: { port?: number }): Promise<string | undefined> {
const mdbBackend = new MDBBackend(backendFactory.create());
// Start HTTP unconditionally.
const factory: mcpServer.ServerBackendFactory = {
...backendFactory,
create: () => mdbBackend
};
const url = await startAsHttp(factory, { port: options?.port || 0 });
process.env.PLAYWRIGHT_DEBUGGER_MCP = url;
if (options?.port !== undefined)
return url;
// Start stdio conditionally.
await mcpServer.connect(factory, new StdioServerTransport(), false);
}
export async function runOnPauseBackendLoop(mdbUrl: string, backend: ServerBackendOnPause, introMessage: string) {
const wrappedBackend = new OnceTimeServerBackendWrapper(backend);
const factory = {
name: 'on-pause-backend',
nameInConfig: 'on-pause-backend',
version: '0.0.0',
create: () => wrappedBackend,
};
const httpServer = await mcpHttp.startHttpServer({ port: 0 });
await mcpHttp.installHttpTransport(httpServer, factory);
const url = mcpHttp.httpAddressToString(httpServer.address());
const client = new Client({ name: 'Internal client', version: '0.0.0' });
client.setRequestHandler(PingRequestSchema, () => ({}));
const transport = new StreamableHTTPClientTransport(new URL(mdbUrl));
await client.connect(transport);
const pushToolsResult = await client.callTool({
name: pushToolsSchema.name,
arguments: {
mcpUrl: url,
introMessage,
},
});
if (pushToolsResult.isError)
errorsDebug('Failed to push tools', pushToolsResult.content);
await transport.terminateSession();
await client.close();
await wrappedBackend.waitForClosed();
httpServer.close();
}
async function startAsHttp(backendFactory: mcpServer.ServerBackendFactory, options: { port: number }) {
const httpServer = await mcpHttp.startHttpServer(options);
await mcpHttp.installHttpTransport(httpServer, backendFactory);
return mcpHttp.httpAddressToString(httpServer.address());
}
class OnceTimeServerBackendWrapper implements mcpServer.ServerBackend {
private _backend: ServerBackendOnPause;
private _selfDestructPromise = new ManualPromise<void>();
constructor(backend: ServerBackendOnPause) {
this._backend = backend;
this._backend.requestSelfDestruct = () => this._selfDestructPromise.resolve();
}
async initialize(server: mcpServer.Server, clientVersion: mcpServer.ClientVersion, roots: mcpServer.Root[]): Promise<void> {
await this._backend.initialize?.(server, clientVersion, roots);
}
async listTools(): Promise<mcpServer.Tool[]> {
return this._backend.listTools();
}
async callTool(name: string, args: mcpServer.CallToolRequest['params']['arguments']): Promise<mcpServer.CallToolResult> {
return this._backend.callTool(name, args);
}
serverClosed(server: mcpServer.Server) {
this._backend.serverClosed?.(server);
this._selfDestructPromise.resolve();
}
async waitForClosed() {
await this._selfDestructPromise;
}
}

View File

@@ -21,7 +21,7 @@ import { zodToJsonSchema } from 'zod-to-json-schema';
import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { ListRootsRequestSchema, PingRequestSchema } from '@modelcontextprotocol/sdk/types.js'; import { ListRootsRequestSchema, PingRequestSchema } from '@modelcontextprotocol/sdk/types.js';
import type { ServerBackend, ClientVersion, Root, Server } from './server.js'; import type { ServerBackend, ClientVersion, Root } from './server.js';
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
import type { Tool, CallToolResult, CallToolRequest } from '@modelcontextprotocol/sdk/types.js'; import type { Tool, CallToolResult, CallToolRequest } from '@modelcontextprotocol/sdk/types.js';
@@ -44,7 +44,7 @@ export class ProxyBackend implements ServerBackend {
this._contextSwitchTool = this._defineContextSwitchTool(); this._contextSwitchTool = this._defineContextSwitchTool();
} }
async initialize(server: Server, clientVersion: ClientVersion, roots: Root[]): Promise<void> { async initialize(clientVersion: ClientVersion, roots: Root[]): Promise<void> {
this._roots = roots; this._roots = roots;
await this._setCurrentClient(this._mcpProviders[0]); await this._setCurrentClient(this._mcpProviders[0]);
} }

View File

@@ -31,12 +31,11 @@ const serverDebug = debug('pw:mcp:server');
const errorsDebug = debug('pw:mcp:errors'); const errorsDebug = debug('pw:mcp:errors');
export type ClientVersion = { name: string, version: string }; export type ClientVersion = { name: string, version: string };
export interface ServerBackend { export interface ServerBackend {
initialize?(server: Server, clientVersion: ClientVersion, roots: Root[]): Promise<void>; initialize?(clientVersion: ClientVersion, roots: Root[]): Promise<void>;
listTools(): Promise<Tool[]>; listTools(): Promise<Tool[]>;
callTool(name: string, args: CallToolRequest['params']['arguments']): Promise<CallToolResult>; callTool(name: string, args: CallToolRequest['params']['arguments']): Promise<CallToolResult>;
serverClosed?(server: Server): void; serverClosed?(): void;
} }
export type ServerBackendFactory = { export type ServerBackendFactory = {
@@ -100,13 +99,13 @@ export function createServer(name: string, version: string, backend: ServerBacke
clientRoots = roots; clientRoots = roots;
} }
const clientVersion = server.getClientVersion() ?? { name: 'unknown', version: 'unknown' }; const clientVersion = server.getClientVersion() ?? { name: 'unknown', version: 'unknown' };
await backend.initialize?.(server, clientVersion, clientRoots); await backend.initialize?.(clientVersion, clientRoots);
initializedPromiseResolve(); initializedPromiseResolve();
} catch (e) { } catch (e) {
errorsDebug(e); errorsDebug(e);
} }
}); });
addServerListener(server, 'close', () => backend.serverClosed?.(server)); addServerListener(server, 'close', () => backend.serverClosed?.());
return server; return server;
} }

View File

@@ -40,7 +40,3 @@ export function toMcpTool(tool: ToolSchema<any>): mcpServer.Tool {
}, },
}; };
} }
export function defineToolSchema<Input extends z.Schema>(tool: ToolSchema<Input>): ToolSchema<Input> {
return tool;
}

View File

@@ -18,7 +18,7 @@ import { EventEmitter } from 'events';
import * as playwright from 'playwright'; import * as playwright from 'playwright';
import { callOnPageNoTrace, waitForCompletion } from './tools/utils.js'; import { callOnPageNoTrace, waitForCompletion } from './tools/utils.js';
import { logUnhandledError } from './utils/log.js'; import { logUnhandledError } from './utils/log.js';
import { ManualPromise } from './mcp/manualPromise.js'; import { ManualPromise } from './utils/manualPromise.js';
import { ModalState } from './tools/tool.js'; import { ModalState } from './tools/tool.js';
import type { Context } from './context.js'; import type { Context } from './context.js';

View File

@@ -19,7 +19,6 @@ import console from './tools/console.js';
import dialogs from './tools/dialogs.js'; import dialogs from './tools/dialogs.js';
import evaluate from './tools/evaluate.js'; import evaluate from './tools/evaluate.js';
import files from './tools/files.js'; import files from './tools/files.js';
import form from './tools/form.js';
import install from './tools/install.js'; import install from './tools/install.js';
import keyboard from './tools/keyboard.js'; import keyboard from './tools/keyboard.js';
import navigate from './tools/navigate.js'; import navigate from './tools/navigate.js';
@@ -40,7 +39,6 @@ export const allTools: Tool<any>[] = [
...dialogs, ...dialogs,
...evaluate, ...evaluate,
...files, ...files,
...form,
...install, ...install,
...keyboard, ...keyboard,
...navigate, ...navigate,

View File

@@ -1,61 +0,0 @@
/**
* 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.
*/
import { z } from 'zod';
import { defineTabTool } from './tool.js';
import { generateLocator } from './utils.js';
import * as javascript from '../utils/codegen.js';
const fillForm = defineTabTool({
capability: 'core',
schema: {
name: 'browser_fill_form',
title: 'Fill form',
description: 'Fill multiple form fields',
inputSchema: z.object({
fields: z.array(z.object({
name: z.string().describe('Human-readable field name'),
type: z.enum(['textbox', 'checkbox', 'radio', 'combobox', 'slider']).describe('Type of the field'),
ref: z.string().describe('Exact target field reference from the page snapshot'),
value: z.string().describe('Value to fill in the field. If the field is a checkbox, the value should be `true` or `false`. If the field is a combobox, the value should be the text of the option.'),
})).describe('Fields to fill in'),
}),
type: 'destructive',
},
handle: async (tab, params, response) => {
for (const field of params.fields) {
const locator = await tab.refLocator({ element: field.name, ref: field.ref });
const locatorSource = `await page.${await generateLocator(locator)}`;
if (field.type === 'textbox' || field.type === 'slider') {
await locator.fill(field.value);
response.addCode(`${locatorSource}.fill(${javascript.quote(field.value)});`);
} else if (field.type === 'checkbox' || field.type === 'radio') {
await locator.setChecked(field.value === 'true');
response.addCode(`${locatorSource}.setChecked(${javascript.quote(field.value)});`);
} else if (field.type === 'combobox') {
await locator.selectOption({ label: field.value });
response.addCode(`${locatorSource}.selectOption(${javascript.quote(field.value)});`);
}
}
},
});
export default [
fillForm,
];

View File

@@ -56,7 +56,24 @@ const goBack = defineTabTool({
}, },
}); });
const goForward = defineTabTool({
capability: 'core',
schema: {
name: 'browser_navigate_forward',
title: 'Go forward',
description: 'Go forward to the next page',
inputSchema: z.object({}),
type: 'readOnly',
},
handle: async (tab, params, response) => {
await tab.page.goForward();
response.setIncludeSnapshot();
response.addCode(`await page.goForward();`);
},
});
export default [ export default [
navigate, navigate,
goBack, goBack,
goForward,
]; ];

View File

@@ -17,48 +17,85 @@
import { z } from 'zod'; import { z } from 'zod';
import { defineTool } from './tool.js'; import { defineTool } from './tool.js';
const browserTabs = defineTool({ const listTabs = defineTool({
capability: 'core-tabs', capability: 'core-tabs',
schema: { schema: {
name: 'browser_tabs', name: 'browser_tab_list',
title: 'Manage tabs', title: 'List tabs',
description: 'List, create, close, or select a browser tab.', description: 'List browser tabs',
inputSchema: z.object({}),
type: 'readOnly',
},
handle: async (context, params, response) => {
await context.ensureTab();
response.setIncludeTabs();
},
});
const selectTab = defineTool({
capability: 'core-tabs',
schema: {
name: 'browser_tab_select',
title: 'Select a tab',
description: 'Select a tab by index',
inputSchema: z.object({ inputSchema: z.object({
action: z.enum(['list', 'new', 'close', 'select']).describe('Operation to perform'), index: z.number().describe('The index of the tab to select'),
index: z.number().optional().describe('Tab index, used for close/select. If omitted for close, current tab is closed.'), }),
type: 'readOnly',
},
handle: async (context, params, response) => {
await context.selectTab(params.index);
response.setIncludeSnapshot();
},
});
const newTab = defineTool({
capability: 'core-tabs',
schema: {
name: 'browser_tab_new',
title: 'Open a new tab',
description: 'Open a new tab',
inputSchema: z.object({
url: z.string().optional().describe('The URL to navigate to in the new tab. If not provided, the new tab will be blank.'),
}),
type: 'readOnly',
},
handle: async (context, params, response) => {
const tab = await context.newTab();
if (params.url)
await tab.navigate(params.url);
response.setIncludeSnapshot();
},
});
const closeTab = defineTool({
capability: 'core-tabs',
schema: {
name: 'browser_tab_close',
title: 'Close a tab',
description: 'Close a tab',
inputSchema: z.object({
index: z.number().optional().describe('The index of the tab to close. Closes current tab if not provided.'),
}), }),
type: 'destructive', type: 'destructive',
}, },
handle: async (context, params, response) => { handle: async (context, params, response) => {
switch (params.action) { await context.closeTab(params.index);
case 'list': { response.setIncludeSnapshot();
await context.ensureTab();
response.setIncludeTabs();
return;
}
case 'new': {
await context.newTab();
response.setIncludeTabs();
return;
}
case 'close': {
await context.closeTab(params.index);
response.setIncludeSnapshot();
return;
}
case 'select': {
if (!params.index)
throw new Error('Tab index is required');
await context.selectTab(params.index);
response.setIncludeSnapshot();
return;
}
}
}, },
}); });
export default [ export default [
browserTabs, listTabs,
newTab,
selectTab,
closeTab,
]; ];

View File

@@ -14,8 +14,6 @@
* limitations under the License. * limitations under the License.
*/ */
import { fileURLToPath } from 'url';
import path from 'path';
import { z } from 'zod'; import { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema'; import { zodToJsonSchema } from 'zod-to-json-schema';
@@ -33,6 +31,8 @@ import { contextFactory } from '../browserContextFactory.js';
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
import type { ClientVersion, ServerBackend } from '../mcp/server.js'; import type { ClientVersion, ServerBackend } from '../mcp/server.js';
import type { Root, Tool, CallToolResult, CallToolRequest } from '@modelcontextprotocol/sdk/types.js'; import type { Root, Tool, CallToolResult, CallToolRequest } from '@modelcontextprotocol/sdk/types.js';
import { fileURLToPath } from 'url';
import path from 'path';
const contextSwitchOptions = z.object({ const contextSwitchOptions = z.object({
connectionString: z.string().optional().describe('The connection string to use to connect to the browser'), connectionString: z.string().optional().describe('The connection string to use to connect to the browser'),
@@ -52,7 +52,7 @@ class VSCodeProxyBackend implements ServerBackend {
this._contextSwitchTool = this._defineContextSwitchTool(); this._contextSwitchTool = this._defineContextSwitchTool();
} }
async initialize(server: mcpServer.Server, clientVersion: ClientVersion, roots: Root[]): Promise<void> { async initialize(clientVersion: ClientVersion, roots: Root[]): Promise<void> {
this._clientVersion = clientVersion; this._clientVersion = clientVersion;
this._roots = roots; this._roots = roots;
const transport = await this._defaultTransportFactory(); const transport = await this._defaultTransportFactory();
@@ -76,7 +76,7 @@ class VSCodeProxyBackend implements ServerBackend {
}) as CallToolResult; }) as CallToolResult;
} }
serverClosed?(server: mcpServer.Server): void { serverClosed?(): void {
void this._currentClient?.close().catch(logUnhandledError); void this._currentClient?.close().catch(logUnhandledError);
} }

View File

@@ -24,7 +24,6 @@ test('test snapshot tool list', async ({ client }) => {
'browser_drag', 'browser_drag',
'browser_evaluate', 'browser_evaluate',
'browser_file_upload', 'browser_file_upload',
'browser_fill_form',
'browser_handle_dialog', 'browser_handle_dialog',
'browser_hover', 'browser_hover',
'browser_select_option', 'browser_select_option',
@@ -32,12 +31,16 @@ test('test snapshot tool list', async ({ client }) => {
'browser_close', 'browser_close',
'browser_install', 'browser_install',
'browser_navigate_back', 'browser_navigate_back',
'browser_navigate_forward',
'browser_navigate', 'browser_navigate',
'browser_network_requests', 'browser_network_requests',
'browser_press_key', 'browser_press_key',
'browser_resize', 'browser_resize',
'browser_snapshot', 'browser_snapshot',
'browser_tabs', 'browser_tab_close',
'browser_tab_list',
'browser_tab_new',
'browser_tab_select',
'browser_take_screenshot', 'browser_take_screenshot',
'browser_wait_for', 'browser_wait_for',
])); ]));
@@ -55,7 +58,6 @@ test('test tool list proxy mode', async ({ startClient }) => {
'browser_drag', 'browser_drag',
'browser_evaluate', 'browser_evaluate',
'browser_file_upload', 'browser_file_upload',
'browser_fill_form',
'browser_handle_dialog', 'browser_handle_dialog',
'browser_hover', 'browser_hover',
'browser_select_option', 'browser_select_option',
@@ -63,12 +65,16 @@ test('test tool list proxy mode', async ({ startClient }) => {
'browser_close', 'browser_close',
'browser_install', 'browser_install',
'browser_navigate_back', 'browser_navigate_back',
'browser_navigate_forward',
'browser_navigate', 'browser_navigate',
'browser_network_requests', 'browser_network_requests',
'browser_press_key', 'browser_press_key',
'browser_resize', 'browser_resize',
'browser_snapshot', 'browser_snapshot',
'browser_tabs', 'browser_tab_close',
'browser_tab_list',
'browser_tab_new',
'browser_tab_select',
'browser_take_screenshot', 'browser_take_screenshot',
'browser_wait_for', 'browser_wait_for',
])); ]));

View File

@@ -1,123 +0,0 @@
/**
* 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.
*/
import { test, expect } from './fixtures.js';
test('browser_fill_form (textbox)', async ({ client, server }) => {
server.setContent('/', `
<!DOCTYPE html>
<html>
<body>
<form>
<label>
<input type="text" id="name" name="name" />
Name
</label>
<label>
<input type="email" id="email" name="email" />
Email
</label>
<label>
<input type="range" id="age" name="age" min="18" max="100" />
Age
</label>
<label>
<select id="country" name="country">
<option value="">Choose a country</option>
<option value="us">United States</option>
<option value="uk">United Kingdom</option>
</select>
Country
</label>
<label>
<input type="checkbox" name="subscribe" value="newsletter" />
Subscribe to newsletter
</label>
</form>
</body>
</html>
`, 'text/html');
await client.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
});
expect(await client.callTool({
name: 'browser_fill_form',
arguments: {
fields: [
{
name: 'Name textbox',
type: 'textbox',
ref: 'e4',
value: 'John Doe'
},
{
name: 'Email textbox',
type: 'textbox',
ref: 'e6',
value: 'john.doe@example.com'
},
{
name: 'Age textbox',
type: 'slider',
ref: 'e8',
value: '25'
},
{
name: 'Country select',
type: 'combobox',
ref: 'e10',
value: 'United States'
},
{
name: 'Subscribe checkbox',
type: 'checkbox',
ref: 'e12',
value: 'true'
},
]
},
})).toHaveResponse({
code: `await page.getByRole('textbox', { name: 'Name' }).fill('John Doe');
await page.getByRole('textbox', { name: 'Email' }).fill('john.doe@example.com');
await page.getByRole('slider', { name: 'Age' }).fill('25');
await page.getByLabel('Choose a country United').selectOption('United States');
await page.getByRole('checkbox', { name: 'Subscribe to newsletter' }).setChecked('true');`,
});
const response = await client.callTool({
name: 'browser_snapshot',
arguments: {
},
});
expect.soft(response).toHaveResponse({
pageState: expect.stringMatching(/textbox "Name".*John Doe/),
});
expect.soft(response).toHaveResponse({
pageState: expect.stringMatching(/textbox "Email".*john.doe@example.com/),
});
expect.soft(response).toHaveResponse({
pageState: expect.stringMatching(/slider "Age".*"25"/),
});
expect.soft(response).toHaveResponse({
pageState: expect.stringContaining('option \"United States\" [selected]'),
});
expect.soft(response).toHaveResponse({
pageState: expect.stringContaining('checkbox \"Subscribe to newsletter\" [checked]'),
});
});

View File

@@ -1,217 +0,0 @@
/**
* 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.
*/
import { z } from 'zod';
import zodToJsonSchema from 'zod-to-json-schema';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { runMainBackend, runOnPauseBackendLoop } from '../src/mcp/mdb.js';
import { test, expect } from './fixtures.js';
import type * as mcpServer from '../src/mcp/server.js';
import type { ServerBackendOnPause } from '../src/mcp/mdb.js';
test('call top level tool', async () => {
const { mdbUrl } = await startMDBAndCLI();
const mdbClient = await createMDBClient(mdbUrl);
const { tools } = await mdbClient.client.listTools();
expect(tools).toEqual([{
name: 'cli_echo',
description: 'Echo a message',
inputSchema: expect.any(Object),
}, {
name: 'cli_pause_in_gdb',
description: 'Pause in gdb',
inputSchema: expect.any(Object),
}, {
name: 'cli_pause_in_gdb_twice',
description: 'Pause in gdb twice',
inputSchema: expect.any(Object),
}
]);
const echoResult = await mdbClient.client.callTool({
name: 'cli_echo',
arguments: {
message: 'Hello, world!',
},
});
expect(echoResult.content).toEqual([{ type: 'text', text: 'Echo: Hello, world!' }]);
await mdbClient.close();
});
test('pause on error', async () => {
const { mdbUrl } = await startMDBAndCLI();
const mdbClient = await createMDBClient(mdbUrl);
// Make a call that results in a recoverable error.
const interruptResult = await mdbClient.client.callTool({
name: 'cli_pause_in_gdb',
arguments: {},
});
expect(interruptResult.content).toEqual([{ type: 'text', text: 'Paused on exception' }]);
// List new inner tools.
const { tools } = await mdbClient.client.listTools();
expect(tools).toEqual([
expect.objectContaining({
name: 'gdb_bt',
}),
expect.objectContaining({
name: 'gdb_continue',
}),
]);
// Call the new inner tool.
const btResult = await mdbClient.client.callTool({
name: 'gdb_bt',
arguments: {},
});
expect(btResult.content).toEqual([{ type: 'text', text: 'Backtrace' }]);
// Continue execution.
const continueResult = await mdbClient.client.callTool({
name: 'gdb_continue',
arguments: {},
});
expect(continueResult.content).toEqual([{ type: 'text', text: 'Done' }]);
await mdbClient.close();
});
test('pause on error twice', async () => {
const { mdbUrl } = await startMDBAndCLI();
const mdbClient = await createMDBClient(mdbUrl);
// Make a call that results in a recoverable error.
const result = await mdbClient.client.callTool({
name: 'cli_pause_in_gdb_twice',
arguments: {},
});
expect(result.content).toEqual([{ type: 'text', text: 'Paused on exception 1' }]);
// Continue execution.
const continueResult1 = await mdbClient.client.callTool({
name: 'gdb_continue',
arguments: {},
});
expect(continueResult1.content).toEqual([{ type: 'text', text: 'Paused on exception 2' }]);
const continueResult2 = await mdbClient.client.callTool({
name: 'gdb_continue',
arguments: {},
});
expect(continueResult2.content).toEqual([{ type: 'text', text: 'Done' }]);
await mdbClient.close();
});
async function startMDBAndCLI(): Promise<{ mdbUrl: string }> {
const mdbUrlBox = { mdbUrl: undefined as string | undefined };
const cliBackendFactory = {
name: 'CLI',
nameInConfig: 'cli',
version: '0.0.0',
create: () => new CLIBackend(mdbUrlBox)
};
const mdbUrl = (await runMainBackend(cliBackendFactory, { port: 0 }))!;
mdbUrlBox.mdbUrl = mdbUrl;
return { mdbUrl };
}
async function createMDBClient(mdbUrl: string): Promise<{ client: Client, close: () => Promise<void> }> {
const client = new Client({ name: 'Internal client', version: '0.0.0' });
const transport = new StreamableHTTPClientTransport(new URL(mdbUrl));
await client.connect(transport);
return {
client,
close: async () => {
await transport.terminateSession();
await client.close();
}
};
}
class CLIBackend implements mcpServer.ServerBackend {
constructor(private readonly mdbUrlBox: { mdbUrl: string | undefined }) {}
async listTools(): Promise<mcpServer.Tool[]> {
return [{
name: 'cli_echo',
description: 'Echo a message',
inputSchema: zodToJsonSchema(z.object({ message: z.string() })) as any,
}, {
name: 'cli_pause_in_gdb',
description: 'Pause in gdb',
inputSchema: zodToJsonSchema(z.object({})) as any,
}, {
name: 'cli_pause_in_gdb_twice',
description: 'Pause in gdb twice',
inputSchema: zodToJsonSchema(z.object({})) as any,
}];
}
async callTool(name: string, args: mcpServer.CallToolRequest['params']['arguments']): Promise<mcpServer.CallToolResult> {
if (name === 'cli_echo')
return { content: [{ type: 'text', text: 'Echo: ' + (args?.message as string) }] };
if (name === 'cli_pause_in_gdb') {
await runOnPauseBackendLoop(this.mdbUrlBox.mdbUrl!, new GDBBackend(), 'Paused on exception');
return { content: [{ type: 'text', text: 'Done' }] };
}
if (name === 'cli_pause_in_gdb_twice') {
await runOnPauseBackendLoop(this.mdbUrlBox.mdbUrl!, new GDBBackend(), 'Paused on exception 1');
await runOnPauseBackendLoop(this.mdbUrlBox.mdbUrl!, new GDBBackend(), 'Paused on exception 2');
return { content: [{ type: 'text', text: 'Done' }] };
}
throw new Error(`Unknown tool: ${name}`);
}
}
class GDBBackend implements ServerBackendOnPause {
private _server!: mcpServer.Server;
async initialize(server: mcpServer.Server): Promise<void> {
this._server = server;
}
async listTools(): Promise<mcpServer.Tool[]> {
return [{
name: 'gdb_bt',
description: 'Print backtrace',
inputSchema: zodToJsonSchema(z.object({})) as any,
}, {
name: 'gdb_continue',
description: 'Continue execution',
inputSchema: zodToJsonSchema(z.object({})) as any,
}];
}
async callTool(name: string, args: mcpServer.CallToolRequest['params']['arguments']): Promise<mcpServer.CallToolResult> {
if (name === 'gdb_bt')
return { content: [{ type: 'text', text: 'Backtrace' }] };
if (name === 'gdb_continue') {
(this as ServerBackendOnPause).requestSelfDestruct?.();
// Stall
await new Promise(f => setTimeout(f, 1000));
}
throw new Error(`Unknown tool: ${name}`);
}
}

View File

@@ -68,12 +68,12 @@ test('check that trace is saved in workspace', async ({ startClient, server }, t
expect(file).toContain('traces'); expect(file).toContain('traces');
}); });
test('should list all tools when listRoots is slow', async ({ startClient }) => { test('should list all tools when listRoots is slow', async ({ startClient, server }, testInfo) => {
const { client } = await startClient({ const { client } = await startClient({
clientName: 'Another custom client', clientName: 'Another custom client',
roots: [], roots: [],
rootsResponseDelay: 1000, rootsResponseDelay: 1000,
}); });
const tools = await client.listTools(); const tools = await client.listTools();
expect(tools.tools.length).toBeGreaterThan(10); expect(tools.tools.length).toBeGreaterThan(20);
}); });

View File

@@ -300,10 +300,7 @@ test('browser_take_screenshot (viewport without snapshot)', async ({ startClient
// Ensure we have a tab but don't navigate anywhere (no snapshot captured) // Ensure we have a tab but don't navigate anywhere (no snapshot captured)
expect(await client.callTool({ expect(await client.callTool({
name: 'browser_tabs', name: 'browser_tab_list',
arguments: {
action: 'list',
},
})).toHaveResponse({ })).toHaveResponse({
tabs: `- 0: (current) [] (about:blank)`, tabs: `- 0: (current) [] (about:blank)`,
}); });

View File

@@ -19,14 +19,8 @@ import { test, expect } from './fixtures.js';
import type { Client } from '@modelcontextprotocol/sdk/client/index.js'; import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
async function createTab(client: Client, title: string, body: string) { async function createTab(client: Client, title: string, body: string) {
await client.callTool({
name: 'browser_tabs',
arguments: {
action: 'new',
},
});
return await client.callTool({ return await client.callTool({
name: 'browser_navigate', name: 'browser_tab_new',
arguments: { arguments: {
url: `data:text/html,<title>${title}</title><body>${body}</body>`, url: `data:text/html,<title>${title}</title><body>${body}</body>`,
}, },
@@ -35,10 +29,7 @@ async function createTab(client: Client, title: string, body: string) {
test('list initial tabs', async ({ client }) => { test('list initial tabs', async ({ client }) => {
expect(await client.callTool({ expect(await client.callTool({
name: 'browser_tabs', name: 'browser_tab_list',
arguments: {
action: 'list',
},
})).toHaveResponse({ })).toHaveResponse({
tabs: `- 0: (current) [] (about:blank)`, tabs: `- 0: (current) [] (about:blank)`,
}); });
@@ -47,10 +38,7 @@ test('list initial tabs', async ({ client }) => {
test('list first tab', async ({ client }) => { test('list first tab', async ({ client }) => {
await createTab(client, 'Tab one', 'Body one'); await createTab(client, 'Tab one', 'Body one');
expect(await client.callTool({ expect(await client.callTool({
name: 'browser_tabs', name: 'browser_tab_list',
arguments: {
action: 'list',
},
})).toHaveResponse({ })).toHaveResponse({
tabs: `- 0: [] (about:blank) tabs: `- 0: [] (about:blank)
- 1: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)`, - 1: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)`,
@@ -87,9 +75,8 @@ test('select tab', async ({ client }) => {
await createTab(client, 'Tab two', 'Body two'); await createTab(client, 'Tab two', 'Body two');
expect(await client.callTool({ expect(await client.callTool({
name: 'browser_tabs', name: 'browser_tab_select',
arguments: { arguments: {
action: 'select',
index: 1, index: 1,
}, },
})).toHaveResponse({ })).toHaveResponse({
@@ -110,9 +97,8 @@ test('close tab', async ({ client }) => {
await createTab(client, 'Tab two', 'Body two'); await createTab(client, 'Tab two', 'Body two');
expect(await client.callTool({ expect(await client.callTool({
name: 'browser_tabs', name: 'browser_tab_close',
arguments: { arguments: {
action: 'close',
index: 2, index: 2,
}, },
})).toHaveResponse({ })).toHaveResponse({