Compare commits
2 Commits
v0.0.35
...
copilot/fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f83a808d39 | ||
|
|
54754d8be1 |
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -28,7 +28,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-15, windows-latest]
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
28
.github/workflows/publish.yml
vendored
28
.github/workflows/publish.yml
vendored
@@ -68,31 +68,3 @@ jobs:
|
||||
for tag in $(echo ${{ steps.build-push.outputs.metadata['image.name'] }} | tr ',' '\n'); do
|
||||
attach_eol_manifest $tag
|
||||
done
|
||||
|
||||
package-extension:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write # Needed to upload release assets
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'npm'
|
||||
- name: Install extension dependencies
|
||||
working-directory: ./extension
|
||||
run: npm ci
|
||||
- name: Build extension
|
||||
working-directory: ./extension
|
||||
run: npm run build
|
||||
- name: Package extension
|
||||
working-directory: ./extension
|
||||
run: |
|
||||
cd dist
|
||||
zip -r ../playwright-mcp-extension-${{ github.event.release.tag_name }}.zip .
|
||||
cd ..
|
||||
- name: Upload extension to release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
gh release upload ${{github.event.release.tag_name}} ./extension/playwright-mcp-extension-${{ github.event.release.tag_name }}.zip
|
||||
|
||||
99
README.md
99
README.md
@@ -56,21 +56,6 @@ Follow the MCP install [guide](https://modelcontextprotocol.io/quickstart/user),
|
||||
|
||||
</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>
|
||||
<summary>Cursor</summary>
|
||||
|
||||
@@ -115,29 +100,6 @@ Go to `Advanced settings` -> `Extensions` -> `Add custom extension`. Name to you
|
||||
Go to `Program` in the right sidebar -> `Install` -> `Edit mcp.json`. Use the standard config above.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>opencode</summary>
|
||||
|
||||
Follow the MCP Servers [documentation](https://opencode.ai/docs/mcp-servers/). For example in `~/.config/opencode/opencode.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"mcp": {
|
||||
"playwright": {
|
||||
"type": "local",
|
||||
"command": [
|
||||
"npx",
|
||||
"@playwright/mcp@latest"
|
||||
],
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Qodo Gen</summary>
|
||||
|
||||
@@ -196,9 +158,6 @@ Playwright MCP server supports following arguments. They can be provided in the
|
||||
--config <path> path to the configuration file.
|
||||
--device <device> device to emulate, for example: "iPhone 15"
|
||||
--executable-path <path> path to the browser executable.
|
||||
--extension Connect to a running browser instance
|
||||
(Edge/Chrome only). Requires the "Playwright MCP
|
||||
Bridge" browser extension to be installed.
|
||||
--headless run browser in headless mode, headed by default
|
||||
--host <host> host to bind server to. Default is localhost. Use
|
||||
0.0.0.0 to bind to all interfaces.
|
||||
@@ -232,7 +191,7 @@ Playwright MCP server supports following arguments. They can be provided in the
|
||||
|
||||
### User profile
|
||||
|
||||
You can run Playwright MCP with persistent profile like a regular browser (default), in isolated contexts for testing sessions, or connect to your existing browser using the browser extension.
|
||||
You can run Playwright MCP with persistent profile like a regular browser (default), or in the isolated contexts for the testing sessions.
|
||||
|
||||
**Persistent profile**
|
||||
|
||||
@@ -272,10 +231,6 @@ state [here](https://playwright.dev/docs/auth).
|
||||
}
|
||||
```
|
||||
|
||||
**Browser Extension**
|
||||
|
||||
The Playwright MCP Chrome Extension allows you to connect to existing browser tabs and leverage your logged-in sessions and browser state. See [extension/README.md](extension/README.md) for installation and setup instructions.
|
||||
|
||||
### Configuration file
|
||||
|
||||
The Playwright MCP server can be configured using a JSON configuration file. You can specify the configuration file
|
||||
@@ -494,15 +449,6 @@ http.createServer(async (req, res) => {
|
||||
|
||||
<!-- 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**
|
||||
- Title: Handle a dialog
|
||||
- Description: Handle a dialog
|
||||
@@ -540,6 +486,14 @@ http.createServer(async (req, res) => {
|
||||
|
||||
<!-- 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**
|
||||
- Title: List network requests
|
||||
- Description: Returns all network requests since loading the page
|
||||
@@ -628,14 +582,39 @@ http.createServer(async (req, res) => {
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_tabs**
|
||||
- Title: Manage tabs
|
||||
- Description: List, create, close, or select a browser tab.
|
||||
- **browser_tab_close**
|
||||
- Title: Close a tab
|
||||
- Description: Close a tab
|
||||
- Parameters:
|
||||
- `action` (string): Operation to perform
|
||||
- `index` (number, optional): Tab index, used for close/select. If omitted for close, current tab is closed.
|
||||
- `index` (number, optional): The index of the tab to close. Closes current tab if not provided.
|
||||
- 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>
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
# Playwright MCP Chrome Extension
|
||||
|
||||
## Introduction
|
||||
|
||||
The Playwright MCP Chrome Extension allows you to connect to pages in your existing browser and leverage the state of your default user profile. This means the AI assistant can interact with websites where you're already logged in, using your existing cookies, sessions, and browser state, providing a seamless experience without requiring separate authentication or setup.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Chrome/Edge/Chromium browser
|
||||
|
||||
## Installation Steps
|
||||
|
||||
### Download the Extension
|
||||
|
||||
Download the latest Chrome extension from GitHub:
|
||||
- **Download link**: https://github.com/microsoft/playwright-mcp/releases
|
||||
|
||||
### Load Chrome Extension
|
||||
|
||||
1. Open Chrome and navigate to `chrome://extensions/`
|
||||
2. Enable "Developer mode" (toggle in the top right corner)
|
||||
3. Click "Load unpacked" and select the extension directory
|
||||
|
||||
### Configure Playwright MCP server
|
||||
|
||||
Configure Playwright MCP server to connect to the browser using the extension by passing the `--extension` option when running the MCP server:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"playwright-extension": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"@playwright/mcp@latest",
|
||||
"--extension"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Browser Tab Selection
|
||||
|
||||
When the LLM interacts with the browser for the first time, it will load a page where you can select which browser tab the LLM will connect to. This allows you to control which specific page the AI assistant will interact with during the session.
|
||||
|
||||
|
||||
@@ -1,22 +1,26 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Playwright MCP Bridge",
|
||||
"version": "0.0.35",
|
||||
"version": "1.0.0",
|
||||
"description": "Share browser tabs with Playwright MCP server",
|
||||
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA9nMS2b0WCohjVHPGb8D9qAdkbIngDqoAjTeSccHJijgcONejge+OJxOQOMLu7b0ovt1c9BiEJa5JcpM+EHFVGL1vluBxK71zmBy1m2f9vZF3HG0LSCp7YRkum9rAIEthDwbkxx6XTvpmAY5rjFa/NON6b9Hlbo+8peUSkoOK7HTwYnnI36asZ9eUTiveIf+DMPLojW2UX33vDWG2UKvMVDewzclb4+uLxAYshY7Mx8we/b44xu+Anb/EBLKjOPk9Yh541xJ5Ozc8EiP/5yxOp9c/lRiYUHaRW+4r0HKZyFt0eZ52ti2iM4Nfk7jRXR7an3JPsUIf5deC/1cVM/+1ZQIDAQAB",
|
||||
|
||||
"permissions": [
|
||||
"debugger",
|
||||
"activeTab",
|
||||
"tabs",
|
||||
"storage"
|
||||
],
|
||||
|
||||
"host_permissions": [
|
||||
"<all_urls>"
|
||||
],
|
||||
|
||||
"background": {
|
||||
"service_worker": "lib/background.js",
|
||||
"type": "module"
|
||||
},
|
||||
|
||||
"action": {
|
||||
"default_title": "Playwright MCP Bridge",
|
||||
"default_icon": {
|
||||
@@ -26,6 +30,7 @@
|
||||
"128": "icons/icon-128.png"
|
||||
}
|
||||
},
|
||||
|
||||
"icons": {
|
||||
"16": "icons/icon-16.png",
|
||||
"32": "icons/icon-32.png",
|
||||
|
||||
4
extension/package-lock.json
generated
4
extension/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@playwright/mcp-extension",
|
||||
"version": "0.0.35",
|
||||
"version": "0.0.32",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@playwright/mcp-extension",
|
||||
"version": "0.0.35",
|
||||
"version": "0.0.32",
|
||||
"license": "Apache-2.0",
|
||||
"devDependencies": {
|
||||
"@types/chrome": "^0.0.315",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@playwright/mcp-extension",
|
||||
"version": "0.0.35",
|
||||
"version": "0.0.32",
|
||||
"description": "Playwright MCP Browser Extension",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
|
||||
@@ -23,8 +23,8 @@ type PageMessage = {
|
||||
type: 'getTabs';
|
||||
} | {
|
||||
type: 'connectToTab';
|
||||
tabId?: number;
|
||||
windowId?: number;
|
||||
tabId: number;
|
||||
windowId: number;
|
||||
mcpRelayUrl: string;
|
||||
} | {
|
||||
type: 'getConnectionStatus';
|
||||
@@ -49,7 +49,7 @@ class TabShareExtension {
|
||||
private _onMessage(message: PageMessage, sender: chrome.runtime.MessageSender, sendResponse: (response: any) => void) {
|
||||
switch (message.type) {
|
||||
case 'connectToMCPRelay':
|
||||
this._connectToRelay(sender.tab!.id!, message.mcpRelayUrl).then(
|
||||
this._connectToRelay(sender.tab!.id!, message.mcpRelayUrl!).then(
|
||||
() => sendResponse({ success: true }),
|
||||
(error: any) => sendResponse({ success: false, error: error.message }));
|
||||
return true;
|
||||
@@ -59,9 +59,7 @@ class TabShareExtension {
|
||||
(error: any) => sendResponse({ success: false, error: error.message }));
|
||||
return true;
|
||||
case 'connectToTab':
|
||||
const tabId = message.tabId || sender.tab?.id!;
|
||||
const windowId = message.windowId || sender.tab?.windowId!;
|
||||
this._connectTab(sender.tab!.id!, tabId, windowId, message.mcpRelayUrl!).then(
|
||||
this._connectTab(sender.tab!.id!, message.tabId, message.windowId, message.mcpRelayUrl!).then(
|
||||
() => sendResponse({ success: true }),
|
||||
(error: any) => sendResponse({ success: false, error: error.message }));
|
||||
return true; // Return true to indicate that the response will be sent asynchronously
|
||||
@@ -98,9 +96,8 @@ class TabShareExtension {
|
||||
this._pendingTabSelection.set(selectorTabId, { connection });
|
||||
debugLog(`Connected to MCP relay`);
|
||||
} catch (error: any) {
|
||||
const message = `Failed to connect to MCP relay: ${error.message}`;
|
||||
debugLog(message);
|
||||
throw new Error(message);
|
||||
debugLog(`Failed to connect to MCP relay:`, error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,7 +193,7 @@ class TabShareExtension {
|
||||
}
|
||||
|
||||
private _onTabUpdated(tabId: number, changeInfo: chrome.tabs.TabChangeInfo, tab: chrome.tabs.Tab) {
|
||||
if (this._connectedTabId === tabId)
|
||||
if (changeInfo.status === 'complete' && this._connectedTabId === tabId)
|
||||
void this._setConnectedTabId(tabId);
|
||||
}
|
||||
|
||||
|
||||
@@ -192,15 +192,4 @@ body {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Link-style button */
|
||||
.link-button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #0066cc;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
}
|
||||
@@ -19,19 +19,15 @@ import { createRoot } from 'react-dom/client';
|
||||
import { Button, TabItem } from './tabItem.js';
|
||||
import type { TabInfo } from './tabItem.js';
|
||||
|
||||
type Status =
|
||||
| { type: 'connecting'; message: string }
|
||||
| { type: 'connected'; message: string }
|
||||
| { type: 'error'; message: string };
|
||||
type StatusType = 'connected' | 'error' | 'connecting';
|
||||
|
||||
const ConnectApp: React.FC = () => {
|
||||
const [tabs, setTabs] = useState<TabInfo[]>([]);
|
||||
const [status, setStatus] = useState<Status | null>(null);
|
||||
const [status, setStatus] = useState<{ type: StatusType; message: string } | null>(null);
|
||||
const [showButtons, setShowButtons] = useState(true);
|
||||
const [showTabList, setShowTabList] = useState(true);
|
||||
const [clientInfo, setClientInfo] = useState('unknown');
|
||||
const [mcpRelayUrl, setMcpRelayUrl] = useState('');
|
||||
const [newTab, setNewTab] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
@@ -59,28 +55,14 @@ const ConnectApp: React.FC = () => {
|
||||
}
|
||||
|
||||
void connectToMCPRelay(relayUrl);
|
||||
|
||||
// If this is a browser_navigate command, hide the tab list and show simple allow/reject
|
||||
if (params.get('newTab') === 'true') {
|
||||
setNewTab(true);
|
||||
setShowTabList(false);
|
||||
} else {
|
||||
void loadTabs();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleReject = useCallback((message: string) => {
|
||||
setShowButtons(false);
|
||||
setShowTabList(false);
|
||||
setStatus({ type: 'error', message });
|
||||
void loadTabs();
|
||||
}, []);
|
||||
|
||||
const connectToMCPRelay = useCallback(async (mcpRelayUrl: string) => {
|
||||
|
||||
const response = await chrome.runtime.sendMessage({ type: 'connectToMCPRelay', mcpRelayUrl });
|
||||
const response = await chrome.runtime.sendMessage({ type: 'connectToMCPRelay', mcpRelayUrl });
|
||||
if (!response.success)
|
||||
handleReject(response.error);
|
||||
}, [handleReject]);
|
||||
setStatus({ type: 'error', message: 'Failed to connect to MCP relay: ' + response.error });
|
||||
}, []);
|
||||
|
||||
const loadTabs = useCallback(async () => {
|
||||
const response = await chrome.runtime.sendMessage({ type: 'getTabs' });
|
||||
@@ -90,7 +72,7 @@ const ConnectApp: React.FC = () => {
|
||||
setStatus({ type: 'error', message: 'Failed to load tabs: ' + response.error });
|
||||
}, []);
|
||||
|
||||
const handleConnectToTab = useCallback(async (tab?: TabInfo) => {
|
||||
const handleConnectToTab = useCallback(async (tab: TabInfo) => {
|
||||
setShowButtons(false);
|
||||
setShowTabList(false);
|
||||
|
||||
@@ -98,8 +80,8 @@ const ConnectApp: React.FC = () => {
|
||||
const response = await chrome.runtime.sendMessage({
|
||||
type: 'connectToTab',
|
||||
mcpRelayUrl,
|
||||
tabId: tab?.id,
|
||||
windowId: tab?.windowId,
|
||||
tabId: tab.id,
|
||||
windowId: tab.windowId,
|
||||
});
|
||||
|
||||
if (response?.success) {
|
||||
@@ -118,40 +100,33 @@ const ConnectApp: React.FC = () => {
|
||||
}
|
||||
}, [clientInfo, mcpRelayUrl]);
|
||||
|
||||
const handleReject = useCallback(() => {
|
||||
setShowButtons(false);
|
||||
setShowTabList(false);
|
||||
setStatus({ type: 'error', message: 'Connection rejected. This tab can be closed.' });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const listener = (message: any) => {
|
||||
if (message.type === 'connectionTimeout')
|
||||
handleReject('Connection timed out.');
|
||||
handleReject();
|
||||
};
|
||||
chrome.runtime.onMessage.addListener(listener);
|
||||
return () => {
|
||||
chrome.runtime.onMessage.removeListener(listener);
|
||||
};
|
||||
}, [handleReject]);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className='app-container'>
|
||||
<div className='content-wrapper'>
|
||||
{status && (
|
||||
<div className='status-container'>
|
||||
<StatusBanner status={status} />
|
||||
<StatusBanner type={status.type} message={status.message} />
|
||||
{showButtons && (
|
||||
<div className='button-container'>
|
||||
{newTab ? (
|
||||
<>
|
||||
<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>
|
||||
<Button variant='reject' onClick={handleReject}>
|
||||
Reject
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -181,13 +156,8 @@ const ConnectApp: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
const StatusBanner: React.FC<{ status: Status }> = ({ status }) => {
|
||||
return (
|
||||
<div className={`status-banner ${status.type}`}>
|
||||
{status.message}
|
||||
</div>
|
||||
);
|
||||
const StatusBanner: React.FC<{ type: StatusType; message: string }> = ({ type, message }) => {
|
||||
return <div className={`status-banner ${type}`}>{message}</div>;
|
||||
};
|
||||
|
||||
// Initialize the React app
|
||||
|
||||
@@ -14,49 +14,37 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { chromium } from 'playwright';
|
||||
import { test as base, expect } from '../../tests/fixtures.js';
|
||||
|
||||
import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import type { BrowserContext } from 'playwright';
|
||||
import type { StartClient } from '../../tests/fixtures.js';
|
||||
|
||||
type BrowserWithExtension = {
|
||||
userDataDir: string;
|
||||
launch: (mode?: 'disable-extension') => Promise<BrowserContext>;
|
||||
launch: () => Promise<BrowserContext>;
|
||||
};
|
||||
|
||||
type TestFixtures = {
|
||||
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) => {
|
||||
const test = base.extend<{ browserWithExtension: BrowserWithExtension }>({
|
||||
browserWithExtension: async ({ mcpBrowser }, use, testInfo) => {
|
||||
// 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#
|
||||
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;
|
||||
const userDataDir = testInfo.outputPath('extension-user-data-dir');
|
||||
await use({
|
||||
userDataDir,
|
||||
launch: async (mode?: 'disable-extension') => {
|
||||
launch: async () => {
|
||||
browserContext = await chromium.launchPersistentContext(userDataDir, {
|
||||
channel: mcpBrowser,
|
||||
// Opening the browser singleton only works in headed.
|
||||
headless: false,
|
||||
// Automation disables singleton browser process behavior, which is necessary for the extension.
|
||||
ignoreDefaultArgs: ['--enable-automation'],
|
||||
args: mode === 'disable-extension' ? [] : [
|
||||
args: [
|
||||
`--disable-extensions-except=${pathToExtension}`,
|
||||
`--load-extension=${pathToExtension}`,
|
||||
],
|
||||
@@ -70,19 +58,14 @@ const test = base.extend<TestFixtures>({
|
||||
return browserContext;
|
||||
}
|
||||
});
|
||||
|
||||
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> {
|
||||
test('navigate with extension', async ({ browserWithExtension, startClient, server }) => {
|
||||
const browserContext = await browserWithExtension.launch();
|
||||
|
||||
const { client } = await startClient({
|
||||
args: [`--connect-tool`],
|
||||
config: {
|
||||
@@ -95,150 +78,75 @@ async function startAndCallConnectTool(browserWithExtension: BrowserWithExtensio
|
||||
expect(await client.callTool({
|
||||
name: 'browser_connect',
|
||||
arguments: {
|
||||
name: 'extension'
|
||||
method: 'extension'
|
||||
}
|
||||
})).toHaveResponse({
|
||||
result: 'Successfully changed connection method.',
|
||||
});
|
||||
|
||||
return client;
|
||||
}
|
||||
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 selectorPage = await confirmationPagePromise;
|
||||
await selectorPage.locator('.tab-item', { hasText: 'Playwright MCP Extension' }).getByRole('button', { name: 'Connect' }).click();
|
||||
|
||||
expect(await navigateResponse).toHaveResponse({
|
||||
pageState: expect.stringContaining(`- generic [active] [ref=e1]: Hello, world!`),
|
||||
});
|
||||
});
|
||||
|
||||
test('snapshot of an existing page', async ({ browserWithExtension, startClient, server }) => {
|
||||
const browserContext = await browserWithExtension.launch();
|
||||
|
||||
const page = await browserContext.newPage();
|
||||
await page.goto(server.HELLO_WORLD);
|
||||
|
||||
// Another empty page.
|
||||
await browserContext.newPage();
|
||||
expect(browserContext.pages()).toHaveLength(3);
|
||||
|
||||
async function startWithExtensionFlag(browserWithExtension: BrowserWithExtension, startClient: StartClient): Promise<Client> {
|
||||
const { client } = await startClient({
|
||||
args: [`--extension`],
|
||||
args: [`--connect-tool`],
|
||||
config: {
|
||||
browser: {
|
||||
userDataDir: browserWithExtension.userDataDir,
|
||||
}
|
||||
},
|
||||
});
|
||||
return client;
|
||||
}
|
||||
|
||||
const testWithOldExtensionVersion = test.extend({
|
||||
pathToExtension: async ({}, use, testInfo) => {
|
||||
const extensionDir = testInfo.outputPath('extension');
|
||||
const oldPath = fileURLToPath(new URL('../dist', import.meta.url));
|
||||
expect(await client.callTool({
|
||||
name: 'browser_connect',
|
||||
arguments: {
|
||||
method: 'extension'
|
||||
}
|
||||
})).toHaveResponse({
|
||||
result: 'Successfully changed connection method.',
|
||||
});
|
||||
expect(browserContext.pages()).toHaveLength(3);
|
||||
|
||||
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');
|
||||
const confirmationPagePromise = browserContext.waitForEvent('page', page => {
|
||||
return page.url().startsWith('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html');
|
||||
});
|
||||
|
||||
await use(extensionDir);
|
||||
},
|
||||
const navigateResponse = client.callTool({
|
||||
name: 'browser_snapshot',
|
||||
arguments: { },
|
||||
});
|
||||
|
||||
const selectorPage = await confirmationPagePromise;
|
||||
expect(browserContext.pages()).toHaveLength(4);
|
||||
|
||||
await selectorPage.locator('.tab-item', { hasText: 'Title' }).getByRole('button', { name: 'Connect' }).click();
|
||||
|
||||
expect(await navigateResponse).toHaveResponse({
|
||||
pageState: expect.stringContaining(`- generic [active] [ref=e1]: Hello, world!`),
|
||||
});
|
||||
|
||||
expect(browserContext.pages()).toHaveLength(4);
|
||||
});
|
||||
|
||||
for (const [mode, startClientMethod] of [
|
||||
['connect-tool', startAndCallConnectTool],
|
||||
['extension-flag', startWithExtensionFlag],
|
||||
] as const) {
|
||||
|
||||
test(`navigate with extension (${mode})`, async ({ browserWithExtension, startClient, server }) => {
|
||||
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 selectorPage = await confirmationPagePromise;
|
||||
// For browser_navigate command, the UI shows Allow/Reject buttons instead of tab selector
|
||||
await selectorPage.getByRole('button', { name: 'Allow' }).click();
|
||||
|
||||
expect(await navigateResponse).toHaveResponse({
|
||||
pageState: expect.stringContaining(`- generic [active] [ref=e1]: Hello, world!`),
|
||||
});
|
||||
});
|
||||
|
||||
test(`snapshot of an existing page (${mode})`, async ({ browserWithExtension, startClient, server }) => {
|
||||
const browserContext = await browserWithExtension.launch();
|
||||
|
||||
const page = await browserContext.newPage();
|
||||
await page.goto(server.HELLO_WORLD);
|
||||
|
||||
// Another empty page.
|
||||
await browserContext.newPage();
|
||||
expect(browserContext.pages()).toHaveLength(3);
|
||||
|
||||
const client = await startClientMethod(browserWithExtension, startClient);
|
||||
expect(browserContext.pages()).toHaveLength(3);
|
||||
|
||||
const confirmationPagePromise = browserContext.waitForEvent('page', page => {
|
||||
return page.url().startsWith('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html');
|
||||
});
|
||||
|
||||
const navigateResponse = client.callTool({
|
||||
name: 'browser_snapshot',
|
||||
arguments: { },
|
||||
});
|
||||
|
||||
const selectorPage = await confirmationPagePromise;
|
||||
expect(browserContext.pages()).toHaveLength(4);
|
||||
|
||||
await selectorPage.locator('.tab-item', { hasText: 'Title' }).getByRole('button', { name: 'Connect' }).click();
|
||||
|
||||
expect(await navigateResponse).toHaveResponse({
|
||||
pageState: expect.stringContaining(`- generic [active] [ref=e1]: Hello, world!`),
|
||||
});
|
||||
|
||||
expect(browserContext.pages()).toHaveLength(4);
|
||||
});
|
||||
|
||||
test(`extension not installed timeout (${mode})`, async ({ browserWithExtension, startClient, server, useShortConnectionTimeout }) => {
|
||||
useShortConnectionTimeout(100);
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: { url: server.HELLO_WORLD },
|
||||
})).toHaveResponse({
|
||||
result: expect.stringContaining('Extension connection timeout. Make sure the "Playwright MCP Bridge" extension is installed.'),
|
||||
isError: true,
|
||||
});
|
||||
|
||||
await confirmationPagePromise;
|
||||
});
|
||||
|
||||
testWithOldExtensionVersion(`works with old extension version (${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 selectorPage = await confirmationPagePromise;
|
||||
// For browser_navigate command, the UI shows Allow/Reject buttons instead of tab selector
|
||||
await selectorPage.getByRole('button', { name: 'Allow' }).click();
|
||||
|
||||
expect(await navigateResponse).toHaveResponse({
|
||||
pageState: expect.stringContaining(`- generic [active] [ref=e1]: Hello, world!`),
|
||||
});
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
32
package-lock.json
generated
32
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@playwright/mcp",
|
||||
"version": "0.0.35",
|
||||
"version": "0.0.33",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@playwright/mcp",
|
||||
"version": "0.0.35",
|
||||
"version": "0.0.33",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.16.0",
|
||||
@@ -14,8 +14,8 @@
|
||||
"debug": "^4.4.1",
|
||||
"dotenv": "^17.2.0",
|
||||
"mime": "^4.0.7",
|
||||
"playwright": "1.55.0-alpha-2025-08-12",
|
||||
"playwright-core": "1.55.0-alpha-2025-08-12",
|
||||
"playwright": "1.55.0-alpha-2025-08-07",
|
||||
"playwright-core": "1.55.0-alpha-2025-08-07",
|
||||
"ws": "^8.18.1",
|
||||
"zod": "^3.24.1",
|
||||
"zod-to-json-schema": "^3.24.4"
|
||||
@@ -27,7 +27,7 @@
|
||||
"@anthropic-ai/sdk": "^0.57.0",
|
||||
"@eslint/eslintrc": "^3.2.0",
|
||||
"@eslint/js": "^9.19.0",
|
||||
"@playwright/test": "1.55.0-alpha-2025-08-12",
|
||||
"@playwright/test": "1.55.0-alpha-2025-08-07",
|
||||
"@stylistic/eslint-plugin": "^3.0.1",
|
||||
"@types/debug": "^4.1.12",
|
||||
"@types/node": "^22.13.10",
|
||||
@@ -703,13 +703,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.55.0-alpha-2025-08-12",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.55.0-alpha-2025-08-12.tgz",
|
||||
"integrity": "sha512-lyq9MDSd4UcOWx5292AYLBfbYYCstg8iLb+lk6LdM69ps6bwmPloZO3Ol3JO3FQQ63qAuW9VD0w+ZYKL0lRmQA==",
|
||||
"version": "1.55.0-alpha-2025-08-07",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.55.0-alpha-2025-08-07.tgz",
|
||||
"integrity": "sha512-N83L8JSSJ+E690HCbgzmXIcbRfM/rlh0uWZhbHbMp9q4qDPABSgvhm0HGiG345PV1ozoqcCI/mXLZPircsmPIA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.55.0-alpha-2025-08-12"
|
||||
"playwright": "1.55.0-alpha-2025-08-07"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
@@ -3745,12 +3745,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.55.0-alpha-2025-08-12",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.0-alpha-2025-08-12.tgz",
|
||||
"integrity": "sha512-daZPM5gX0VTG6ae3/qOpEKc9NxoavkM2lfL0UIzTG0k+yK8ZeSPYo63iewZhVANsWRm0BT+XQ1NniAUOwWQ+xA==",
|
||||
"version": "1.55.0-alpha-2025-08-07",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.0-alpha-2025-08-07.tgz",
|
||||
"integrity": "sha512-rH8kdQOZzhjxC6FOL9zSEDwPl88ZqQq9QEvRDONWhzKwRQ/jOXlEZRxm8QRCBdrLqBMTGHx/YOaP7MIV//rtIA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.55.0-alpha-2025-08-12"
|
||||
"playwright-core": "1.55.0-alpha-2025-08-07"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
@@ -3763,9 +3763,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.55.0-alpha-2025-08-12",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.0-alpha-2025-08-12.tgz",
|
||||
"integrity": "sha512-4uxOd9xmeF6gqdsORzzlXd7p795vcACOiAGVHHEiTuFXsD83LYH+0C/SYLWB0Z+fAq4LdKGsy0qEfTm0JkY8Ig==",
|
||||
"version": "1.55.0-alpha-2025-08-07",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.0-alpha-2025-08-07.tgz",
|
||||
"integrity": "sha512-NUuC6R0/dLk1QKiYoJL8NUsQAC6Je0C2BpuIg5h4wcvBwJ5TFldslmik17Txg3TXBSqwgG76DAl4Q6UdHGn54Q==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
|
||||
11
package.json
11
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@playwright/mcp",
|
||||
"version": "0.0.35",
|
||||
"version": "0.0.33",
|
||||
"description": "Playwright Tools for MCP",
|
||||
"type": "module",
|
||||
"repository": {
|
||||
@@ -17,9 +17,8 @@
|
||||
"license": "Apache-2.0",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"lint": "npm run update-readme && npm run check-deps && eslint . && tsc --noEmit",
|
||||
"lint": "npm run update-readme && eslint . && tsc --noEmit",
|
||||
"lint-fix": "eslint . --fix",
|
||||
"check-deps": "node utils/check-deps.js",
|
||||
"update-readme": "node utils/update-readme.js",
|
||||
"watch": "tsc --watch",
|
||||
"test": "playwright test",
|
||||
@@ -43,8 +42,8 @@
|
||||
"debug": "^4.4.1",
|
||||
"dotenv": "^17.2.0",
|
||||
"mime": "^4.0.7",
|
||||
"playwright": "1.55.0-alpha-2025-08-12",
|
||||
"playwright-core": "1.55.0-alpha-2025-08-12",
|
||||
"playwright": "1.55.0-alpha-2025-08-07",
|
||||
"playwright-core": "1.55.0-alpha-2025-08-07",
|
||||
"ws": "^8.18.1",
|
||||
"zod": "^3.24.1",
|
||||
"zod-to-json-schema": "^3.24.4"
|
||||
@@ -53,7 +52,7 @@
|
||||
"@anthropic-ai/sdk": "^0.57.0",
|
||||
"@eslint/eslintrc": "^3.2.0",
|
||||
"@eslint/js": "^9.19.0",
|
||||
"@playwright/test": "1.55.0-alpha-2025-08-12",
|
||||
"@playwright/test": "1.55.0-alpha-2025-08-07",
|
||||
"@stylistic/eslint-plugin": "^3.0.1",
|
||||
"@types/debug": "^4.1.12",
|
||||
"@types/node": "^22.13.10",
|
||||
|
||||
@@ -22,10 +22,12 @@ export default defineConfig<TestOptions>({
|
||||
testDir: './tests',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
workers: process.env.CI ? 2 : undefined,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: 'list',
|
||||
projects: [
|
||||
{ name: 'chrome' },
|
||||
{ name: 'msedge', use: { mcpBrowser: 'msedge' } },
|
||||
{ name: 'chromium', use: { mcpBrowser: 'chromium' } },
|
||||
...process.env.MCP_IN_DOCKER ? [{
|
||||
name: 'chromium-docker',
|
||||
@@ -37,6 +39,5 @@ export default defineConfig<TestOptions>({
|
||||
}] : [],
|
||||
{ name: 'firefox', use: { mcpBrowser: 'firefox' } },
|
||||
{ name: 'webkit', use: { mcpBrowser: 'webkit' } },
|
||||
... process.platform === 'win32' ? [{ name: 'msedge', use: { mcpBrowser: 'msedge' } }] : [],
|
||||
],
|
||||
});
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
[*]
|
||||
./tools/
|
||||
./mcp/
|
||||
./utils/
|
||||
|
||||
[program.ts]
|
||||
***
|
||||
@@ -21,10 +21,8 @@ import path from 'path';
|
||||
import * as playwright from 'playwright';
|
||||
// @ts-ignore
|
||||
import { registryDirectory } from 'playwright-core/lib/server/registry/index';
|
||||
// @ts-ignore
|
||||
import { startTraceViewerServer } from 'playwright-core/lib/server';
|
||||
import { logUnhandledError, testDebug } from './utils/log.js';
|
||||
import { createHash } from './utils/guid.js';
|
||||
import { logUnhandledError, testDebug } from './log.js';
|
||||
import { createHash } from './utils.js';
|
||||
import { outputFile } from './config.js';
|
||||
|
||||
import type { FullConfig } from './config.js';
|
||||
@@ -42,24 +40,29 @@ export function contextFactory(config: FullConfig): BrowserContextFactory {
|
||||
export type ClientInfo = { name?: string, version?: string, rootPath?: string };
|
||||
|
||||
export interface BrowserContextFactory {
|
||||
createContext(clientInfo: ClientInfo, abortSignal: AbortSignal, toolName: string | undefined): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }>;
|
||||
readonly name: string;
|
||||
readonly description: string;
|
||||
createContext(clientInfo: ClientInfo, abortSignal: AbortSignal): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }>;
|
||||
}
|
||||
|
||||
class BaseContextFactory implements BrowserContextFactory {
|
||||
readonly name: string;
|
||||
readonly description: string;
|
||||
readonly config: FullConfig;
|
||||
private _logName: string;
|
||||
protected _browserPromise: Promise<playwright.Browser> | undefined;
|
||||
protected _tracesDir: string | undefined;
|
||||
|
||||
constructor(name: string, config: FullConfig) {
|
||||
this._logName = name;
|
||||
constructor(name: string, description: string, config: FullConfig) {
|
||||
this.name = name;
|
||||
this.description = description;
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
protected async _obtainBrowser(clientInfo: ClientInfo): Promise<playwright.Browser> {
|
||||
protected async _obtainBrowser(): Promise<playwright.Browser> {
|
||||
if (this._browserPromise)
|
||||
return this._browserPromise;
|
||||
testDebug(`obtain browser (${this._logName})`);
|
||||
this._browserPromise = this._doObtainBrowser(clientInfo);
|
||||
testDebug(`obtain browser (${this.name})`);
|
||||
this._browserPromise = this._doObtainBrowser();
|
||||
void this._browserPromise.then(browser => {
|
||||
browser.on('disconnected', () => {
|
||||
this._browserPromise = undefined;
|
||||
@@ -70,13 +73,16 @@ class BaseContextFactory implements BrowserContextFactory {
|
||||
return this._browserPromise;
|
||||
}
|
||||
|
||||
protected async _doObtainBrowser(clientInfo: ClientInfo): Promise<playwright.Browser> {
|
||||
protected async _doObtainBrowser(): Promise<playwright.Browser> {
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
|
||||
async createContext(clientInfo: ClientInfo): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
|
||||
testDebug(`create browser context (${this._logName})`);
|
||||
const browser = await this._obtainBrowser(clientInfo);
|
||||
if (this.config.saveTrace)
|
||||
this._tracesDir = await outputFile(this.config, clientInfo.rootPath, `traces-${Date.now()}`);
|
||||
|
||||
testDebug(`create browser context (${this.name})`);
|
||||
const browser = await this._obtainBrowser();
|
||||
const browserContext = await this._doCreateContext(browser);
|
||||
return { browserContext, close: () => this._closeBrowserContext(browserContext, browser) };
|
||||
}
|
||||
@@ -86,12 +92,12 @@ class BaseContextFactory implements BrowserContextFactory {
|
||||
}
|
||||
|
||||
private async _closeBrowserContext(browserContext: playwright.BrowserContext, browser: playwright.Browser) {
|
||||
testDebug(`close browser context (${this._logName})`);
|
||||
testDebug(`close browser context (${this.name})`);
|
||||
if (browser.contexts().length === 1)
|
||||
this._browserPromise = undefined;
|
||||
await browserContext.close().catch(logUnhandledError);
|
||||
if (browser.contexts().length === 0) {
|
||||
testDebug(`close browser (${this._logName})`);
|
||||
testDebug(`close browser (${this.name})`);
|
||||
await browser.close().catch(logUnhandledError);
|
||||
}
|
||||
}
|
||||
@@ -99,14 +105,14 @@ class BaseContextFactory implements BrowserContextFactory {
|
||||
|
||||
class IsolatedContextFactory extends BaseContextFactory {
|
||||
constructor(config: FullConfig) {
|
||||
super('isolated', config);
|
||||
super('isolated', 'Create a new isolated browser context', config);
|
||||
}
|
||||
|
||||
protected override async _doObtainBrowser(clientInfo: ClientInfo): Promise<playwright.Browser> {
|
||||
protected override async _doObtainBrowser(): Promise<playwright.Browser> {
|
||||
await injectCdpPort(this.config.browser);
|
||||
const browserType = playwright[this.config.browser.browserName];
|
||||
return browserType.launch({
|
||||
tracesDir: await startTraceServer(this.config, clientInfo.rootPath),
|
||||
tracesDir: this._tracesDir,
|
||||
...this.config.browser.launchOptions,
|
||||
handleSIGINT: false,
|
||||
handleSIGTERM: false,
|
||||
@@ -124,7 +130,7 @@ class IsolatedContextFactory extends BaseContextFactory {
|
||||
|
||||
class CdpContextFactory extends BaseContextFactory {
|
||||
constructor(config: FullConfig) {
|
||||
super('cdp', config);
|
||||
super('cdp', 'Connect to a browser over CDP', config);
|
||||
}
|
||||
|
||||
protected override async _doObtainBrowser(): Promise<playwright.Browser> {
|
||||
@@ -138,7 +144,7 @@ class CdpContextFactory extends BaseContextFactory {
|
||||
|
||||
class RemoteContextFactory extends BaseContextFactory {
|
||||
constructor(config: FullConfig) {
|
||||
super('remote', config);
|
||||
super('remote', 'Connect to a browser using a remote endpoint', config);
|
||||
}
|
||||
|
||||
protected override async _doObtainBrowser(): Promise<playwright.Browser> {
|
||||
@@ -169,7 +175,9 @@ class PersistentContextFactory implements BrowserContextFactory {
|
||||
await injectCdpPort(this.config.browser);
|
||||
testDebug('create browser context (persistent)');
|
||||
const userDataDir = this.config.browser.userDataDir ?? await this._createUserDataDir(clientInfo.rootPath);
|
||||
const tracesDir = await startTraceServer(this.config, clientInfo.rootPath);
|
||||
let tracesDir: string | undefined;
|
||||
if (this.config.saveTrace)
|
||||
tracesDir = await outputFile(this.config, clientInfo.rootPath, `traces-${Date.now()}`);
|
||||
|
||||
this._userDataDirs.add(userDataDir);
|
||||
testDebug('lock user data dir', userDataDir);
|
||||
@@ -234,16 +242,3 @@ async function findFreePort(): Promise<number> {
|
||||
server.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function startTraceServer(config: FullConfig, rootPath: string | undefined): Promise<string | undefined> {
|
||||
if (!config.saveTrace)
|
||||
return undefined;
|
||||
|
||||
const tracesDir = await outputFile(config, rootPath, `traces-${Date.now()}`);
|
||||
const server = await startTraceViewerServer();
|
||||
const urlPrefix = server.urlPrefix('human-readable');
|
||||
const url = urlPrefix + '/trace/index.html?trace=' + tracesDir + '/trace.json';
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('\nTrace viewer listening on ' + url);
|
||||
return tracesDir;
|
||||
}
|
||||
|
||||
@@ -15,35 +15,50 @@
|
||||
*/
|
||||
|
||||
import { fileURLToPath } from 'url';
|
||||
import { z } from 'zod';
|
||||
import { FullConfig } from './config.js';
|
||||
import { Context } from './context.js';
|
||||
import { logUnhandledError } from './utils/log.js';
|
||||
import { logUnhandledError } from './log.js';
|
||||
import { Response } from './response.js';
|
||||
import { SessionLog } from './sessionLog.js';
|
||||
import { filteredTools } from './tools.js';
|
||||
import { toMcpTool } from './mcp/tool.js';
|
||||
import { packageJSON } from './package.js';
|
||||
import { defineTool } from './tools/tool.js';
|
||||
|
||||
import type { Tool } from './tools/tool.js';
|
||||
import type { BrowserContextFactory } from './browserContextFactory.js';
|
||||
import type * as mcpServer from './mcp/server.js';
|
||||
import type { ServerBackend } from './mcp/server.js';
|
||||
|
||||
type NonEmptyArray<T> = [T, ...T[]];
|
||||
|
||||
export type FactoryList = NonEmptyArray<BrowserContextFactory>;
|
||||
|
||||
export class BrowserServerBackend implements ServerBackend {
|
||||
name = 'Playwright';
|
||||
version = packageJSON.version;
|
||||
|
||||
private _tools: Tool[];
|
||||
private _context: Context | undefined;
|
||||
private _sessionLog: SessionLog | undefined;
|
||||
private _config: FullConfig;
|
||||
private _browserContextFactory: BrowserContextFactory;
|
||||
|
||||
constructor(config: FullConfig, factory: BrowserContextFactory) {
|
||||
constructor(config: FullConfig, factories: FactoryList) {
|
||||
this._config = config;
|
||||
this._browserContextFactory = factory;
|
||||
this._browserContextFactory = factories[0];
|
||||
this._tools = filteredTools(config);
|
||||
if (factories.length > 1)
|
||||
this._tools.push(this._defineContextSwitchTool(factories));
|
||||
}
|
||||
|
||||
async initialize(server: mcpServer.Server, clientVersion: mcpServer.ClientVersion, roots: mcpServer.Root[]): Promise<void> {
|
||||
async initialize(server: mcpServer.Server): Promise<void> {
|
||||
const capabilities = server.getClientCapabilities() as mcpServer.ClientCapabilities;
|
||||
let rootPath: string | undefined;
|
||||
if (roots.length > 0) {
|
||||
if (capabilities.roots && (
|
||||
server.getClientVersion()?.name === 'Visual Studio Code' ||
|
||||
server.getClientVersion()?.name === 'Visual Studio Code - Insiders')) {
|
||||
const { roots } = await server.listRoots();
|
||||
const firstRootUri = roots[0]?.uri;
|
||||
const url = firstRootUri ? new URL(firstRootUri) : undefined;
|
||||
rootPath = url ? fileURLToPath(url) : undefined;
|
||||
@@ -54,22 +69,19 @@ export class BrowserServerBackend implements ServerBackend {
|
||||
config: this._config,
|
||||
browserContextFactory: this._browserContextFactory,
|
||||
sessionLog: this._sessionLog,
|
||||
clientInfo: { ...clientVersion, rootPath },
|
||||
clientInfo: { ...server.getClientVersion(), rootPath },
|
||||
});
|
||||
}
|
||||
|
||||
async listTools(): Promise<mcpServer.Tool[]> {
|
||||
return this._tools.map(tool => toMcpTool(tool.schema));
|
||||
tools(): mcpServer.ToolSchema<any>[] {
|
||||
return this._tools.map(tool => tool.schema);
|
||||
}
|
||||
|
||||
async callTool(name: string, rawArguments: mcpServer.CallToolRequest['params']['arguments']) {
|
||||
const tool = this._tools.find(tool => tool.schema.name === name)!;
|
||||
if (!tool)
|
||||
throw new Error(`Tool "${name}" not found`);
|
||||
const parsedArguments = tool.schema.inputSchema.parse(rawArguments || {});
|
||||
async callTool(schema: mcpServer.ToolSchema<any>, parsedArguments: any) {
|
||||
const context = this._context!;
|
||||
const response = new Response(context, name, parsedArguments);
|
||||
context.setRunningTool(name);
|
||||
const response = new Response(context, schema.name, parsedArguments);
|
||||
const tool = this._tools.find(tool => tool.schema.name === schema.name)!;
|
||||
context.setRunningTool(true);
|
||||
try {
|
||||
await tool.handle(context, parsedArguments, response);
|
||||
await response.finish();
|
||||
@@ -77,12 +89,54 @@ export class BrowserServerBackend implements ServerBackend {
|
||||
} catch (error: any) {
|
||||
response.addError(String(error));
|
||||
} finally {
|
||||
context.setRunningTool(undefined);
|
||||
context.setRunningTool(false);
|
||||
}
|
||||
return response.serialize();
|
||||
}
|
||||
|
||||
serverClosed() {
|
||||
void this._context?.dispose().catch(logUnhandledError);
|
||||
void this._context!.dispose().catch(logUnhandledError);
|
||||
}
|
||||
|
||||
private _defineContextSwitchTool(factories: FactoryList): Tool<any> {
|
||||
const self = this;
|
||||
return defineTool({
|
||||
capability: 'core',
|
||||
|
||||
schema: {
|
||||
name: 'browser_connect',
|
||||
title: 'Connect to a browser context',
|
||||
description: [
|
||||
'Connect to a browser using one of the available methods:',
|
||||
...factories.map(factory => `- "${factory.name}": ${factory.description}`),
|
||||
].join('\n'),
|
||||
inputSchema: z.object({
|
||||
method: z.enum(factories.map(factory => factory.name) as [string, ...string[]]).default(factories[0].name).describe('The method to use to connect to the browser'),
|
||||
}),
|
||||
type: 'readOnly',
|
||||
},
|
||||
|
||||
async handle(context, params, response) {
|
||||
const factory = factories.find(factory => factory.name === params.method);
|
||||
if (!factory) {
|
||||
response.addError('Unknown connection method: ' + params.method);
|
||||
return;
|
||||
}
|
||||
await self._setContextFactory(factory);
|
||||
response.addResult('Successfully changed connection method.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async _setContextFactory(newFactory: BrowserContextFactory) {
|
||||
if (this._context) {
|
||||
const options = {
|
||||
...this._context.options,
|
||||
browserContextFactory: newFactory,
|
||||
};
|
||||
await this._context.dispose();
|
||||
this._context = new Context(options);
|
||||
}
|
||||
this._browserContextFactory = newFactory;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { devices } from 'playwright';
|
||||
import { sanitizeForFilePath } from './utils/fileUtils.js';
|
||||
import { sanitizeForFilePath } from './utils.js';
|
||||
|
||||
import type { Config, ToolCapability } from '../config.js';
|
||||
import type { BrowserContextOptions, LaunchOptions } from 'playwright';
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
import debug from 'debug';
|
||||
import * as playwright from 'playwright';
|
||||
|
||||
import { logUnhandledError } from './utils/log.js';
|
||||
import { logUnhandledError } from './log.js';
|
||||
import { Tab } from './tab.js';
|
||||
import { outputFile } from './config.js';
|
||||
|
||||
@@ -50,7 +50,7 @@ export class Context {
|
||||
|
||||
private static _allContexts: Set<Context> = new Set();
|
||||
private _closeBrowserContextPromise: Promise<void> | undefined;
|
||||
private _runningToolName: string | undefined;
|
||||
private _isRunningTool: boolean = false;
|
||||
private _abortController = new AbortController();
|
||||
|
||||
constructor(options: ContextOptions) {
|
||||
@@ -145,11 +145,11 @@ export class Context {
|
||||
}
|
||||
|
||||
isRunningTool() {
|
||||
return this._runningToolName !== undefined;
|
||||
return this._isRunningTool;
|
||||
}
|
||||
|
||||
setRunningTool(name: string | undefined) {
|
||||
this._runningToolName = name;
|
||||
setRunningTool(isRunningTool: boolean) {
|
||||
this._isRunningTool = isRunningTool;
|
||||
}
|
||||
|
||||
private async _closeBrowserContextImpl() {
|
||||
@@ -202,7 +202,7 @@ export class Context {
|
||||
if (this._closeBrowserContextPromise)
|
||||
throw new Error('Another browser context is being closed.');
|
||||
// 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;
|
||||
await this._setupRequestInterception(browserContext);
|
||||
if (this.sessionLog)
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
[*]
|
||||
../mcp/
|
||||
../utils/
|
||||
@@ -26,10 +26,9 @@ import { spawn } from 'child_process';
|
||||
import http from 'http';
|
||||
import debug from 'debug';
|
||||
import { WebSocket, WebSocketServer } from 'ws';
|
||||
import { httpAddressToString } from '../mcp/http.js';
|
||||
import { logUnhandledError } from '../utils/log.js';
|
||||
import { ManualPromise } from '../mcp/manualPromise.js';
|
||||
|
||||
import { httpAddressToString } from '../httpServer.js';
|
||||
import { logUnhandledError } from '../log.js';
|
||||
import { ManualPromise } from '../manualPromise.js';
|
||||
import type websocket from 'ws';
|
||||
import type { ClientInfo } from '../browserContextFactory.js';
|
||||
|
||||
@@ -93,34 +92,25 @@ export class CDPRelayServer {
|
||||
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');
|
||||
if (this._extensionConnection)
|
||||
return;
|
||||
this._connectBrowser(clientInfo, toolName);
|
||||
this._connectBrowser(clientInfo);
|
||||
debugLogger('Waiting for incoming extension connection');
|
||||
await Promise.race([
|
||||
this._extensionConnectionPromise,
|
||||
new Promise((_, reject) => setTimeout(() => {
|
||||
reject(new Error(`Extension connection timeout. Make sure the "Playwright MCP Bridge" extension is installed. See https://github.com/microsoft/playwright-mcp/blob/main/extension/README.md for installation instructions.`));
|
||||
}, process.env.PWMCP_TEST_CONNECTION_TIMEOUT ? parseInt(process.env.PWMCP_TEST_CONNECTION_TIMEOUT, 10) : 5_000)),
|
||||
new Promise((_, reject) => abortSignal.addEventListener('abort', reject))
|
||||
]);
|
||||
debugLogger('Extension connection established');
|
||||
}
|
||||
|
||||
private _connectBrowser(clientInfo: ClientInfo, toolName: string | undefined) {
|
||||
private _connectBrowser(clientInfo: ClientInfo) {
|
||||
const mcpRelayEndpoint = `${this._wsHost}${this._extensionPath}`;
|
||||
// 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');
|
||||
url.searchParams.set('mcpRelayUrl', mcpRelayEndpoint);
|
||||
const client = {
|
||||
name: clientInfo.name,
|
||||
version: clientInfo.version,
|
||||
};
|
||||
url.searchParams.set('client', JSON.stringify(client));
|
||||
if (toolName)
|
||||
url.searchParams.set('newTab', String(toolName === 'browser_navigate'));
|
||||
url.searchParams.set('client', JSON.stringify(clientInfo));
|
||||
const href = url.toString();
|
||||
const executableInfo = registry.findExecutable(this._browserChannel);
|
||||
if (!executableInfo)
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
import debug from 'debug';
|
||||
import * as playwright from 'playwright';
|
||||
import { startHttpServer } from '../mcp/http.js';
|
||||
import { startHttpServer } from '../httpServer.js';
|
||||
import { CDPRelayServer } from './cdpRelay.js';
|
||||
|
||||
import type { BrowserContextFactory, ClientInfo } from '../browserContextFactory.js';
|
||||
@@ -24,6 +24,9 @@ import type { BrowserContextFactory, ClientInfo } from '../browserContextFactory
|
||||
const debugLogger = debug('pw:mcp:relay');
|
||||
|
||||
export class ExtensionContextFactory implements BrowserContextFactory {
|
||||
name = 'extension';
|
||||
description = 'Connect to a browser using the Playwright MCP extension';
|
||||
|
||||
private _browserChannel: string;
|
||||
private _userDataDir?: string;
|
||||
|
||||
@@ -32,8 +35,8 @@ export class ExtensionContextFactory implements BrowserContextFactory {
|
||||
this._userDataDir = userDataDir;
|
||||
}
|
||||
|
||||
async createContext(clientInfo: ClientInfo, abortSignal: AbortSignal, toolName: string | undefined): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
|
||||
const browser = await this._obtainBrowser(clientInfo, abortSignal, toolName);
|
||||
async createContext(clientInfo: ClientInfo, abortSignal: AbortSignal): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
|
||||
const browser = await this._obtainBrowser(clientInfo, abortSignal);
|
||||
return {
|
||||
browserContext: browser.contexts()[0],
|
||||
close: async () => {
|
||||
@@ -43,9 +46,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);
|
||||
await relay.ensureExtensionConnectionForMCPContext(clientInfo, abortSignal, toolName);
|
||||
await relay.ensureExtensionConnectionForMCPContext(clientInfo, abortSignal);
|
||||
return await playwright.chromium.connectOverCDP(relay.cdpEndpoint());
|
||||
}
|
||||
|
||||
|
||||
31
src/extension/main.ts
Normal file
31
src/extension/main.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* 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 { ExtensionContextFactory } from './extensionContextFactory.js';
|
||||
import { BrowserServerBackend } from '../browserServerBackend.js';
|
||||
import * as mcpTransport from '../mcp/transport.js';
|
||||
|
||||
import type { FullConfig } from '../config.js';
|
||||
|
||||
export async function runWithExtension(config: FullConfig) {
|
||||
const contextFactory = new ExtensionContextFactory(config.browser.launchOptions.channel || 'chrome', config.browser.userDataDir);
|
||||
const serverBackendFactory = () => new BrowserServerBackend(config, [contextFactory]);
|
||||
await mcpTransport.start(serverBackendFactory, config.server);
|
||||
}
|
||||
|
||||
export function createExtensionContextFactory(config: FullConfig) {
|
||||
return new ExtensionContextFactory(config.browser.launchOptions.channel || 'chrome', config.browser.userDataDir);
|
||||
}
|
||||
@@ -17,6 +17,8 @@
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { FullConfig } from './config.js';
|
||||
|
||||
export function cacheDir() {
|
||||
let cacheDirectory: string;
|
||||
if (process.platform === 'linux')
|
||||
@@ -30,10 +32,6 @@ export function cacheDir() {
|
||||
return path.join(cacheDirectory, 'ms-playwright');
|
||||
}
|
||||
|
||||
export function sanitizeForFilePath(s: string) {
|
||||
const sanitize = (s: string) => s.replace(/[\x00-\x2C\x2E-\x2F\x3A-\x40\x5B-\x60\x7B-\x7F]+/g, '-');
|
||||
const separator = s.lastIndexOf('.');
|
||||
if (separator === -1)
|
||||
return sanitize(s);
|
||||
return sanitize(s.substring(0, separator)) + '.' + sanitize(s.substring(separator + 1));
|
||||
export async function userDataDir(browserConfig: FullConfig['browser']) {
|
||||
return path.join(cacheDir(), 'ms-playwright', `mcp-${browserConfig.launchOptions?.channel ?? browserConfig?.browserName}-profile`);
|
||||
}
|
||||
44
src/httpServer.ts
Normal file
44
src/httpServer.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* 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 assert from 'assert';
|
||||
import http from 'http';
|
||||
|
||||
import type * as net from 'net';
|
||||
|
||||
export async function startHttpServer(config: { host?: string, port?: number }): Promise<http.Server> {
|
||||
const { host, port } = config;
|
||||
const httpServer = http.createServer();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
httpServer.on('error', reject);
|
||||
httpServer.listen(port, host, () => {
|
||||
resolve();
|
||||
httpServer.removeListener('error', reject);
|
||||
});
|
||||
});
|
||||
return httpServer;
|
||||
}
|
||||
|
||||
export function httpAddressToString(address: string | net.AddressInfo | null): string {
|
||||
assert(address, 'Could not bind server socket');
|
||||
if (typeof address === 'string')
|
||||
return address;
|
||||
const resolvedPort = address.port;
|
||||
let resolvedHost = address.family === 'IPv4' ? address.address : `[${address.address}]`;
|
||||
if (resolvedHost === '0.0.0.0' || resolvedHost === '[::]')
|
||||
resolvedHost = 'localhost';
|
||||
return `http://${resolvedHost}:${resolvedPort}`;
|
||||
}
|
||||
@@ -18,7 +18,6 @@ import { BrowserServerBackend } from './browserServerBackend.js';
|
||||
import { resolveConfig } from './config.js';
|
||||
import { contextFactory } from './browserContextFactory.js';
|
||||
import * as mcpServer from './mcp/server.js';
|
||||
import { packageJSON } from './utils/package.js';
|
||||
|
||||
import type { Config } from '../config.js';
|
||||
import type { BrowserContext } from 'playwright';
|
||||
@@ -28,7 +27,7 @@ import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
export async function createConnection(userConfig: Config = {}, contextGetter?: () => Promise<BrowserContext>): Promise<Server> {
|
||||
const config = await resolveConfig(userConfig);
|
||||
const factory = contextGetter ? new SimpleBrowserContextFactory(contextGetter) : contextFactory(config);
|
||||
return mcpServer.createServer('Playwright', packageJSON.version, new BrowserServerBackend(config, factory), false);
|
||||
return mcpServer.createServer(new BrowserServerBackend(config, [factory]), false);
|
||||
}
|
||||
|
||||
class SimpleBrowserContextFactory implements BrowserContextFactory {
|
||||
|
||||
@@ -27,7 +27,7 @@ export function escapeWithQuotes(text: string, char: string = '\'') {
|
||||
if (char === '"')
|
||||
return char + escapedText.replace(/["]/g, '\\"') + char;
|
||||
if (char === '`')
|
||||
return char + escapedText.replace(/[`]/g, '\\`') + char;
|
||||
return char + escapedText.replace(/[`]/g, '`') + char;
|
||||
throw new Error('Invalid escape char');
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
[*]
|
||||
../
|
||||
../loop/
|
||||
../mcp/
|
||||
../utils/
|
||||
@@ -23,7 +23,6 @@ import { OpenAIDelegate } from '../loop/loopOpenAI.js';
|
||||
import { ClaudeDelegate } from '../loop/loopClaude.js';
|
||||
import { InProcessTransport } from '../mcp/inProcessTransport.js';
|
||||
import * as mcpServer from '../mcp/server.js';
|
||||
import { packageJSON } from '../utils/package.js';
|
||||
|
||||
import type { LLMDelegate } from '../loop/loop.js';
|
||||
import type { FullConfig } from '../config.js';
|
||||
@@ -45,15 +44,15 @@ export class Context {
|
||||
}
|
||||
|
||||
static async create(config: FullConfig) {
|
||||
const client = new Client({ name: 'Playwright Proxy', version: packageJSON.version });
|
||||
const client = new Client({ name: 'Playwright Proxy', version: '1.0.0' });
|
||||
const browserContextFactory = contextFactory(config);
|
||||
const server = mcpServer.createServer('Playwright Subagent', packageJSON.version, new BrowserServerBackend(config, browserContextFactory), false);
|
||||
const server = mcpServer.createServer(new BrowserServerBackend(config, [browserContextFactory]), false);
|
||||
await client.connect(new InProcessTransport(server));
|
||||
await client.ping();
|
||||
return new Context(config, client);
|
||||
}
|
||||
|
||||
async runTask(task: string, oneShot: boolean = false): Promise<mcpServer.CallToolResult> {
|
||||
async runTask(task: string, oneShot: boolean = false): Promise<mcpServer.ToolResponse> {
|
||||
const messages = await runTask(this._delegate, this._client!, task, oneShot);
|
||||
const lines: string[] = [];
|
||||
|
||||
|
||||
@@ -17,11 +17,11 @@
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
import * as mcpServer from '../mcp/server.js';
|
||||
import { packageJSON } from '../utils/package.js';
|
||||
import * as mcpTransport from '../mcp/transport.js';
|
||||
import { packageJSON } from '../package.js';
|
||||
import { Context } from './context.js';
|
||||
import { perform } from './perform.js';
|
||||
import { snapshot } from './snapshot.js';
|
||||
import { toMcpTool } from '../mcp/tool.js';
|
||||
|
||||
import type { FullConfig } from '../config.js';
|
||||
import type { ServerBackend } from '../mcp/server.js';
|
||||
@@ -29,16 +29,13 @@ import type { Tool } from './tool.js';
|
||||
|
||||
export async function runLoopTools(config: FullConfig) {
|
||||
dotenv.config();
|
||||
const serverBackendFactory = {
|
||||
name: 'Playwright',
|
||||
nameInConfig: 'playwright-loop',
|
||||
version: packageJSON.version,
|
||||
create: () => new LoopToolsServerBackend(config)
|
||||
};
|
||||
await mcpServer.start(serverBackendFactory, config.server);
|
||||
const serverBackendFactory = () => new LoopToolsServerBackend(config);
|
||||
await mcpTransport.start(serverBackendFactory, config.server);
|
||||
}
|
||||
|
||||
class LoopToolsServerBackend implements ServerBackend {
|
||||
readonly name = 'Playwright';
|
||||
readonly version = packageJSON.version;
|
||||
private _config: FullConfig;
|
||||
private _context: Context | undefined;
|
||||
private _tools: Tool<any>[] = [perform, snapshot];
|
||||
@@ -51,13 +48,12 @@ class LoopToolsServerBackend implements ServerBackend {
|
||||
this._context = await Context.create(this._config);
|
||||
}
|
||||
|
||||
async listTools(): Promise<mcpServer.Tool[]> {
|
||||
return this._tools.map(tool => toMcpTool(tool.schema));
|
||||
tools(): mcpServer.ToolSchema<any>[] {
|
||||
return this._tools.map(tool => tool.schema);
|
||||
}
|
||||
|
||||
async callTool(name: string, args: mcpServer.CallToolRequest['params']['arguments']): Promise<mcpServer.CallToolResult> {
|
||||
const tool = this._tools.find(tool => tool.schema.name === name)!;
|
||||
const parsedArguments = tool.schema.inputSchema.parse(args || {});
|
||||
async callTool(schema: mcpServer.ToolSchema<any>, parsedArguments: any): Promise<mcpServer.ToolResponse> {
|
||||
const tool = this._tools.find(tool => tool.schema.name === schema.name)!;
|
||||
return await tool.handle(this._context!, parsedArguments);
|
||||
}
|
||||
|
||||
|
||||
@@ -17,12 +17,11 @@
|
||||
import type { z } from 'zod';
|
||||
import type * as mcpServer from '../mcp/server.js';
|
||||
import type { Context } from './context.js';
|
||||
import type { ToolSchema } from '../mcp/tool.js';
|
||||
|
||||
|
||||
export type Tool<Input extends z.Schema = z.Schema> = {
|
||||
schema: ToolSchema<Input>;
|
||||
handle: (context: Context, params: z.output<Input>) => Promise<mcpServer.CallToolResult>;
|
||||
schema: mcpServer.ToolSchema<Input>;
|
||||
handle: (context: Context, params: z.output<Input>) => Promise<mcpServer.ToolResponse>;
|
||||
};
|
||||
|
||||
export function defineTool<Input extends z.Schema>(tool: Tool<Input>): Tool<Input> {
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
[*]
|
||||
@@ -1 +1 @@
|
||||
- Generic MCP utils, no dependencies on anything.
|
||||
- Generic MCP utils, no dependencies on Playwright here.
|
||||
|
||||
239
src/mcp/mdb.ts
239
src/mcp/mdb.ts
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,128 +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 { zodToJsonSchema } from 'zod-to-json-schema';
|
||||
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { ListRootsRequestSchema, PingRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
||||
|
||||
import type { ServerBackend, ClientVersion, Root, Server } from './server.js';
|
||||
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
||||
import type { Tool, CallToolResult, CallToolRequest } from '@modelcontextprotocol/sdk/types.js';
|
||||
|
||||
export type MCPProvider = {
|
||||
name: string;
|
||||
description: string;
|
||||
connect(): Promise<Transport>;
|
||||
};
|
||||
|
||||
const errorsDebug = debug('pw:mcp:errors');
|
||||
|
||||
export class ProxyBackend implements ServerBackend {
|
||||
private _mcpProviders: MCPProvider[];
|
||||
private _currentClient: Client | undefined;
|
||||
private _contextSwitchTool: Tool;
|
||||
private _roots: Root[] = [];
|
||||
|
||||
constructor(mcpProviders: MCPProvider[]) {
|
||||
this._mcpProviders = mcpProviders;
|
||||
this._contextSwitchTool = this._defineContextSwitchTool();
|
||||
}
|
||||
|
||||
async initialize(server: Server, clientVersion: ClientVersion, roots: Root[]): Promise<void> {
|
||||
this._roots = roots;
|
||||
await this._setCurrentClient(this._mcpProviders[0]);
|
||||
}
|
||||
|
||||
async listTools(): Promise<Tool[]> {
|
||||
const response = await this._currentClient!.listTools();
|
||||
if (this._mcpProviders.length === 1)
|
||||
return response.tools;
|
||||
return [
|
||||
...response.tools,
|
||||
this._contextSwitchTool,
|
||||
];
|
||||
}
|
||||
|
||||
async callTool(name: string, args: CallToolRequest['params']['arguments']): Promise<CallToolResult> {
|
||||
if (name === this._contextSwitchTool.name)
|
||||
return this._callContextSwitchTool(args);
|
||||
return await this._currentClient!.callTool({
|
||||
name,
|
||||
arguments: args,
|
||||
}) as CallToolResult;
|
||||
}
|
||||
|
||||
serverClosed?(): void {
|
||||
void this._currentClient?.close().catch(errorsDebug);
|
||||
}
|
||||
|
||||
private async _callContextSwitchTool(params: any): Promise<CallToolResult> {
|
||||
try {
|
||||
const factory = this._mcpProviders.find(factory => factory.name === params.name);
|
||||
if (!factory)
|
||||
throw new Error('Unknown connection method: ' + params.name);
|
||||
|
||||
await this._setCurrentClient(factory);
|
||||
return {
|
||||
content: [{ type: 'text', text: '### Result\nSuccessfully changed connection method.\n' }],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `### Result\nError: ${error}\n` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private _defineContextSwitchTool(): Tool {
|
||||
return {
|
||||
name: 'browser_connect',
|
||||
description: [
|
||||
'Connect to a browser using one of the available methods:',
|
||||
...this._mcpProviders.map(factory => `- "${factory.name}": ${factory.description}`),
|
||||
].join('\n'),
|
||||
inputSchema: zodToJsonSchema(z.object({
|
||||
name: z.enum(this._mcpProviders.map(factory => factory.name) as [string, ...string[]]).default(this._mcpProviders[0].name).describe('The method to use to connect to the browser'),
|
||||
}), { strictUnions: true }) as Tool['inputSchema'],
|
||||
annotations: {
|
||||
title: 'Connect to a browser context',
|
||||
readOnlyHint: true,
|
||||
openWorldHint: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async _setCurrentClient(factory: MCPProvider) {
|
||||
await this._currentClient?.close();
|
||||
this._currentClient = undefined;
|
||||
|
||||
const client = new Client({ name: 'Playwright MCP Proxy', version: '0.0.0' });
|
||||
client.registerCapabilities({
|
||||
roots: {
|
||||
listRoots: true,
|
||||
},
|
||||
});
|
||||
client.setRequestHandler(ListRootsRequestSchema, () => ({ roots: this._roots }));
|
||||
client.setRequestHandler(PingRequestSchema, () => ({}));
|
||||
|
||||
const transport = await factory.connect();
|
||||
await client.connect(transport);
|
||||
this._currentClient = client;
|
||||
}
|
||||
}
|
||||
@@ -14,67 +14,80 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import debug from 'debug';
|
||||
|
||||
import { z } from 'zod';
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import { httpAddressToString, installHttpTransport, startHttpServer } from './http.js';
|
||||
import { InProcessTransport } from './inProcessTransport.js';
|
||||
import { zodToJsonSchema } from 'zod-to-json-schema';
|
||||
import { ManualPromise } from '../manualPromise.js';
|
||||
import { logUnhandledError } from '../log.js';
|
||||
|
||||
import type { Tool, CallToolResult, CallToolRequest, Root } from '@modelcontextprotocol/sdk/types.js';
|
||||
import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types.js';
|
||||
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
||||
export type { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
export type { Tool, CallToolResult, CallToolRequest, Root } from '@modelcontextprotocol/sdk/types.js';
|
||||
|
||||
const serverDebug = debug('pw:mcp:server');
|
||||
const errorsDebug = debug('pw:mcp:errors');
|
||||
|
||||
export type ClientVersion = { name: string, version: string };
|
||||
|
||||
export interface ServerBackend {
|
||||
initialize?(server: Server, clientVersion: ClientVersion, roots: Root[]): Promise<void>;
|
||||
listTools(): Promise<Tool[]>;
|
||||
callTool(name: string, args: CallToolRequest['params']['arguments']): Promise<CallToolResult>;
|
||||
serverClosed?(server: Server): void;
|
||||
}
|
||||
|
||||
export type ServerBackendFactory = {
|
||||
name: string;
|
||||
nameInConfig: string;
|
||||
version: string;
|
||||
create: () => ServerBackend;
|
||||
export type ClientCapabilities = {
|
||||
roots?: {
|
||||
listRoots?: boolean
|
||||
};
|
||||
};
|
||||
|
||||
export async function connect(factory: ServerBackendFactory, transport: Transport, runHeartbeat: boolean) {
|
||||
const server = createServer(factory.name, factory.version, factory.create(), runHeartbeat);
|
||||
export type ToolResponse = {
|
||||
content: (TextContent | ImageContent)[];
|
||||
isError?: boolean;
|
||||
};
|
||||
|
||||
export type ToolSchema<Input extends z.Schema> = {
|
||||
name: string;
|
||||
title: string;
|
||||
description: string;
|
||||
inputSchema: Input;
|
||||
type: 'readOnly' | 'destructive';
|
||||
};
|
||||
|
||||
export type ToolHandler = (toolName: string, params: any) => Promise<ToolResponse>;
|
||||
|
||||
export interface ServerBackend {
|
||||
name: string;
|
||||
version: string;
|
||||
initialize?(server: Server): Promise<void>;
|
||||
tools(): ToolSchema<any>[];
|
||||
callTool(schema: ToolSchema<any>, parsedArguments: any): Promise<ToolResponse>;
|
||||
serverClosed?(): void;
|
||||
}
|
||||
|
||||
export type ServerBackendFactory = () => ServerBackend;
|
||||
|
||||
export async function connect(serverBackendFactory: ServerBackendFactory, transport: Transport, runHeartbeat: boolean) {
|
||||
const backend = serverBackendFactory();
|
||||
const server = createServer(backend, runHeartbeat);
|
||||
await server.connect(transport);
|
||||
}
|
||||
|
||||
export async function wrapInProcess(backend: ServerBackend): Promise<Transport> {
|
||||
const server = createServer('Internal', '0.0.0', backend, false);
|
||||
return new InProcessTransport(server);
|
||||
}
|
||||
|
||||
export function createServer(name: string, version: string, backend: ServerBackend, runHeartbeat: boolean): Server {
|
||||
let initializedPromiseResolve = () => {};
|
||||
const initializedPromise = new Promise<void>(resolve => initializedPromiseResolve = resolve);
|
||||
const server = new Server({ name, version }, {
|
||||
export function createServer(backend: ServerBackend, runHeartbeat: boolean): Server {
|
||||
const initializedPromise = new ManualPromise<void>();
|
||||
const server = new Server({ name: backend.name, version: backend.version }, {
|
||||
capabilities: {
|
||||
tools: {},
|
||||
}
|
||||
});
|
||||
|
||||
const tools = backend.tools();
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
serverDebug('listTools');
|
||||
await initializedPromise;
|
||||
const tools = await backend.listTools();
|
||||
return { tools };
|
||||
return { tools: tools.map(tool => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
inputSchema: zodToJsonSchema(tool.inputSchema),
|
||||
annotations: {
|
||||
title: tool.title,
|
||||
readOnlyHint: tool.type === 'readOnly',
|
||||
destructiveHint: tool.type === 'destructive',
|
||||
openWorldHint: true,
|
||||
},
|
||||
})) };
|
||||
});
|
||||
|
||||
let heartbeatRunning = false;
|
||||
server.setRequestHandler(CallToolRequestSchema, async request => {
|
||||
serverDebug('callTool', request);
|
||||
await initializedPromise;
|
||||
|
||||
if (runHeartbeat && !heartbeatRunning) {
|
||||
@@ -82,31 +95,24 @@ export function createServer(name: string, version: string, backend: ServerBacke
|
||||
startHeartbeat(server);
|
||||
}
|
||||
|
||||
const errorResult = (...messages: string[]) => ({
|
||||
content: [{ type: 'text', text: '### Result\n' + messages.join('\n') }],
|
||||
isError: true,
|
||||
});
|
||||
const tool = tools.find(tool => tool.name === request.params.name) as ToolSchema<any>;
|
||||
if (!tool)
|
||||
return errorResult(`Error: Tool "${request.params.name}" not found`);
|
||||
|
||||
try {
|
||||
return await backend.callTool(request.params.name, request.params.arguments || {});
|
||||
return await backend.callTool(tool, tool.inputSchema.parse(request.params.arguments || {}));
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [{ type: 'text', text: '### Result\n' + String(error) }],
|
||||
isError: true,
|
||||
};
|
||||
return errorResult(String(error));
|
||||
}
|
||||
});
|
||||
addServerListener(server, 'initialized', async () => {
|
||||
try {
|
||||
const capabilities = server.getClientCapabilities();
|
||||
let clientRoots: Root[] = [];
|
||||
if (capabilities?.roots) {
|
||||
const { roots } = await server.listRoots(undefined, { timeout: 2_000 }).catch(() => ({ roots: [] }));
|
||||
clientRoots = roots;
|
||||
}
|
||||
const clientVersion = server.getClientVersion() ?? { name: 'unknown', version: 'unknown' };
|
||||
await backend.initialize?.(server, clientVersion, clientRoots);
|
||||
initializedPromiseResolve();
|
||||
} catch (e) {
|
||||
errorsDebug(e);
|
||||
}
|
||||
addServerListener(server, 'initialized', () => {
|
||||
backend.initialize?.(server).then(() => initializedPromise.resolve()).catch(logUnhandledError);
|
||||
});
|
||||
addServerListener(server, 'close', () => backend.serverClosed?.(server));
|
||||
addServerListener(server, 'close', () => backend.serverClosed?.());
|
||||
return server;
|
||||
}
|
||||
|
||||
@@ -132,27 +138,3 @@ function addServerListener(server: Server, event: 'close' | 'initialized', liste
|
||||
listener();
|
||||
};
|
||||
}
|
||||
|
||||
export async function start(serverBackendFactory: ServerBackendFactory, options: { host?: string; port?: number }) {
|
||||
if (options.port === undefined) {
|
||||
await connect(serverBackendFactory, new StdioServerTransport(), false);
|
||||
return;
|
||||
}
|
||||
|
||||
const httpServer = await startHttpServer(options);
|
||||
await installHttpTransport(httpServer, serverBackendFactory);
|
||||
const url = httpAddressToString(httpServer.address());
|
||||
|
||||
const mcpConfig: any = { mcpServers: { } };
|
||||
mcpConfig.mcpServers[serverBackendFactory.nameInConfig] = {
|
||||
url: `${url}/mcp`
|
||||
};
|
||||
const message = [
|
||||
`Listening on ${url}`,
|
||||
'Put this in your client config:',
|
||||
JSON.stringify(mcpConfig, undefined, 2),
|
||||
'For legacy SSE transport support, you can use the /sse endpoint instead.',
|
||||
].join('\n');
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(message);
|
||||
}
|
||||
|
||||
@@ -1,46 +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 { zodToJsonSchema } from 'zod-to-json-schema';
|
||||
|
||||
import type { z } from 'zod';
|
||||
import type * as mcpServer from './server.js';
|
||||
|
||||
export type ToolSchema<Input extends z.Schema> = {
|
||||
name: string;
|
||||
title: string;
|
||||
description: string;
|
||||
inputSchema: Input;
|
||||
type: 'readOnly' | 'destructive';
|
||||
};
|
||||
|
||||
export function toMcpTool(tool: ToolSchema<any>): mcpServer.Tool {
|
||||
return {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
inputSchema: zodToJsonSchema(tool.inputSchema, { strictUnions: true }) as mcpServer.Tool['inputSchema'],
|
||||
annotations: {
|
||||
title: tool.title,
|
||||
readOnlyHint: tool.type === 'readOnly',
|
||||
destructiveHint: tool.type === 'destructive',
|
||||
openWorldHint: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function defineToolSchema<Input extends z.Schema>(tool: ToolSchema<Input>): ToolSchema<Input> {
|
||||
return tool;
|
||||
}
|
||||
@@ -14,62 +14,33 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import assert from 'assert';
|
||||
import net from 'net';
|
||||
import http from 'http';
|
||||
import crypto from 'crypto';
|
||||
|
||||
import debug from 'debug';
|
||||
|
||||
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import { httpAddressToString, startHttpServer } from '../httpServer.js';
|
||||
import * as mcpServer from './server.js';
|
||||
|
||||
import type { ServerBackendFactory } from './server.js';
|
||||
|
||||
export async function start(serverBackendFactory: ServerBackendFactory, options: { host?: string; port?: number }) {
|
||||
if (options.port !== undefined) {
|
||||
const httpServer = await startHttpServer(options);
|
||||
startHttpTransport(httpServer, serverBackendFactory);
|
||||
} else {
|
||||
await startStdioTransport(serverBackendFactory);
|
||||
}
|
||||
}
|
||||
|
||||
async function startStdioTransport(serverBackendFactory: ServerBackendFactory) {
|
||||
await mcpServer.connect(serverBackendFactory, new StdioServerTransport(), false);
|
||||
}
|
||||
|
||||
const testDebug = debug('pw:mcp:test');
|
||||
|
||||
export async function startHttpServer(config: { host?: string, port?: number }, abortSignal?: AbortSignal): Promise<http.Server> {
|
||||
const { host, port } = config;
|
||||
const httpServer = http.createServer();
|
||||
decorateServer(httpServer);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
httpServer.on('error', reject);
|
||||
abortSignal?.addEventListener('abort', () => {
|
||||
httpServer.close();
|
||||
reject(new Error('Aborted'));
|
||||
});
|
||||
httpServer.listen(port, host, () => {
|
||||
resolve();
|
||||
httpServer.removeListener('error', reject);
|
||||
});
|
||||
});
|
||||
return httpServer;
|
||||
}
|
||||
|
||||
export function httpAddressToString(address: string | net.AddressInfo | null): string {
|
||||
assert(address, 'Could not bind server socket');
|
||||
if (typeof address === 'string')
|
||||
return address;
|
||||
const resolvedPort = address.port;
|
||||
let resolvedHost = address.family === 'IPv4' ? address.address : `[${address.address}]`;
|
||||
if (resolvedHost === '0.0.0.0' || resolvedHost === '[::]')
|
||||
resolvedHost = 'localhost';
|
||||
return `http://${resolvedHost}:${resolvedPort}`;
|
||||
}
|
||||
|
||||
export async function installHttpTransport(httpServer: http.Server, serverBackendFactory: ServerBackendFactory) {
|
||||
const sseSessions = new Map();
|
||||
const streamableSessions = new Map();
|
||||
httpServer.on('request', async (req, res) => {
|
||||
const url = new URL(`http://localhost${req.url}`);
|
||||
if (url.pathname.startsWith('/sse'))
|
||||
await handleSSE(serverBackendFactory, req, res, url, sseSessions);
|
||||
else
|
||||
await handleStreamable(serverBackendFactory, req, res, streamableSessions);
|
||||
});
|
||||
}
|
||||
|
||||
async function handleSSE(serverBackendFactory: ServerBackendFactory, req: http.IncomingMessage, res: http.ServerResponse, url: URL, sessions: Map<string, SSEServerTransport>) {
|
||||
if (req.method === 'POST') {
|
||||
const sessionId = url.searchParams.get('sessionId');
|
||||
@@ -138,18 +109,29 @@ async function handleStreamable(serverBackendFactory: ServerBackendFactory, req:
|
||||
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));
|
||||
function startHttpTransport(httpServer: http.Server, serverBackendFactory: ServerBackendFactory) {
|
||||
const sseSessions = new Map();
|
||||
const streamableSessions = new Map();
|
||||
httpServer.on('request', async (req, res) => {
|
||||
const url = new URL(`http://localhost${req.url}`);
|
||||
if (url.pathname.startsWith('/sse'))
|
||||
await handleSSE(serverBackendFactory, req, res, url, sseSessions);
|
||||
else
|
||||
await handleStreamable(serverBackendFactory, req, res, streamableSessions);
|
||||
});
|
||||
|
||||
const close = server.close;
|
||||
server.close = (callback?: (err?: Error) => void) => {
|
||||
for (const socket of sockets)
|
||||
socket.destroy();
|
||||
sockets.clear();
|
||||
return close.call(server, callback);
|
||||
};
|
||||
const url = httpAddressToString(httpServer.address());
|
||||
const message = [
|
||||
`Listening on ${url}`,
|
||||
'Put this in your client config:',
|
||||
JSON.stringify({
|
||||
'mcpServers': {
|
||||
'playwright': {
|
||||
'url': `${url}/mcp`
|
||||
}
|
||||
}
|
||||
}, undefined, 2),
|
||||
'For legacy SSE transport support, you can use the /sse endpoint instead.',
|
||||
].join('\n');
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(message);
|
||||
}
|
||||
@@ -19,4 +19,4 @@ import path from 'path';
|
||||
import url from 'url';
|
||||
|
||||
const __filename = url.fileURLToPath(import.meta.url);
|
||||
export const packageJSON = JSON.parse(fs.readFileSync(path.join(path.dirname(__filename), '..', '..', 'package.json'), 'utf8'));
|
||||
export const packageJSON = JSON.parse(fs.readFileSync(path.join(path.dirname(__filename), '..', 'package.json'), 'utf8'));
|
||||
@@ -15,18 +15,17 @@
|
||||
*/
|
||||
|
||||
import { program, Option } from 'commander';
|
||||
import * as mcpServer from './mcp/server.js';
|
||||
// @ts-ignore
|
||||
import { startTraceViewerServer } from 'playwright-core/lib/server';
|
||||
|
||||
import * as mcpTransport from './mcp/transport.js';
|
||||
import { commaSeparatedList, resolveCLIConfig, semicolonSeparatedList } from './config.js';
|
||||
import { packageJSON } from './utils/package.js';
|
||||
import { packageJSON } from './package.js';
|
||||
import { createExtensionContextFactory, runWithExtension } from './extension/main.js';
|
||||
import { BrowserServerBackend, FactoryList } from './browserServerBackend.js';
|
||||
import { Context } from './context.js';
|
||||
import { contextFactory } from './browserContextFactory.js';
|
||||
import { runLoopTools } from './loopTools/main.js';
|
||||
import { ProxyBackend } from './mcp/proxyBackend.js';
|
||||
import { BrowserServerBackend } from './browserServerBackend.js';
|
||||
import { ExtensionContextFactory } from './extension/extensionContextFactory.js';
|
||||
|
||||
import { runVSCodeTools } from './vscode/host.js';
|
||||
import type { MCPProvider } from './mcp/proxyBackend.js';
|
||||
|
||||
program
|
||||
.version('Version ' + packageJSON.version)
|
||||
@@ -40,7 +39,6 @@ program
|
||||
.option('--config <path>', 'path to the configuration file.')
|
||||
.option('--device <device>', 'device to emulate, for example: "iPhone 15"')
|
||||
.option('--executable-path <path>', 'path to the browser executable.')
|
||||
.option('--extension', 'Connect to a running browser instance (Edge/Chrome only). Requires the "Playwright MCP Bridge" browser extension to be installed.')
|
||||
.option('--headless', 'run browser in headless mode, headed by default')
|
||||
.option('--host <host>', 'host to bind server to. Default is localhost. Use 0.0.0.0 to bind to all interfaces.')
|
||||
.option('--ignore-https-errors', 'ignore https errors')
|
||||
@@ -57,8 +55,8 @@ program
|
||||
.option('--user-agent <ua string>', 'specify user agent string')
|
||||
.option('--user-data-dir <path>', 'path to the user data directory. If not specified, a temporary directory will be created.')
|
||||
.option('--viewport-size <size>', 'specify browser viewport size in pixels, for example "1280, 720"')
|
||||
.addOption(new Option('--extension', 'Connect to a running browser instance (Edge/Chrome only). Requires the "Playwright MCP Bridge" browser extension to be installed.').hideHelp())
|
||||
.addOption(new Option('--connect-tool', 'Allow to switch between different browser connection methods.').hideHelp())
|
||||
.addOption(new Option('--vscode', 'VS Code tools.').hideHelp())
|
||||
.addOption(new Option('--loop-tools', 'Run loop tools').hideHelp())
|
||||
.addOption(new Option('--vision', 'Legacy option, use --caps=vision instead').hideHelp())
|
||||
.action(async options => {
|
||||
@@ -69,62 +67,31 @@ program
|
||||
console.error('The --vision option is deprecated, use --caps=vision instead');
|
||||
options.caps = 'vision';
|
||||
}
|
||||
|
||||
const config = await resolveCLIConfig(options);
|
||||
const browserContextFactory = contextFactory(config);
|
||||
const extensionContextFactory = new ExtensionContextFactory(config.browser.launchOptions.channel || 'chrome', config.browser.userDataDir);
|
||||
|
||||
if (options.extension) {
|
||||
const serverBackendFactory: mcpServer.ServerBackendFactory = {
|
||||
name: 'Playwright w/ extension',
|
||||
nameInConfig: 'playwright-extension',
|
||||
version: packageJSON.version,
|
||||
create: () => new BrowserServerBackend(config, extensionContextFactory)
|
||||
};
|
||||
await mcpServer.start(serverBackendFactory, config.server);
|
||||
await runWithExtension(config);
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.vscode) {
|
||||
await runVSCodeTools(config);
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.loopTools) {
|
||||
await runLoopTools(config);
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.connectTool) {
|
||||
const providers: MCPProvider[] = [
|
||||
{
|
||||
name: 'default',
|
||||
description: 'Starts standalone browser',
|
||||
connect: () => mcpServer.wrapInProcess(new BrowserServerBackend(config, browserContextFactory)),
|
||||
},
|
||||
{
|
||||
name: 'extension',
|
||||
description: 'Connect to a browser using the Playwright MCP extension',
|
||||
connect: () => mcpServer.wrapInProcess(new BrowserServerBackend(config, extensionContextFactory)),
|
||||
},
|
||||
];
|
||||
const factory: mcpServer.ServerBackendFactory = {
|
||||
name: 'Playwright w/ switch',
|
||||
nameInConfig: 'playwright-switch',
|
||||
version: packageJSON.version,
|
||||
create: () => new ProxyBackend(providers),
|
||||
};
|
||||
await mcpServer.start(factory, config.server);
|
||||
return;
|
||||
}
|
||||
const browserContextFactory = contextFactory(config);
|
||||
const factories: FactoryList = [browserContextFactory];
|
||||
if (options.connectTool)
|
||||
factories.push(createExtensionContextFactory(config));
|
||||
const serverBackendFactory = () => new BrowserServerBackend(config, factories);
|
||||
await mcpTransport.start(serverBackendFactory, config.server);
|
||||
|
||||
const factory: mcpServer.ServerBackendFactory = {
|
||||
name: 'Playwright',
|
||||
nameInConfig: 'playwright',
|
||||
version: packageJSON.version,
|
||||
create: () => new BrowserServerBackend(config, browserContextFactory)
|
||||
};
|
||||
await mcpServer.start(factory, config.server);
|
||||
if (config.saveTrace) {
|
||||
const server = await startTraceViewerServer();
|
||||
const urlPrefix = server.urlPrefix('human-readable');
|
||||
const url = urlPrefix + '/trace/index.html?trace=' + config.browser.launchOptions.tracesDir + '/trace.json';
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('\nTrace viewer listening on ' + url);
|
||||
}
|
||||
});
|
||||
|
||||
function setupExitWatchdog() {
|
||||
|
||||
@@ -18,7 +18,7 @@ import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { Response } from './response.js';
|
||||
import { logUnhandledError } from './utils/log.js';
|
||||
import { logUnhandledError } from './log.js';
|
||||
import { outputFile } from './config.js';
|
||||
|
||||
import type { FullConfig } from './config.js';
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
import { EventEmitter } from 'events';
|
||||
import * as playwright from 'playwright';
|
||||
import { callOnPageNoTrace, waitForCompletion } from './tools/utils.js';
|
||||
import { logUnhandledError } from './utils/log.js';
|
||||
import { ManualPromise } from './mcp/manualPromise.js';
|
||||
import { logUnhandledError } from './log.js';
|
||||
import { ManualPromise } from './manualPromise.js';
|
||||
import { ModalState } from './tools/tool.js';
|
||||
|
||||
import type { Context } from './context.js';
|
||||
|
||||
@@ -19,7 +19,6 @@ import console from './tools/console.js';
|
||||
import dialogs from './tools/dialogs.js';
|
||||
import evaluate from './tools/evaluate.js';
|
||||
import files from './tools/files.js';
|
||||
import form from './tools/form.js';
|
||||
import install from './tools/install.js';
|
||||
import keyboard from './tools/keyboard.js';
|
||||
import navigate from './tools/navigate.js';
|
||||
@@ -40,7 +39,6 @@ export const allTools: Tool<any>[] = [
|
||||
...dialogs,
|
||||
...evaluate,
|
||||
...files,
|
||||
...form,
|
||||
...install,
|
||||
...keyboard,
|
||||
...navigate,
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
[*]
|
||||
../utils/
|
||||
@@ -17,7 +17,7 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { defineTabTool } from './tool.js';
|
||||
import * as javascript from '../utils/codegen.js';
|
||||
import * as javascript from '../javascript.js';
|
||||
import { generateLocator } from './utils.js';
|
||||
|
||||
import type * as playwright from 'playwright';
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
@@ -19,7 +19,7 @@ import { z } from 'zod';
|
||||
import { defineTabTool } from './tool.js';
|
||||
import { elementSchema } from './snapshot.js';
|
||||
import { generateLocator } from './utils.js';
|
||||
import * as javascript from '../utils/codegen.js';
|
||||
import * as javascript from '../javascript.js';
|
||||
|
||||
const pressKey = defineTabTool({
|
||||
capability: 'core',
|
||||
|
||||
@@ -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 [
|
||||
navigate,
|
||||
goBack,
|
||||
goForward,
|
||||
];
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
import { z } from 'zod';
|
||||
import { defineTabTool } from './tool.js';
|
||||
|
||||
import * as javascript from '../utils/codegen.js';
|
||||
import * as javascript from '../javascript.js';
|
||||
|
||||
const pdfSchema = z.object({
|
||||
filename: z.string().optional().describe('File name to save the pdf to. Defaults to `page-{timestamp}.pdf` if not specified.'),
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { defineTabTool } from './tool.js';
|
||||
import * as javascript from '../utils/codegen.js';
|
||||
import * as javascript from '../javascript.js';
|
||||
import { generateLocator } from './utils.js';
|
||||
|
||||
import type * as playwright from 'playwright';
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { defineTabTool, defineTool } from './tool.js';
|
||||
import * as javascript from '../utils/codegen.js';
|
||||
import * as javascript from '../javascript.js';
|
||||
import { generateLocator } from './utils.js';
|
||||
|
||||
const snapshot = defineTool({
|
||||
|
||||
@@ -17,48 +17,85 @@
|
||||
import { z } from 'zod';
|
||||
import { defineTool } from './tool.js';
|
||||
|
||||
const browserTabs = defineTool({
|
||||
const listTabs = defineTool({
|
||||
capability: 'core-tabs',
|
||||
|
||||
schema: {
|
||||
name: 'browser_tabs',
|
||||
title: 'Manage tabs',
|
||||
description: 'List, create, close, or select a browser tab.',
|
||||
name: 'browser_tab_list',
|
||||
title: 'List tabs',
|
||||
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({
|
||||
action: z.enum(['list', 'new', 'close', 'select']).describe('Operation to perform'),
|
||||
index: z.number().optional().describe('Tab index, used for close/select. If omitted for close, current tab is closed.'),
|
||||
index: z.number().describe('The index of the tab to select'),
|
||||
}),
|
||||
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',
|
||||
},
|
||||
|
||||
handle: async (context, params, response) => {
|
||||
switch (params.action) {
|
||||
case 'list': {
|
||||
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;
|
||||
}
|
||||
}
|
||||
await context.closeTab(params.index);
|
||||
response.setIncludeSnapshot();
|
||||
},
|
||||
});
|
||||
|
||||
export default [
|
||||
browserTabs,
|
||||
listTabs,
|
||||
newTab,
|
||||
selectTab,
|
||||
closeTab,
|
||||
];
|
||||
|
||||
@@ -20,7 +20,7 @@ import type * as playwright from 'playwright';
|
||||
import type { ToolCapability } from '../../config.js';
|
||||
import type { Tab } from '../tab.js';
|
||||
import type { Response } from '../response.js';
|
||||
import type { ToolSchema } from '../mcp/tool.js';
|
||||
import type { ToolSchema } from '../mcp/server.js';
|
||||
|
||||
export type FileUploadModalState = {
|
||||
type: 'fileChooser';
|
||||
|
||||
@@ -36,8 +36,12 @@ const wait = defineTool({
|
||||
if (!params.text && !params.textGone && !params.time)
|
||||
throw new Error('Either time, text or textGone must be provided');
|
||||
|
||||
const code: string[] = [];
|
||||
|
||||
if (params.time) {
|
||||
response.addCode(`await new Promise(f => setTimeout(f, ${params.time!} * 1000));`);
|
||||
const timeCode = `await new Promise(f => setTimeout(f, ${params.time!} * 1000));`;
|
||||
code.push(timeCode);
|
||||
response.addCode(timeCode);
|
||||
await new Promise(f => setTimeout(f, Math.min(30000, params.time! * 1000)));
|
||||
}
|
||||
|
||||
@@ -46,12 +50,16 @@ const wait = defineTool({
|
||||
const goneLocator = params.textGone ? tab.page.getByText(params.textGone).first() : undefined;
|
||||
|
||||
if (goneLocator) {
|
||||
response.addCode(`await page.getByText(${JSON.stringify(params.textGone)}).first().waitFor({ state: 'hidden' });`);
|
||||
const goneCode = `await page.getByText(${JSON.stringify(params.textGone)}).first().waitFor({ state: 'hidden' });`;
|
||||
code.push(goneCode);
|
||||
response.addCode(goneCode);
|
||||
await goneLocator.waitFor({ state: 'hidden' });
|
||||
}
|
||||
|
||||
if (locator) {
|
||||
response.addCode(`await page.getByText(${JSON.stringify(params.text)}).first().waitFor({ state: 'visible' });`);
|
||||
const locatorCode = `await page.getByText(${JSON.stringify(params.text)}).first().waitFor({ state: 'visible' });`;
|
||||
code.push(locatorCode);
|
||||
response.addCode(locatorCode);
|
||||
await locator.waitFor({ state: 'visible' });
|
||||
}
|
||||
|
||||
|
||||
@@ -16,10 +16,14 @@
|
||||
|
||||
import crypto from 'crypto';
|
||||
|
||||
export function createGuid(): string {
|
||||
return crypto.randomBytes(16).toString('hex');
|
||||
}
|
||||
|
||||
export function createHash(data: string): string {
|
||||
return crypto.createHash('sha256').update(data).digest('hex').slice(0, 7);
|
||||
}
|
||||
|
||||
export function sanitizeForFilePath(s: string) {
|
||||
const sanitize = (s: string) => s.replace(/[\x00-\x2C\x2E-\x2F\x3A-\x40\x5B-\x60\x7B-\x7F]+/g, '-');
|
||||
const separator = s.lastIndexOf('.');
|
||||
if (separator === -1)
|
||||
return sanitize(s);
|
||||
return sanitize(s.substring(0, separator)) + '.' + sanitize(s.substring(separator + 1));
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
[*]
|
||||
../mcp/
|
||||
../utils/
|
||||
../config.js
|
||||
../browserServerBackend.js
|
||||
../browserContextFactory.js
|
||||
@@ -1,149 +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 { fileURLToPath } from 'url';
|
||||
import path from 'path';
|
||||
import { z } from 'zod';
|
||||
import { zodToJsonSchema } from 'zod-to-json-schema';
|
||||
|
||||
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { ListRootsRequestSchema, PingRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||
import * as mcpServer from '../mcp/server.js';
|
||||
import { logUnhandledError } from '../utils/log.js';
|
||||
import { packageJSON } from '../utils/package.js';
|
||||
|
||||
import { FullConfig } from '../config.js';
|
||||
import { BrowserServerBackend } from '../browserServerBackend.js';
|
||||
import { contextFactory } from '../browserContextFactory.js';
|
||||
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
||||
import type { ClientVersion, ServerBackend } from '../mcp/server.js';
|
||||
import type { Root, Tool, CallToolResult, CallToolRequest } from '@modelcontextprotocol/sdk/types.js';
|
||||
|
||||
const contextSwitchOptions = z.object({
|
||||
connectionString: z.string().optional().describe('The connection string to use to connect to the browser'),
|
||||
lib: z.string().optional().describe('The library to use for the connection'),
|
||||
});
|
||||
|
||||
class VSCodeProxyBackend implements ServerBackend {
|
||||
name = 'Playwright MCP Client Switcher';
|
||||
version = packageJSON.version;
|
||||
|
||||
private _currentClient: Client | undefined;
|
||||
private _contextSwitchTool: Tool;
|
||||
private _roots: Root[] = [];
|
||||
private _clientVersion?: ClientVersion;
|
||||
|
||||
constructor(private readonly _config: FullConfig, private readonly _defaultTransportFactory: () => Promise<Transport>) {
|
||||
this._contextSwitchTool = this._defineContextSwitchTool();
|
||||
}
|
||||
|
||||
async initialize(server: mcpServer.Server, clientVersion: ClientVersion, roots: Root[]): Promise<void> {
|
||||
this._clientVersion = clientVersion;
|
||||
this._roots = roots;
|
||||
const transport = await this._defaultTransportFactory();
|
||||
await this._setCurrentClient(transport);
|
||||
}
|
||||
|
||||
async listTools(): Promise<Tool[]> {
|
||||
const response = await this._currentClient!.listTools();
|
||||
return [
|
||||
...response.tools,
|
||||
this._contextSwitchTool,
|
||||
];
|
||||
}
|
||||
|
||||
async callTool(name: string, args: CallToolRequest['params']['arguments']): Promise<CallToolResult> {
|
||||
if (name === this._contextSwitchTool.name)
|
||||
return this._callContextSwitchTool(args as any);
|
||||
return await this._currentClient!.callTool({
|
||||
name,
|
||||
arguments: args,
|
||||
}) as CallToolResult;
|
||||
}
|
||||
|
||||
serverClosed?(server: mcpServer.Server): void {
|
||||
void this._currentClient?.close().catch(logUnhandledError);
|
||||
}
|
||||
|
||||
private async _callContextSwitchTool(params: z.infer<typeof contextSwitchOptions>): Promise<CallToolResult> {
|
||||
if (!params.connectionString || !params.lib) {
|
||||
const transport = await this._defaultTransportFactory();
|
||||
await this._setCurrentClient(transport);
|
||||
return {
|
||||
content: [{ type: 'text', text: '### Result\nSuccessfully disconnected.\n' }],
|
||||
};
|
||||
}
|
||||
|
||||
await this._setCurrentClient(
|
||||
new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
cwd: process.cwd(),
|
||||
args: [
|
||||
path.join(fileURLToPath(import.meta.url), '..', 'main.js'),
|
||||
JSON.stringify(this._config),
|
||||
params.connectionString,
|
||||
params.lib,
|
||||
],
|
||||
})
|
||||
);
|
||||
return {
|
||||
content: [{ type: 'text', text: '### Result\nSuccessfully connected.\n' }],
|
||||
};
|
||||
}
|
||||
|
||||
private _defineContextSwitchTool(): Tool {
|
||||
return {
|
||||
name: 'browser_connect',
|
||||
description: 'Do not call, this tool is used in the integration with the Playwright VS Code Extension and meant for programmatic usage only.',
|
||||
inputSchema: zodToJsonSchema(contextSwitchOptions, { strictUnions: true }) as Tool['inputSchema'],
|
||||
annotations: {
|
||||
title: 'Connect to a browser running in VS Code.',
|
||||
readOnlyHint: true,
|
||||
openWorldHint: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async _setCurrentClient(transport: Transport) {
|
||||
await this._currentClient?.close();
|
||||
this._currentClient = undefined;
|
||||
|
||||
const client = new Client(this._clientVersion!);
|
||||
client.registerCapabilities({
|
||||
roots: {
|
||||
listRoots: true,
|
||||
},
|
||||
});
|
||||
client.setRequestHandler(ListRootsRequestSchema, () => ({ roots: this._roots }));
|
||||
client.setRequestHandler(PingRequestSchema, () => ({}));
|
||||
|
||||
await client.connect(transport);
|
||||
this._currentClient = client;
|
||||
}
|
||||
}
|
||||
|
||||
export async function runVSCodeTools(config: FullConfig) {
|
||||
const serverBackendFactory: mcpServer.ServerBackendFactory = {
|
||||
name: 'Playwright w/ vscode',
|
||||
nameInConfig: 'playwright-vscode',
|
||||
version: packageJSON.version,
|
||||
create: () => new VSCodeProxyBackend(config, () => mcpServer.wrapInProcess(new BrowserServerBackend(config, contextFactory(config))))
|
||||
};
|
||||
await mcpServer.start(serverBackendFactory, config.server);
|
||||
return;
|
||||
}
|
||||
@@ -1,75 +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 { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import * as mcpServer from '../mcp/server.js';
|
||||
import { BrowserServerBackend } from '../browserServerBackend.js';
|
||||
import { BrowserContextFactory, ClientInfo } from '../browserContextFactory.js';
|
||||
import type { FullConfig } from '../config.js';
|
||||
import type { BrowserContext } from 'playwright-core';
|
||||
|
||||
class VSCodeBrowserContextFactory implements BrowserContextFactory {
|
||||
name = 'vscode';
|
||||
description = 'Connect to a browser running in the Playwright VS Code extension';
|
||||
|
||||
constructor(private _config: FullConfig, private _playwright: typeof import('playwright'), private _connectionString: string) {}
|
||||
|
||||
async createContext(clientInfo: ClientInfo, abortSignal: AbortSignal): Promise<{ browserContext: BrowserContext; close: () => Promise<void>; }> {
|
||||
let launchOptions: any = this._config.browser.launchOptions;
|
||||
if (this._config.browser.userDataDir) {
|
||||
launchOptions = {
|
||||
...launchOptions,
|
||||
...this._config.browser.contextOptions,
|
||||
userDataDir: this._config.browser.userDataDir,
|
||||
};
|
||||
}
|
||||
const connectionString = new URL(this._connectionString);
|
||||
connectionString.searchParams.set('launch-options', JSON.stringify(launchOptions));
|
||||
|
||||
const browserType = this._playwright.chromium; // it could also be firefox or webkit, we just need some browser type to call `connect` on
|
||||
const browser = await browserType.connect(connectionString.toString());
|
||||
|
||||
const context = browser.contexts()[0] ?? await browser.newContext(this._config.browser.contextOptions);
|
||||
|
||||
return {
|
||||
browserContext: context,
|
||||
close: async () => {
|
||||
await browser.close();
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function main(config: FullConfig, connectionString: string, lib: string) {
|
||||
const playwright = await import(lib).then(mod => mod.default ?? mod);
|
||||
const factory = new VSCodeBrowserContextFactory(config, playwright, connectionString);
|
||||
await mcpServer.connect(
|
||||
{
|
||||
name: 'Playwright MCP',
|
||||
nameInConfig: 'playwright-vscode',
|
||||
create: () => new BrowserServerBackend(config, factory),
|
||||
version: 'unused'
|
||||
},
|
||||
new StdioServerTransport(),
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
await main(
|
||||
JSON.parse(process.argv[2]),
|
||||
process.argv[3],
|
||||
process.argv[4]
|
||||
);
|
||||
@@ -24,7 +24,6 @@ test('test snapshot tool list', async ({ client }) => {
|
||||
'browser_drag',
|
||||
'browser_evaluate',
|
||||
'browser_file_upload',
|
||||
'browser_fill_form',
|
||||
'browser_handle_dialog',
|
||||
'browser_hover',
|
||||
'browser_select_option',
|
||||
@@ -32,43 +31,16 @@ test('test snapshot tool list', async ({ client }) => {
|
||||
'browser_close',
|
||||
'browser_install',
|
||||
'browser_navigate_back',
|
||||
'browser_navigate_forward',
|
||||
'browser_navigate',
|
||||
'browser_network_requests',
|
||||
'browser_press_key',
|
||||
'browser_resize',
|
||||
'browser_snapshot',
|
||||
'browser_tabs',
|
||||
'browser_take_screenshot',
|
||||
'browser_wait_for',
|
||||
]));
|
||||
});
|
||||
|
||||
test('test tool list proxy mode', async ({ startClient }) => {
|
||||
const { client } = await startClient({
|
||||
args: ['--connect-tool'],
|
||||
});
|
||||
const { tools } = await client.listTools();
|
||||
expect(new Set(tools.map(t => t.name))).toEqual(new Set([
|
||||
'browser_click',
|
||||
'browser_connect', // the extra tool
|
||||
'browser_console_messages',
|
||||
'browser_drag',
|
||||
'browser_evaluate',
|
||||
'browser_file_upload',
|
||||
'browser_fill_form',
|
||||
'browser_handle_dialog',
|
||||
'browser_hover',
|
||||
'browser_select_option',
|
||||
'browser_type',
|
||||
'browser_close',
|
||||
'browser_install',
|
||||
'browser_navigate_back',
|
||||
'browser_navigate',
|
||||
'browser_network_requests',
|
||||
'browser_press_key',
|
||||
'browser_resize',
|
||||
'browser_snapshot',
|
||||
'browser_tabs',
|
||||
'browser_tab_close',
|
||||
'browser_tab_list',
|
||||
'browser_tab_new',
|
||||
'browser_tab_select',
|
||||
'browser_take_screenshot',
|
||||
'browser_wait_for',
|
||||
]));
|
||||
|
||||
@@ -40,18 +40,14 @@ type CDPServer = {
|
||||
start: () => Promise<BrowserContext>;
|
||||
};
|
||||
|
||||
export type StartClient = (options?: {
|
||||
clientName?: string,
|
||||
args?: string[],
|
||||
config?: Config,
|
||||
roots?: { name: string, uri: string }[],
|
||||
rootsResponseDelay?: number,
|
||||
}) => Promise<{ client: Client, stderr: () => string }>;
|
||||
|
||||
|
||||
type TestFixtures = {
|
||||
client: Client;
|
||||
startClient: StartClient;
|
||||
startClient: (options?: {
|
||||
clientName?: string,
|
||||
args?: string[],
|
||||
config?: Config,
|
||||
roots?: { name: string, uri: string }[],
|
||||
}) => Promise<{ client: Client, stderr: () => string }>;
|
||||
wsEndpoint: string;
|
||||
cdpServer: CDPServer;
|
||||
server: TestServer;
|
||||
@@ -72,7 +68,7 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
|
||||
|
||||
startClient: async ({ mcpHeadless, mcpBrowser, mcpMode }, use, testInfo) => {
|
||||
const configDir = path.dirname(test.info().config.configFile!);
|
||||
const clients: Client[] = [];
|
||||
let client: Client | undefined;
|
||||
|
||||
await use(async options => {
|
||||
const args: string[] = [];
|
||||
@@ -90,11 +86,9 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
|
||||
args.push(`--config=${path.relative(configDir, configFile)}`);
|
||||
}
|
||||
|
||||
const client = new Client({ name: options?.clientName ?? 'test', version: '1.0.0' }, options?.roots ? { capabilities: { roots: {} } } : undefined);
|
||||
client = new Client({ name: options?.clientName ?? 'test', version: '1.0.0' }, options?.roots ? { capabilities: { roots: {} } } : undefined);
|
||||
if (options?.roots) {
|
||||
client.setRequestHandler(ListRootsRequestSchema, async request => {
|
||||
if (options.rootsResponseDelay)
|
||||
await new Promise(resolve => setTimeout(resolve, options.rootsResponseDelay));
|
||||
return {
|
||||
roots: options.roots,
|
||||
};
|
||||
@@ -107,13 +101,12 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
|
||||
process.stderr.write(data);
|
||||
stderrBuffer += data.toString();
|
||||
});
|
||||
clients.push(client);
|
||||
await client.connect(transport);
|
||||
await client.ping();
|
||||
return { client, stderr: () => stderrBuffer };
|
||||
});
|
||||
|
||||
await Promise.all(clients.map(client => client.close()));
|
||||
await client?.close();
|
||||
},
|
||||
|
||||
wsEndpoint: async ({ }, use) => {
|
||||
@@ -130,8 +123,6 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
|
||||
await use({
|
||||
endpoint: `http://localhost:${port}`,
|
||||
start: async () => {
|
||||
if (browserContext)
|
||||
throw new Error('CDP server already exists');
|
||||
browserContext = await chromium.launchPersistentContext(testInfo.outputPath('cdp-user-data-dir'), {
|
||||
channel: mcpBrowser,
|
||||
headless: true,
|
||||
|
||||
@@ -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]'),
|
||||
});
|
||||
});
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -19,13 +19,13 @@ import path from 'path';
|
||||
import { pathToFileURL } from 'url';
|
||||
|
||||
import { test, expect } from './fixtures.js';
|
||||
import { createHash } from '../src/utils/guid.js';
|
||||
import { createHash } from '../src/utils.js';
|
||||
|
||||
const p = process.platform === 'win32' ? 'c:\\non\\existent\\folder' : '/non/existent/folder';
|
||||
|
||||
test('should use separate user data by root path', async ({ startClient, server }, testInfo) => {
|
||||
const { client } = await startClient({
|
||||
clientName: 'Visual Studio Code',
|
||||
clientName: 'Visual Studio Code', // Simulate VS Code client, roots only work with it
|
||||
roots: [
|
||||
{
|
||||
name: 'test',
|
||||
@@ -44,11 +44,12 @@ test('should use separate user data by root path', async ({ startClient, server
|
||||
expect(file).toContain(hash);
|
||||
});
|
||||
|
||||
test('check that trace is saved in workspace', async ({ startClient, server }, testInfo) => {
|
||||
|
||||
test('check that trace is saved in workspace', async ({ startClient, server, mcpMode }, testInfo) => {
|
||||
const rootPath = testInfo.outputPath('workspace');
|
||||
const { client } = await startClient({
|
||||
args: ['--save-trace'],
|
||||
clientName: 'My client',
|
||||
clientName: 'Visual Studio Code - Insiders', // Simulate VS Code client, roots only work with it
|
||||
roots: [
|
||||
{
|
||||
name: 'workspace',
|
||||
@@ -67,13 +68,3 @@ test('check that trace is saved in workspace', async ({ startClient, server }, t
|
||||
const [file] = await fs.promises.readdir(path.join(rootPath, '.playwright-mcp'));
|
||||
expect(file).toContain('traces');
|
||||
});
|
||||
|
||||
test('should list all tools when listRoots is slow', async ({ startClient }) => {
|
||||
const { client } = await startClient({
|
||||
clientName: 'Another custom client',
|
||||
roots: [],
|
||||
rootsResponseDelay: 1000,
|
||||
});
|
||||
const tools = await client.listTools();
|
||||
expect(tools.tools.length).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
expect(await client.callTool({
|
||||
name: 'browser_tabs',
|
||||
arguments: {
|
||||
action: 'list',
|
||||
},
|
||||
name: 'browser_tab_list',
|
||||
})).toHaveResponse({
|
||||
tabs: `- 0: (current) [] (about:blank)`,
|
||||
});
|
||||
|
||||
@@ -19,14 +19,8 @@ import { test, expect } from './fixtures.js';
|
||||
import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
|
||||
async function createTab(client: Client, title: string, body: string) {
|
||||
await client.callTool({
|
||||
name: 'browser_tabs',
|
||||
arguments: {
|
||||
action: 'new',
|
||||
},
|
||||
});
|
||||
return await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
name: 'browser_tab_new',
|
||||
arguments: {
|
||||
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 }) => {
|
||||
expect(await client.callTool({
|
||||
name: 'browser_tabs',
|
||||
arguments: {
|
||||
action: 'list',
|
||||
},
|
||||
name: 'browser_tab_list',
|
||||
})).toHaveResponse({
|
||||
tabs: `- 0: (current) [] (about:blank)`,
|
||||
});
|
||||
@@ -47,10 +38,7 @@ test('list initial tabs', async ({ client }) => {
|
||||
test('list first tab', async ({ client }) => {
|
||||
await createTab(client, 'Tab one', 'Body one');
|
||||
expect(await client.callTool({
|
||||
name: 'browser_tabs',
|
||||
arguments: {
|
||||
action: 'list',
|
||||
},
|
||||
name: 'browser_tab_list',
|
||||
})).toHaveResponse({
|
||||
tabs: `- 0: [] (about:blank)
|
||||
- 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');
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_tabs',
|
||||
name: 'browser_tab_select',
|
||||
arguments: {
|
||||
action: 'select',
|
||||
index: 1,
|
||||
},
|
||||
})).toHaveResponse({
|
||||
@@ -110,9 +97,8 @@ test('close tab', async ({ client }) => {
|
||||
await createTab(client, 'Tab two', 'Body two');
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_tabs',
|
||||
name: 'browser_tab_close',
|
||||
arguments: {
|
||||
action: 'close',
|
||||
index: 2,
|
||||
},
|
||||
})).toHaveResponse({
|
||||
|
||||
@@ -1,54 +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_connect(vscode) works', async ({ startClient, playwright, browserName }) => {
|
||||
const { client } = await startClient({
|
||||
args: ['--vscode'],
|
||||
});
|
||||
|
||||
const server = await playwright[browserName].launchServer();
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_connect',
|
||||
arguments: {
|
||||
connectionString: server.wsEndpoint(),
|
||||
lib: import.meta.resolve('playwright'),
|
||||
}
|
||||
})).toHaveResponse({
|
||||
result: 'Successfully connected.'
|
||||
});
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: {
|
||||
url: 'data:text/html,foo'
|
||||
}
|
||||
})).toHaveResponse({
|
||||
pageState: expect.stringContaining('foo'),
|
||||
});
|
||||
|
||||
await server.close();
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_snapshot',
|
||||
arguments: {}
|
||||
}), 'it actually used the server').toHaveResponse({
|
||||
isError: true,
|
||||
result: expect.stringContaining('ECONNREFUSED')
|
||||
});
|
||||
});
|
||||
@@ -47,8 +47,8 @@ test('browser_wait_for(text)', async ({ client, server }) => {
|
||||
expect(await client.callTool({
|
||||
name: 'browser_wait_for',
|
||||
arguments: { text: 'Text to appear' },
|
||||
code: `await page.getByText("Text to appear").first().waitFor({ state: 'visible' });`,
|
||||
})).toHaveResponse({
|
||||
code: `await page.getByText("Text to appear").first().waitFor({ state: 'visible' });`,
|
||||
pageState: expect.stringContaining(`- generic [ref=e3]: Text to appear`),
|
||||
});
|
||||
});
|
||||
@@ -84,8 +84,8 @@ test('browser_wait_for(textGone)', async ({ client, server }) => {
|
||||
expect(await client.callTool({
|
||||
name: 'browser_wait_for',
|
||||
arguments: { textGone: 'Text to disappear' },
|
||||
code: `await page.getByText("Text to disappear").first().waitFor({ state: 'hidden' });`,
|
||||
})).toHaveResponse({
|
||||
code: `await page.getByText("Text to disappear").first().waitFor({ state: 'hidden' });`,
|
||||
pageState: expect.stringContaining(`- generic [ref=e3]: Text to appear`),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,179 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Copyright 2019 Google Inc. All rights reserved.
|
||||
* Modifications 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.
|
||||
*/
|
||||
|
||||
// @ts-check
|
||||
|
||||
import fs from 'fs';
|
||||
import ts from 'typescript';
|
||||
import path from 'path';
|
||||
|
||||
const __dirname = path.dirname(new URL(import.meta.url).pathname);
|
||||
|
||||
const depsCache = {};
|
||||
const packageRoot = path.resolve(__dirname, '..');
|
||||
|
||||
async function checkDeps() {
|
||||
const deps = new Set();
|
||||
const src = path.join(packageRoot, 'src');
|
||||
|
||||
const program = ts.createProgram({
|
||||
options: {
|
||||
allowJs: true,
|
||||
target: ts.ScriptTarget.ESNext,
|
||||
strict: true,
|
||||
},
|
||||
rootNames: listAllFiles(src),
|
||||
});
|
||||
const sourceFiles = program.getSourceFiles();
|
||||
const errors = [];
|
||||
sourceFiles.filter(x => !x.fileName.includes(path.sep + 'node_modules' + path.sep) && !x.fileName.includes(path.sep + 'bundles' + path.sep)).map(x => visit(x, x.fileName, x.getFullText()));
|
||||
|
||||
if (errors.length) {
|
||||
for (const error of errors)
|
||||
console.log(error);
|
||||
console.log(`--------------------------------------------------------`);
|
||||
console.log(`Changing the project structure or adding new components?`);
|
||||
console.log(`Update DEPS in ${packageRoot}`);
|
||||
console.log(`--------------------------------------------------------`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function visit(node, fileName, text) {
|
||||
if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) {
|
||||
if (node.importClause) {
|
||||
if (node.importClause.isTypeOnly)
|
||||
return;
|
||||
if (node.importClause.namedBindings && ts.isNamedImports(node.importClause.namedBindings)) {
|
||||
if (node.importClause.namedBindings.elements.every(e => e.isTypeOnly))
|
||||
return;
|
||||
}
|
||||
}
|
||||
const importName = node.moduleSpecifier.text;
|
||||
let importPath;
|
||||
if (importName.startsWith('.'))
|
||||
importPath = path.resolve(path.dirname(fileName), importName);
|
||||
|
||||
const mergedDeps = calculateDeps(fileName);
|
||||
if (mergedDeps.includes('***'))
|
||||
return;
|
||||
if (importPath) {
|
||||
if (!fs.existsSync(importPath)) {
|
||||
if (fs.existsSync(importPath + '.ts'))
|
||||
importPath = importPath + '.ts';
|
||||
else if (fs.existsSync(importPath + '.tsx'))
|
||||
importPath = importPath + '.tsx';
|
||||
else if (fs.existsSync(importPath + '.d.ts'))
|
||||
importPath = importPath + '.d.ts';
|
||||
}
|
||||
|
||||
if (!allowImport(fileName, importPath, mergedDeps))
|
||||
errors.push(`Disallowed import ${path.relative(packageRoot, importPath)} in ${path.relative(packageRoot, fileName)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const fullStart = node.getFullStart();
|
||||
const commentRanges = ts.getLeadingCommentRanges(text, fullStart);
|
||||
for (const range of commentRanges || []) {
|
||||
const comment = text.substring(range.pos, range.end);
|
||||
if (comment.includes('@no-check-deps'))
|
||||
return;
|
||||
}
|
||||
|
||||
if (importName.startsWith('@'))
|
||||
deps.add(importName.split('/').slice(0, 2).join('/'));
|
||||
else
|
||||
deps.add(importName.split('/')[0]);
|
||||
}
|
||||
ts.forEachChild(node, x => visit(x, fileName, text));
|
||||
}
|
||||
|
||||
function calculateDeps(from) {
|
||||
const fromDirectory = path.dirname(from);
|
||||
let depsDirectory = fromDirectory;
|
||||
while (depsDirectory.startsWith(packageRoot) && !depsCache[depsDirectory] && !fs.existsSync(path.join(depsDirectory, 'DEPS.list')))
|
||||
depsDirectory = path.dirname(depsDirectory);
|
||||
if (!depsDirectory.startsWith(packageRoot))
|
||||
return [];
|
||||
|
||||
let deps = depsCache[depsDirectory];
|
||||
if (!deps) {
|
||||
const depsListFile = path.join(depsDirectory, 'DEPS.list');
|
||||
deps = {};
|
||||
let group = [];
|
||||
for (const line of fs.readFileSync(depsListFile, 'utf-8').split('\n').filter(Boolean).filter(l => !l.startsWith('#'))) {
|
||||
const groupMatch = line.match(/\[(.*)\]/);
|
||||
if (groupMatch) {
|
||||
group = [];
|
||||
deps[groupMatch[1]] = group;
|
||||
continue;
|
||||
}
|
||||
if (line === '***')
|
||||
group.push('***');
|
||||
else
|
||||
group.push(path.resolve(depsDirectory, line));
|
||||
}
|
||||
depsCache[depsDirectory] = deps;
|
||||
}
|
||||
|
||||
return [...(deps['*'] || []), ...(deps[path.relative(depsDirectory, from)] || [])]
|
||||
}
|
||||
|
||||
function allowImport(from, to, mergedDeps) {
|
||||
const fromDirectory = path.dirname(from);
|
||||
const toDirectory = isDirectory(to) ? to : path.dirname(to);
|
||||
if (to === toDirectory)
|
||||
to = path.join(to, 'index.ts');
|
||||
if (fromDirectory === toDirectory)
|
||||
return true;
|
||||
|
||||
for (const dep of mergedDeps) {
|
||||
if (dep === '***')
|
||||
return true;
|
||||
if (to === dep || toDirectory === dep)
|
||||
return true;
|
||||
if (dep.endsWith('**')) {
|
||||
const parent = dep.substring(0, dep.length - 2);
|
||||
if (to.startsWith(parent))
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function listAllFiles(dir) {
|
||||
const dirs = fs.readdirSync(dir, { withFileTypes: true });
|
||||
const result = [];
|
||||
dirs.forEach(d => {
|
||||
const res = path.resolve(dir, d.name);
|
||||
if (d.isDirectory())
|
||||
result.push(...listAllFiles(res));
|
||||
else
|
||||
result.push(res);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
checkDeps().catch(e => {
|
||||
console.error(e && e.stack ? e.stack : e);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
function isDirectory(dir) {
|
||||
return fs.existsSync(dir) && fs.statSync(dir).isDirectory();
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
// @ts-check
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import child_process from 'child_process';
|
||||
import { argv } from 'process';
|
||||
|
||||
const __dirname = path.dirname(new URL(import.meta.url).pathname);
|
||||
|
||||
const readJSON = async (filePath) => JSON.parse(await fs.promises.readFile(filePath, 'utf8'));
|
||||
const writeJSON = async (filePath, json) => {
|
||||
await fs.promises.writeFile(filePath, JSON.stringify(json, null, 2) + '\n');
|
||||
}
|
||||
|
||||
async function updatePackageJSON(dir, version) {
|
||||
const packageJSONPath = path.join(dir, 'package.json');
|
||||
const packageJSON = await readJSON(packageJSONPath);
|
||||
console.log(`Updating ${packageJSONPath} to version ${version}`);
|
||||
packageJSON.version = version;
|
||||
await writeJSON(packageJSONPath, packageJSON);
|
||||
|
||||
// Run npm i to update package-lock.json
|
||||
child_process.execSync('npm i', {
|
||||
cwd: dir
|
||||
});
|
||||
}
|
||||
|
||||
async function updateExtensionManifest(dir, version) {
|
||||
const manifestPath = path.join(dir, 'manifest.json');
|
||||
const manifest = await readJSON(manifestPath);
|
||||
console.log(`Updating ${manifestPath} to version ${version}`);
|
||||
manifest.version = version;
|
||||
await writeJSON(manifestPath, manifest);
|
||||
}
|
||||
|
||||
async function setVersion(version) {
|
||||
if (version.startsWith('v'))
|
||||
throw new Error('version must not start with "v"');
|
||||
|
||||
const packageRoot = path.join(__dirname, '..');
|
||||
await updatePackageJSON(packageRoot, version)
|
||||
await updatePackageJSON(path.join(packageRoot, 'extension'), version)
|
||||
await updateExtensionManifest(path.join(packageRoot, 'extension'), version)
|
||||
}
|
||||
|
||||
if (argv.length !== 3) {
|
||||
console.error('Usage: set-version <version>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
setVersion(argv[2]);
|
||||
Reference in New Issue
Block a user