Merge branch 'main' into vscode-client-factory
This commit is contained in:
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -28,7 +28,7 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
os: [ubuntu-latest, macos-15, windows-latest]
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|||||||
28
.github/workflows/publish.yml
vendored
28
.github/workflows/publish.yml
vendored
@@ -68,3 +68,31 @@ jobs:
|
|||||||
for tag in $(echo ${{ steps.build-push.outputs.metadata['image.name'] }} | tr ',' '\n'); do
|
for tag in $(echo ${{ steps.build-push.outputs.metadata['image.name'] }} | tr ',' '\n'); do
|
||||||
attach_eol_manifest $tag
|
attach_eol_manifest $tag
|
||||||
done
|
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
|
||||||
|
|||||||
32
README.md
32
README.md
@@ -100,6 +100,29 @@ 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.
|
Go to `Program` in the right sidebar -> `Install` -> `Edit mcp.json`. Use the standard config above.
|
||||||
</details>
|
</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>
|
<details>
|
||||||
<summary>Qodo Gen</summary>
|
<summary>Qodo Gen</summary>
|
||||||
|
|
||||||
@@ -158,6 +181,9 @@ Playwright MCP server supports following arguments. They can be provided in the
|
|||||||
--config <path> path to the configuration file.
|
--config <path> path to the configuration file.
|
||||||
--device <device> device to emulate, for example: "iPhone 15"
|
--device <device> device to emulate, for example: "iPhone 15"
|
||||||
--executable-path <path> path to the browser executable.
|
--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
|
--headless run browser in headless mode, headed by default
|
||||||
--host <host> host to bind server to. Default is localhost. Use
|
--host <host> host to bind server to. Default is localhost. Use
|
||||||
0.0.0.0 to bind to all interfaces.
|
0.0.0.0 to bind to all interfaces.
|
||||||
@@ -191,7 +217,7 @@ Playwright MCP server supports following arguments. They can be provided in the
|
|||||||
|
|
||||||
### User profile
|
### User profile
|
||||||
|
|
||||||
You can run Playwright MCP with persistent profile like a regular browser (default), or in the isolated contexts for the testing sessions.
|
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.
|
||||||
|
|
||||||
**Persistent profile**
|
**Persistent profile**
|
||||||
|
|
||||||
@@ -231,6 +257,10 @@ 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
|
### Configuration file
|
||||||
|
|
||||||
The Playwright MCP server can be configured using a JSON configuration file. You can specify the configuration file
|
The Playwright MCP server can be configured using a JSON configuration file. You can specify the configuration file
|
||||||
|
|||||||
48
extension/README.md
Normal file
48
extension/README.md
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# 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,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "Playwright MCP Bridge",
|
"name": "Playwright MCP Bridge",
|
||||||
"version": "1.0.0",
|
"version": "0.0.34",
|
||||||
"description": "Share browser tabs with Playwright MCP server",
|
"description": "Share browser tabs with Playwright MCP server",
|
||||||
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA9nMS2b0WCohjVHPGb8D9qAdkbIngDqoAjTeSccHJijgcONejge+OJxOQOMLu7b0ovt1c9BiEJa5JcpM+EHFVGL1vluBxK71zmBy1m2f9vZF3HG0LSCp7YRkum9rAIEthDwbkxx6XTvpmAY5rjFa/NON6b9Hlbo+8peUSkoOK7HTwYnnI36asZ9eUTiveIf+DMPLojW2UX33vDWG2UKvMVDewzclb4+uLxAYshY7Mx8we/b44xu+Anb/EBLKjOPk9Yh541xJ5Ozc8EiP/5yxOp9c/lRiYUHaRW+4r0HKZyFt0eZ52ti2iM4Nfk7jRXR7an3JPsUIf5deC/1cVM/+1ZQIDAQAB",
|
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA9nMS2b0WCohjVHPGb8D9qAdkbIngDqoAjTeSccHJijgcONejge+OJxOQOMLu7b0ovt1c9BiEJa5JcpM+EHFVGL1vluBxK71zmBy1m2f9vZF3HG0LSCp7YRkum9rAIEthDwbkxx6XTvpmAY5rjFa/NON6b9Hlbo+8peUSkoOK7HTwYnnI36asZ9eUTiveIf+DMPLojW2UX33vDWG2UKvMVDewzclb4+uLxAYshY7Mx8we/b44xu+Anb/EBLKjOPk9Yh541xJ5Ozc8EiP/5yxOp9c/lRiYUHaRW+4r0HKZyFt0eZ52ti2iM4Nfk7jRXR7an3JPsUIf5deC/1cVM/+1ZQIDAQAB",
|
||||||
|
|
||||||
|
|||||||
4
extension/package-lock.json
generated
4
extension/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@playwright/mcp-extension",
|
"name": "@playwright/mcp-extension",
|
||||||
"version": "0.0.32",
|
"version": "0.0.34",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@playwright/mcp-extension",
|
"name": "@playwright/mcp-extension",
|
||||||
"version": "0.0.32",
|
"version": "0.0.34",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/chrome": "^0.0.315",
|
"@types/chrome": "^0.0.315",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@playwright/mcp-extension",
|
"name": "@playwright/mcp-extension",
|
||||||
"version": "0.0.32",
|
"version": "0.0.34",
|
||||||
"description": "Playwright MCP Browser Extension",
|
"description": "Playwright MCP Browser Extension",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ class TabShareExtension {
|
|||||||
private _onMessage(message: PageMessage, sender: chrome.runtime.MessageSender, sendResponse: (response: any) => void) {
|
private _onMessage(message: PageMessage, sender: chrome.runtime.MessageSender, sendResponse: (response: any) => void) {
|
||||||
switch (message.type) {
|
switch (message.type) {
|
||||||
case 'connectToMCPRelay':
|
case 'connectToMCPRelay':
|
||||||
this._connectToRelay(sender.tab!.id!, message.mcpRelayUrl!).then(
|
this._connectToRelay(sender.tab!.id!, message.mcpRelayUrl).then(
|
||||||
() => sendResponse({ success: true }),
|
() => sendResponse({ success: true }),
|
||||||
(error: any) => sendResponse({ success: false, error: error.message }));
|
(error: any) => sendResponse({ success: false, error: error.message }));
|
||||||
return true;
|
return true;
|
||||||
@@ -96,8 +96,9 @@ class TabShareExtension {
|
|||||||
this._pendingTabSelection.set(selectorTabId, { connection });
|
this._pendingTabSelection.set(selectorTabId, { connection });
|
||||||
debugLog(`Connected to MCP relay`);
|
debugLog(`Connected to MCP relay`);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
debugLog(`Failed to connect to MCP relay:`, error.message);
|
const message = `Failed to connect to MCP relay: ${error.message}`;
|
||||||
throw error;
|
debugLog(message);
|
||||||
|
throw new Error(message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,7 +194,7 @@ class TabShareExtension {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private _onTabUpdated(tabId: number, changeInfo: chrome.tabs.TabChangeInfo, tab: chrome.tabs.Tab) {
|
private _onTabUpdated(tabId: number, changeInfo: chrome.tabs.TabChangeInfo, tab: chrome.tabs.Tab) {
|
||||||
if (changeInfo.status === 'complete' && this._connectedTabId === tabId)
|
if (this._connectedTabId === tabId)
|
||||||
void this._setConnectedTabId(tabId);
|
void this._setConnectedTabId(tabId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,11 +19,15 @@ import { createRoot } from 'react-dom/client';
|
|||||||
import { Button, TabItem } from './tabItem.js';
|
import { Button, TabItem } from './tabItem.js';
|
||||||
import type { TabInfo } from './tabItem.js';
|
import type { TabInfo } from './tabItem.js';
|
||||||
|
|
||||||
type StatusType = 'connected' | 'error' | 'connecting';
|
type Status =
|
||||||
|
| { type: 'connecting'; message: string }
|
||||||
|
| { type: 'connected'; message: string }
|
||||||
|
| { type: 'error'; message: string }
|
||||||
|
| { type: 'error'; versionMismatch: { pwMcpVersion: string; extensionVersion: string } };
|
||||||
|
|
||||||
const ConnectApp: React.FC = () => {
|
const ConnectApp: React.FC = () => {
|
||||||
const [tabs, setTabs] = useState<TabInfo[]>([]);
|
const [tabs, setTabs] = useState<TabInfo[]>([]);
|
||||||
const [status, setStatus] = useState<{ type: StatusType; message: string } | null>(null);
|
const [status, setStatus] = useState<Status | null>(null);
|
||||||
const [showButtons, setShowButtons] = useState(true);
|
const [showButtons, setShowButtons] = useState(true);
|
||||||
const [showTabList, setShowTabList] = useState(true);
|
const [showTabList, setShowTabList] = useState(true);
|
||||||
const [clientInfo, setClientInfo] = useState('unknown');
|
const [clientInfo, setClientInfo] = useState('unknown');
|
||||||
@@ -54,16 +58,38 @@ const ConnectApp: React.FC = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pwMcpVersion = params.get('pwMcpVersion');
|
||||||
|
const extensionVersion = chrome.runtime.getManifest().version;
|
||||||
|
if (pwMcpVersion !== extensionVersion) {
|
||||||
|
setShowButtons(false);
|
||||||
|
setShowTabList(false);
|
||||||
|
setStatus({
|
||||||
|
type: 'error',
|
||||||
|
versionMismatch: {
|
||||||
|
pwMcpVersion: pwMcpVersion || 'unknown',
|
||||||
|
extensionVersion
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
void connectToMCPRelay(relayUrl);
|
void connectToMCPRelay(relayUrl);
|
||||||
void loadTabs();
|
void loadTabs();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const connectToMCPRelay = useCallback(async (mcpRelayUrl: string) => {
|
const handleReject = useCallback((message: string) => {
|
||||||
const response = await chrome.runtime.sendMessage({ type: 'connectToMCPRelay', mcpRelayUrl });
|
setShowButtons(false);
|
||||||
if (!response.success)
|
setShowTabList(false);
|
||||||
setStatus({ type: 'error', message: 'Failed to connect to MCP relay: ' + response.error });
|
setStatus({ type: 'error', message });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const connectToMCPRelay = useCallback(async (mcpRelayUrl: string) => {
|
||||||
|
|
||||||
|
const response = await chrome.runtime.sendMessage({ type: 'connectToMCPRelay', mcpRelayUrl });
|
||||||
|
if (!response.success)
|
||||||
|
handleReject(response.error);
|
||||||
|
}, [handleReject]);
|
||||||
|
|
||||||
const loadTabs = useCallback(async () => {
|
const loadTabs = useCallback(async () => {
|
||||||
const response = await chrome.runtime.sendMessage({ type: 'getTabs' });
|
const response = await chrome.runtime.sendMessage({ type: 'getTabs' });
|
||||||
if (response.success)
|
if (response.success)
|
||||||
@@ -100,31 +126,25 @@ const ConnectApp: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [clientInfo, mcpRelayUrl]);
|
}, [clientInfo, mcpRelayUrl]);
|
||||||
|
|
||||||
const handleReject = useCallback(() => {
|
|
||||||
setShowButtons(false);
|
|
||||||
setShowTabList(false);
|
|
||||||
setStatus({ type: 'error', message: 'Connection rejected. This tab can be closed.' });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const listener = (message: any) => {
|
const listener = (message: any) => {
|
||||||
if (message.type === 'connectionTimeout')
|
if (message.type === 'connectionTimeout')
|
||||||
handleReject();
|
handleReject('Connection timed out.');
|
||||||
};
|
};
|
||||||
chrome.runtime.onMessage.addListener(listener);
|
chrome.runtime.onMessage.addListener(listener);
|
||||||
return () => {
|
return () => {
|
||||||
chrome.runtime.onMessage.removeListener(listener);
|
chrome.runtime.onMessage.removeListener(listener);
|
||||||
};
|
};
|
||||||
}, []);
|
}, [handleReject]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='app-container'>
|
<div className='app-container'>
|
||||||
<div className='content-wrapper'>
|
<div className='content-wrapper'>
|
||||||
{status && (
|
{status && (
|
||||||
<div className='status-container'>
|
<div className='status-container'>
|
||||||
<StatusBanner type={status.type} message={status.message} />
|
<StatusBanner status={status} />
|
||||||
{showButtons && (
|
{showButtons && (
|
||||||
<Button variant='reject' onClick={handleReject}>
|
<Button variant='reject' onClick={() => handleReject('Connection rejected. This tab can be closed.')}>
|
||||||
Reject
|
Reject
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -156,8 +176,27 @@ const ConnectApp: React.FC = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const StatusBanner: React.FC<{ type: StatusType; message: string }> = ({ type, message }) => {
|
const VersionMismatchError: React.FC<{ pwMcpVersion: string; extensionVersion: string }> = ({ pwMcpVersion, extensionVersion }) => {
|
||||||
return <div className={`status-banner ${type}`}>{message}</div>;
|
const readmeUrl = 'https://github.com/microsoft/playwright-mcp/blob/main/extension/README.md';
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
Incompatible Playwright MCP version: {pwMcpVersion} (extension version: {extensionVersion}).
|
||||||
|
Please install the latest version of the extension.{' '}
|
||||||
|
See <a href={readmeUrl} target='_blank' rel='noopener noreferrer'>installation instructions</a>.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const StatusBanner: React.FC<{ status: Status }> = ({ status }) => {
|
||||||
|
return (
|
||||||
|
<div className={`status-banner ${status.type}`}>
|
||||||
|
{'versionMismatch' in status ? (
|
||||||
|
<VersionMismatchError pwMcpVersion={status.versionMismatch.pwMcpVersion} extensionVersion={status.versionMismatch.extensionVersion} />
|
||||||
|
) : (
|
||||||
|
status.message
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Initialize the React app
|
// Initialize the React app
|
||||||
|
|||||||
@@ -14,37 +14,50 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import { chromium } from 'playwright';
|
import { chromium } from 'playwright';
|
||||||
|
import packageJSON from '../../package.json' assert { type: 'json' };
|
||||||
import { test as base, expect } from '../../tests/fixtures.js';
|
import { test as base, expect } from '../../tests/fixtures.js';
|
||||||
|
|
||||||
|
import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||||
import type { BrowserContext } from 'playwright';
|
import type { BrowserContext } from 'playwright';
|
||||||
|
import type { StartClient } from '../../tests/fixtures.js';
|
||||||
|
|
||||||
type BrowserWithExtension = {
|
type BrowserWithExtension = {
|
||||||
userDataDir: string;
|
userDataDir: string;
|
||||||
launch: () => Promise<BrowserContext>;
|
launch: (mode?: 'disable-extension') => Promise<BrowserContext>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const test = base.extend<{ browserWithExtension: BrowserWithExtension }>({
|
type TestFixtures = {
|
||||||
browserWithExtension: async ({ mcpBrowser }, use, testInfo) => {
|
browserWithExtension: BrowserWithExtension,
|
||||||
|
pathToExtension: string,
|
||||||
|
useShortConnectionTimeout: (timeoutMs: number) => void
|
||||||
|
};
|
||||||
|
|
||||||
|
const test = base.extend<TestFixtures>({
|
||||||
|
pathToExtension: async ({}, use) => {
|
||||||
|
await use(fileURLToPath(new URL('../dist', import.meta.url)));
|
||||||
|
},
|
||||||
|
|
||||||
|
browserWithExtension: async ({ mcpBrowser, pathToExtension }, use, testInfo) => {
|
||||||
// The flags no longer work in Chrome since
|
// The flags no longer work in Chrome since
|
||||||
// https://chromium.googlesource.com/chromium/src/+/290ed8046692651ce76088914750cb659b65fb17%5E%21/chrome/browser/extensions/extension_service.cc?pli=1#
|
// https://chromium.googlesource.com/chromium/src/+/290ed8046692651ce76088914750cb659b65fb17%5E%21/chrome/browser/extensions/extension_service.cc?pli=1#
|
||||||
test.skip('chromium' !== mcpBrowser, '--load-extension is not supported for official builds of Chromium');
|
test.skip('chromium' !== mcpBrowser, '--load-extension is not supported for official builds of Chromium');
|
||||||
|
|
||||||
const pathToExtension = fileURLToPath(new URL('../dist', import.meta.url));
|
|
||||||
|
|
||||||
let browserContext: BrowserContext | undefined;
|
let browserContext: BrowserContext | undefined;
|
||||||
const userDataDir = testInfo.outputPath('extension-user-data-dir');
|
const userDataDir = testInfo.outputPath('extension-user-data-dir');
|
||||||
await use({
|
await use({
|
||||||
userDataDir,
|
userDataDir,
|
||||||
launch: async () => {
|
launch: async (mode?: 'disable-extension') => {
|
||||||
browserContext = await chromium.launchPersistentContext(userDataDir, {
|
browserContext = await chromium.launchPersistentContext(userDataDir, {
|
||||||
channel: mcpBrowser,
|
channel: mcpBrowser,
|
||||||
// Opening the browser singleton only works in headed.
|
// Opening the browser singleton only works in headed.
|
||||||
headless: false,
|
headless: false,
|
||||||
// Automation disables singleton browser process behavior, which is necessary for the extension.
|
// Automation disables singleton browser process behavior, which is necessary for the extension.
|
||||||
ignoreDefaultArgs: ['--enable-automation'],
|
ignoreDefaultArgs: ['--enable-automation'],
|
||||||
args: [
|
args: mode === 'disable-extension' ? [] : [
|
||||||
`--disable-extensions-except=${pathToExtension}`,
|
`--disable-extensions-except=${pathToExtension}`,
|
||||||
`--load-extension=${pathToExtension}`,
|
`--load-extension=${pathToExtension}`,
|
||||||
],
|
],
|
||||||
@@ -58,95 +71,174 @@ const test = base.extend<{ browserWithExtension: BrowserWithExtension }>({
|
|||||||
return browserContext;
|
return browserContext;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
await browserContext?.close();
|
await browserContext?.close();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
useShortConnectionTimeout: async ({}, use) => {
|
||||||
|
await use((timeoutMs: number) => {
|
||||||
|
process.env.PWMCP_TEST_CONNECTION_TIMEOUT = timeoutMs.toString();
|
||||||
|
});
|
||||||
|
process.env.PWMCP_TEST_CONNECTION_TIMEOUT = undefined;
|
||||||
|
},
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
async function startAndCallConnectTool(browserWithExtension: BrowserWithExtension, startClient: StartClient): Promise<Client> {
|
||||||
|
const { client } = await startClient({
|
||||||
|
args: [`--connect-tool`],
|
||||||
|
config: {
|
||||||
|
browser: {
|
||||||
|
userDataDir: browserWithExtension.userDataDir,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_connect',
|
||||||
|
arguments: {
|
||||||
|
name: 'extension'
|
||||||
|
}
|
||||||
|
})).toHaveResponse({
|
||||||
|
result: 'Successfully changed connection method.',
|
||||||
|
});
|
||||||
|
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startWithExtensionFlag(browserWithExtension: BrowserWithExtension, startClient: StartClient): Promise<Client> {
|
||||||
|
const { client } = await startClient({
|
||||||
|
args: [`--extension`],
|
||||||
|
config: {
|
||||||
|
browser: {
|
||||||
|
userDataDir: browserWithExtension.userDataDir,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
const testWithOldVersion = test.extend({
|
||||||
|
pathToExtension: async ({}, use, testInfo) => {
|
||||||
|
const extensionDir = testInfo.outputPath('extension');
|
||||||
|
const oldPath = fileURLToPath(new URL('../dist', import.meta.url));
|
||||||
|
|
||||||
|
await fs.promises.cp(oldPath, extensionDir, { recursive: true });
|
||||||
|
const manifestPath = path.join(extensionDir, 'manifest.json');
|
||||||
|
const manifest = JSON.parse(await fs.promises.readFile(manifestPath, 'utf8'));
|
||||||
|
manifest.version = '0.0.1';
|
||||||
|
await fs.promises.writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
|
||||||
|
|
||||||
|
await use(extensionDir);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
test('navigate with extension', async ({ browserWithExtension, startClient, server }) => {
|
for (const [mode, startClientMethod] of [
|
||||||
const browserContext = await browserWithExtension.launch();
|
['connect-tool', startAndCallConnectTool],
|
||||||
|
['extension-flag', startWithExtensionFlag],
|
||||||
|
] as const) {
|
||||||
|
|
||||||
const { client } = await startClient({
|
test(`navigate with extension (${mode})`, async ({ browserWithExtension, startClient, server }) => {
|
||||||
args: [`--connect-tool`],
|
const browserContext = await browserWithExtension.launch();
|
||||||
config: {
|
|
||||||
browser: {
|
const client = await startClientMethod(browserWithExtension, startClient);
|
||||||
userDataDir: browserWithExtension.userDataDir,
|
|
||||||
}
|
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!`),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(await client.callTool({
|
test(`snapshot of an existing page (${mode})`, async ({ browserWithExtension, startClient, server }) => {
|
||||||
name: 'browser_connect',
|
const browserContext = await browserWithExtension.launch();
|
||||||
arguments: {
|
|
||||||
name: 'extension'
|
const page = await browserContext.newPage();
|
||||||
}
|
await page.goto(server.HELLO_WORLD);
|
||||||
})).toHaveResponse({
|
|
||||||
result: 'Successfully changed connection method.',
|
// 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);
|
||||||
});
|
});
|
||||||
|
|
||||||
const confirmationPagePromise = browserContext.waitForEvent('page', page => {
|
test(`extension not installed timeout (${mode})`, async ({ browserWithExtension, startClient, server, useShortConnectionTimeout }) => {
|
||||||
return page.url().startsWith('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html');
|
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;
|
||||||
});
|
});
|
||||||
|
|
||||||
const navigateResponse = client.callTool({
|
testWithOldVersion(`extension version mismatch (${mode})`, async ({ browserWithExtension, startClient, server, useShortConnectionTimeout }) => {
|
||||||
name: 'browser_navigate',
|
useShortConnectionTimeout(500);
|
||||||
arguments: { url: server.HELLO_WORLD },
|
|
||||||
|
// Prelaunch the browser, so that it is properly closed after the test.
|
||||||
|
const browserContext = await browserWithExtension.launch();
|
||||||
|
|
||||||
|
const client = await startClientMethod(browserWithExtension, startClient);
|
||||||
|
|
||||||
|
const confirmationPagePromise = browserContext.waitForEvent('page', page => {
|
||||||
|
return page.url().startsWith('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html');
|
||||||
|
});
|
||||||
|
|
||||||
|
const navigateResponse = client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.HELLO_WORLD },
|
||||||
|
});
|
||||||
|
|
||||||
|
const confirmationPage = await confirmationPagePromise;
|
||||||
|
await expect(confirmationPage.locator('.status-banner')).toHaveText(`Incompatible Playwright MCP version: ${packageJSON.version} (extension version: 0.0.1). Please install the latest version of the extension. See installation instructions.`);
|
||||||
|
|
||||||
|
expect(await navigateResponse).toHaveResponse({
|
||||||
|
result: expect.stringContaining('Extension connection timeout.'),
|
||||||
|
isError: true,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
const { client } = await startClient({
|
|
||||||
args: [`--connect-tool`],
|
|
||||||
config: {
|
|
||||||
browser: {
|
|
||||||
userDataDir: browserWithExtension.userDataDir,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(await client.callTool({
|
|
||||||
name: 'browser_connect',
|
|
||||||
arguments: {
|
|
||||||
name: 'extension'
|
|
||||||
}
|
|
||||||
})).toHaveResponse({
|
|
||||||
result: 'Successfully changed connection method.',
|
|
||||||
});
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@playwright/mcp",
|
"name": "@playwright/mcp",
|
||||||
"version": "0.0.33",
|
"version": "0.0.34",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@playwright/mcp",
|
"name": "@playwright/mcp",
|
||||||
"version": "0.0.33",
|
"version": "0.0.34",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.16.0",
|
"@modelcontextprotocol/sdk": "^1.16.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@playwright/mcp",
|
"name": "@playwright/mcp",
|
||||||
"version": "0.0.33",
|
"version": "0.0.34",
|
||||||
"description": "Playwright Tools for MCP",
|
"description": "Playwright Tools for MCP",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ export default defineConfig<TestOptions>({
|
|||||||
reporter: 'list',
|
reporter: 'list',
|
||||||
projects: [
|
projects: [
|
||||||
{ name: 'chrome' },
|
{ name: 'chrome' },
|
||||||
{ name: 'msedge', use: { mcpBrowser: 'msedge' } },
|
|
||||||
{ name: 'chromium', use: { mcpBrowser: 'chromium' } },
|
{ name: 'chromium', use: { mcpBrowser: 'chromium' } },
|
||||||
...process.env.MCP_IN_DOCKER ? [{
|
...process.env.MCP_IN_DOCKER ? [{
|
||||||
name: 'chromium-docker',
|
name: 'chromium-docker',
|
||||||
@@ -38,5 +37,6 @@ export default defineConfig<TestOptions>({
|
|||||||
}] : [],
|
}] : [],
|
||||||
{ name: 'firefox', use: { mcpBrowser: 'firefox' } },
|
{ name: 'firefox', use: { mcpBrowser: 'firefox' } },
|
||||||
{ name: 'webkit', use: { mcpBrowser: 'webkit' } },
|
{ name: 'webkit', use: { mcpBrowser: 'webkit' } },
|
||||||
|
... process.platform === 'win32' ? [{ name: 'msedge', use: { mcpBrowser: 'msedge' } }] : [],
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -42,27 +42,23 @@ export function contextFactory(config: FullConfig): BrowserContextFactory {
|
|||||||
export type ClientInfo = { name?: string, version?: string, rootPath?: string };
|
export type ClientInfo = { name?: string, version?: string, rootPath?: string };
|
||||||
|
|
||||||
export interface BrowserContextFactory {
|
export interface BrowserContextFactory {
|
||||||
readonly name: string;
|
|
||||||
readonly description: string;
|
|
||||||
createContext(clientInfo: ClientInfo, abortSignal: AbortSignal): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }>;
|
createContext(clientInfo: ClientInfo, abortSignal: AbortSignal): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
class BaseContextFactory implements BrowserContextFactory {
|
class BaseContextFactory implements BrowserContextFactory {
|
||||||
readonly name: string;
|
|
||||||
readonly description: string;
|
|
||||||
readonly config: FullConfig;
|
readonly config: FullConfig;
|
||||||
|
private _logName: string;
|
||||||
protected _browserPromise: Promise<playwright.Browser> | undefined;
|
protected _browserPromise: Promise<playwright.Browser> | undefined;
|
||||||
|
|
||||||
constructor(name: string, description: string, config: FullConfig) {
|
constructor(name: string, config: FullConfig) {
|
||||||
this.name = name;
|
this._logName = name;
|
||||||
this.description = description;
|
|
||||||
this.config = config;
|
this.config = config;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async _obtainBrowser(clientInfo: ClientInfo): Promise<playwright.Browser> {
|
protected async _obtainBrowser(clientInfo: ClientInfo): Promise<playwright.Browser> {
|
||||||
if (this._browserPromise)
|
if (this._browserPromise)
|
||||||
return this._browserPromise;
|
return this._browserPromise;
|
||||||
testDebug(`obtain browser (${this.name})`);
|
testDebug(`obtain browser (${this._logName})`);
|
||||||
this._browserPromise = this._doObtainBrowser(clientInfo);
|
this._browserPromise = this._doObtainBrowser(clientInfo);
|
||||||
void this._browserPromise.then(browser => {
|
void this._browserPromise.then(browser => {
|
||||||
browser.on('disconnected', () => {
|
browser.on('disconnected', () => {
|
||||||
@@ -79,7 +75,7 @@ class BaseContextFactory implements BrowserContextFactory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async createContext(clientInfo: ClientInfo): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
|
async createContext(clientInfo: ClientInfo): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
|
||||||
testDebug(`create browser context (${this.name})`);
|
testDebug(`create browser context (${this._logName})`);
|
||||||
const browser = await this._obtainBrowser(clientInfo);
|
const browser = await this._obtainBrowser(clientInfo);
|
||||||
const browserContext = await this._doCreateContext(browser);
|
const browserContext = await this._doCreateContext(browser);
|
||||||
return { browserContext, close: () => this._closeBrowserContext(browserContext, browser) };
|
return { browserContext, close: () => this._closeBrowserContext(browserContext, browser) };
|
||||||
@@ -90,12 +86,12 @@ class BaseContextFactory implements BrowserContextFactory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async _closeBrowserContext(browserContext: playwright.BrowserContext, browser: playwright.Browser) {
|
private async _closeBrowserContext(browserContext: playwright.BrowserContext, browser: playwright.Browser) {
|
||||||
testDebug(`close browser context (${this.name})`);
|
testDebug(`close browser context (${this._logName})`);
|
||||||
if (browser.contexts().length === 1)
|
if (browser.contexts().length === 1)
|
||||||
this._browserPromise = undefined;
|
this._browserPromise = undefined;
|
||||||
await browserContext.close().catch(logUnhandledError);
|
await browserContext.close().catch(logUnhandledError);
|
||||||
if (browser.contexts().length === 0) {
|
if (browser.contexts().length === 0) {
|
||||||
testDebug(`close browser (${this.name})`);
|
testDebug(`close browser (${this._logName})`);
|
||||||
await browser.close().catch(logUnhandledError);
|
await browser.close().catch(logUnhandledError);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -103,7 +99,7 @@ class BaseContextFactory implements BrowserContextFactory {
|
|||||||
|
|
||||||
class IsolatedContextFactory extends BaseContextFactory {
|
class IsolatedContextFactory extends BaseContextFactory {
|
||||||
constructor(config: FullConfig) {
|
constructor(config: FullConfig) {
|
||||||
super('isolated', 'Create a new isolated browser context', config);
|
super('isolated', config);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override async _doObtainBrowser(clientInfo: ClientInfo): Promise<playwright.Browser> {
|
protected override async _doObtainBrowser(clientInfo: ClientInfo): Promise<playwright.Browser> {
|
||||||
@@ -128,7 +124,7 @@ class IsolatedContextFactory extends BaseContextFactory {
|
|||||||
|
|
||||||
class CdpContextFactory extends BaseContextFactory {
|
class CdpContextFactory extends BaseContextFactory {
|
||||||
constructor(config: FullConfig) {
|
constructor(config: FullConfig) {
|
||||||
super('cdp', 'Connect to a browser over CDP', config);
|
super('cdp', config);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override async _doObtainBrowser(): Promise<playwright.Browser> {
|
protected override async _doObtainBrowser(): Promise<playwright.Browser> {
|
||||||
@@ -142,7 +138,7 @@ class CdpContextFactory extends BaseContextFactory {
|
|||||||
|
|
||||||
class RemoteContextFactory extends BaseContextFactory {
|
class RemoteContextFactory extends BaseContextFactory {
|
||||||
constructor(config: FullConfig) {
|
constructor(config: FullConfig) {
|
||||||
super('remote', 'Connect to a browser using a remote endpoint', config);
|
super('remote', config);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override async _doObtainBrowser(): Promise<playwright.Browser> {
|
protected override async _doObtainBrowser(): Promise<playwright.Browser> {
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import { logUnhandledError } from './utils/log.js';
|
|||||||
import { Response } from './response.js';
|
import { Response } from './response.js';
|
||||||
import { SessionLog } from './sessionLog.js';
|
import { SessionLog } from './sessionLog.js';
|
||||||
import { filteredTools } from './tools.js';
|
import { filteredTools } from './tools.js';
|
||||||
import { packageJSON } from './utils/package.js';
|
|
||||||
import { toMcpTool } from './mcp/tool.js';
|
import { toMcpTool } from './mcp/tool.js';
|
||||||
|
|
||||||
import type { Tool } from './tools/tool.js';
|
import type { Tool } from './tools/tool.js';
|
||||||
@@ -30,9 +29,6 @@ import type * as mcpServer from './mcp/server.js';
|
|||||||
import type { ServerBackend } from './mcp/server.js';
|
import type { ServerBackend } from './mcp/server.js';
|
||||||
|
|
||||||
export class BrowserServerBackend implements ServerBackend {
|
export class BrowserServerBackend implements ServerBackend {
|
||||||
name = 'Playwright';
|
|
||||||
version = packageJSON.version;
|
|
||||||
|
|
||||||
private _tools: Tool[];
|
private _tools: Tool[];
|
||||||
private _context: Context | undefined;
|
private _context: Context | undefined;
|
||||||
private _sessionLog: SessionLog | undefined;
|
private _sessionLog: SessionLog | undefined;
|
||||||
@@ -45,11 +41,9 @@ export class BrowserServerBackend implements ServerBackend {
|
|||||||
this._tools = filteredTools(config);
|
this._tools = filteredTools(config);
|
||||||
}
|
}
|
||||||
|
|
||||||
async initialize(server: mcpServer.Server): Promise<void> {
|
async initialize(clientVersion: mcpServer.ClientVersion, roots: mcpServer.Root[]): Promise<void> {
|
||||||
const capabilities = server.getClientCapabilities();
|
|
||||||
let rootPath: string | undefined;
|
let rootPath: string | undefined;
|
||||||
if (capabilities?.roots) {
|
if (roots.length > 0) {
|
||||||
const { roots } = await server.listRoots();
|
|
||||||
const firstRootUri = roots[0]?.uri;
|
const firstRootUri = roots[0]?.uri;
|
||||||
const url = firstRootUri ? new URL(firstRootUri) : undefined;
|
const url = firstRootUri ? new URL(firstRootUri) : undefined;
|
||||||
rootPath = url ? fileURLToPath(url) : undefined;
|
rootPath = url ? fileURLToPath(url) : undefined;
|
||||||
@@ -60,7 +54,7 @@ export class BrowserServerBackend implements ServerBackend {
|
|||||||
config: this._config,
|
config: this._config,
|
||||||
browserContextFactory: this._browserContextFactory,
|
browserContextFactory: this._browserContextFactory,
|
||||||
sessionLog: this._sessionLog,
|
sessionLog: this._sessionLog,
|
||||||
clientInfo: { ...server.getClientVersion(), rootPath },
|
clientInfo: { ...clientVersion, rootPath },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,9 +26,11 @@ import { spawn } from 'child_process';
|
|||||||
import http from 'http';
|
import http from 'http';
|
||||||
import debug from 'debug';
|
import debug from 'debug';
|
||||||
import { WebSocket, WebSocketServer } from 'ws';
|
import { WebSocket, WebSocketServer } from 'ws';
|
||||||
import { httpAddressToString } from '../utils/httpServer.js';
|
import { httpAddressToString } from '../mcp/http.js';
|
||||||
import { logUnhandledError } from '../utils/log.js';
|
import { logUnhandledError } from '../utils/log.js';
|
||||||
import { ManualPromise } from '../utils/manualPromise.js';
|
import { ManualPromise } from '../utils/manualPromise.js';
|
||||||
|
import { packageJSON } from '../utils/package.js';
|
||||||
|
|
||||||
import type websocket from 'ws';
|
import type websocket from 'ws';
|
||||||
import type { ClientInfo } from '../browserContextFactory.js';
|
import type { ClientInfo } from '../browserContextFactory.js';
|
||||||
|
|
||||||
@@ -100,6 +102,9 @@ export class CDPRelayServer {
|
|||||||
debugLogger('Waiting for incoming extension connection');
|
debugLogger('Waiting for incoming extension connection');
|
||||||
await Promise.race([
|
await Promise.race([
|
||||||
this._extensionConnectionPromise,
|
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))
|
new Promise((_, reject) => abortSignal.addEventListener('abort', reject))
|
||||||
]);
|
]);
|
||||||
debugLogger('Extension connection established');
|
debugLogger('Extension connection established');
|
||||||
@@ -110,7 +115,12 @@ export class CDPRelayServer {
|
|||||||
// Need to specify "key" in the manifest.json to make the id stable when loading from file.
|
// Need to specify "key" in the manifest.json to make the id stable when loading from file.
|
||||||
const url = new URL('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html');
|
const url = new URL('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html');
|
||||||
url.searchParams.set('mcpRelayUrl', mcpRelayEndpoint);
|
url.searchParams.set('mcpRelayUrl', mcpRelayEndpoint);
|
||||||
url.searchParams.set('client', JSON.stringify(clientInfo));
|
const client = {
|
||||||
|
name: clientInfo.name,
|
||||||
|
version: clientInfo.version,
|
||||||
|
};
|
||||||
|
url.searchParams.set('client', JSON.stringify(client));
|
||||||
|
url.searchParams.set('pwMcpVersion', packageJSON.version);
|
||||||
const href = url.toString();
|
const href = url.toString();
|
||||||
const executableInfo = registry.findExecutable(this._browserChannel);
|
const executableInfo = registry.findExecutable(this._browserChannel);
|
||||||
if (!executableInfo)
|
if (!executableInfo)
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
|
|
||||||
import debug from 'debug';
|
import debug from 'debug';
|
||||||
import * as playwright from 'playwright';
|
import * as playwright from 'playwright';
|
||||||
import { startHttpServer } from '../utils/httpServer.js';
|
import { startHttpServer } from '../mcp/http.js';
|
||||||
import { CDPRelayServer } from './cdpRelay.js';
|
import { CDPRelayServer } from './cdpRelay.js';
|
||||||
|
|
||||||
import type { BrowserContextFactory, ClientInfo } from '../browserContextFactory.js';
|
import type { BrowserContextFactory, ClientInfo } from '../browserContextFactory.js';
|
||||||
@@ -24,9 +24,6 @@ import type { BrowserContextFactory, ClientInfo } from '../browserContextFactory
|
|||||||
const debugLogger = debug('pw:mcp:relay');
|
const debugLogger = debug('pw:mcp:relay');
|
||||||
|
|
||||||
export class ExtensionContextFactory implements BrowserContextFactory {
|
export class ExtensionContextFactory implements BrowserContextFactory {
|
||||||
name = 'extension';
|
|
||||||
description = 'Connect to a browser using the Playwright MCP extension';
|
|
||||||
|
|
||||||
private _browserChannel: string;
|
private _browserChannel: string;
|
||||||
private _userDataDir?: string;
|
private _userDataDir?: string;
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { BrowserServerBackend } from './browserServerBackend.js';
|
|||||||
import { resolveConfig } from './config.js';
|
import { resolveConfig } from './config.js';
|
||||||
import { contextFactory } from './browserContextFactory.js';
|
import { contextFactory } from './browserContextFactory.js';
|
||||||
import * as mcpServer from './mcp/server.js';
|
import * as mcpServer from './mcp/server.js';
|
||||||
|
import { packageJSON } from './utils/package.js';
|
||||||
|
|
||||||
import type { Config } from '../config.js';
|
import type { Config } from '../config.js';
|
||||||
import type { BrowserContext } from 'playwright';
|
import type { BrowserContext } from 'playwright';
|
||||||
@@ -27,7 +28,7 @@ import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|||||||
export async function createConnection(userConfig: Config = {}, contextGetter?: () => Promise<BrowserContext>): Promise<Server> {
|
export async function createConnection(userConfig: Config = {}, contextGetter?: () => Promise<BrowserContext>): Promise<Server> {
|
||||||
const config = await resolveConfig(userConfig);
|
const config = await resolveConfig(userConfig);
|
||||||
const factory = contextGetter ? new SimpleBrowserContextFactory(contextGetter) : contextFactory(config);
|
const factory = contextGetter ? new SimpleBrowserContextFactory(contextGetter) : contextFactory(config);
|
||||||
return mcpServer.createServer(new BrowserServerBackend(config, factory), false);
|
return mcpServer.createServer('Playwright', packageJSON.version, new BrowserServerBackend(config, factory), false);
|
||||||
}
|
}
|
||||||
|
|
||||||
class SimpleBrowserContextFactory implements BrowserContextFactory {
|
class SimpleBrowserContextFactory implements BrowserContextFactory {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { OpenAIDelegate } from '../loop/loopOpenAI.js';
|
|||||||
import { ClaudeDelegate } from '../loop/loopClaude.js';
|
import { ClaudeDelegate } from '../loop/loopClaude.js';
|
||||||
import { InProcessTransport } from '../mcp/inProcessTransport.js';
|
import { InProcessTransport } from '../mcp/inProcessTransport.js';
|
||||||
import * as mcpServer from '../mcp/server.js';
|
import * as mcpServer from '../mcp/server.js';
|
||||||
|
import { packageJSON } from '../utils/package.js';
|
||||||
|
|
||||||
import type { LLMDelegate } from '../loop/loop.js';
|
import type { LLMDelegate } from '../loop/loop.js';
|
||||||
import type { FullConfig } from '../config.js';
|
import type { FullConfig } from '../config.js';
|
||||||
@@ -44,9 +45,9 @@ export class Context {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static async create(config: FullConfig) {
|
static async create(config: FullConfig) {
|
||||||
const client = new Client({ name: 'Playwright Proxy', version: '1.0.0' });
|
const client = new Client({ name: 'Playwright Proxy', version: packageJSON.version });
|
||||||
const browserContextFactory = contextFactory(config);
|
const browserContextFactory = contextFactory(config);
|
||||||
const server = mcpServer.createServer(new BrowserServerBackend(config, browserContextFactory), false);
|
const server = mcpServer.createServer('Playwright Subagent', packageJSON.version, new BrowserServerBackend(config, browserContextFactory), false);
|
||||||
await client.connect(new InProcessTransport(server));
|
await client.connect(new InProcessTransport(server));
|
||||||
await client.ping();
|
await client.ping();
|
||||||
return new Context(config, client);
|
return new Context(config, client);
|
||||||
|
|||||||
@@ -17,7 +17,6 @@
|
|||||||
import dotenv from 'dotenv';
|
import dotenv from 'dotenv';
|
||||||
|
|
||||||
import * as mcpServer from '../mcp/server.js';
|
import * as mcpServer from '../mcp/server.js';
|
||||||
import * as mcpTransport from '../mcp/transport.js';
|
|
||||||
import { packageJSON } from '../utils/package.js';
|
import { packageJSON } from '../utils/package.js';
|
||||||
import { Context } from './context.js';
|
import { Context } from './context.js';
|
||||||
import { perform } from './perform.js';
|
import { perform } from './perform.js';
|
||||||
@@ -30,13 +29,16 @@ import type { Tool } from './tool.js';
|
|||||||
|
|
||||||
export async function runLoopTools(config: FullConfig) {
|
export async function runLoopTools(config: FullConfig) {
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
const serverBackendFactory = () => new LoopToolsServerBackend(config);
|
const serverBackendFactory = {
|
||||||
await mcpTransport.start(serverBackendFactory, config.server);
|
name: 'Playwright',
|
||||||
|
nameInConfig: 'playwright-loop',
|
||||||
|
version: packageJSON.version,
|
||||||
|
create: () => new LoopToolsServerBackend(config)
|
||||||
|
};
|
||||||
|
await mcpServer.start(serverBackendFactory, config.server);
|
||||||
}
|
}
|
||||||
|
|
||||||
class LoopToolsServerBackend implements ServerBackend {
|
class LoopToolsServerBackend implements ServerBackend {
|
||||||
readonly name = 'Playwright';
|
|
||||||
readonly version = packageJSON.version;
|
|
||||||
private _config: FullConfig;
|
private _config: FullConfig;
|
||||||
private _context: Context | undefined;
|
private _context: Context | undefined;
|
||||||
private _tools: Tool<any>[] = [perform, snapshot];
|
private _tools: Tool<any>[] = [perform, snapshot];
|
||||||
|
|||||||
@@ -1,2 +1 @@
|
|||||||
[*]
|
[*]
|
||||||
../utils/
|
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
- Generic MCP utils, no dependencies on Playwright here.
|
- Generic MCP utils, no dependencies on anything.
|
||||||
|
|||||||
@@ -14,33 +14,61 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import assert from 'assert';
|
||||||
|
import net from 'net';
|
||||||
import http from 'http';
|
import http from 'http';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
|
|
||||||
import debug from 'debug';
|
import debug from 'debug';
|
||||||
|
|
||||||
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
||||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
||||||
import { httpAddressToString, startHttpServer } from '../utils/httpServer.js';
|
|
||||||
import * as mcpServer from './server.js';
|
import * as mcpServer from './server.js';
|
||||||
|
|
||||||
import type { ServerBackendFactory } 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');
|
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();
|
||||||
|
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>) {
|
async function handleSSE(serverBackendFactory: ServerBackendFactory, req: http.IncomingMessage, res: http.ServerResponse, url: URL, sessions: Map<string, SSEServerTransport>) {
|
||||||
if (req.method === 'POST') {
|
if (req.method === 'POST') {
|
||||||
const sessionId = url.searchParams.get('sessionId');
|
const sessionId = url.searchParams.get('sessionId');
|
||||||
@@ -108,30 +136,3 @@ async function handleStreamable(serverBackendFactory: ServerBackendFactory, req:
|
|||||||
res.statusCode = 400;
|
res.statusCode = 400;
|
||||||
res.end('Invalid request');
|
res.end('Invalid request');
|
||||||
}
|
}
|
||||||
|
|
||||||
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 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);
|
|
||||||
}
|
|
||||||
@@ -14,30 +14,26 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import debug from 'debug';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { zodToJsonSchema } from 'zod-to-json-schema';
|
import { zodToJsonSchema } from 'zod-to-json-schema';
|
||||||
|
|
||||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||||
import { ListRootsRequestSchema, PingRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
import { ListRootsRequestSchema, PingRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
||||||
import { logUnhandledError } from '../utils/log.js';
|
|
||||||
import { packageJSON } from '../utils/package.js';
|
|
||||||
|
|
||||||
|
import type { ServerBackend, ClientVersion, Root } from './server.js';
|
||||||
import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
||||||
import type { ServerBackend } from './server.js';
|
|
||||||
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
||||||
import type { Root, Tool, CallToolResult, CallToolRequest } from '@modelcontextprotocol/sdk/types.js';
|
import type { Tool, CallToolResult, CallToolRequest } from '@modelcontextprotocol/sdk/types.js';
|
||||||
|
|
||||||
export type MCPProvider = {
|
export type MCPProvider = {
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
connect(options: any): Promise<Transport>;
|
connect(): Promise<Transport>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export class ProxyBackend implements ServerBackend {
|
const errorsDebug = debug('pw:mcp:errors');
|
||||||
name = 'Playwright MCP Client Switcher';
|
|
||||||
version = packageJSON.version;
|
|
||||||
|
|
||||||
|
export class ProxyBackend implements ServerBackend {
|
||||||
private _mcpProviders: MCPProvider[];
|
private _mcpProviders: MCPProvider[];
|
||||||
private _currentClient: Client | undefined;
|
private _currentClient: Client | undefined;
|
||||||
private _contextSwitchTool: Tool;
|
private _contextSwitchTool: Tool;
|
||||||
@@ -48,15 +44,9 @@ export class ProxyBackend implements ServerBackend {
|
|||||||
this._contextSwitchTool = this._defineContextSwitchTool();
|
this._contextSwitchTool = this._defineContextSwitchTool();
|
||||||
}
|
}
|
||||||
|
|
||||||
async initialize(server: Server): Promise<void> {
|
async initialize(clientVersion: ClientVersion, roots: Root[]): Promise<void> {
|
||||||
const version = server.getClientVersion();
|
this._roots = roots;
|
||||||
const capabilities = server.getClientCapabilities();
|
await this._setCurrentClient(this._mcpProviders[0]);
|
||||||
if (capabilities?.roots && version && clientsWithRoots.includes(version.name)) {
|
|
||||||
const { roots } = await server.listRoots();
|
|
||||||
this._roots = roots;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this._setCurrentClient(this._mcpProviders[0], undefined);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async listTools(): Promise<Tool[]> {
|
async listTools(): Promise<Tool[]> {
|
||||||
@@ -79,7 +69,7 @@ export class ProxyBackend implements ServerBackend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
serverClosed?(): void {
|
serverClosed?(): void {
|
||||||
void this._currentClient?.close().catch(logUnhandledError);
|
void this._currentClient?.close().catch(errorsDebug);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _callContextSwitchTool(params: any): Promise<CallToolResult> {
|
private async _callContextSwitchTool(params: any): Promise<CallToolResult> {
|
||||||
@@ -120,11 +110,11 @@ export class ProxyBackend implements ServerBackend {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _setCurrentClient(factory: MCPProvider, options: any) {
|
private async _setCurrentClient(factory: MCPProvider) {
|
||||||
await this._currentClient?.close();
|
await this._currentClient?.close();
|
||||||
this._currentClient = undefined;
|
this._currentClient = undefined;
|
||||||
|
|
||||||
const client = new Client({ name: 'Playwright MCP Proxy', version: packageJSON.version });
|
const client = new Client({ name: 'Playwright MCP Proxy', version: '0.0.0' });
|
||||||
client.registerCapabilities({
|
client.registerCapabilities({
|
||||||
roots: {
|
roots: {
|
||||||
listRoots: true,
|
listRoots: true,
|
||||||
@@ -133,10 +123,8 @@ export class ProxyBackend implements ServerBackend {
|
|||||||
client.setRequestHandler(ListRootsRequestSchema, () => ({ roots: this._roots }));
|
client.setRequestHandler(ListRootsRequestSchema, () => ({ roots: this._roots }));
|
||||||
client.setRequestHandler(PingRequestSchema, () => ({}));
|
client.setRequestHandler(PingRequestSchema, () => ({}));
|
||||||
|
|
||||||
const transport = await factory.connect(options);
|
const transport = await factory.connect();
|
||||||
await client.connect(transport);
|
await client.connect(transport);
|
||||||
this._currentClient = client;
|
this._currentClient = client;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const clientsWithRoots = ['Visual Studio Code', 'Visual Studio Code - Insiders'];
|
|
||||||
|
|||||||
@@ -15,38 +15,50 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import debug from 'debug';
|
import debug from 'debug';
|
||||||
|
|
||||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||||
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
||||||
import { ManualPromise } from '../utils/manualPromise.js';
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||||
import { logUnhandledError } from '../utils/log.js';
|
import { httpAddressToString, installHttpTransport, startHttpServer } from './http.js';
|
||||||
|
import { InProcessTransport } from './inProcessTransport.js';
|
||||||
|
|
||||||
import type { Tool, CallToolResult, CallToolRequest } from '@modelcontextprotocol/sdk/types.js';
|
import type { Tool, CallToolResult, CallToolRequest, Root } from '@modelcontextprotocol/sdk/types.js';
|
||||||
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
||||||
export type { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
export type { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||||
export type { Tool, CallToolResult, CallToolRequest } from '@modelcontextprotocol/sdk/types.js';
|
export type { Tool, CallToolResult, CallToolRequest, Root } from '@modelcontextprotocol/sdk/types.js';
|
||||||
|
|
||||||
const serverDebug = debug('pw:mcp:server');
|
const serverDebug = debug('pw:mcp:server');
|
||||||
|
const errorsDebug = debug('pw:mcp:errors');
|
||||||
|
|
||||||
|
export type ClientVersion = { name: string, version: string };
|
||||||
export interface ServerBackend {
|
export interface ServerBackend {
|
||||||
name: string;
|
initialize?(clientVersion: ClientVersion, roots: Root[]): Promise<void>;
|
||||||
version: string;
|
|
||||||
initialize?(server: Server): Promise<void>;
|
|
||||||
listTools(): Promise<Tool[]>;
|
listTools(): Promise<Tool[]>;
|
||||||
callTool(name: string, args: CallToolRequest['params']['arguments']): Promise<CallToolResult>;
|
callTool(name: string, args: CallToolRequest['params']['arguments']): Promise<CallToolResult>;
|
||||||
serverClosed?(): void;
|
serverClosed?(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ServerBackendFactory = () => ServerBackend;
|
export type ServerBackendFactory = {
|
||||||
|
name: string;
|
||||||
|
nameInConfig: string;
|
||||||
|
version: string;
|
||||||
|
create: () => ServerBackend;
|
||||||
|
};
|
||||||
|
|
||||||
export async function connect(serverBackendFactory: ServerBackendFactory, transport: Transport, runHeartbeat: boolean) {
|
export async function connect(factory: ServerBackendFactory, transport: Transport, runHeartbeat: boolean) {
|
||||||
const backend = serverBackendFactory();
|
const server = createServer(factory.name, factory.version, factory.create(), runHeartbeat);
|
||||||
const server = createServer(backend, runHeartbeat);
|
|
||||||
await server.connect(transport);
|
await server.connect(transport);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createServer(backend: ServerBackend, runHeartbeat: boolean): Server {
|
export async function wrapInProcess(backend: ServerBackend): Promise<Transport> {
|
||||||
const initializedPromise = new ManualPromise<void>();
|
const server = createServer('Internal', '0.0.0', backend, false);
|
||||||
const server = new Server({ name: backend.name, version: backend.version }, {
|
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 }, {
|
||||||
capabilities: {
|
capabilities: {
|
||||||
tools: {},
|
tools: {},
|
||||||
}
|
}
|
||||||
@@ -78,8 +90,20 @@ export function createServer(backend: ServerBackend, runHeartbeat: boolean): Ser
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
addServerListener(server, 'initialized', () => {
|
addServerListener(server, 'initialized', async () => {
|
||||||
backend.initialize?.(server).then(() => initializedPromise.resolve()).catch(logUnhandledError);
|
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?.(clientVersion, clientRoots);
|
||||||
|
initializedPromiseResolve();
|
||||||
|
} catch (e) {
|
||||||
|
errorsDebug(e);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
addServerListener(server, 'close', () => backend.serverClosed?.());
|
addServerListener(server, 'close', () => backend.serverClosed?.());
|
||||||
return server;
|
return server;
|
||||||
@@ -107,3 +131,27 @@ function addServerListener(server: Server, event: 'close' | 'initialized', liste
|
|||||||
listener();
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,7 +16,6 @@
|
|||||||
|
|
||||||
import { program, Option } from 'commander';
|
import { program, Option } from 'commander';
|
||||||
import * as mcpServer from './mcp/server.js';
|
import * as mcpServer from './mcp/server.js';
|
||||||
import * as mcpTransport from './mcp/transport.js';
|
|
||||||
import { commaSeparatedList, resolveCLIConfig, semicolonSeparatedList } from './config.js';
|
import { commaSeparatedList, resolveCLIConfig, semicolonSeparatedList } from './config.js';
|
||||||
import { packageJSON } from './utils/package.js';
|
import { packageJSON } from './utils/package.js';
|
||||||
import { Context } from './context.js';
|
import { Context } from './context.js';
|
||||||
@@ -25,13 +24,10 @@ import { runLoopTools } from './loopTools/main.js';
|
|||||||
import { ProxyBackend } from './mcp/proxyBackend.js';
|
import { ProxyBackend } from './mcp/proxyBackend.js';
|
||||||
import { BrowserServerBackend } from './browserServerBackend.js';
|
import { BrowserServerBackend } from './browserServerBackend.js';
|
||||||
import { ExtensionContextFactory } from './extension/extensionContextFactory.js';
|
import { ExtensionContextFactory } from './extension/extensionContextFactory.js';
|
||||||
import { InProcessTransport } from './mcp/inProcessTransport.js';
|
|
||||||
|
|
||||||
import { VSCodeMCPFactory } from './vscode/host.js';
|
import { VSCodeMCPFactory } from './vscode/host.js';
|
||||||
import { VSCodeProxyBackend } from './vscode/proxyBackend.js';
|
import { VSCodeProxyBackend } from './vscode/proxyBackend.js';
|
||||||
import type { MCPProvider } from './mcp/proxyBackend.js';
|
import type { MCPProvider } from './mcp/proxyBackend.js';
|
||||||
import type { FullConfig } from './config.js';
|
|
||||||
import type { BrowserContextFactory } from './browserContextFactory.js';
|
|
||||||
|
|
||||||
program
|
program
|
||||||
.version('Version ' + packageJSON.version)
|
.version('Version ' + packageJSON.version)
|
||||||
@@ -45,6 +41,7 @@ program
|
|||||||
.option('--config <path>', 'path to the configuration file.')
|
.option('--config <path>', 'path to the configuration file.')
|
||||||
.option('--device <device>', 'device to emulate, for example: "iPhone 15"')
|
.option('--device <device>', 'device to emulate, for example: "iPhone 15"')
|
||||||
.option('--executable-path <path>', 'path to the browser executable.')
|
.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('--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('--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')
|
.option('--ignore-https-errors', 'ignore https errors')
|
||||||
@@ -61,7 +58,6 @@ program
|
|||||||
.option('--user-agent <ua string>', 'specify user agent string')
|
.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('--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"')
|
.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('--connect-tool', 'Allow to switch between different browser connection methods.').hideHelp())
|
||||||
.addOption(new Option('--vscode', 'VS Code tools.').hideHelp())
|
.addOption(new Option('--vscode', 'VS Code tools.').hideHelp())
|
||||||
.addOption(new Option('--loop-tools', 'Run loop tools').hideHelp())
|
.addOption(new Option('--loop-tools', 'Run loop tools').hideHelp())
|
||||||
@@ -74,12 +70,19 @@ program
|
|||||||
console.error('The --vision option is deprecated, use --caps=vision instead');
|
console.error('The --vision option is deprecated, use --caps=vision instead');
|
||||||
options.caps = 'vision';
|
options.caps = 'vision';
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = await resolveCLIConfig(options);
|
const config = await resolveCLIConfig(options);
|
||||||
|
const browserContextFactory = contextFactory(config);
|
||||||
|
const extensionContextFactory = new ExtensionContextFactory(config.browser.launchOptions.channel || 'chrome', config.browser.userDataDir);
|
||||||
|
|
||||||
if (options.extension) {
|
if (options.extension) {
|
||||||
const contextFactory = createExtensionContextFactory(config);
|
const serverBackendFactory: mcpServer.ServerBackendFactory = {
|
||||||
const serverBackendFactory = () => new BrowserServerBackend(config, contextFactory);
|
name: 'Playwright w/ extension',
|
||||||
await mcpTransport.start(serverBackendFactory, config.server);
|
nameInConfig: 'playwright-extension',
|
||||||
|
version: packageJSON.version,
|
||||||
|
create: () => new BrowserServerBackend(config, extensionContextFactory)
|
||||||
|
};
|
||||||
|
await mcpServer.start(serverBackendFactory, config.server);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,22 +91,36 @@ program
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.vscode) {
|
if (options.connectTool) {
|
||||||
const browserContextFactory = contextFactory(config);
|
const providers: MCPProvider[] = [
|
||||||
const vscodeBackendFactory = () => new VSCodeProxyBackend(config, browserContextFactory);
|
{
|
||||||
await mcpTransport.start(vscodeBackendFactory, config.server);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const browserContextFactory = contextFactory(config);
|
const factory: mcpServer.ServerBackendFactory = {
|
||||||
const providers: MCPProvider[] = [mcpProviderForBrowserContextFactory(config, browserContextFactory)];
|
name: 'Playwright',
|
||||||
if (options.connectTool) {
|
nameInConfig: 'playwright',
|
||||||
providers.push(
|
version: packageJSON.version,
|
||||||
mcpProviderForBrowserContextFactory(config, createExtensionContextFactory(config)),
|
create: () => new BrowserServerBackend(config, browserContextFactory)
|
||||||
new VSCodeMCPFactory(config), // TODO: automatically add this based on the client name
|
};
|
||||||
);
|
await mcpServer.start(factory, config.server);
|
||||||
}
|
|
||||||
await mcpTransport.start(() => new ProxyBackend(providers), config.server);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function setupExitWatchdog() {
|
function setupExitWatchdog() {
|
||||||
@@ -122,19 +139,4 @@ function setupExitWatchdog() {
|
|||||||
process.on('SIGTERM', handleExit);
|
process.on('SIGTERM', handleExit);
|
||||||
}
|
}
|
||||||
|
|
||||||
function createExtensionContextFactory(config: FullConfig) {
|
|
||||||
return new ExtensionContextFactory(config.browser.launchOptions.channel || 'chrome', config.browser.userDataDir);
|
|
||||||
}
|
|
||||||
|
|
||||||
function mcpProviderForBrowserContextFactory(config: FullConfig, browserContextFactory: BrowserContextFactory) {
|
|
||||||
return {
|
|
||||||
name: browserContextFactory.name,
|
|
||||||
description: browserContextFactory.description,
|
|
||||||
connect: async () => {
|
|
||||||
const server = mcpServer.createServer(new BrowserServerBackend(config, browserContextFactory), false);
|
|
||||||
return new InProcessTransport(server);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
void program.parseAsync(process.argv);
|
void program.parseAsync(process.argv);
|
||||||
|
|||||||
@@ -1,44 +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 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}`;
|
|
||||||
}
|
|
||||||
@@ -40,15 +40,18 @@ type CDPServer = {
|
|||||||
start: () => Promise<BrowserContext>;
|
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 = {
|
type TestFixtures = {
|
||||||
client: Client;
|
client: Client;
|
||||||
startClient: (options?: {
|
startClient: StartClient;
|
||||||
clientName?: string,
|
|
||||||
args?: string[],
|
|
||||||
config?: Config,
|
|
||||||
roots?: { name: string, uri: string }[],
|
|
||||||
rootsResponseDelay?: number,
|
|
||||||
}) => Promise<{ client: Client, stderr: () => string }>;
|
|
||||||
wsEndpoint: string;
|
wsEndpoint: string;
|
||||||
cdpServer: CDPServer;
|
cdpServer: CDPServer;
|
||||||
server: TestServer;
|
server: TestServer;
|
||||||
@@ -69,7 +72,7 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
|
|||||||
|
|
||||||
startClient: async ({ mcpHeadless, mcpBrowser, mcpMode }, use, testInfo) => {
|
startClient: async ({ mcpHeadless, mcpBrowser, mcpMode }, use, testInfo) => {
|
||||||
const configDir = path.dirname(test.info().config.configFile!);
|
const configDir = path.dirname(test.info().config.configFile!);
|
||||||
let client: Client | undefined;
|
const clients: Client[] = [];
|
||||||
|
|
||||||
await use(async options => {
|
await use(async options => {
|
||||||
const args: string[] = [];
|
const args: string[] = [];
|
||||||
@@ -87,7 +90,7 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
|
|||||||
args.push(`--config=${path.relative(configDir, configFile)}`);
|
args.push(`--config=${path.relative(configDir, configFile)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
client = new Client({ name: options?.clientName ?? 'test', version: '1.0.0' }, options?.roots ? { capabilities: { roots: {} } } : undefined);
|
const client = new Client({ name: options?.clientName ?? 'test', version: '1.0.0' }, options?.roots ? { capabilities: { roots: {} } } : undefined);
|
||||||
if (options?.roots) {
|
if (options?.roots) {
|
||||||
client.setRequestHandler(ListRootsRequestSchema, async request => {
|
client.setRequestHandler(ListRootsRequestSchema, async request => {
|
||||||
if (options.rootsResponseDelay)
|
if (options.rootsResponseDelay)
|
||||||
@@ -104,12 +107,13 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
|
|||||||
process.stderr.write(data);
|
process.stderr.write(data);
|
||||||
stderrBuffer += data.toString();
|
stderrBuffer += data.toString();
|
||||||
});
|
});
|
||||||
|
clients.push(client);
|
||||||
await client.connect(transport);
|
await client.connect(transport);
|
||||||
await client.ping();
|
await client.ping();
|
||||||
return { client, stderr: () => stderrBuffer };
|
return { client, stderr: () => stderrBuffer };
|
||||||
});
|
});
|
||||||
|
|
||||||
await client?.close();
|
await Promise.all(clients.map(client => client.close()));
|
||||||
},
|
},
|
||||||
|
|
||||||
wsEndpoint: async ({ }, use) => {
|
wsEndpoint: async ({ }, use) => {
|
||||||
@@ -126,6 +130,8 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
|
|||||||
await use({
|
await use({
|
||||||
endpoint: `http://localhost:${port}`,
|
endpoint: `http://localhost:${port}`,
|
||||||
start: async () => {
|
start: async () => {
|
||||||
|
if (browserContext)
|
||||||
|
throw new Error('CDP server already exists');
|
||||||
browserContext = await chromium.launchPersistentContext(testInfo.outputPath('cdp-user-data-dir'), {
|
browserContext = await chromium.launchPersistentContext(testInfo.outputPath('cdp-user-data-dir'), {
|
||||||
channel: mcpBrowser,
|
channel: mcpBrowser,
|
||||||
headless: true,
|
headless: true,
|
||||||
|
|||||||
@@ -23,65 +23,57 @@ import { createHash } from '../src/utils/guid.js';
|
|||||||
|
|
||||||
const p = process.platform === 'win32' ? 'c:\\non\\existent\\folder' : '/non/existent/folder';
|
const p = process.platform === 'win32' ? 'c:\\non\\existent\\folder' : '/non/existent/folder';
|
||||||
|
|
||||||
for (const mode of ['default', 'proxy']) {
|
test('should use separate user data by root path', async ({ startClient, server }, testInfo) => {
|
||||||
const extraArgs = mode === 'proxy' ? ['--connect-tool'] : [];
|
const { client } = await startClient({
|
||||||
|
clientName: 'Visual Studio Code',
|
||||||
test.describe(`${mode} mode`, () => {
|
roots: [
|
||||||
test('should use separate user data by root path', async ({ startClient, server }, testInfo) => {
|
{
|
||||||
const { client } = await startClient({
|
name: 'test',
|
||||||
args: extraArgs,
|
uri: 'file://' + p.replace(/\\/g, '/'),
|
||||||
clientName: 'Visual Studio Code', // Simulate VS Code client, roots only work with it
|
}
|
||||||
roots: [
|
],
|
||||||
{
|
|
||||||
name: 'test',
|
|
||||||
uri: 'file://' + p.replace(/\\/g, '/'),
|
|
||||||
}
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
await client.callTool({
|
|
||||||
name: 'browser_navigate',
|
|
||||||
arguments: { url: server.HELLO_WORLD },
|
|
||||||
});
|
|
||||||
|
|
||||||
const hash = createHash(p);
|
|
||||||
const [file] = await fs.promises.readdir(testInfo.outputPath('ms-playwright'));
|
|
||||||
expect(file).toContain(hash);
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
test('check that trace is saved in workspace', async ({ startClient, server }, testInfo) => {
|
|
||||||
const rootPath = testInfo.outputPath('workspace');
|
|
||||||
const { client } = await startClient({
|
|
||||||
args: ['--save-trace', ...extraArgs],
|
|
||||||
clientName: 'Visual Studio Code - Insiders', // Simulate VS Code client, roots only work with it
|
|
||||||
roots: [
|
|
||||||
{
|
|
||||||
name: 'workspace',
|
|
||||||
uri: pathToFileURL(rootPath).toString(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(await client.callTool({
|
|
||||||
name: 'browser_navigate',
|
|
||||||
arguments: { url: server.HELLO_WORLD },
|
|
||||||
})).toHaveResponse({
|
|
||||||
code: expect.stringContaining(`page.goto('http://localhost`),
|
|
||||||
});
|
|
||||||
|
|
||||||
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, server }, testInfo) => {
|
|
||||||
const { client } = await startClient({
|
|
||||||
clientName: 'Visual Studio Code', // Simulate VS Code client, roots only work with it
|
|
||||||
roots: [],
|
|
||||||
rootsResponseDelay: 1000,
|
|
||||||
});
|
|
||||||
const tools = await client.listTools();
|
|
||||||
expect(tools.tools.length).toBeGreaterThan(20);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.HELLO_WORLD },
|
||||||
|
});
|
||||||
|
|
||||||
|
const hash = createHash(p);
|
||||||
|
const [file] = await fs.promises.readdir(testInfo.outputPath('ms-playwright'));
|
||||||
|
expect(file).toContain(hash);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('check that trace is saved in workspace', async ({ startClient, server }, testInfo) => {
|
||||||
|
const rootPath = testInfo.outputPath('workspace');
|
||||||
|
const { client } = await startClient({
|
||||||
|
args: ['--save-trace'],
|
||||||
|
clientName: 'My client',
|
||||||
|
roots: [
|
||||||
|
{
|
||||||
|
name: 'workspace',
|
||||||
|
uri: pathToFileURL(rootPath).toString(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.HELLO_WORLD },
|
||||||
|
})).toHaveResponse({
|
||||||
|
code: expect.stringContaining(`page.goto('http://localhost`),
|
||||||
|
});
|
||||||
|
|
||||||
|
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, server }, testInfo) => {
|
||||||
|
const { client } = await startClient({
|
||||||
|
clientName: 'Another custom client',
|
||||||
|
roots: [],
|
||||||
|
rootsResponseDelay: 1000,
|
||||||
|
});
|
||||||
|
const tools = await client.listTools();
|
||||||
|
expect(tools.tools.length).toBeGreaterThan(20);
|
||||||
|
});
|
||||||
|
|||||||
68
utils/set-version.js
Normal file
68
utils/set-version.js
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
#!/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