5 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
13257ce625 Fix streamable HTTP transport connection cleanup
Co-authored-by: pavelfeldman <883973+pavelfeldman@users.noreply.github.com>
2025-07-18 20:43:59 +00:00
copilot-swe-agent[bot]
046958e7d9 Migrate tests from /sse to /mcp endpoint and rename file
- Renamed sse.spec.ts to http.spec.ts with git mv to preserve history
- Updated most tests to use StreamableHTTPClientTransport with /mcp endpoint instead of SSEClientTransport with /sse endpoint
- Changed existing "streamable http transport" test to "sse transport" test using /sse endpoint
- Updated test names from "sse transport" to "http transport" to reflect new default
- Removed SSE session-specific debug log expectations from tests since streamable HTTP doesn't use SSE sessions
- This provides better test coverage for the new default /mcp endpoint while maintaining legacy /sse testing

Co-authored-by: pavelfeldman <883973+pavelfeldman@users.noreply.github.com>
2025-07-18 18:24:59 +00:00
copilot-swe-agent[bot]
8d7f1fa231 Fix routing to make /mcp default transport
Co-authored-by: pavelfeldman <883973+pavelfeldman@users.noreply.github.com>
2025-07-18 18:09:12 +00:00
copilot-swe-agent[bot]
9ca9e82006 Migrate to /mcp streamable transport by default
Co-authored-by: pavelfeldman <883973+pavelfeldman@users.noreply.github.com>
2025-07-18 16:04:59 +00:00
copilot-swe-agent[bot]
3b9397dc80 Initial plan 2025-07-18 15:53:13 +00:00
82 changed files with 1413 additions and 3701 deletions

View File

@@ -1,44 +0,0 @@
name: "Copilot Setup Steps"
# Automatically run the setup steps when they are changed to allow for easy validation, and
# allow manual testing through the repository's "Actions" tab
on:
workflow_dispatch:
push:
paths:
- .github/workflows/copilot-setup-steps.yml
pull_request:
paths:
- .github/workflows/copilot-setup-steps.yml
jobs:
# The job MUST be called `copilot-setup-steps` or it will not be picked up by Copilot.
copilot-setup-steps:
runs-on: ubuntu-latest
# Set the permissions to the lowest permissions possible needed for your steps.
# Copilot will be given its own token for its operations.
permissions:
# If you want to clone the repository as part of your setup steps, for example to install dependencies, you'll need the `contents: read` permission. If you don't clone the repository in your setup steps, Copilot will do this for you automatically after the steps complete.
contents: read
# You can define any steps you want, and they will run before the agent starts.
# If you do not check out your code, Copilot will do this for you.
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "18.19"
cache: "npm"
- name: Install JavaScript dependencies
run: npm ci
- name: Playwright install
run: npx playwright install --with-deps
- name: Build
run: npm run build

3
.gitignore vendored
View File

@@ -3,7 +3,6 @@ node_modules/
test-results/
playwright-report/
.vscode/mcp.json
.idea
.DS_Store
.env
sessions/

View File

@@ -88,18 +88,6 @@ Follow the MCP install [guide](https://github.com/google-gemini/gemini-cli/blob/
Go to `Advanced settings` -> `Extensions` -> `Add custom extension`. Name to your liking, use type `STDIO`, and set the `command` to `npx @playwright/mcp`. Click "Add Extension".
</details>
<details>
<summary>LM Studio</summary>
#### Click the button to install:
[![Add MCP Server playwright to LM Studio](https://files.lmstudio.ai/deeplink/mcp-install-light.svg)](https://lmstudio.ai/install-mcp?name=playwright&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyJAcGxheXdyaWdodC9tY3BAbGF0ZXN0Il19)
#### Or install manually:
Go to `Program` in the right sidebar -> `Install` -> `Edit mcp.json`. Use the standard config above.
</details>
<details>
<summary>Qodo Gen</summary>
@@ -111,13 +99,7 @@ Click <code>Save</code>.
<details>
<summary>VS Code</summary>
#### Click the button to install:
[<img src="https://img.shields.io/badge/VS_Code-VS_Code?style=flat-square&label=Install%20Server&color=0098FF" alt="Install in VS Code">](https://insiders.vscode.dev/redirect?url=vscode%3Amcp%2Finstall%3F%257B%2522name%2522%253A%2522playwright%2522%252C%2522command%2522%253A%2522npx%2522%252C%2522args%2522%253A%255B%2522%2540playwright%252Fmcp%2540latest%2522%255D%257D) [<img alt="Install in VS Code Insiders" src="https://img.shields.io/badge/VS_Code_Insiders-VS_Code_Insiders?style=flat-square&label=Install%20Server&color=24bfa5">](https://insiders.vscode.dev/redirect?url=vscode-insiders%3Amcp%2Finstall%3F%257B%2522name%2522%253A%2522playwright%2522%252C%2522command%2522%253A%2522npx%2522%252C%2522args%2522%253A%255B%2522%2540playwright%252Fmcp%2540latest%2522%255D%257D)
#### Or install manually:
Follow the MCP install [guide](https://code.visualstudio.com/docs/copilot/chat/mcp-servers#_add-an-mcp-server), use the standard config above. You can also install the Playwright MCP server using the VS Code CLI:
You can also install the Playwright MCP server using the VS Code CLI:
```bash
# For VS Code
@@ -174,8 +156,6 @@ Playwright MCP server supports following arguments. They can be provided in the
example ".com,chromium.org,.domain.com"
--proxy-server <proxy> specify proxy server, for example
"http://myproxy:3128" or "socks5://myproxy:8080"
--save-session Whether to save the Playwright MCP session into
the output directory.
--save-trace Whether to save the Playwright Trace of the
session into the output directory.
--storage-state <path> path to the storage state file for isolated
@@ -323,7 +303,7 @@ run the MCP server from environment with the DISPLAY and pass the `--port` flag
npx @playwright/mcp@latest --port 8931
```
And then in MCP client config, set the `url` to the HTTP endpoint:
And then in MCP client config, set the `url` to the MCP endpoint:
```js
{
@@ -335,6 +315,8 @@ And then in MCP client config, set the `url` to the HTTP endpoint:
}
```
For legacy SSE transport support, you can use `/sse` instead of `/mcp` in the URL.
<details>
<summary><b>Docker</b></summary>
@@ -548,7 +530,6 @@ http.createServer(async (req, res) => {
- `filename` (string, optional): File name to save the screenshot to. Defaults to `page-{timestamp}.{png|jpeg}` if not specified.
- `element` (string, optional): Human-readable element description used to obtain permission to screenshot the element. If not provided, the screenshot will be taken of viewport. If element is provided, ref must be provided too.
- `ref` (string, optional): Exact target element reference from the page snapshot. If not provided, the screenshot will be taken of viewport. If ref is provided, element must be provided too.
- `fullPage` (boolean, optional): When true, takes a screenshot of the full scrollable page, instead of the currently visible viewport. Cannot be used with element screenshots.
- Read-only: **true**
<!-- NOTE: This has been generated via update-readme.js -->

5
config.d.ts vendored
View File

@@ -85,11 +85,6 @@ export type Config = {
*/
capabilities?: ToolCapability[];
/**
* Whether to save the Playwright session into the output directory.
*/
saveSession?: boolean;
/**
* Whether to save the Playwright trace of the session into the output directory.
*/

View File

@@ -192,31 +192,6 @@ const languageOptions = {
}
};
const importOrderRules = {
"import/order": [
2,
{
groups: [
"builtin",
"external",
"internal",
["parent", "sibling"],
"index",
"type",
],
},
],
"import/consistent-type-specifier-style": [2, "prefer-top-level"],
};
const noFloatingPromisesRules = {
"@typescript-eslint/no-floating-promises": "error",
};
const noBooleanCompareRules = {
"@typescript-eslint/no-unnecessary-boolean-literal-compare": 2,
};
export default [
{
ignores: ["**/*.js"],
@@ -225,11 +200,6 @@ export default [
files: ["**/*.ts", "**/*.tsx"],
plugins,
languageOptions,
rules: {
...baseRules,
...importOrderRules,
...noFloatingPromisesRules,
...noBooleanCompareRules,
},
rules: baseRules,
},
];

View File

@@ -1,34 +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.
-->
<!DOCTYPE html>
<html>
<head>
<title>Playwright MCP extension</title>
</head>
<body>
<h3>Playwright MCP extension</h3>
<div id="status-container"></div>
<div class="button-row">
<button id="continue-btn">Continue</button>
<button id="reject-btn">Reject</button>
</div>
<div id="tab-list-container">
<h4>Select page to expose to MCP server:</h4>
<div id="tab-list"></div>
</div>
<script src="lib/connect.js"></script>
</body>
</html>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 571 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

View File

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

View File

@@ -1,124 +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 { RelayConnection, debugLog } from './relayConnection.js';
type PageMessage = {
type: 'connectToMCPRelay';
mcpRelayUrl: string;
tabId: number;
windowId: number;
} | {
type: 'getTabs';
};
class TabShareExtension {
private _activeConnection: RelayConnection | undefined;
private _connectedTabId: number | null = null;
constructor() {
chrome.tabs.onRemoved.addListener(this._onTabRemoved.bind(this));
chrome.tabs.onUpdated.addListener(this._onTabUpdated.bind(this));
chrome.runtime.onMessage.addListener(this._onMessage.bind(this));
}
// Promise-based message handling is not supported in Chrome: https://issues.chromium.org/issues/40753031
private _onMessage(message: PageMessage, sender: chrome.runtime.MessageSender, sendResponse: (response: any) => void) {
switch (message.type) {
case 'connectToMCPRelay':
this._connectTab(message.tabId, message.windowId, message.mcpRelayUrl!).then(
() => sendResponse({ success: true }),
(error: any) => sendResponse({ success: false, error: error.message }));
return true; // Return true to indicate that the response will be sent asynchronously
case 'getTabs':
this._getTabs().then(
tabs => sendResponse({ success: true, tabs, currentTabId: sender.tab?.id }),
(error: any) => sendResponse({ success: false, error: error.message }));
return true;
}
return false;
}
private async _connectTab(tabId: number, windowId: number, mcpRelayUrl: string): Promise<void> {
try {
debugLog(`Connecting tab ${tabId} to bridge at ${mcpRelayUrl}`);
const socket = new WebSocket(mcpRelayUrl);
await new Promise<void>((resolve, reject) => {
socket.onopen = () => resolve();
socket.onerror = () => reject(new Error('WebSocket error'));
setTimeout(() => reject(new Error('Connection timeout')), 5000);
});
const connection = new RelayConnection(socket, tabId);
const connectionClosed = (m: string) => {
debugLog(m);
if (this._activeConnection === connection) {
this._activeConnection = undefined;
void this._setConnectedTabId(null);
}
};
socket.onclose = () => connectionClosed('WebSocket closed');
socket.onerror = error => connectionClosed(`WebSocket error: ${error}`);
this._activeConnection = connection;
await Promise.all([
this._setConnectedTabId(tabId),
chrome.tabs.update(tabId, { active: true }),
chrome.windows.update(windowId, { focused: true }),
]);
debugLog(`Connected to MCP bridge`);
} catch (error: any) {
await this._setConnectedTabId(null);
debugLog(`Failed to connect tab ${tabId}:`, error.message);
throw error;
}
}
private async _setConnectedTabId(tabId: number | null): Promise<void> {
const oldTabId = this._connectedTabId;
this._connectedTabId = tabId;
if (oldTabId && oldTabId !== tabId)
await this._updateBadge(oldTabId, { text: '', color: null });
if (tabId)
await this._updateBadge(tabId, { text: '●', color: '#4CAF50' });
}
private async _updateBadge(tabId: number, { text, color }: { text: string; color: string | null }): Promise<void> {
await chrome.action.setBadgeText({ tabId, text });
if (color)
await chrome.action.setBadgeBackgroundColor({ tabId, color });
}
private async _onTabRemoved(tabId: number): Promise<void> {
if (this._connectedTabId !== tabId)
return;
this._activeConnection?.close('Browser tab closed');
this._activeConnection = undefined;
this._connectedTabId = null;
}
private async _onTabUpdated(tabId: number, changeInfo: chrome.tabs.TabChangeInfo, tab: chrome.tabs.Tab): Promise<void> {
if (changeInfo.status === 'complete' && this._connectedTabId === tabId)
await this._setConnectedTabId(tabId);
}
private async _getTabs(): Promise<chrome.tabs.Tab[]> {
const tabs = await chrome.tabs.query({});
return tabs;
}
}
new TabShareExtension();

View File

@@ -1,172 +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.
*/
interface TabInfo {
id: number;
windowId: number;
title: string;
url: string;
favIconUrl?: string;
}
class ConnectPage {
private _tabList: HTMLElement;
private _tabListContainer: HTMLElement;
private _statusContainer: HTMLElement;
private _selectedTab: TabInfo | undefined;
constructor() {
this._tabList = document.getElementById('tab-list')!;
this._tabListContainer = document.getElementById('tab-list-container')!;
this._statusContainer = document.getElementById('status-container') as HTMLElement;
this._addButtonHandlers();
void this._loadTabs();
}
private _addButtonHandlers() {
const continueBtn = document.getElementById('continue-btn') as HTMLButtonElement;
const rejectBtn = document.getElementById('reject-btn') as HTMLButtonElement;
const buttonRow = document.querySelector('.button-row') as HTMLElement;
const params = new URLSearchParams(window.location.search);
const mcpRelayUrl = params.get('mcpRelayUrl');
if (!mcpRelayUrl) {
buttonRow.style.display = 'none';
this._showStatus('error', 'Missing mcpRelayUrl parameter in URL.');
return;
}
let clientInfo = 'unknown';
try {
const client = JSON.parse(params.get('client') || '{}');
clientInfo = `${client.name}/${client.version}`;
} catch (e) {
this._showStatus('error', 'Failed to parse client version.');
return;
}
this._showStatus('connecting', `MCP client "${clientInfo}" is trying to connect. Do you want to continue?`);
rejectBtn.addEventListener('click', async () => {
buttonRow.style.display = 'none';
this._tabListContainer.style.display = 'none';
this._showStatus('error', 'Connection rejected. This tab can be closed.');
});
continueBtn.addEventListener('click', async () => {
buttonRow.style.display = 'none';
this._tabListContainer.style.display = 'none';
try {
const selectedTab = this._selectedTab;
if (!selectedTab) {
this._showStatus('error', 'Tab not selected.');
return;
}
const response = await chrome.runtime.sendMessage({
type: 'connectToMCPRelay',
mcpRelayUrl,
tabId: selectedTab.id,
windowId: selectedTab.windowId,
});
if (response?.success)
this._showStatus('connected', `MCP client "${clientInfo}" connected.`);
else
this._showStatus('error', response?.error || `MCP client "${clientInfo}" failed to connect.`);
} catch (e) {
this._showStatus('error', `MCP client "${clientInfo}" failed to connect: ${e}`);
}
});
}
private async _loadTabs(): Promise<void> {
try {
const response = await chrome.runtime.sendMessage({ type: 'getTabs' });
if (response.success)
this._populateTabList(response.tabs, response.currentTabId);
else
this._showStatus('error', 'Failed to load tabs: ' + response.error);
} catch (error) {
this._showStatus('error', 'Failed to communicate with background script: ' + error);
}
}
private _populateTabList(tabs: TabInfo[], currentTabId: number): void {
this._tabList.replaceChildren();
this._selectedTab = tabs.find(tab => tab.id === currentTabId);
tabs.forEach((tab, index) => {
const tabElement = this._createTabElement(tab);
this._tabList.appendChild(tabElement);
});
}
private _createTabElement(tab: TabInfo): HTMLElement {
const disabled = tab.url.startsWith('chrome://');
const tabInfoDiv = document.createElement('div');
tabInfoDiv.className = 'tab-info';
tabInfoDiv.style.padding = '5px';
if (disabled)
tabInfoDiv.style.opacity = '0.5';
const radioButton = document.createElement('input');
radioButton.type = 'radio';
radioButton.name = 'tab-selection';
radioButton.checked = tab.id === this._selectedTab?.id;
radioButton.id = `tab-${tab.id}`;
radioButton.addEventListener('change', e => {
if (radioButton.checked)
this._selectedTab = tab;
});
if (disabled)
radioButton.disabled = true;
const favicon = document.createElement('img');
favicon.className = 'tab-favicon';
if (tab.favIconUrl)
favicon.src = tab.favIconUrl;
favicon.alt = '';
favicon.style.height = '16px';
favicon.style.width = '16px';
const title = document.createElement('span');
title.style.paddingLeft = '5px';
title.className = 'tab-title';
title.textContent = tab.title || 'Untitled';
const url = document.createElement('span');
url.style.paddingLeft = '5px';
url.className = 'tab-url';
url.textContent = tab.url;
tabInfoDiv.appendChild(radioButton);
tabInfoDiv.appendChild(favicon);
tabInfoDiv.appendChild(title);
tabInfoDiv.appendChild(url);
return tabInfoDiv;
}
private _showStatus(type: 'connected' | 'error' | 'connecting', message: string) {
const div = document.createElement('div');
div.className = `status ${type}`;
div.textContent = message;
this._statusContainer.replaceChildren(div);
}
}
new ConnectPage();

View File

@@ -1,166 +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.
*/
export function debugLog(...args: unknown[]): void {
const enabled = true;
if (enabled) {
// eslint-disable-next-line no-console
console.log('[Extension]', ...args);
}
}
type ProtocolCommand = {
id: number;
method: string;
params?: any;
};
type ProtocolResponse = {
id?: number;
method?: string;
params?: any;
result?: any;
error?: string;
};
export class RelayConnection {
private _debuggee: chrome.debugger.Debuggee;
private _ws: WebSocket;
private _eventListener: (source: chrome.debugger.DebuggerSession, method: string, params: any) => void;
private _detachListener: (source: chrome.debugger.Debuggee, reason: string) => void;
constructor(ws: WebSocket, tabId: number) {
this._debuggee = { tabId };
this._ws = ws;
this._ws.onmessage = this._onMessage.bind(this);
// Store listeners for cleanup
this._eventListener = this._onDebuggerEvent.bind(this);
this._detachListener = this._onDebuggerDetach.bind(this);
chrome.debugger.onEvent.addListener(this._eventListener);
chrome.debugger.onDetach.addListener(this._detachListener);
}
close(message: string): void {
chrome.debugger.onEvent.removeListener(this._eventListener);
chrome.debugger.onDetach.removeListener(this._detachListener);
this._ws.close(1000, message);
}
private async _detachDebugger(): Promise<void> {
await chrome.debugger.detach(this._debuggee);
}
private _onDebuggerEvent(source: chrome.debugger.DebuggerSession, method: string, params: any): void {
if (source.tabId !== this._debuggee.tabId)
return;
debugLog('Forwarding CDP event:', method, params);
const sessionId = source.sessionId;
this._sendMessage({
method: 'forwardCDPEvent',
params: {
sessionId,
method,
params,
},
});
}
private _onDebuggerDetach(source: chrome.debugger.Debuggee, reason: string): void {
if (source.tabId !== this._debuggee.tabId)
return;
this._sendMessage({
method: 'detachedFromTab',
params: {
tabId: this._debuggee.tabId,
reason,
},
});
this._debuggee = { };
}
private _onMessage(event: MessageEvent): void {
this._onMessageAsync(event).catch(e => debugLog('Error handling message:', e));
}
private async _onMessageAsync(event: MessageEvent): Promise<void> {
let message: ProtocolCommand;
try {
message = JSON.parse(event.data);
} catch (error: any) {
debugLog('Error parsing message:', error);
this._sendError(-32700, `Error parsing message: ${error.message}`);
return;
}
debugLog('Received message:', message);
const response: ProtocolResponse = {
id: message.id,
};
try {
response.result = await this._handleCommand(message);
} catch (error: any) {
debugLog('Error handling command:', error);
response.error = error.message;
}
debugLog('Sending response:', response);
this._sendMessage(response);
}
private async _handleCommand(message: ProtocolCommand): Promise<any> {
if (!this._debuggee.tabId)
throw new Error('No tab is connected. Please go to the Playwright MCP extension and select the tab you want to connect to.');
if (message.method === 'attachToTab') {
debugLog('Attaching debugger to tab:', this._debuggee);
await chrome.debugger.attach(this._debuggee, '1.3');
const result: any = await chrome.debugger.sendCommand(this._debuggee, 'Target.getTargetInfo');
return {
targetInfo: result?.targetInfo,
};
}
if (message.method === 'detachFromTab') {
debugLog('Detaching debugger from tab:', this._debuggee);
return await this._detachDebugger();
}
if (message.method === 'forwardCDPCommand') {
const { sessionId, method, params } = message.params;
debugLog('CDP command:', method, params);
const debuggerSession: chrome.debugger.DebuggerSession = {
...this._debuggee,
sessionId,
};
// Forward CDP command to chrome.debugger
return await chrome.debugger.sendCommand(
debuggerSession,
method,
params
);
}
}
private _sendError(code: number, message: string): void {
this._sendMessage({
error: {
code,
message,
},
});
}
private _sendMessage(message: any): void {
this._ws.send(JSON.stringify(message));
}
}

View File

@@ -1,15 +0,0 @@
{
"compilerOptions": {
"target": "ESNext",
"esModuleInterop": true,
"moduleResolution": "node",
"strict": true,
"module": "ESNext",
"rootDir": "src",
"outDir": "./lib",
"resolveJsonModule": true,
},
"include": [
"src",
],
}

7
index.d.ts vendored
View File

@@ -19,5 +19,10 @@ import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
import type { Config } from './config.js';
import type { BrowserContext } from 'playwright';
export declare function createConnection(config?: Config, contextGetter?: () => Promise<BrowserContext>): Promise<Server>;
export type Connection = {
server: Server;
close(): Promise<void>;
};
export declare function createConnection(config?: Config, contextGetter?: () => Promise<BrowserContext>): Promise<Connection>;
export {};

180
package-lock.json generated
View File

@@ -1,18 +1,17 @@
{
"name": "@playwright/mcp",
"version": "0.0.32",
"version": "0.0.31",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@playwright/mcp",
"version": "0.0.32",
"version": "0.0.31",
"license": "Apache-2.0",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.16.0",
"@modelcontextprotocol/sdk": "^1.11.0",
"commander": "^13.1.0",
"debug": "^4.4.1",
"dotenv": "^17.2.0",
"mime": "^4.0.7",
"playwright": "1.55.0-alpha-1752701791000",
"playwright-core": "1.55.0-alpha-1752701791000",
@@ -23,7 +22,6 @@
"mcp-server-playwright": "cli.js"
},
"devDependencies": {
"@anthropic-ai/sdk": "^0.57.0",
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.19.0",
"@playwright/test": "1.55.0-alpha-1752701791000",
@@ -38,23 +36,12 @@
"eslint": "^9.19.0",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-notice": "^1.0.0",
"openai": "^5.10.2",
"typescript": "^5.8.2"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@anthropic-ai/sdk": {
"version": "0.57.0",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.57.0.tgz",
"integrity": "sha512-z5LMy0MWu0+w2hflUgj4RlJr1R+0BxKXL7ldXTO8FasU8fu599STghO+QKwId2dAD0d464aHtU+ChWuRHw4FNw==",
"dev": true,
"license": "MIT",
"bin": {
"anthropic-ai-sdk": "bin/cli"
}
},
"node_modules/@eslint-community/eslint-utils": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.5.1.tgz",
@@ -85,9 +72,9 @@
}
},
"node_modules/@eslint/config-array": {
"version": "0.21.0",
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz",
"integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==",
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.19.2.tgz",
"integrity": "sha512-GNKqxfHG2ySmJOBSHg7LxeUx4xpuCoFjacmlCoYWEbaPXLwvfIjixRI12xCQZeULksQb23uiA8F40w5TojpV7w==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
@@ -100,9 +87,9 @@
}
},
"node_modules/@eslint/config-helpers": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.0.tgz",
"integrity": "sha512-ViuymvFmcJi04qdZeDc2whTHryouGcDlaxPqarTD0ZE10ISpxGUVZGZDx4w01upyIynL3iu6IXH2bS1NhclQMw==",
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.1.0.tgz",
"integrity": "sha512-kLrdPDJE1ckPo94kmPPf9Hfd0DU0Jw6oKYrhe+pwSC0iTUInmTa+w6fw8sGgcfkFJGNdWOUeOaDM4quW4a7OkA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
@@ -110,9 +97,9 @@
}
},
"node_modules/@eslint/core": {
"version": "0.15.1",
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.1.tgz",
"integrity": "sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA==",
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.12.0.tgz",
"integrity": "sha512-cmrR6pytBuSMTaBweKoGMwu3EiHiEC+DoyupPmlZ0HxBJBtIxwe+j/E4XPIKNx+Q74c8lXKPwYawBf5glsTkHg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
@@ -123,9 +110,9 @@
}
},
"node_modules/@eslint/eslintrc": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz",
"integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==",
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.0.tgz",
"integrity": "sha512-yaVPAiNAalnCZedKLdR21GOGILMLKPyqSLWaAjQFvYA2i/ciDi8ArYVr69Anohb6cH2Ukhqti4aFnYyPm8wdwQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -147,16 +134,13 @@
}
},
"node_modules/@eslint/js": {
"version": "9.31.0",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.31.0.tgz",
"integrity": "sha512-LOm5OVt7D4qiKCqoiPbA7LWmI+tbw1VbTUowBcUMgQSuM6poJufkFkYDcQpo5KfgD39TnNySV26QjOh7VFpSyw==",
"version": "9.22.0",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.22.0.tgz",
"integrity": "sha512-vLFajx9o8d1/oL2ZkpMYbkLv8nDB6yaIwFNt7nI4+I80U/z03SxmfOMsLbvWr3p7C+Wnoh//aOu2pQW8cS0HCQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"url": "https://eslint.org/donate"
}
},
"node_modules/@eslint/object-schema": {
@@ -170,13 +154,13 @@
}
},
"node_modules/@eslint/plugin-kit": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.4.tgz",
"integrity": "sha512-Ul5l+lHEcw3L5+k8POx6r74mxEYKG5kOb6Xpy2gCRW6zweT6TEhAf8vhxGgjhqrd/VO/Dirhsb+1hNpD1ue9hw==",
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.7.tgz",
"integrity": "sha512-JubJ5B2pJ4k4yGxaNLdbjrnk9d/iDz6/q8wOilpIowd6PJPgaxCuHBnBszq7Ce2TyMrywm5r4PnKm6V3iiZF+g==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@eslint/core": "^0.15.1",
"@eslint/core": "^0.12.0",
"levn": "^0.4.1"
},
"engines": {
@@ -250,17 +234,15 @@
}
},
"node_modules/@modelcontextprotocol/sdk": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.16.0.tgz",
"integrity": "sha512-8ofX7gkZcLj9H9rSd50mCgm3SSF8C7XoclxJuLoV0Cz3rEQ1tv9MZRYYvJtm9n1BiEQQMzSmE/w2AEkNacLYfg==",
"version": "1.11.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.11.0.tgz",
"integrity": "sha512-k/1pb70eD638anoi0e8wUGAlbMJXyvdV4p62Ko+EZ7eBe1xMx8Uhak1R5DgfoofsK5IBBnRwsYGTaLZl+6/+RQ==",
"license": "MIT",
"dependencies": {
"ajv": "^6.12.6",
"content-type": "^1.0.5",
"cors": "^2.8.5",
"cross-spawn": "^7.0.5",
"cross-spawn": "^7.0.3",
"eventsource": "^3.0.2",
"eventsource-parser": "^3.0.0",
"express": "^5.0.1",
"express-rate-limit": "^7.5.0",
"pkce-challenge": "^5.0.0",
@@ -611,9 +593,9 @@
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -705,9 +687,9 @@
}
},
"node_modules/acorn": {
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"version": "8.14.1",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz",
"integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==",
"dev": true,
"license": "MIT",
"bin": {
@@ -731,6 +713,7 @@
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"dev": true,
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.1",
@@ -940,9 +923,9 @@
}
},
"node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1285,18 +1268,6 @@
"node": ">=0.10.0"
}
},
"node_modules/dotenv": {
"version": "17.2.0",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.0.tgz",
"integrity": "sha512-Q4sgBT60gzd0BB0lSyYD3xM4YxrXA9y4uBDof1JNYGzOXrQdQ6yX+7XIAqoFOGQFOTK1D3Hts5OllpxMDZFONQ==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -1489,20 +1460,20 @@
}
},
"node_modules/eslint": {
"version": "9.31.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.31.0.tgz",
"integrity": "sha512-QldCVh/ztyKJJZLr4jXNUByx3gR+TDYZCRXEktiZoUR3PGy4qCmSbkxcIle8GEwGpb5JBZazlaJ/CxLidXdEbQ==",
"version": "9.22.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.22.0.tgz",
"integrity": "sha512-9V/QURhsRN40xuHXWjV64yvrzMjcz7ZyNoF2jJFmy9j/SLk0u1OLSZgXi28MrXjymnjEGSR80WCdab3RGMDveQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.12.1",
"@eslint/config-array": "^0.21.0",
"@eslint/config-helpers": "^0.3.0",
"@eslint/core": "^0.15.0",
"@eslint/eslintrc": "^3.3.1",
"@eslint/js": "9.31.0",
"@eslint/plugin-kit": "^0.3.1",
"@eslint/config-array": "^0.19.2",
"@eslint/config-helpers": "^0.1.0",
"@eslint/core": "^0.12.0",
"@eslint/eslintrc": "^3.3.0",
"@eslint/js": "9.22.0",
"@eslint/plugin-kit": "^0.2.7",
"@humanfs/node": "^0.16.6",
"@humanwhocodes/module-importer": "^1.0.1",
"@humanwhocodes/retry": "^0.4.2",
@@ -1513,9 +1484,9 @@
"cross-spawn": "^7.0.6",
"debug": "^4.3.2",
"escape-string-regexp": "^4.0.0",
"eslint-scope": "^8.4.0",
"eslint-visitor-keys": "^4.2.1",
"espree": "^10.4.0",
"eslint-scope": "^8.3.0",
"eslint-visitor-keys": "^4.2.0",
"espree": "^10.3.0",
"esquery": "^1.5.0",
"esutils": "^2.0.2",
"fast-deep-equal": "^3.1.3",
@@ -1669,9 +1640,9 @@
}
},
"node_modules/eslint-scope": {
"version": "8.4.0",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz",
"integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==",
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.3.0.tgz",
"integrity": "sha512-pUNxi75F8MJ/GdeKtVLSbYg4ZI34J6C0C7sbL4YOp2exGwen7ZsuBqKzUhXd0qMQ362yET3z+uPwKeg/0C2XCQ==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
@@ -1699,9 +1670,9 @@
}
},
"node_modules/eslint/node_modules/eslint-visitor-keys": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
"integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz",
"integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==",
"dev": true,
"license": "Apache-2.0",
"engines": {
@@ -1712,15 +1683,15 @@
}
},
"node_modules/espree": {
"version": "10.4.0",
"resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
"integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==",
"version": "10.3.0",
"resolved": "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz",
"integrity": "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"acorn": "^8.15.0",
"acorn": "^8.14.0",
"acorn-jsx": "^5.3.2",
"eslint-visitor-keys": "^4.2.1"
"eslint-visitor-keys": "^4.2.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -1730,9 +1701,9 @@
}
},
"node_modules/espree/node_modules/eslint-visitor-keys": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
"integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz",
"integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==",
"dev": true,
"license": "Apache-2.0",
"engines": {
@@ -1879,6 +1850,7 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true,
"license": "MIT"
},
"node_modules/fast-glob": {
@@ -1915,6 +1887,7 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
"integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
"dev": true,
"license": "MIT"
},
"node_modules/fast-levenshtein": {
@@ -2835,6 +2808,7 @@
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
"dev": true,
"license": "MIT"
},
"node_modules/json-stable-stringify-without-jsonify": {
@@ -3179,28 +3153,6 @@
"wrappy": "1"
}
},
"node_modules/openai": {
"version": "5.10.2",
"resolved": "https://registry.npmjs.org/openai/-/openai-5.10.2.tgz",
"integrity": "sha512-n+vi74LzHtvlKcDPn9aApgELGiu5CwhaLG40zxLTlFQdoSJCLACORIPC2uVQ3JEYAbqapM+XyRKFy2Thej7bIw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"openai": "bin/cli"
},
"peerDependencies": {
"ws": "^8.18.0",
"zod": "^3.23.8"
},
"peerDependenciesMeta": {
"ws": {
"optional": true
},
"zod": {
"optional": true
}
}
},
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -3415,6 +3367,7 @@
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -4208,6 +4161,7 @@
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
"integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"punycode": "^2.1.0"

View File

@@ -1,6 +1,6 @@
{
"name": "@playwright/mcp",
"version": "0.0.32",
"version": "0.0.31",
"description": "Playwright Tools for MCP",
"type": "module",
"repository": {
@@ -17,18 +17,15 @@
"license": "Apache-2.0",
"scripts": {
"build": "tsc",
"build:extension": "tsc --project extension",
"lint": "npm run update-readme && eslint . && tsc --noEmit",
"lint-fix": "eslint . --fix",
"update-readme": "node utils/update-readme.js",
"watch": "tsc --watch",
"watch:extension": "tsc --watch --project extension",
"test": "playwright test",
"ctest": "playwright test --project=chrome",
"ftest": "playwright test --project=firefox",
"wtest": "playwright test --project=webkit",
"run-server": "node lib/browserServer.js",
"clean": "rm -rf lib extension/lib",
"clean": "rm -rf lib",
"npm-publish": "npm run clean && npm run build && npm run test && npm publish"
},
"exports": {
@@ -39,10 +36,9 @@
}
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.16.0",
"@modelcontextprotocol/sdk": "^1.11.0",
"commander": "^13.1.0",
"debug": "^4.4.1",
"dotenv": "^17.2.0",
"mime": "^4.0.7",
"playwright": "1.55.0-alpha-1752701791000",
"playwright-core": "1.55.0-alpha-1752701791000",
@@ -50,7 +46,6 @@
"zod-to-json-schema": "^3.24.4"
},
"devDependencies": {
"@anthropic-ai/sdk": "^0.57.0",
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.19.0",
"@playwright/test": "1.55.0-alpha-1752701791000",
@@ -65,7 +60,6 @@
"eslint": "^9.19.0",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-notice": "^1.0.0",
"openai": "^5.10.2",
"typescript": "^5.8.2"
},
"bin": {

View File

@@ -36,7 +36,7 @@ export function contextFactory(browserConfig: FullConfig['browser']): BrowserCon
}
export interface BrowserContextFactory {
createContext(clientInfo: { name: string, version: string }): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }>;
createContext(): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }>;
}
class BaseContextFactory implements BrowserContextFactory {
@@ -217,7 +217,7 @@ async function injectCdpPort(browserConfig: FullConfig['browser']) {
(browserConfig.launchOptions as any).cdpPort = await findFreePort();
}
async function findFreePort(): Promise<number> {
async function findFreePort() {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.listen(0, () => {

View File

@@ -1,69 +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 { FullConfig } from './config.js';
import { Context } from './context.js';
import { logUnhandledError } from './log.js';
import { Response } from './response.js';
import { SessionLog } from './sessionLog.js';
import { filteredTools } from './tools.js';
import { packageJSON } from './package.js';
import type { BrowserContextFactory } from './browserContextFactory.js';
import type * as mcpServer from './mcp/server.js';
import type { ServerBackend } from './mcp/server.js';
import type { Tool } from './tools/tool.js';
export class BrowserServerBackend implements ServerBackend {
name = 'Playwright';
version = packageJSON.version;
onclose?: () => void;
private _tools: Tool[];
private _context: Context;
private _sessionLog: SessionLog | undefined;
constructor(config: FullConfig, browserContextFactory: BrowserContextFactory) {
this._tools = filteredTools(config);
this._context = new Context(this._tools, config, browserContextFactory);
}
async initialize() {
this._sessionLog = this._context.config.saveSession ? await SessionLog.create(this._context.config) : undefined;
}
tools(): mcpServer.ToolSchema<any>[] {
return this._tools.map(tool => tool.schema);
}
async callTool(schema: mcpServer.ToolSchema<any>, parsedArguments: any) {
const response = new Response(this._context, schema.name, parsedArguments);
const tool = this._tools.find(tool => tool.schema.name === schema.name)!;
await tool.handle(this._context, parsedArguments, response);
if (this._sessionLog)
await this._sessionLog.log(response);
return await response.serialize();
}
serverInitialized(version: mcpServer.ClientVersion | undefined) {
this._context.clientVersion = version;
}
serverClosed() {
this.onclose?.();
void this._context.dispose().catch(logUnhandledError);
}
}

View File

@@ -18,9 +18,10 @@ import fs from 'fs';
import os from 'os';
import path from 'path';
import { devices } from 'playwright';
import { sanitizeForFilePath } from './tools/utils.js';
import type { Config, ToolCapability } from '../config.js';
import type { BrowserContextOptions, LaunchOptions } from 'playwright';
import { sanitizeForFilePath } from './tools/utils.js';
export type CLIOptions = {
allowedOrigins?: string[];
@@ -42,7 +43,6 @@ export type CLIOptions = {
port?: number;
proxyBypass?: string;
proxyServer?: string;
saveSession?: boolean;
saveTrace?: boolean;
storageState?: string;
userAgent?: string;
@@ -133,7 +133,7 @@ export function configFromCLIOptions(cliOptions: CLIOptions): Config {
};
// --no-sandbox was passed, disable the sandbox
if (cliOptions.sandbox === false)
if (!cliOptions.sandbox)
launchOptions.chromiumSandbox = false;
if (cliOptions.proxyServer) {
@@ -190,7 +190,6 @@ export function configFromCLIOptions(cliOptions: CLIOptions): Config {
allowedOrigins: cliOptions.allowedOrigins,
blockedOrigins: cliOptions.blockedOrigins,
},
saveSession: cliOptions.saveSession,
saveTrace: cliOptions.saveTrace,
outputDir: cliOptions.outputDir,
imageResponses: cliOptions.imageResponses,

96
src/connection.ts Normal file
View File

@@ -0,0 +1,96 @@
/**
* 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 { Server as McpServer } from '@modelcontextprotocol/sdk/server/index.js';
import { CallToolRequestSchema, ListToolsRequestSchema, Tool as McpTool } from '@modelcontextprotocol/sdk/types.js';
import { zodToJsonSchema } from 'zod-to-json-schema';
import { Context } from './context.js';
import { allTools } from './tools.js';
import { packageJSON } from './package.js';
import { FullConfig } from './config.js';
import type { BrowserContextFactory } from './browserContextFactory.js';
export function createConnection(config: FullConfig, browserContextFactory: BrowserContextFactory): Connection {
const tools = allTools.filter(tool => tool.capability.startsWith('core') || config.capabilities?.includes(tool.capability));
const context = new Context(tools, config, browserContextFactory);
const server = new McpServer({ name: 'Playwright', version: packageJSON.version }, {
capabilities: {
tools: {},
}
});
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: tools.map(tool => ({
name: tool.schema.name,
description: tool.schema.description,
inputSchema: zodToJsonSchema(tool.schema.inputSchema),
annotations: {
title: tool.schema.title,
readOnlyHint: tool.schema.type === 'readOnly',
destructiveHint: tool.schema.type === 'destructive',
openWorldHint: true,
},
})) as McpTool[],
};
});
server.setRequestHandler(CallToolRequestSchema, async request => {
const errorResult = (...messages: string[]) => ({
content: [{ type: 'text', text: messages.join('\n') }],
isError: true,
});
const tool = tools.find(tool => tool.schema.name === request.params.name);
if (!tool)
return errorResult(`Tool "${request.params.name}" not found`);
const modalStates = context.modalStates().map(state => state.type);
if (tool.clearsModalState && !modalStates.includes(tool.clearsModalState))
return errorResult(`The tool "${request.params.name}" can only be used when there is related modal state present.`, ...context.modalStatesMarkdown());
if (!tool.clearsModalState && modalStates.length)
return errorResult(`Tool "${request.params.name}" does not handle the modal state.`, ...context.modalStatesMarkdown());
try {
return await context.run(tool, request.params.arguments);
} catch (error) {
return errorResult(String(error));
}
});
return new Connection(server, context);
}
export class Connection {
readonly server: McpServer;
readonly context: Context;
constructor(server: McpServer, context: Context) {
this.server = server;
this.context = context;
this.server.oninitialized = () => {
this.context.clientVersion = this.server.getClientVersion();
};
}
async close() {
await this.server.close();
await this.context.close();
}
}

View File

@@ -17,13 +17,19 @@
import debug from 'debug';
import * as playwright from 'playwright';
import { logUnhandledError } from './log.js';
import { callOnPageNoTrace, waitForCompletion } from './tools/utils.js';
import { ManualPromise } from './manualPromise.js';
import { Tab } from './tab.js';
import { outputFile } from './config.js';
import type { Tool } from './tools/tool.js';
import type { ModalState, Tool, ToolActionResult } from './tools/tool.js';
import type { FullConfig } from './config.js';
import type { BrowserContextFactory } from './browserContextFactory.js';
type PendingAction = {
dialogShown: ManualPromise<void>;
};
const testDebug = debug('pw:mcp:test');
export class Context {
@@ -33,34 +39,54 @@ export class Context {
private _browserContextFactory: BrowserContextFactory;
private _tabs: Tab[] = [];
private _currentTab: Tab | undefined;
private _modalStates: (ModalState & { tab: Tab })[] = [];
private _pendingAction: PendingAction | undefined;
private _downloads: { download: playwright.Download, finished: boolean, outputFile: string }[] = [];
clientVersion: { name: string; version: string; } | undefined;
private static _allContexts: Set<Context> = new Set();
private _closeBrowserContextPromise: Promise<void> | undefined;
constructor(tools: Tool[], config: FullConfig, browserContextFactory: BrowserContextFactory) {
this.tools = tools;
this.config = config;
this._browserContextFactory = browserContextFactory;
testDebug('create context');
Context._allContexts.add(this);
}
static async disposeAll() {
await Promise.all([...Context._allContexts].map(context => context.dispose()));
clientSupportsImages(): boolean {
if (this.config.imageResponses === 'omit')
return false;
return true;
}
modalStates(): ModalState[] {
return this._modalStates;
}
setModalState(modalState: ModalState, inTab: Tab) {
this._modalStates.push({ ...modalState, tab: inTab });
}
clearModalState(modalState: ModalState) {
this._modalStates = this._modalStates.filter(state => state !== modalState);
}
modalStatesMarkdown(): string[] {
const result: string[] = ['### Modal state'];
if (this._modalStates.length === 0)
result.push('- There is no modal state present');
for (const state of this._modalStates) {
const tool = this.tools.find(tool => tool.clearsModalState === state.type);
result.push(`- [${state.description}]: can be handled by the "${tool?.schema.name}" tool`);
}
return result;
}
tabs(): Tab[] {
return this._tabs;
}
currentTab(): Tab | undefined {
return this._currentTab;
}
currentTabOrDie(): Tab {
if (!this._currentTab)
throw new Error('No open pages available. Use the "browser_navigate" tool to navigate to a page first.');
throw new Error('No current snapshot available. Capture a snapshot or navigate to a new location first.');
return this._currentTab;
}
@@ -72,12 +98,8 @@ export class Context {
}
async selectTab(index: number) {
const tab = this._tabs[index];
if (!tab)
throw new Error(`Tab ${index} not found`);
await tab.page.bringToFront();
this._currentTab = tab;
return tab;
this._currentTab = this._tabs[index];
await this._currentTab.page.bringToFront();
}
async ensureTab(): Promise<Tab> {
@@ -87,18 +109,9 @@ export class Context {
return this._currentTab!;
}
async listTabsMarkdown(force: boolean = false): Promise<string[]> {
if (this._tabs.length === 1 && !force)
return [];
if (!this._tabs.length) {
return [
'### No open tabs',
'Use the "browser_navigate" tool to navigate to a page first.',
'',
];
}
async listTabsMarkdown(): Promise<string> {
if (!this._tabs.length)
return '### No tabs open';
const lines: string[] = ['### Open tabs'];
for (let i = 0; i < this._tabs.length; i++) {
const tab = this._tabs[i];
@@ -107,17 +120,158 @@ export class Context {
const current = tab === this._currentTab ? ' (current)' : '';
lines.push(`- ${i}:${current} [${title}] (${url})`);
}
lines.push('');
return lines;
return lines.join('\n');
}
async closeTab(index: number | undefined): Promise<string> {
async closeTab(index: number | undefined) {
const tab = index === undefined ? this._currentTab : this._tabs[index];
if (!tab)
throw new Error(`Tab ${index} not found`);
const url = tab.page.url();
await tab.page.close();
return url;
await tab?.page.close();
return await this.listTabsMarkdown();
}
async run(tool: Tool, params: Record<string, unknown> | undefined) {
// Tab management is done outside of the action() call.
const toolResult = await tool.handle(this, tool.schema.inputSchema.parse(params || {}));
const { code, action, waitForNetwork, captureSnapshot, resultOverride } = toolResult;
if (resultOverride)
return resultOverride;
if (!this._currentTab) {
return {
content: [{
type: 'text',
text: 'No open pages available. Use the "browser_navigate" tool to navigate to a page first.',
}],
};
}
const tab = this.currentTabOrDie();
// TODO: race against modal dialogs to resolve clicks.
const actionResult = await this._raceAgainstModalDialogs(async () => {
try {
if (waitForNetwork)
return await waitForCompletion(this, tab, async () => action?.()) ?? undefined;
else
return await action?.() ?? undefined;
} finally {
if (captureSnapshot && !this._javaScriptBlocked())
await tab.captureSnapshot();
}
});
const result: string[] = [];
result.push(`### Ran Playwright code
\`\`\`js
${code.join('\n')}
\`\`\``);
if (this.modalStates().length) {
result.push('', ...this.modalStatesMarkdown());
return {
content: [{
type: 'text',
text: result.join('\n'),
}],
};
}
const messages = tab.takeRecentConsoleMessages();
if (messages.length) {
result.push('', `### New console messages`);
for (const message of messages)
result.push(`- ${trim(message.toString(), 100)}`);
}
if (this._downloads.length) {
result.push('', '### Downloads');
for (const entry of this._downloads) {
if (entry.finished)
result.push(`- Downloaded file ${entry.download.suggestedFilename()} to ${entry.outputFile}`);
else
result.push(`- Downloading file ${entry.download.suggestedFilename()} ...`);
}
}
if (captureSnapshot && tab.hasSnapshot()) {
if (this.tabs().length > 1)
result.push('', await this.listTabsMarkdown());
if (this.tabs().length > 1)
result.push('', '### Current tab');
else
result.push('', '### Page state');
result.push(
`- Page URL: ${tab.page.url()}`,
`- Page Title: ${await tab.title()}`
);
result.push(tab.snapshotOrDie().text());
}
const content = actionResult?.content ?? [];
return {
content: [
...content,
{
type: 'text',
text: result.join('\n'),
}
],
};
}
async waitForTimeout(time: number) {
if (!this._currentTab || this._javaScriptBlocked()) {
await new Promise(f => setTimeout(f, time));
return;
}
await callOnPageNoTrace(this._currentTab.page, page => {
return page.evaluate(() => new Promise(f => setTimeout(f, 1000)));
});
}
private async _raceAgainstModalDialogs(action: () => Promise<ToolActionResult>): Promise<ToolActionResult> {
this._pendingAction = {
dialogShown: new ManualPromise(),
};
let result: ToolActionResult | undefined;
try {
await Promise.race([
action().then(r => result = r),
this._pendingAction.dialogShown,
]);
} finally {
this._pendingAction = undefined;
}
return result;
}
private _javaScriptBlocked(): boolean {
return this._modalStates.some(state => state.type === 'dialog');
}
dialogShown(tab: Tab, dialog: playwright.Dialog) {
this.setModalState({
type: 'dialog',
description: `"${dialog.type()}" dialog with message "${dialog.message()}"`,
dialog,
}, tab);
this._pendingAction?.dialogShown.resolve();
}
async downloadStarted(tab: Tab, download: playwright.Download) {
const entry = {
download,
finished: false,
outputFile: await outputFile(this.config, download.suggestedFilename())
};
this._downloads.push(entry);
await download.saveAs(entry.outputFile);
entry.finished = true;
}
private _onPageCreated(page: playwright.Page) {
@@ -128,6 +282,7 @@ export class Context {
}
private _onPageClosed(tab: Tab) {
this._modalStates = this._modalStates.filter(state => state.tab !== tab);
const index = this._tabs.indexOf(tab);
if (index === -1)
return;
@@ -136,17 +291,10 @@ export class Context {
if (this._currentTab === tab)
this._currentTab = this._tabs[Math.min(index, this._tabs.length - 1)];
if (!this._tabs.length)
void this.closeBrowserContext();
void this.close();
}
async closeBrowserContext() {
if (!this._closeBrowserContextPromise)
this._closeBrowserContextPromise = this._closeBrowserContextImpl().catch(logUnhandledError);
await this._closeBrowserContextPromise;
this._closeBrowserContextPromise = undefined;
}
private async _closeBrowserContextImpl() {
async close() {
if (!this._browserContextPromise)
return;
@@ -162,11 +310,6 @@ export class Context {
});
}
async dispose() {
await this.closeBrowserContext();
Context._allContexts.delete(this);
}
private async _setupRequestInterception(context: playwright.BrowserContext) {
if (this.config.network?.allowedOrigins?.length) {
await context.route('**', route => route.abort('blockedbyclient'));
@@ -192,10 +335,8 @@ export class Context {
}
private async _setupBrowserContext(): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
if (this._closeBrowserContextPromise)
throw new Error('Another browser context is being closed.');
// TODO: move to the browser context factory to make it based on isolation mode.
const result = await this._browserContextFactory.createContext(this.clientVersion!);
const result = await this._browserContextFactory.createContext();
const { browserContext } = result;
await this._setupRequestInterception(browserContext);
for (const page of browserContext.pages())
@@ -212,3 +353,9 @@ export class Context {
return result;
}
}
function trim(text: string, maxLength: number) {
if (text.length <= maxLength)
return text;
return text.slice(0, maxLength) + '...';
}

View File

@@ -1,440 +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.
*/
/**
* WebSocket server that bridges Playwright MCP and Chrome Extension
*
* Endpoints:
* - /cdp/guid - Full CDP interface for Playwright MCP
* - /extension/guid - Extension connection for chrome.debugger forwarding
*/
import http from 'http';
import { spawn } from 'child_process';
import { WebSocket, WebSocketServer } from 'ws';
import debug from 'debug';
import * as playwright from 'playwright';
// @ts-ignore
const { registry } = await import('playwright-core/lib/server/registry/index');
import { httpAddressToString, startHttpServer } from '../httpServer.js';
import { logUnhandledError } from '../log.js';
import { ManualPromise } from '../manualPromise.js';
import type { BrowserContextFactory } from '../browserContextFactory.js';
import type websocket from 'ws';
const debugLogger = debug('pw:mcp:relay');
type CDPCommand = {
id: number;
sessionId?: string;
method: string;
params?: any;
};
type CDPResponse = {
id?: number;
sessionId?: string;
method?: string;
params?: any;
result?: any;
error?: { code?: number; message: string };
};
export class CDPRelayServer {
private _wsHost: string;
private _browserChannel: string;
private _cdpPath: string;
private _extensionPath: string;
private _wss: WebSocketServer;
private _playwrightConnection: WebSocket | null = null;
private _extensionConnection: ExtensionConnection | null = null;
private _connectedTabInfo: {
targetInfo: any;
// Page sessionId that should be used by this connection.
sessionId: string;
} | undefined;
private _nextSessionId: number = 1;
private _extensionConnectionPromise!: ManualPromise<void>;
constructor(server: http.Server, browserChannel: string) {
this._wsHost = httpAddressToString(server.address()).replace(/^http/, 'ws');
this._browserChannel = browserChannel;
const uuid = crypto.randomUUID();
this._cdpPath = `/cdp/${uuid}`;
this._extensionPath = `/extension/${uuid}`;
this._resetExtensionConnection();
this._wss = new WebSocketServer({ server });
this._wss.on('connection', this._onConnection.bind(this));
}
cdpEndpoint() {
return `${this._wsHost}${this._cdpPath}`;
}
extensionEndpoint() {
return `${this._wsHost}${this._extensionPath}`;
}
async ensureExtensionConnectionForMCPContext(clientInfo: { name: string, version: string }) {
debugLogger('Ensuring extension connection for MCP context');
if (this._extensionConnection)
return;
await this._connectBrowser(clientInfo);
debugLogger('Waiting for incoming extension connection');
await this._extensionConnectionPromise;
debugLogger('Extension connection established');
}
private async _connectBrowser(clientInfo: { name: string, version: string }) {
const mcpRelayEndpoint = `${this._wsHost}${this._extensionPath}`;
// Need to specify "key" in the manifest.json to make the id stable when loading from file.
const url = new URL('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html');
url.searchParams.set('mcpRelayUrl', mcpRelayEndpoint);
url.searchParams.set('client', JSON.stringify(clientInfo));
const href = url.toString();
const executableInfo = registry.findExecutable(this._browserChannel);
if (!executableInfo)
throw new Error(`Unsupported channel: "${this._browserChannel}"`);
const executablePath = executableInfo.executablePath();
if (!executablePath)
throw new Error(`"${this._browserChannel}" executable not found. Make sure it is installed at a standard location.`);
spawn(executablePath, [href], {
windowsHide: true,
detached: true,
shell: false,
stdio: 'ignore',
});
}
stop(): void {
this.closeConnections('Server stopped');
this._wss.close();
}
closeConnections(reason: string) {
this._closePlaywrightConnection(reason);
this._closeExtensionConnection(reason);
}
private _onConnection(ws: WebSocket, request: http.IncomingMessage): void {
const url = new URL(`http://localhost${request.url}`);
debugLogger(`New connection to ${url.pathname}`);
if (url.pathname === this._cdpPath) {
this._handlePlaywrightConnection(ws);
} else if (url.pathname === this._extensionPath) {
this._handleExtensionConnection(ws);
} else {
debugLogger(`Invalid path: ${url.pathname}`);
ws.close(4004, 'Invalid path');
}
}
private _handlePlaywrightConnection(ws: WebSocket): void {
if (this._playwrightConnection) {
debugLogger('Rejecting second Playwright connection');
ws.close(1000, 'Another CDP client already connected');
return;
}
this._playwrightConnection = ws;
ws.on('message', async data => {
try {
const message = JSON.parse(data.toString());
await this._handlePlaywrightMessage(message);
} catch (error: any) {
debugLogger(`Error while handling Playwright message\n${data.toString()}\n`, error);
}
});
ws.on('close', () => {
if (this._playwrightConnection !== ws)
return;
this._playwrightConnection = null;
this._closeExtensionConnection('Playwright client disconnected');
debugLogger('Playwright WebSocket closed');
});
ws.on('error', error => {
debugLogger('Playwright WebSocket error:', error);
});
debugLogger('Playwright MCP connected');
}
private _closeExtensionConnection(reason: string) {
this._extensionConnection?.close(reason);
this._extensionConnectionPromise.reject(new Error(reason));
this._resetExtensionConnection();
}
private _resetExtensionConnection() {
this._connectedTabInfo = undefined;
this._extensionConnection = null;
this._extensionConnectionPromise = new ManualPromise();
void this._extensionConnectionPromise.catch(logUnhandledError);
}
private _closePlaywrightConnection(reason: string) {
if (this._playwrightConnection?.readyState === WebSocket.OPEN)
this._playwrightConnection.close(1000, reason);
this._playwrightConnection = null;
}
private _handleExtensionConnection(ws: WebSocket): void {
if (this._extensionConnection) {
ws.close(1000, 'Another extension connection already established');
return;
}
this._extensionConnection = new ExtensionConnection(ws);
this._extensionConnection.onclose = (c, reason) => {
debugLogger('Extension WebSocket closed:', reason, c === this._extensionConnection);
if (this._extensionConnection !== c)
return;
this._resetExtensionConnection();
this._closePlaywrightConnection(`Extension disconnected: ${reason}`);
};
this._extensionConnection.onmessage = this._handleExtensionMessage.bind(this);
this._extensionConnectionPromise.resolve();
}
private _handleExtensionMessage(method: string, params: any) {
switch (method) {
case 'forwardCDPEvent':
const sessionId = params.sessionId || this._connectedTabInfo?.sessionId;
this._sendToPlaywright({
sessionId,
method: params.method,
params: params.params
});
break;
case 'detachedFromTab':
debugLogger('← Debugger detached from tab:', params);
this._connectedTabInfo = undefined;
break;
}
}
private async _handlePlaywrightMessage(message: CDPCommand): Promise<void> {
debugLogger('← Playwright:', `${message.method} (id=${message.id})`);
const { id, sessionId, method, params } = message;
try {
const result = await this._handleCDPCommand(method, params, sessionId);
this._sendToPlaywright({ id, sessionId, result });
} catch (e) {
debugLogger('Error in the extension:', e);
this._sendToPlaywright({
id,
sessionId,
error: { message: (e as Error).message }
});
}
}
private async _handleCDPCommand(method: string, params: any, sessionId: string | undefined): Promise<any> {
switch (method) {
case 'Browser.getVersion': {
return {
protocolVersion: '1.3',
product: 'Chrome/Extension-Bridge',
userAgent: 'CDP-Bridge-Server/1.0.0',
};
}
case 'Browser.setDownloadBehavior': {
return { };
}
case 'Target.setAutoAttach': {
// Forward child session handling.
if (sessionId)
break;
// Simulate auto-attach behavior with real target info
const { targetInfo } = await this._extensionConnection!.send('attachToTab');
this._connectedTabInfo = {
targetInfo,
sessionId: `pw-tab-${this._nextSessionId++}`,
};
debugLogger('Simulating auto-attach');
this._sendToPlaywright({
method: 'Target.attachedToTarget',
params: {
sessionId: this._connectedTabInfo.sessionId,
targetInfo: {
...this._connectedTabInfo.targetInfo,
attached: true,
},
waitingForDebugger: false
}
});
return { };
}
case 'Target.getTargetInfo': {
return this._connectedTabInfo?.targetInfo;
}
}
return await this._forwardToExtension(method, params, sessionId);
}
private async _forwardToExtension(method: string, params: any, sessionId: string | undefined): Promise<any> {
if (!this._extensionConnection)
throw new Error('Extension not connected');
// Top level sessionId is only passed between the relay and the client.
if (this._connectedTabInfo?.sessionId === sessionId)
sessionId = undefined;
return await this._extensionConnection.send('forwardCDPCommand', { sessionId, method, params });
}
private _sendToPlaywright(message: CDPResponse): void {
debugLogger('→ Playwright:', `${message.method ?? `response(id=${message.id})`}`);
this._playwrightConnection?.send(JSON.stringify(message));
}
}
class ExtensionContextFactory implements BrowserContextFactory {
private _relay: CDPRelayServer;
private _browserPromise: Promise<playwright.Browser> | undefined;
constructor(relay: CDPRelayServer) {
this._relay = relay;
}
async createContext(clientInfo: { name: string, version: string }): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
// First call will establish the connection to the extension.
if (!this._browserPromise)
this._browserPromise = this._obtainBrowser(clientInfo);
const browser = await this._browserPromise;
return {
browserContext: browser.contexts()[0],
close: async () => {
debugLogger('close() called for browser context, ignoring');
}
};
}
clientDisconnected() {
this._relay.closeConnections('MCP client disconnected');
this._browserPromise = undefined;
}
private async _obtainBrowser(clientInfo: { name: string, version: string }): Promise<playwright.Browser> {
await this._relay.ensureExtensionConnectionForMCPContext(clientInfo);
const browser = await playwright.chromium.connectOverCDP(this._relay.cdpEndpoint());
browser.on('disconnected', () => {
this._browserPromise = undefined;
debugLogger('Browser disconnected');
});
return browser;
}
}
export async function startCDPRelayServer(browserChannel: string, abortController: AbortController) {
const httpServer = await startHttpServer({});
const cdpRelayServer = new CDPRelayServer(httpServer, browserChannel);
abortController.signal.addEventListener('abort', () => cdpRelayServer.stop());
debugLogger(`CDP relay server started, extension endpoint: ${cdpRelayServer.extensionEndpoint()}.`);
return new ExtensionContextFactory(cdpRelayServer);
}
type ExtensionResponse = {
id?: number;
method?: string;
params?: any;
result?: any;
error?: string;
};
class ExtensionConnection {
private readonly _ws: WebSocket;
private readonly _callbacks = new Map<number, { resolve: (o: any) => void, reject: (e: Error) => void, error: Error }>();
private _lastId = 0;
onmessage?: (method: string, params: any) => void;
onclose?: (self: ExtensionConnection, reason: string) => void;
constructor(ws: WebSocket) {
this._ws = ws;
this._ws.on('message', this._onMessage.bind(this));
this._ws.on('close', this._onClose.bind(this));
this._ws.on('error', this._onError.bind(this));
}
async send(method: string, params?: any, sessionId?: string): Promise<any> {
if (this._ws.readyState !== WebSocket.OPEN)
throw new Error(`Unexpected WebSocket state: ${this._ws.readyState}`);
const id = ++this._lastId;
this._ws.send(JSON.stringify({ id, method, params, sessionId }));
const error = new Error(`Protocol error: ${method}`);
return new Promise((resolve, reject) => {
this._callbacks.set(id, { resolve, reject, error });
});
}
close(message: string) {
debugLogger('closing extension connection:', message);
if (this._ws.readyState === WebSocket.OPEN)
this._ws.close(1000, message);
}
private _onMessage(event: websocket.RawData) {
const eventData = event.toString();
let parsedJson;
try {
parsedJson = JSON.parse(eventData);
} catch (e: any) {
debugLogger(`<closing ws> Closing websocket due to malformed JSON. eventData=${eventData} e=${e?.message}`);
this._ws.close();
return;
}
try {
this._handleParsedMessage(parsedJson);
} catch (e: any) {
debugLogger(`<closing ws> Closing websocket due to failed onmessage callback. eventData=${eventData} e=${e?.message}`);
this._ws.close();
}
}
private _handleParsedMessage(object: ExtensionResponse) {
if (object.id && this._callbacks.has(object.id)) {
const callback = this._callbacks.get(object.id)!;
this._callbacks.delete(object.id);
if (object.error) {
const error = callback.error;
error.message = object.error;
callback.reject(error);
} else {
callback.resolve(object.result);
}
} else if (object.id) {
debugLogger('← Extension: unexpected response', object);
} else {
this.onmessage?.(object.method!, object.params);
}
}
private _onClose(event: websocket.CloseEvent) {
debugLogger(`<ws closed> code=${event.code} reason=${event.reason}`);
this._dispose();
this.onclose?.(this, event.reason);
}
private _onError(event: websocket.ErrorEvent) {
debugLogger(`<ws error> message=${event.message} type=${event.type} target=${event.target}`);
this._dispose();
}
private _dispose() {
for (const callback of this._callbacks.values())
callback.reject(new Error('WebSocket closed'));
this._callbacks.clear();
}
}

View File

@@ -1,39 +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 { startCDPRelayServer } from './cdpRelay.js';
import { BrowserServerBackend } from '../browserServerBackend.js';
import * as mcpTransport from '../mcp/transport.js';
import type { FullConfig } from '../config.js';
export async function runWithExtension(config: FullConfig, abortController: AbortController) {
const contextFactory = await startCDPRelayServer(config.browser.launchOptions.channel || 'chrome', abortController);
let backend: BrowserServerBackend | undefined;
const serverBackendFactory = () => {
if (backend)
throw new Error('Another MCP client is still connected. Only one connection at a time is allowed.');
backend = new BrowserServerBackend(config, contextFactory);
backend.onclose = () => {
contextFactory.clientDisconnected();
backend = undefined;
};
return backend;
};
await mcpTransport.start(serverBackendFactory, config.server);
}

View File

@@ -14,31 +14,219 @@
* limitations under the License.
*/
import assert from 'assert';
import fs from 'fs';
import path from 'path';
import http from 'http';
import net from 'net';
import type * as net from 'net';
import mime from 'mime';
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;
import { ManualPromise } from './manualPromise.js';
export type ServerRouteHandler = (request: http.IncomingMessage, response: http.ServerResponse) => void;
export type Transport = {
sendEvent?: (method: string, params: any) => void;
close?: () => void;
onconnect: () => void;
dispatch: (method: string, params: any) => Promise<any>;
onclose: () => void;
};
export class HttpServer {
private _server: http.Server;
private _urlPrefixPrecise: string = '';
private _urlPrefixHumanReadable: string = '';
private _port: number = 0;
private _routes: { prefix?: string, exact?: string, handler: ServerRouteHandler }[] = [];
constructor() {
this._server = http.createServer(this._onRequest.bind(this));
decorateServer(this._server);
}
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}`;
server() {
return this._server;
}
routePrefix(prefix: string, handler: ServerRouteHandler) {
this._routes.push({ prefix, handler });
}
routePath(path: string, handler: ServerRouteHandler) {
this._routes.push({ exact: path, handler });
}
port(): number {
return this._port;
}
private async _tryStart(port: number | undefined, host: string) {
const errorPromise = new ManualPromise();
const errorListener = (error: Error) => errorPromise.reject(error);
this._server.on('error', errorListener);
try {
this._server.listen(port, host);
await Promise.race([
new Promise(cb => this._server!.once('listening', cb)),
errorPromise,
]);
} finally {
this._server.removeListener('error', errorListener);
}
}
async start(options: { port?: number, preferredPort?: number, host?: string } = {}): Promise<void> {
const host = options.host || 'localhost';
if (options.preferredPort) {
try {
await this._tryStart(options.preferredPort, host);
} catch (e: any) {
if (!e || !e.message || !e.message.includes('EADDRINUSE'))
throw e;
await this._tryStart(undefined, host);
}
} else {
await this._tryStart(options.port, host);
}
const address = this._server.address();
if (typeof address === 'string') {
this._urlPrefixPrecise = address;
this._urlPrefixHumanReadable = address;
} else {
this._port = address!.port;
const resolvedHost = address!.family === 'IPv4' ? address!.address : `[${address!.address}]`;
this._urlPrefixPrecise = `http://${resolvedHost}:${address!.port}`;
this._urlPrefixHumanReadable = `http://${host}:${address!.port}`;
}
}
async stop() {
await new Promise(cb => this._server!.close(cb));
}
urlPrefix(purpose: 'human-readable' | 'precise'): string {
return purpose === 'human-readable' ? this._urlPrefixHumanReadable : this._urlPrefixPrecise;
}
serveFile(request: http.IncomingMessage, response: http.ServerResponse, absoluteFilePath: string, headers?: { [name: string]: string }): boolean {
try {
for (const [name, value] of Object.entries(headers || {}))
response.setHeader(name, value);
if (request.headers.range)
this._serveRangeFile(request, response, absoluteFilePath);
else
this._serveFile(response, absoluteFilePath);
return true;
} catch (e) {
return false;
}
}
_serveFile(response: http.ServerResponse, absoluteFilePath: string) {
const content = fs.readFileSync(absoluteFilePath);
response.statusCode = 200;
const contentType = mime.getType(path.extname(absoluteFilePath)) || 'application/octet-stream';
response.setHeader('Content-Type', contentType);
response.setHeader('Content-Length', content.byteLength);
response.end(content);
}
_serveRangeFile(request: http.IncomingMessage, response: http.ServerResponse, absoluteFilePath: string) {
const range = request.headers.range;
if (!range || !range.startsWith('bytes=') || range.includes(', ') || [...range].filter(char => char === '-').length !== 1) {
response.statusCode = 400;
return response.end('Bad request');
}
// Parse the range header: https://datatracker.ietf.org/doc/html/rfc7233#section-2.1
const [startStr, endStr] = range.replace(/bytes=/, '').split('-');
// Both start and end (when passing to fs.createReadStream) and the range header are inclusive and start counting at 0.
let start: number;
let end: number;
const size = fs.statSync(absoluteFilePath).size;
if (startStr !== '' && endStr === '') {
// No end specified: use the whole file
start = +startStr;
end = size - 1;
} else if (startStr === '' && endStr !== '') {
// No start specified: calculate start manually
start = size - +endStr;
end = size - 1;
} else {
start = +startStr;
end = +endStr;
}
// Handle unavailable range request
if (Number.isNaN(start) || Number.isNaN(end) || start >= size || end >= size || start > end) {
// Return the 416 Range Not Satisfiable: https://datatracker.ietf.org/doc/html/rfc7233#section-4.4
response.writeHead(416, {
'Content-Range': `bytes */${size}`
});
return response.end();
}
// Sending Partial Content: https://datatracker.ietf.org/doc/html/rfc7233#section-4.1
response.writeHead(206, {
'Content-Range': `bytes ${start}-${end}/${size}`,
'Accept-Ranges': 'bytes',
'Content-Length': end - start + 1,
'Content-Type': mime.getType(path.extname(absoluteFilePath))!,
});
const readable = fs.createReadStream(absoluteFilePath, { start, end });
readable.pipe(response);
}
private _onRequest(request: http.IncomingMessage, response: http.ServerResponse) {
if (request.method === 'OPTIONS') {
response.writeHead(200);
response.end();
return;
}
request.on('error', () => response.end());
try {
if (!request.url) {
response.end();
return;
}
const url = new URL('http://localhost' + request.url);
for (const route of this._routes) {
if (route.exact && url.pathname === route.exact) {
route.handler(request, response);
return;
}
if (route.prefix && url.pathname.startsWith(route.prefix)) {
route.handler(request, response);
return;
}
}
response.statusCode = 404;
response.end();
} catch (e) {
response.end();
}
}
}
function decorateServer(server: net.Server) {
const sockets = new Set<net.Socket>();
server.on('connection', socket => {
sockets.add(socket);
socket.once('close', () => sockets.delete(socket));
});
const close = server.close;
server.close = (callback?: (err?: Error) => void) => {
for (const socket of sockets)
socket.destroy();
sockets.clear();
return close.call(server, callback);
};
}

View File

@@ -14,20 +14,19 @@
* limitations under the License.
*/
import { BrowserServerBackend } from './browserServerBackend.js';
import { createConnection as createConnectionImpl } from './connection.js';
import type { Connection } from '../index.js';
import { resolveConfig } from './config.js';
import { contextFactory } from './browserContextFactory.js';
import * as mcpServer from './mcp/server.js';
import type { Config } from '../config.js';
import type { BrowserContext } from 'playwright';
import type { BrowserContextFactory } from './browserContextFactory.js';
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<Connection> {
const config = await resolveConfig(userConfig);
const factory = contextGetter ? new SimpleBrowserContextFactory(contextGetter) : contextFactory(config.browser);
return mcpServer.createServer(new BrowserServerBackend(config, factory), false);
return createConnectionImpl(config, factory);
}
class SimpleBrowserContextFactory implements BrowserContextFactory {

View File

@@ -1,108 +0,0 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import debug from 'debug';
import type { Tool, ImageContent, TextContent } from '@modelcontextprotocol/sdk/types.js';
import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
export type LLMToolCall = {
name: string;
arguments: any;
id: string;
};
export type LLMTool = {
name: string;
description: string;
inputSchema: any;
};
export type LLMMessage =
| { role: 'user'; content: string }
| { role: 'assistant'; content: string; toolCalls?: LLMToolCall[] }
| { role: 'tool'; toolCallId: string; content: string; isError?: boolean };
export type LLMConversation = {
messages: LLMMessage[];
tools: LLMTool[];
};
export interface LLMDelegate {
createConversation(task: string, tools: Tool[], oneShot: boolean): LLMConversation;
makeApiCall(conversation: LLMConversation): Promise<LLMToolCall[]>;
addToolResults(conversation: LLMConversation, results: Array<{ toolCallId: string; content: string; isError?: boolean }>): void;
checkDoneToolCall(toolCall: LLMToolCall): string | null;
}
export async function runTask(delegate: LLMDelegate, client: Client, task: string, oneShot: boolean = false): Promise<LLMMessage[]> {
const { tools } = await client.listTools();
const taskContent = oneShot ? `Perform following task: ${task}.` : `Perform following task: ${task}. Once the task is complete, call the "done" tool.`;
const conversation = delegate.createConversation(taskContent, tools, oneShot);
for (let iteration = 0; iteration < 5; ++iteration) {
debug('history')('Making API call for iteration', iteration);
const toolCalls = await delegate.makeApiCall(conversation);
if (toolCalls.length === 0)
throw new Error('Call the "done" tool when the task is complete.');
const toolResults: Array<{ toolCallId: string; content: string; isError?: boolean }> = [];
for (const toolCall of toolCalls) {
const doneResult = delegate.checkDoneToolCall(toolCall);
if (doneResult !== null)
return conversation.messages;
const { name, arguments: args, id } = toolCall;
try {
debug('tool')(name, args);
const response = await client.callTool({
name,
arguments: args,
});
const responseContent = (response.content || []) as (TextContent | ImageContent)[];
debug('tool')(responseContent);
const text = responseContent.filter(part => part.type === 'text').map(part => part.text).join('\n');
toolResults.push({
toolCallId: id,
content: text,
});
} catch (error) {
debug('tool')(error);
toolResults.push({
toolCallId: id,
content: `Error while executing tool "${name}": ${error instanceof Error ? error.message : String(error)}\n\nPlease try to recover and complete the task.`,
isError: true,
});
// Skip remaining tool calls for this iteration
for (const remainingToolCall of toolCalls.slice(toolCalls.indexOf(toolCall) + 1)) {
toolResults.push({
toolCallId: remainingToolCall.id,
content: `This tool call is skipped due to previous error.`,
isError: true,
});
}
break;
}
}
delegate.addToolResults(conversation, toolResults);
if (oneShot)
return conversation.messages;
}
throw new Error('Failed to perform step, max attempts reached');
}

View File

@@ -1,177 +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 type Anthropic from '@anthropic-ai/sdk';
import type { LLMDelegate, LLMConversation, LLMToolCall, LLMTool } from './loop.js';
import type { Tool } from '@modelcontextprotocol/sdk/types.js';
const model = 'claude-sonnet-4-20250514';
export class ClaudeDelegate implements LLMDelegate {
private _anthropic: Anthropic | undefined;
async anthropic(): Promise<Anthropic> {
if (!this._anthropic) {
const anthropic = await import('@anthropic-ai/sdk');
this._anthropic = new anthropic.Anthropic();
}
return this._anthropic;
}
createConversation(task: string, tools: Tool[], oneShot: boolean): LLMConversation {
const llmTools: LLMTool[] = tools.map(tool => ({
name: tool.name,
description: tool.description || '',
inputSchema: tool.inputSchema,
}));
if (!oneShot) {
llmTools.push({
name: 'done',
description: 'Call this tool when the task is complete.',
inputSchema: {
type: 'object',
properties: {},
},
});
}
return {
messages: [{
role: 'user',
content: task
}],
tools: llmTools,
};
}
async makeApiCall(conversation: LLMConversation): Promise<LLMToolCall[]> {
// Convert generic messages to Claude format
const claudeMessages: Anthropic.Messages.MessageParam[] = [];
for (const message of conversation.messages) {
if (message.role === 'user') {
claudeMessages.push({
role: 'user',
content: message.content
});
} else if (message.role === 'assistant') {
const content: Anthropic.Messages.ContentBlock[] = [];
// Add text content
if (message.content) {
content.push({
type: 'text',
text: message.content,
citations: []
});
}
// Add tool calls
if (message.toolCalls) {
for (const toolCall of message.toolCalls) {
content.push({
type: 'tool_use',
id: toolCall.id,
name: toolCall.name,
input: toolCall.arguments
});
}
}
claudeMessages.push({
role: 'assistant',
content
});
} else if (message.role === 'tool') {
// Tool results are added differently - we need to find if there's already a user message with tool results
const lastMessage = claudeMessages[claudeMessages.length - 1];
const toolResult: Anthropic.Messages.ToolResultBlockParam = {
type: 'tool_result',
tool_use_id: message.toolCallId,
content: message.content,
is_error: message.isError,
};
if (lastMessage && lastMessage.role === 'user' && Array.isArray(lastMessage.content)) {
// Add to existing tool results message
(lastMessage.content as Anthropic.Messages.ToolResultBlockParam[]).push(toolResult);
} else {
// Create new tool results message
claudeMessages.push({
role: 'user',
content: [toolResult]
});
}
}
}
// Convert generic tools to Claude format
const claudeTools: Anthropic.Messages.Tool[] = conversation.tools.map(tool => ({
name: tool.name,
description: tool.description,
input_schema: tool.inputSchema,
}));
const anthropic = await this.anthropic();
const response = await anthropic.messages.create({
model,
max_tokens: 10000,
messages: claudeMessages,
tools: claudeTools,
});
// Extract tool calls and add assistant message to generic conversation
const toolCalls = response.content.filter(block => block.type === 'tool_use') as Anthropic.Messages.ToolUseBlock[];
const textContent = response.content.filter(block => block.type === 'text').map(block => (block as Anthropic.Messages.TextBlock).text).join('');
const llmToolCalls: LLMToolCall[] = toolCalls.map(toolCall => ({
name: toolCall.name,
arguments: toolCall.input as any,
id: toolCall.id,
}));
// Add assistant message to generic conversation
conversation.messages.push({
role: 'assistant',
content: textContent,
toolCalls: llmToolCalls.length > 0 ? llmToolCalls : undefined
});
return llmToolCalls;
}
addToolResults(
conversation: LLMConversation,
results: Array<{ toolCallId: string; content: string; isError?: boolean }>
): void {
for (const result of results) {
conversation.messages.push({
role: 'tool',
toolCallId: result.toolCallId,
content: result.content,
isError: result.isError,
});
}
}
checkDoneToolCall(toolCall: LLMToolCall): string | null {
if (toolCall.name === 'done')
return (toolCall.arguments as { result: string }).result;
return null;
}
}

View File

@@ -1,168 +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 type OpenAI from 'openai';
import type { LLMDelegate, LLMConversation, LLMToolCall, LLMTool } from './loop.js';
import type { Tool } from '@modelcontextprotocol/sdk/types.js';
const model = 'gpt-4.1';
export class OpenAIDelegate implements LLMDelegate {
private _openai: OpenAI | undefined;
async openai(): Promise<OpenAI> {
if (!this._openai) {
const oai = await import('openai');
this._openai = new oai.OpenAI();
}
return this._openai;
}
createConversation(task: string, tools: Tool[], oneShot: boolean): LLMConversation {
const genericTools: LLMTool[] = tools.map(tool => ({
name: tool.name,
description: tool.description || '',
inputSchema: tool.inputSchema,
}));
if (!oneShot) {
genericTools.push({
name: 'done',
description: 'Call this tool when the task is complete.',
inputSchema: {
type: 'object',
properties: {},
},
});
}
return {
messages: [{
role: 'user',
content: task
}],
tools: genericTools,
};
}
async makeApiCall(conversation: LLMConversation): Promise<LLMToolCall[]> {
// Convert generic messages to OpenAI format
const openaiMessages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [];
for (const message of conversation.messages) {
if (message.role === 'user') {
openaiMessages.push({
role: 'user',
content: message.content
});
} else if (message.role === 'assistant') {
const toolCalls: OpenAI.Chat.Completions.ChatCompletionMessageToolCall[] = [];
if (message.toolCalls) {
for (const toolCall of message.toolCalls) {
toolCalls.push({
id: toolCall.id,
type: 'function',
function: {
name: toolCall.name,
arguments: JSON.stringify(toolCall.arguments)
}
});
}
}
const assistantMessage: OpenAI.Chat.Completions.ChatCompletionAssistantMessageParam = {
role: 'assistant'
};
if (message.content)
assistantMessage.content = message.content;
if (toolCalls.length > 0)
assistantMessage.tool_calls = toolCalls;
openaiMessages.push(assistantMessage);
} else if (message.role === 'tool') {
openaiMessages.push({
role: 'tool',
tool_call_id: message.toolCallId,
content: message.content,
});
}
}
// Convert generic tools to OpenAI format
const openaiTools: OpenAI.Chat.Completions.ChatCompletionTool[] = conversation.tools.map(tool => ({
type: 'function',
function: {
name: tool.name,
description: tool.description,
parameters: tool.inputSchema,
},
}));
const openai = await this.openai();
const response = await openai.chat.completions.create({
model,
messages: openaiMessages,
tools: openaiTools,
tool_choice: 'auto'
});
const message = response.choices[0].message;
// Extract tool calls and add assistant message to generic conversation
const toolCalls = message.tool_calls || [];
const genericToolCalls: LLMToolCall[] = toolCalls.map(toolCall => {
const functionCall = toolCall.function;
return {
name: functionCall.name,
arguments: JSON.parse(functionCall.arguments),
id: toolCall.id,
};
});
// Add assistant message to generic conversation
conversation.messages.push({
role: 'assistant',
content: message.content || '',
toolCalls: genericToolCalls.length > 0 ? genericToolCalls : undefined
});
return genericToolCalls;
}
addToolResults(
conversation: LLMConversation,
results: Array<{ toolCallId: string; content: string; isError?: boolean }>
): void {
for (const result of results) {
conversation.messages.push({
role: 'tool',
toolCallId: result.toolCallId,
content: result.content,
isError: result.isError,
});
}
}
checkDoneToolCall(toolCall: LLMToolCall): string | null {
if (toolCall.name === 'done')
return toolCall.arguments.result;
return null;
}
}

View File

@@ -1,72 +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.
*/
/* eslint-disable no-console */
import path from 'path';
import url from 'url';
import dotenv from 'dotenv';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { program } from 'commander';
import { OpenAIDelegate } from './loopOpenAI.js';
import { ClaudeDelegate } from './loopClaude.js';
import { runTask } from './loop.js';
import type { LLMDelegate } from './loop.js';
dotenv.config();
const __filename = url.fileURLToPath(import.meta.url);
async function run(delegate: LLMDelegate) {
const transport = new StdioClientTransport({
command: 'node',
args: [
path.resolve(__filename, '../../../cli.js'),
'--save-session',
'--output-dir', path.resolve(__filename, '../../../sessions')
],
stderr: 'inherit',
env: process.env as Record<string, string>,
});
const client = new Client({ name: 'test', version: '1.0.0' });
await client.connect(transport);
await client.ping();
for (const task of tasks) {
const messages = await runTask(delegate, client, task);
for (const message of messages)
console.log(`${message.role}: ${message.content}`);
}
await client.close();
}
const tasks = [
'Open https://playwright.dev/',
];
program
.option('--model <model>', 'model to use')
.action(async options => {
if (options.model === 'claude')
await run(new ClaudeDelegate());
else
await run(new OpenAIDelegate());
});
void program.parseAsync(process.argv);

View File

@@ -1,77 +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 { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { contextFactory } from '../browserContextFactory.js';
import { BrowserServerBackend } from '../browserServerBackend.js';
import { Context as BrowserContext } from '../context.js';
import { runTask } from '../loop/loop.js';
import { OpenAIDelegate } from '../loop/loopOpenAI.js';
import { ClaudeDelegate } from '../loop/loopClaude.js';
import { InProcessTransport } from '../mcp/inProcessTransport.js';
import * as mcpServer from '../mcp/server.js';
import type { LLMDelegate } from '../loop/loop.js';
import type { FullConfig } from '../config.js';
export class Context {
readonly config: FullConfig;
private _client: Client;
private _delegate: LLMDelegate;
constructor(config: FullConfig, client: Client) {
this.config = config;
this._client = client;
if (process.env.OPENAI_API_KEY)
this._delegate = new OpenAIDelegate();
else if (process.env.ANTHROPIC_API_KEY)
this._delegate = new ClaudeDelegate();
else
throw new Error('No LLM API key found. Please set OPENAI_API_KEY or ANTHROPIC_API_KEY environment variable.');
}
static async create(config: FullConfig) {
const client = new Client({ name: 'Playwright Proxy', version: '1.0.0' });
const browserContextFactory = contextFactory(config.browser);
const server = mcpServer.createServer(new BrowserServerBackend(config, browserContextFactory), false);
await client.connect(new InProcessTransport(server));
await client.ping();
return new Context(config, client);
}
async runTask(task: string, oneShot: boolean = false): Promise<mcpServer.ToolResponse> {
const messages = await runTask(this._delegate, this._client!, task, oneShot);
const lines: string[] = [];
// Skip the first message, which is the user's task.
for (const message of messages.slice(1)) {
// Trim out all page snapshots.
if (!message.content.trim())
continue;
const index = oneShot ? -1 : message.content.indexOf('### Page state');
const trimmedContent = index === -1 ? message.content : message.content.substring(0, index);
lines.push(`[${message.role}]:`, trimmedContent);
}
return {
content: [{ type: 'text', text: lines.join('\n') }],
};
}
async close() {
await BrowserContext.disposeAll();
}
}

View File

@@ -1,63 +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 dotenv from 'dotenv';
import * as mcpServer from '../mcp/server.js';
import * as mcpTransport from '../mcp/transport.js';
import { packageJSON } from '../package.js';
import { Context } from './context.js';
import { perform } from './perform.js';
import { snapshot } from './snapshot.js';
import type { FullConfig } from '../config.js';
import type { ServerBackend } from '../mcp/server.js';
import type { Tool } from './tool.js';
export async function runLoopTools(config: FullConfig) {
dotenv.config();
const serverBackendFactory = () => new LoopToolsServerBackend(config);
await mcpTransport.start(serverBackendFactory, config.server);
}
class LoopToolsServerBackend implements ServerBackend {
readonly name = 'Playwright';
readonly version = packageJSON.version;
private _config: FullConfig;
private _context: Context | undefined;
private _tools: Tool<any>[] = [perform, snapshot];
constructor(config: FullConfig) {
this._config = config;
}
async initialize() {
this._context = await Context.create(this._config);
}
tools(): mcpServer.ToolSchema<any>[] {
return this._tools.map(tool => tool.schema);
}
async callTool(schema: mcpServer.ToolSchema<any>, parsedArguments: any): Promise<mcpServer.ToolResponse> {
const tool = this._tools.find(tool => tool.schema.name === schema.name)!;
return await tool.handle(this._context!, parsedArguments);
}
serverClosed() {
void this._context!.close();
}
}

View File

@@ -1,36 +0,0 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { z } from 'zod';
import { defineTool } from './tool.js';
const performSchema = z.object({
task: z.string().describe('The task to perform with the browser'),
});
export const perform = defineTool({
schema: {
name: 'browser_perform',
title: 'Perform a task with the browser',
description: 'Perform a task with the browser. It can click, type, export, capture screenshot, drag, hover, select options, etc.',
inputSchema: performSchema,
type: 'destructive',
},
handle: async (context, params) => {
return await context.runTask(params.task);
},
});

View File

@@ -1,32 +0,0 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { z } from 'zod';
import { defineTool } from './tool.js';
export const snapshot = defineTool({
schema: {
name: 'browser_snapshot',
title: 'Take a snapshot of the browser',
description: 'Take a snapshot of the browser to read what is on the page.',
inputSchema: z.object({}),
type: 'readOnly',
},
handle: async (context, params) => {
return await context.runTask('Capture browser snapshot', true);
},
});

View File

@@ -1,29 +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 type { z } from 'zod';
import type * as mcpServer from '../mcp/server.js';
import type { Context } from './context.js';
export type Tool<Input extends z.Schema = z.Schema> = {
schema: mcpServer.ToolSchema<Input>;
handle: (context: Context, params: z.output<Input>) => Promise<mcpServer.ToolResponse>;
};
export function defineTool<Input extends z.Schema>(tool: Tool<Input>): Tool<Input> {
return tool;
}

View File

@@ -1 +0,0 @@
- Generic MCP utils, no dependencies on Playwright here.

View File

@@ -1,92 +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 type { Server } from '@modelcontextprotocol/sdk/server/index.js';
import type { Transport, TransportSendOptions } from '@modelcontextprotocol/sdk/shared/transport.js';
import type { JSONRPCMessage, MessageExtraInfo } from '@modelcontextprotocol/sdk/types.js';
export class InProcessTransport implements Transport {
private _server: Server;
private _serverTransport: InProcessServerTransport;
private _connected: boolean = false;
constructor(server: Server) {
this._server = server;
this._serverTransport = new InProcessServerTransport(this);
}
async start(): Promise<void> {
if (this._connected)
throw new Error('InprocessTransport already started!');
await this._server.connect(this._serverTransport);
this._connected = true;
}
async send(message: JSONRPCMessage, options?: TransportSendOptions): Promise<void> {
if (!this._connected)
throw new Error('Transport not connected');
this._serverTransport._receiveFromClient(message);
}
async close(): Promise<void> {
if (this._connected) {
this._connected = false;
this.onclose?.();
this._serverTransport.onclose?.();
}
}
onclose?: (() => void) | undefined;
onerror?: ((error: Error) => void) | undefined;
onmessage?: ((message: JSONRPCMessage, extra?: MessageExtraInfo) => void) | undefined;
sessionId?: string | undefined;
setProtocolVersion?: ((version: string) => void) | undefined;
_receiveFromServer(message: JSONRPCMessage, extra?: MessageExtraInfo): void {
this.onmessage?.(message, extra);
}
}
class InProcessServerTransport implements Transport {
private _clientTransport: InProcessTransport;
constructor(clientTransport: InProcessTransport) {
this._clientTransport = clientTransport;
}
async start(): Promise<void> {
}
async send(message: JSONRPCMessage, options?: TransportSendOptions): Promise<void> {
this._clientTransport._receiveFromServer(message);
}
async close(): Promise<void> {
this.onclose?.();
}
onclose?: (() => void) | undefined;
onerror?: ((error: Error) => void) | undefined;
onmessage?: ((message: JSONRPCMessage, extra?: MessageExtraInfo) => void) | undefined;
sessionId?: string | undefined;
setProtocolVersion?: ((version: string) => void) | undefined;
_receiveFromClient(message: JSONRPCMessage): void {
this.onmessage?.(message);
}
}

View File

@@ -1,131 +0,0 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { z } from 'zod';
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
import { zodToJsonSchema } from 'zod-to-json-schema';
import type { ImageContent, Implementation, TextContent } from '@modelcontextprotocol/sdk/types.js';
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
export type ClientVersion = Implementation;
export type ToolResponse = {
content: (TextContent | ImageContent)[];
isError?: boolean;
};
export type ToolSchema<Input extends z.Schema> = {
name: string;
title: string;
description: string;
inputSchema: Input;
type: 'readOnly' | 'destructive';
};
export type ToolHandler = (toolName: string, params: any) => Promise<ToolResponse>;
export interface ServerBackend {
name: string;
version: string;
initialize?(): Promise<void>;
tools(): ToolSchema<any>[];
callTool(schema: ToolSchema<any>, parsedArguments: any): Promise<ToolResponse>;
serverInitialized?(version: ClientVersion | undefined): void;
serverClosed?(): void;
}
export type ServerBackendFactory = () => ServerBackend;
export async function connect(serverBackendFactory: ServerBackendFactory, transport: Transport, runHeartbeat: boolean) {
const backend = serverBackendFactory();
await backend.initialize?.();
const server = createServer(backend, runHeartbeat);
await server.connect(transport);
}
export function createServer(backend: ServerBackend, runHeartbeat: boolean): Server {
const server = new Server({ name: backend.name, version: backend.version }, {
capabilities: {
tools: {},
}
});
const tools = backend.tools();
server.setRequestHandler(ListToolsRequestSchema, async () => {
return { tools: tools.map(tool => ({
name: tool.name,
description: tool.description,
inputSchema: zodToJsonSchema(tool.inputSchema),
annotations: {
title: tool.title,
readOnlyHint: tool.type === 'readOnly',
destructiveHint: tool.type === 'destructive',
openWorldHint: true,
},
})) };
});
let heartbeatRunning = false;
server.setRequestHandler(CallToolRequestSchema, async request => {
if (runHeartbeat && !heartbeatRunning) {
heartbeatRunning = true;
startHeartbeat(server);
}
const errorResult = (...messages: string[]) => ({
content: [{ type: 'text', text: messages.join('\n') }],
isError: true,
});
const tool = tools.find(tool => tool.name === request.params.name) as ToolSchema<any>;
if (!tool)
return errorResult(`Tool "${request.params.name}" not found`);
try {
return await backend.callTool(tool, tool.inputSchema.parse(request.params.arguments || {}));
} catch (error) {
return errorResult(String(error));
}
});
addServerListener(server, 'initialized', () => backend.serverInitialized?.(server.getClientVersion()));
addServerListener(server, 'close', () => backend.serverClosed?.());
return server;
}
const startHeartbeat = (server: Server) => {
const beat = () => {
Promise.race([
server.ping(),
new Promise((_, reject) => setTimeout(() => reject(new Error('ping timeout')), 5000)),
]).then(() => {
setTimeout(beat, 3000);
}).catch(() => {
void server.close();
});
};
beat();
};
function addServerListener(server: Server, event: 'close' | 'initialized', listener: () => void) {
const oldListener = server[`on${event}`];
server[`on${event}`] = () => {
oldListener?.();
listener();
};
}

View File

@@ -14,9 +14,9 @@
* limitations under the License.
*/
import fs from 'fs';
import path from 'path';
import url from 'url';
import fs from 'node:fs';
import url from 'node:url';
import path from 'node:path';
const __filename = url.fileURLToPath(import.meta.url);
export const packageJSON = JSON.parse(fs.readFileSync(path.join(path.dirname(__filename), '..', 'package.json'), 'utf8'));

55
src/pageSnapshot.ts Normal file
View File

@@ -0,0 +1,55 @@
/**
* 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 * as playwright from 'playwright';
import { callOnPageNoTrace } from './tools/utils.js';
type PageEx = playwright.Page & {
_snapshotForAI: () => Promise<string>;
};
export class PageSnapshot {
private _page: playwright.Page;
private _text!: string;
constructor(page: playwright.Page) {
this._page = page;
}
static async create(page: playwright.Page): Promise<PageSnapshot> {
const snapshot = new PageSnapshot(page);
await snapshot._build();
return snapshot;
}
text(): string {
return this._text;
}
private async _build() {
const snapshot = await callOnPageNoTrace(this._page, page => (page as PageEx)._snapshotForAI());
this._text = [
`- Page Snapshot:`,
'```yaml',
snapshot,
'```',
].join('\n');
}
refLocator(params: { element: string, ref: string }): playwright.Locator {
return this._page.locator(`aria-ref=${params.ref}`).describe(params.element);
}
}

View File

@@ -18,14 +18,10 @@ import { program, Option } from 'commander';
// @ts-ignore
import { startTraceViewerServer } from 'playwright-core/lib/server';
import * as mcpTransport from './mcp/transport.js';
import { startHttpServer, startHttpTransport, startStdioTransport } from './transport.js';
import { commaSeparatedList, resolveCLIConfig, semicolonSeparatedList } from './config.js';
import { Server } from './server.js';
import { packageJSON } from './package.js';
import { runWithExtension } from './extension/main.js';
import { BrowserServerBackend } from './browserServerBackend.js';
import { Context } from './context.js';
import { contextFactory } from './browserContextFactory.js';
import { runLoopTools } from './loopTools/main.js';
program
.version('Version ' + packageJSON.version)
@@ -49,37 +45,28 @@ program
.option('--port <port>', 'port to listen on for SSE transport.')
.option('--proxy-bypass <bypass>', 'comma-separated domains to bypass proxy, for example ".com,chromium.org,.domain.com"')
.option('--proxy-server <proxy>', 'specify proxy server, for example "http://myproxy:3128" or "socks5://myproxy:8080"')
.option('--save-session', 'Whether to save the Playwright MCP session into the output directory.')
.option('--save-trace', 'Whether to save the Playwright Trace of the session into the output directory.')
.option('--storage-state <path>', 'path to the storage state file for isolated sessions.')
.option('--user-agent <ua string>', 'specify user agent string')
.option('--user-data-dir <path>', 'path to the user data directory. If not specified, a temporary directory will be created.')
.option('--viewport-size <size>', 'specify browser viewport size in pixels, for example "1280, 720"')
.addOption(new Option('--extension', 'Connect to a running browser instance (Edge/Chrome only). Requires the "Playwright MCP Bridge" browser extension to be installed.').hideHelp())
.addOption(new Option('--loop-tools', 'Run loop tools').hideHelp())
.addOption(new Option('--vision', 'Legacy option, use --caps=vision instead').hideHelp())
.action(async options => {
const abortController = setupExitWatchdog();
if (options.vision) {
// eslint-disable-next-line no-console
console.error('The --vision option is deprecated, use --caps=vision instead');
options.caps = 'vision';
}
const config = await resolveCLIConfig(options);
const httpServer = config.server.port !== undefined ? await startHttpServer(config.server) : undefined;
if (options.extension) {
await runWithExtension(config, abortController);
return;
}
if (options.loopTools) {
await runLoopTools(config);
return;
}
const server = new Server(config);
server.setupExitWatchdog();
const browserContextFactory = contextFactory(config.browser);
const serverBackendFactory = () => new BrowserServerBackend(config, browserContextFactory);
await mcpTransport.start(serverBackendFactory, config.server);
if (httpServer)
startHttpTransport(httpServer, server);
else
await startStdioTransport(server);
if (config.saveTrace) {
const server = await startTraceViewerServer();
@@ -90,25 +77,4 @@ program
}
});
function setupExitWatchdog() {
const abortController = new AbortController();
let isExiting = false;
const handleExit = async () => {
if (isExiting)
return;
isExiting = true;
setTimeout(() => process.exit(0), 15000);
abortController.abort('Process exiting');
await Context.disposeAll();
process.exit(0);
};
process.stdin.on('close', handleExit);
process.on('SIGINT', handleExit);
process.on('SIGTERM', handleExit);
return abortController;
}
void program.parseAsync(process.argv);

View File

@@ -1,121 +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 type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types.js';
import type { Context } from './context.js';
export class Response {
private _result: string[] = [];
private _code: string[] = [];
private _images: { contentType: string, data: Buffer }[] = [];
private _context: Context;
private _includeSnapshot = false;
private _includeTabs = false;
private _snapshot: string | undefined;
readonly toolName: string;
readonly toolArgs: Record<string, any>;
constructor(context: Context, toolName: string, toolArgs: Record<string, any>) {
this._context = context;
this.toolName = toolName;
this.toolArgs = toolArgs;
}
addResult(result: string) {
this._result.push(result);
}
result() {
return this._result.join('\n');
}
addCode(code: string) {
this._code.push(code);
}
code() {
return this._code.join('\n');
}
addImage(image: { contentType: string, data: Buffer }) {
this._images.push(image);
}
images() {
return this._images;
}
setIncludeSnapshot() {
this._includeSnapshot = true;
}
setIncludeTabs() {
this._includeTabs = true;
}
async snapshot(): Promise<string> {
if (this._snapshot !== undefined)
return this._snapshot;
if (this._includeSnapshot && this._context.currentTab())
this._snapshot = await this._context.currentTabOrDie().captureSnapshot();
else
this._snapshot = '';
return this._snapshot;
}
async serialize(): Promise<{ content: (TextContent | ImageContent)[] }> {
const response: string[] = [];
// Start with command result.
if (this._result.length) {
response.push('### Result');
response.push(this._result.join('\n'));
response.push('');
}
// Add code if it exists.
if (this._code.length) {
response.push(`### Ran Playwright code
\`\`\`js
${this._code.join('\n')}
\`\`\``);
response.push('');
}
// List browser tabs.
if (this._includeSnapshot || this._includeTabs)
response.push(...(await this._context.listTabsMarkdown(this._includeTabs)));
// Add snapshot if provided.
const snapshot = await this.snapshot();
if (snapshot)
response.push(snapshot, '');
// Main response part
const content: (TextContent | ImageContent)[] = [
{ type: 'text', text: response.join('\n') },
];
// Image attachments.
if (this._context.config.imageResponses !== 'omit') {
for (const image of this._images)
content.push({ type: 'image', data: image.data.toString('base64'), mimeType: image.contentType });
}
return { content };
}
}

59
src/server.ts Normal file
View File

@@ -0,0 +1,59 @@
/**
* 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 { createConnection } from './connection.js';
import { contextFactory } from './browserContextFactory.js';
import type { FullConfig } from './config.js';
import type { Connection } from './connection.js';
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
import type { BrowserContextFactory } from './browserContextFactory.js';
export class Server {
readonly config: FullConfig;
private _connectionList: Connection[] = [];
private _browserConfig: FullConfig['browser'];
private _contextFactory: BrowserContextFactory;
constructor(config: FullConfig) {
this.config = config;
this._browserConfig = config.browser;
this._contextFactory = contextFactory(this._browserConfig);
}
async createConnection(transport: Transport): Promise<Connection> {
const connection = createConnection(this.config, this._contextFactory);
this._connectionList.push(connection);
await connection.server.connect(transport);
return connection;
}
setupExitWatchdog() {
let isExiting = false;
const handleExit = async () => {
if (isExiting)
return;
isExiting = true;
setTimeout(() => process.exit(0), 15000);
await Promise.all(this._connectionList.map(connection => connection.close()));
process.exit(0);
};
process.stdin.on('close', handleExit);
process.on('SIGINT', handleExit);
process.on('SIGTERM', handleExit);
}
}

View File

@@ -1,92 +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 fs from 'fs';
import path from 'path';
import { outputFile } from './config.js';
import { Response } from './response.js';
import type { FullConfig } from './config.js';
let sessionOrdinal = 0;
export class SessionLog {
private _folder: string;
private _file: string;
private _ordinal = 0;
constructor(sessionFolder: string) {
this._folder = sessionFolder;
this._file = path.join(this._folder, 'session.md');
}
static async create(config: FullConfig): Promise<SessionLog> {
const sessionFolder = await outputFile(config, `session-${(++sessionOrdinal).toString().padStart(3, '0')}`);
await fs.promises.mkdir(sessionFolder, { recursive: true });
// eslint-disable-next-line no-console
console.error(`Session: ${sessionFolder}`);
return new SessionLog(sessionFolder);
}
async log(response: Response) {
const prefix = `${(++this._ordinal).toString().padStart(3, '0')}`;
const lines: string[] = [
`### Tool: ${response.toolName}`,
``,
`- Args`,
'```json',
JSON.stringify(response.toolArgs, null, 2),
'```',
];
if (response.result()) {
lines.push(
`- Result`,
'```',
response.result(),
'```');
}
if (response.code()) {
lines.push(
`- Code`,
'```js',
response.code(),
'```');
}
const snapshot = await response.snapshot();
if (snapshot) {
const fileName = `${prefix}.snapshot.yml`;
await fs.promises.writeFile(path.join(this._folder, fileName), snapshot);
lines.push(`- Snapshot: ${fileName}`);
}
for (const image of response.images()) {
const fileName = `${prefix}.screenshot.${extension(image.contentType)}`;
await fs.promises.writeFile(path.join(this._folder, fileName), image.data);
lines.push(`- Screenshot: ${fileName}`);
}
lines.push('', '');
await fs.promises.appendFile(this._file, lines.join('\n'));
}
}
function extension(contentType: string): 'jpg' | 'png' {
if (contentType === 'image/jpeg')
return 'jpg';
return 'png';
}

View File

@@ -14,40 +14,24 @@
* limitations under the License.
*/
import { EventEmitter } from 'events';
import * as playwright from 'playwright';
import { callOnPageNoTrace, waitForCompletion } from './tools/utils.js';
import { PageSnapshot } from './pageSnapshot.js';
import { callOnPageNoTrace } from './tools/utils.js';
import { logUnhandledError } from './log.js';
import { ManualPromise } from './manualPromise.js';
import { ModalState } from './tools/tool.js';
import { outputFile } from './config.js';
import type { Context } from './context.js';
type PageEx = playwright.Page & {
_snapshotForAI: () => Promise<string>;
};
export const TabEvents = {
modalState: 'modalState'
};
export type TabEventsInterface = {
[TabEvents.modalState]: [modalState: ModalState];
};
export class Tab extends EventEmitter<TabEventsInterface> {
export class Tab {
readonly context: Context;
readonly page: playwright.Page;
private _consoleMessages: ConsoleMessage[] = [];
private _recentConsoleMessages: ConsoleMessage[] = [];
private _requests: Map<playwright.Request, playwright.Response | null> = new Map();
private _snapshot: PageSnapshot | undefined;
private _onPageClose: (tab: Tab) => void;
private _modalStates: ModalState[] = [];
private _downloads: { download: playwright.Download, finished: boolean, outputFile: string }[] = [];
constructor(context: Context, page: playwright.Page, onPageClose: (tab: Tab) => void) {
super();
this.context = context;
this.page = page;
this._onPageClose = onPageClose;
@@ -57,63 +41,20 @@ export class Tab extends EventEmitter<TabEventsInterface> {
page.on('response', response => this._requests.set(response.request(), response));
page.on('close', () => this._onClose());
page.on('filechooser', chooser => {
this.setModalState({
this.context.setModalState({
type: 'fileChooser',
description: 'File chooser',
fileChooser: chooser,
}, this);
});
});
page.on('dialog', dialog => this._dialogShown(dialog));
page.on('dialog', dialog => this.context.dialogShown(this, dialog));
page.on('download', download => {
void this._downloadStarted(download);
void this.context.downloadStarted(this, download);
});
page.setDefaultNavigationTimeout(60000);
page.setDefaultTimeout(5000);
}
modalStates(): ModalState[] {
return this._modalStates;
}
setModalState(modalState: ModalState) {
this._modalStates.push(modalState);
this.emit(TabEvents.modalState, modalState);
}
clearModalState(modalState: ModalState) {
this._modalStates = this._modalStates.filter(state => state !== modalState);
}
modalStatesMarkdown(): string[] {
const result: string[] = ['### Modal state'];
if (this._modalStates.length === 0)
result.push('- There is no modal state present');
for (const state of this._modalStates) {
const tool = this.context.tools.filter(tool => 'clearsModalState' in tool).find(tool => tool.clearsModalState === state.type);
result.push(`- [${state.description}]: can be handled by the "${tool?.schema.name}" tool`);
}
return result;
}
private _dialogShown(dialog: playwright.Dialog) {
this.setModalState({
type: 'dialog',
description: `"${dialog.type()}" dialog with message "${dialog.message()}"`,
dialog,
});
}
private async _downloadStarted(download: playwright.Download) {
const entry = {
download,
finished: false,
outputFile: await outputFile(this.context.config, download.suggestedFilename())
};
this._downloads.push(entry);
await download.saveAs(entry.outputFile);
entry.finished = true;
}
private _clearCollectedArtifacts() {
this._consoleMessages.length = 0;
this._recentConsoleMessages.length = 0;
@@ -154,19 +95,26 @@ export class Tab extends EventEmitter<TabEventsInterface> {
// on chromium, the download event is fired *after* page.goto rejects, so we wait a lil bit
const download = await Promise.race([
downloadEvent,
new Promise(resolve => setTimeout(resolve, 3000)),
new Promise(resolve => setTimeout(resolve, 1000)),
]);
if (!download)
throw e;
// Make sure other "download" listeners are notified first.
await new Promise(resolve => setTimeout(resolve, 500));
return;
}
// Cap load event to 5 seconds, the page is operational at this point.
await this.waitForLoadState('load', { timeout: 5000 });
}
hasSnapshot(): boolean {
return !!this._snapshot;
}
snapshotOrDie(): PageSnapshot {
if (!this._snapshot)
throw new Error('No snapshot available');
return this._snapshot;
}
consoleMessages(): ConsoleMessage[] {
return this._consoleMessages;
}
@@ -175,103 +123,15 @@ export class Tab extends EventEmitter<TabEventsInterface> {
return this._requests;
}
private _takeRecentConsoleMarkdown(): string[] {
if (!this._recentConsoleMessages.length)
return [];
const result = this._recentConsoleMessages.map(message => {
return `- ${trim(message.toString(), 100)}`;
});
return [`### New console messages`, ...result, ''];
async captureSnapshot() {
this._snapshot = await PageSnapshot.create(this.page);
}
private _listDownloadsMarkdown(): string[] {
if (!this._downloads.length)
return [];
const result: string[] = ['### Downloads'];
for (const entry of this._downloads) {
if (entry.finished)
result.push(`- Downloaded file ${entry.download.suggestedFilename()} to ${entry.outputFile}`);
else
result.push(`- Downloading file ${entry.download.suggestedFilename()} ...`);
}
result.push('');
takeRecentConsoleMessages(): ConsoleMessage[] {
const result = this._recentConsoleMessages.slice();
this._recentConsoleMessages.length = 0;
return result;
}
async captureSnapshot(): Promise<string> {
const result: string[] = [];
if (this.modalStates().length) {
result.push(...this.modalStatesMarkdown());
return result.join('\n');
}
result.push(...this._takeRecentConsoleMarkdown());
result.push(...this._listDownloadsMarkdown());
await this._raceAgainstModalStates(async () => {
const snapshot = await (this.page as PageEx)._snapshotForAI();
result.push(
`### Page state`,
`- Page URL: ${this.page.url()}`,
`- Page Title: ${await this.page.title()}`,
`- Page Snapshot:`,
'```yaml',
snapshot,
'```',
);
});
return result.join('\n');
}
private _javaScriptBlocked(): boolean {
return this._modalStates.some(state => state.type === 'dialog');
}
private async _raceAgainstModalStates(action: () => Promise<void>): Promise<ModalState | undefined> {
if (this.modalStates().length)
return this.modalStates()[0];
const promise = new ManualPromise<ModalState>();
const listener = (modalState: ModalState) => promise.resolve(modalState);
this.once(TabEvents.modalState, listener);
return await Promise.race([
action().then(() => {
this.off(TabEvents.modalState, listener);
return undefined;
}),
promise,
]);
}
async waitForCompletion(callback: () => Promise<void>) {
await this._raceAgainstModalStates(() => waitForCompletion(this, callback));
}
async refLocator(params: { element: string, ref: string }): Promise<playwright.Locator> {
return (await this.refLocators([params]))[0];
}
async refLocators(params: { element: string, ref: string }[]): Promise<playwright.Locator[]> {
const snapshot = await (this.page as PageEx)._snapshotForAI();
return params.map(param => {
if (!snapshot.includes(`[ref=${param.ref}]`))
throw new Error(`Ref ${param.ref} not found in the current page snapshot. Try capturing new snapshot.`);
return this.page.locator(`aria-ref=${param.ref}`).describe(param.element);
});
}
async waitForTimeout(time: number) {
if (this._javaScriptBlocked()) {
await new Promise(f => setTimeout(f, time));
return;
}
await callOnPageNoTrace(this.page, page => {
return page.evaluate(() => new Promise(f => setTimeout(f, 1000)));
});
}
}
export type ConsoleMessage = {
@@ -302,9 +162,3 @@ function pageErrorToConsoleMessage(errorOrValue: Error | any): ConsoleMessage {
toString: () => String(errorOrValue),
};
}
function trim(text: string, maxLength: number) {
if (text.length <= maxLength)
return text;
return text.slice(0, maxLength) + '...';
}

View File

@@ -31,7 +31,6 @@ import wait from './tools/wait.js';
import mouse from './tools/mouse.js';
import type { Tool } from './tools/tool.js';
import type { FullConfig } from './config.js';
export const allTools: Tool<any>[] = [
...common,
@@ -50,7 +49,3 @@ export const allTools: Tool<any>[] = [
...tabs,
...wait,
];
export function filteredTools(config: FullConfig) {
return allTools.filter(tool => tool.capability.startsWith('core') || config.capabilities?.includes(tool.capability));
}

View File

@@ -15,7 +15,7 @@
*/
import { z } from 'zod';
import { defineTabTool, defineTool } from './tool.js';
import { defineTool } from './tool.js';
const close = defineTool({
capability: 'core',
@@ -28,14 +28,17 @@ const close = defineTool({
type: 'readOnly',
},
handle: async (context, params, response) => {
await context.closeBrowserContext();
response.setIncludeTabs();
response.addCode(`await page.close()`);
handle: async context => {
await context.close();
return {
code: [`await page.close()`],
captureSnapshot: false,
waitForNetwork: false,
};
},
});
const resize = defineTabTool({
const resize = defineTool({
capability: 'core',
schema: {
name: 'browser_resize',
@@ -48,13 +51,24 @@ const resize = defineTabTool({
type: 'readOnly',
},
handle: async (tab, params, response) => {
response.addCode(`// Resize browser window to ${params.width}x${params.height}`);
response.addCode(`await page.setViewportSize({ width: ${params.width}, height: ${params.height} });`);
handle: async (context, params) => {
const tab = context.currentTabOrDie();
await tab.waitForCompletion(async () => {
const code = [
`// Resize browser window to ${params.width}x${params.height}`,
`await page.setViewportSize({ width: ${params.width}, height: ${params.height} });`
];
const action = async () => {
await tab.page.setViewportSize({ width: params.width, height: params.height });
});
};
return {
code,
action,
captureSnapshot: true,
waitForNetwork: true
};
},
});

View File

@@ -15,9 +15,9 @@
*/
import { z } from 'zod';
import { defineTabTool } from './tool.js';
import { defineTool } from './tool.js';
const console = defineTabTool({
const console = defineTool({
capability: 'core',
schema: {
name: 'browser_console_messages',
@@ -26,8 +26,19 @@ const console = defineTabTool({
inputSchema: z.object({}),
type: 'readOnly',
},
handle: async (tab, params, response) => {
tab.consoleMessages().map(message => response.addResult(message.toString()));
handle: async context => {
const messages = context.currentTabOrDie().consoleMessages();
const log = messages.map(message => message.toString()).join('\n');
return {
code: [`// <internal code to get console messages>`],
action: async () => {
return {
content: [{ type: 'text', text: log }]
};
},
captureSnapshot: false,
waitForNetwork: false,
};
},
});

View File

@@ -15,9 +15,9 @@
*/
import { z } from 'zod';
import { defineTabTool } from './tool.js';
import { defineTool } from './tool.js';
const handleDialog = defineTabTool({
const handleDialog = defineTool({
capability: 'core',
schema: {
@@ -31,20 +31,27 @@ const handleDialog = defineTabTool({
type: 'destructive',
},
handle: async (tab, params, response) => {
response.setIncludeSnapshot();
const dialogState = tab.modalStates().find(state => state.type === 'dialog');
handle: async (context, params) => {
const dialogState = context.modalStates().find(state => state.type === 'dialog');
if (!dialogState)
throw new Error('No dialog visible');
tab.clearModalState(dialogState);
await tab.waitForCompletion(async () => {
if (params.accept)
await dialogState.dialog.accept(params.promptText);
else
await dialogState.dialog.dismiss();
});
context.clearModalState(dialogState);
const code = [
`// <internal code to handle "${dialogState.dialog.type()}" dialog>`,
];
return {
code,
captureSnapshot: true,
waitForNetwork: false,
};
},
clearsModalState: 'dialog',

View File

@@ -16,7 +16,7 @@
import { z } from 'zod';
import { defineTabTool } from './tool.js';
import { defineTool } from './tool.js';
import * as javascript from '../javascript.js';
import { generateLocator } from './utils.js';
@@ -28,7 +28,7 @@ const evaluateSchema = z.object({
ref: z.string().optional().describe('Exact target element reference from the page snapshot'),
});
const evaluate = defineTabTool({
const evaluate = defineTool({
capability: 'core',
schema: {
name: 'browser_evaluate',
@@ -38,22 +38,31 @@ const evaluate = defineTabTool({
type: 'destructive',
},
handle: async (tab, params, response) => {
response.setIncludeSnapshot();
handle: async (context, params) => {
const tab = context.currentTabOrDie();
const code: string[] = [];
let locator: playwright.Locator | undefined;
if (params.ref && params.element) {
locator = await tab.refLocator({ ref: params.ref, element: params.element });
response.addCode(`await page.${await generateLocator(locator)}.evaluate(${javascript.quote(params.function)});`);
const snapshot = tab.snapshotOrDie();
locator = snapshot.refLocator({ ref: params.ref, element: params.element });
code.push(`await page.${await generateLocator(locator)}.evaluate(${javascript.quote(params.function)});`);
} else {
response.addCode(`await page.evaluate(${javascript.quote(params.function)});`);
code.push(`await page.evaluate(${javascript.quote(params.function)});`);
}
await tab.waitForCompletion(async () => {
return {
code,
action: async () => {
const receiver = locator ?? tab.page as any;
const result = await receiver._evaluateFunction(params.function);
response.addResult(JSON.stringify(result, null, 2) || 'undefined');
});
return {
content: [{ type: 'text', text: '- Result: ' + (JSON.stringify(result, null, 2) || 'undefined') }],
};
},
captureSnapshot: false,
waitForNetwork: false,
};
},
});

View File

@@ -15,9 +15,9 @@
*/
import { z } from 'zod';
import { defineTabTool } from './tool.js';
import { defineTool } from './tool.js';
const uploadFile = defineTabTool({
const uploadFile = defineTool({
capability: 'core',
schema: {
@@ -30,20 +30,26 @@ const uploadFile = defineTabTool({
type: 'destructive',
},
handle: async (tab, params, response) => {
response.setIncludeSnapshot();
const modalState = tab.modalStates().find(state => state.type === 'fileChooser');
handle: async (context, params) => {
const modalState = context.modalStates().find(state => state.type === 'fileChooser');
if (!modalState)
throw new Error('No file chooser visible');
response.addCode(`// Select files for upload`);
response.addCode(`await fileChooser.setFiles(${JSON.stringify(params.paths)})`);
const code = [
`// <internal code to chose files ${params.paths.join(', ')}`,
];
tab.clearModalState(modalState);
await tab.waitForCompletion(async () => {
const action = async () => {
await modalState.fileChooser.setFiles(params.paths);
});
context.clearModalState(modalState);
};
return {
code,
action,
captureSnapshot: true,
waitForNetwork: true,
};
},
clearsModalState: 'fileChooser',
});

View File

@@ -16,10 +16,11 @@
import { fork } from 'child_process';
import path from 'path';
import { fileURLToPath } from 'url';
import { z } from 'zod';
import { defineTool } from './tool.js';
import { fileURLToPath } from 'node:url';
const install = defineTool({
capability: 'core-install',
@@ -31,7 +32,7 @@ const install = defineTool({
type: 'destructive',
},
handle: async (context, params, response) => {
handle: async context => {
const channel = context.config.browser?.launchOptions?.channel ?? context.config.browser?.browserName ?? 'chrome';
const cliUrl = import.meta.resolve('playwright/package.json');
const cliPath = path.join(fileURLToPath(cliUrl), '..', 'cli.js');
@@ -49,7 +50,11 @@ const install = defineTool({
reject(new Error(`Failed to install browser: ${output.join('')}`));
});
});
response.setIncludeTabs();
return {
code: [`// Browser ${channel} installed`],
captureSnapshot: false,
waitForNetwork: false,
};
},
});

View File

@@ -16,12 +16,12 @@
import { z } from 'zod';
import { defineTabTool } from './tool.js';
import { defineTool } from './tool.js';
import { elementSchema } from './snapshot.js';
import { generateLocator } from './utils.js';
import * as javascript from '../javascript.js';
const pressKey = defineTabTool({
const pressKey = defineTool({
capability: 'core',
schema: {
@@ -34,14 +34,22 @@ const pressKey = defineTabTool({
type: 'destructive',
},
handle: async (tab, params, response) => {
response.setIncludeSnapshot();
response.addCode(`// Press ${params.key}`);
response.addCode(`await page.keyboard.press('${params.key}');`);
handle: async (context, params) => {
const tab = context.currentTabOrDie();
await tab.waitForCompletion(async () => {
await tab.page.keyboard.press(params.key);
});
const code = [
`// Press ${params.key}`,
`await page.keyboard.press('${params.key}');`,
];
const action = () => tab.page.keyboard.press(params.key);
return {
code,
action,
captureSnapshot: true,
waitForNetwork: true
};
},
});
@@ -51,7 +59,7 @@ const typeSchema = elementSchema.extend({
slowly: z.boolean().optional().describe('Whether to type one character at a time. Useful for triggering key handlers in the page. By default entire text is filled in at once.'),
});
const type = defineTabTool({
const type = defineTool({
capability: 'core',
schema: {
name: 'browser_type',
@@ -61,27 +69,35 @@ const type = defineTabTool({
type: 'destructive',
},
handle: async (tab, params, response) => {
const locator = await tab.refLocator(params);
handle: async (context, params) => {
const snapshot = context.currentTabOrDie().snapshotOrDie();
const locator = snapshot.refLocator(params);
const code: string[] = [];
const steps: (() => Promise<void>)[] = [];
await tab.waitForCompletion(async () => {
if (params.slowly) {
response.setIncludeSnapshot();
response.addCode(`// Press "${params.text}" sequentially into "${params.element}"`);
response.addCode(`await page.${await generateLocator(locator)}.pressSequentially(${javascript.quote(params.text)});`);
await locator.pressSequentially(params.text);
code.push(`// Press "${params.text}" sequentially into "${params.element}"`);
code.push(`await page.${await generateLocator(locator)}.pressSequentially(${javascript.quote(params.text)});`);
steps.push(() => locator.pressSequentially(params.text));
} else {
response.addCode(`// Fill "${params.text}" into "${params.element}"`);
response.addCode(`await page.${await generateLocator(locator)}.fill(${javascript.quote(params.text)});`);
await locator.fill(params.text);
code.push(`// Fill "${params.text}" into "${params.element}"`);
code.push(`await page.${await generateLocator(locator)}.fill(${javascript.quote(params.text)});`);
steps.push(() => locator.fill(params.text));
}
if (params.submit) {
response.setIncludeSnapshot();
response.addCode(`await page.${await generateLocator(locator)}.press('Enter');`);
await locator.press('Enter');
code.push(`// Submit text`);
code.push(`await page.${await generateLocator(locator)}.press('Enter');`);
steps.push(() => locator.press('Enter'));
}
});
return {
code,
action: () => steps.reduce((acc, step) => acc.then(step), Promise.resolve()),
captureSnapshot: true,
waitForNetwork: true,
};
},
});

View File

@@ -15,13 +15,13 @@
*/
import { z } from 'zod';
import { defineTabTool } from './tool.js';
import { defineTool } from './tool.js';
const elementSchema = z.object({
element: z.string().describe('Human-readable element description used to obtain permission to interact with the element'),
});
const mouseMove = defineTabTool({
const mouseMove = defineTool({
capability: 'vision',
schema: {
name: 'browser_mouse_move_xy',
@@ -34,17 +34,23 @@ const mouseMove = defineTabTool({
type: 'readOnly',
},
handle: async (tab, params, response) => {
response.addCode(`// Move mouse to (${params.x}, ${params.y})`);
response.addCode(`await page.mouse.move(${params.x}, ${params.y});`);
await tab.waitForCompletion(async () => {
await tab.page.mouse.move(params.x, params.y);
});
handle: async (context, params) => {
const tab = context.currentTabOrDie();
const code = [
`// Move mouse to (${params.x}, ${params.y})`,
`await page.mouse.move(${params.x}, ${params.y});`,
];
const action = () => tab.page.mouse.move(params.x, params.y);
return {
code,
action,
captureSnapshot: false,
waitForNetwork: false
};
},
});
const mouseClick = defineTabTool({
const mouseClick = defineTool({
capability: 'vision',
schema: {
name: 'browser_mouse_click_xy',
@@ -57,23 +63,29 @@ const mouseClick = defineTabTool({
type: 'destructive',
},
handle: async (tab, params, response) => {
response.setIncludeSnapshot();
response.addCode(`// Click mouse at coordinates (${params.x}, ${params.y})`);
response.addCode(`await page.mouse.move(${params.x}, ${params.y});`);
response.addCode(`await page.mouse.down();`);
response.addCode(`await page.mouse.up();`);
await tab.waitForCompletion(async () => {
handle: async (context, params) => {
const tab = context.currentTabOrDie();
const code = [
`// Click mouse at coordinates (${params.x}, ${params.y})`,
`await page.mouse.move(${params.x}, ${params.y});`,
`await page.mouse.down();`,
`await page.mouse.up();`,
];
const action = async () => {
await tab.page.mouse.move(params.x, params.y);
await tab.page.mouse.down();
await tab.page.mouse.up();
});
};
return {
code,
action,
captureSnapshot: false,
waitForNetwork: true,
};
},
});
const mouseDrag = defineTabTool({
const mouseDrag = defineTool({
capability: 'vision',
schema: {
name: 'browser_mouse_drag_xy',
@@ -88,21 +100,30 @@ const mouseDrag = defineTabTool({
type: 'destructive',
},
handle: async (tab, params, response) => {
response.setIncludeSnapshot();
handle: async (context, params) => {
const tab = context.currentTabOrDie();
response.addCode(`// Drag mouse from (${params.startX}, ${params.startY}) to (${params.endX}, ${params.endY})`);
response.addCode(`await page.mouse.move(${params.startX}, ${params.startY});`);
response.addCode(`await page.mouse.down();`);
response.addCode(`await page.mouse.move(${params.endX}, ${params.endY});`);
response.addCode(`await page.mouse.up();`);
const code = [
`// Drag mouse from (${params.startX}, ${params.startY}) to (${params.endX}, ${params.endY})`,
`await page.mouse.move(${params.startX}, ${params.startY});`,
`await page.mouse.down();`,
`await page.mouse.move(${params.endX}, ${params.endY});`,
`await page.mouse.up();`,
];
await tab.waitForCompletion(async () => {
const action = async () => {
await tab.page.mouse.move(params.startX, params.startY);
await tab.page.mouse.down();
await tab.page.mouse.move(params.endX, params.endY);
await tab.page.mouse.up();
});
};
return {
code,
action,
captureSnapshot: false,
waitForNetwork: true,
};
},
});

View File

@@ -15,7 +15,7 @@
*/
import { z } from 'zod';
import { defineTool, defineTabTool } from './tool.js';
import { defineTool } from './tool.js';
const navigate = defineTool({
capability: 'core',
@@ -30,17 +30,24 @@ const navigate = defineTool({
type: 'destructive',
},
handle: async (context, params, response) => {
handle: async (context, params) => {
const tab = await context.ensureTab();
await tab.navigate(params.url);
response.setIncludeSnapshot();
response.addCode(`// Navigate to ${params.url}`);
response.addCode(`await page.goto('${params.url}');`);
const code = [
`// Navigate to ${params.url}`,
`await page.goto('${params.url}');`,
];
return {
code,
captureSnapshot: true,
waitForNetwork: false,
};
},
});
const goBack = defineTabTool({
const goBack = defineTool({
capability: 'core',
schema: {
name: 'browser_navigate_back',
@@ -50,15 +57,23 @@ const goBack = defineTabTool({
type: 'readOnly',
},
handle: async (tab, params, response) => {
handle: async context => {
const tab = await context.ensureTab();
await tab.page.goBack();
response.setIncludeSnapshot();
response.addCode(`// Navigate back`);
response.addCode(`await page.goBack();`);
const code = [
`// Navigate back`,
`await page.goBack();`,
];
return {
code,
captureSnapshot: true,
waitForNetwork: false,
};
},
});
const goForward = defineTabTool({
const goForward = defineTool({
capability: 'core',
schema: {
name: 'browser_navigate_forward',
@@ -67,11 +82,18 @@ const goForward = defineTabTool({
inputSchema: z.object({}),
type: 'readOnly',
},
handle: async (tab, params, response) => {
handle: async context => {
const tab = context.currentTabOrDie();
await tab.page.goForward();
response.setIncludeSnapshot();
response.addCode(`// Navigate forward`);
response.addCode(`await page.goForward();`);
const code = [
`// Navigate forward`,
`await page.goForward();`,
];
return {
code,
captureSnapshot: true,
waitForNetwork: false,
};
},
});

View File

@@ -15,11 +15,11 @@
*/
import { z } from 'zod';
import { defineTabTool } from './tool.js';
import { defineTool } from './tool.js';
import type * as playwright from 'playwright';
const requests = defineTabTool({
const requests = defineTool({
capability: 'core',
schema: {
@@ -30,9 +30,19 @@ const requests = defineTabTool({
type: 'readOnly',
},
handle: async (tab, params, response) => {
const requests = tab.requests();
[...requests.entries()].forEach(([req, res]) => response.addResult(renderRequest(req, res)));
handle: async context => {
const requests = context.currentTabOrDie().requests();
const log = [...requests.entries()].map(([request, response]) => renderRequest(request, response)).join('\n');
return {
code: [`// <internal code to list network requests>`],
action: async () => {
return {
content: [{ type: 'text', text: log }]
};
},
captureSnapshot: false,
waitForNetwork: false,
};
},
});

View File

@@ -15,7 +15,7 @@
*/
import { z } from 'zod';
import { defineTabTool } from './tool.js';
import { defineTool } from './tool.js';
import * as javascript from '../javascript.js';
import { outputFile } from '../config.js';
@@ -24,7 +24,7 @@ const pdfSchema = z.object({
filename: z.string().optional().describe('File name to save the pdf to. Defaults to `page-{timestamp}.pdf` if not specified.'),
});
const pdf = defineTabTool({
const pdf = defineTool({
capability: 'pdf',
schema: {
@@ -35,12 +35,21 @@ const pdf = defineTabTool({
type: 'readOnly',
},
handle: async (tab, params, response) => {
const fileName = await outputFile(tab.context.config, params.filename ?? `page-${new Date().toISOString()}.pdf`);
response.addCode(`// Save page as ${fileName}`);
response.addCode(`await page.pdf(${javascript.formatObject({ path: fileName })});`);
response.addResult(`Saved page as ${fileName}`);
await tab.page.pdf({ path: fileName });
handle: async (context, params) => {
const tab = context.currentTabOrDie();
const fileName = await outputFile(context.config, params.filename ?? `page-${new Date().toISOString()}.pdf`);
const code = [
`// Save page as ${fileName}`,
`await page.pdf(${javascript.formatObject({ path: fileName })});`,
];
return {
code,
action: async () => tab.page.pdf({ path: fileName }).then(() => {}),
captureSnapshot: false,
waitForNetwork: false,
};
},
});

View File

@@ -16,7 +16,7 @@
import { z } from 'zod';
import { defineTabTool } from './tool.js';
import { defineTool } from './tool.js';
import * as javascript from '../javascript.js';
import { outputFile } from '../config.js';
import { generateLocator } from './utils.js';
@@ -28,20 +28,14 @@ const screenshotSchema = z.object({
filename: z.string().optional().describe('File name to save the screenshot to. Defaults to `page-{timestamp}.{png|jpeg}` if not specified.'),
element: z.string().optional().describe('Human-readable element description used to obtain permission to screenshot the element. If not provided, the screenshot will be taken of viewport. If element is provided, ref must be provided too.'),
ref: z.string().optional().describe('Exact target element reference from the page snapshot. If not provided, the screenshot will be taken of viewport. If ref is provided, element must be provided too.'),
fullPage: z.boolean().optional().describe('When true, takes a screenshot of the full scrollable page, instead of the currently visible viewport. Cannot be used with element screenshots.'),
}).refine(data => {
return !!data.element === !!data.ref;
}, {
message: 'Both element and ref must be provided or neither.',
path: ['ref', 'element']
}).refine(data => {
return !(data.fullPage && (data.element || data.ref));
}, {
message: 'fullPage cannot be used with element screenshots.',
path: ['fullPage']
});
const screenshot = defineTabTool({
const screenshot = defineTool({
capability: 'core',
schema: {
name: 'browser_take_screenshot',
@@ -51,35 +45,43 @@ const screenshot = defineTabTool({
type: 'readOnly',
},
handle: async (tab, params, response) => {
handle: async (context, params) => {
const tab = context.currentTabOrDie();
const snapshot = tab.snapshotOrDie();
const fileType = params.raw ? 'png' : 'jpeg';
const fileName = await outputFile(tab.context.config, params.filename ?? `page-${new Date().toISOString()}.${fileType}`);
const options: playwright.PageScreenshotOptions = {
type: fileType,
quality: fileType === 'png' ? undefined : 50,
scale: 'css',
path: fileName,
...(params.fullPage !== undefined && { fullPage: params.fullPage })
};
const fileName = await outputFile(context.config, params.filename ?? `page-${new Date().toISOString()}.${fileType}`);
const options: playwright.PageScreenshotOptions = { type: fileType, quality: fileType === 'png' ? undefined : 50, scale: 'css', path: fileName };
const isElementScreenshot = params.element && params.ref;
const screenshotTarget = isElementScreenshot ? params.element : (params.fullPage ? 'full page' : 'viewport');
response.addCode(`// Screenshot ${screenshotTarget} and save it as ${fileName}`);
const code = [
`// Screenshot ${isElementScreenshot ? params.element : 'viewport'} and save it as ${fileName}`,
];
// Only get snapshot when element screenshot is needed
const locator = params.ref ? await tab.refLocator({ element: params.element || '', ref: params.ref }) : null;
const locator = params.ref ? snapshot.refLocator({ element: params.element || '', ref: params.ref }) : null;
if (locator)
response.addCode(`await page.${await generateLocator(locator)}.screenshot(${javascript.formatObject(options)});`);
code.push(`await page.${await generateLocator(locator)}.screenshot(${javascript.formatObject(options)});`);
else
response.addCode(`await page.screenshot(${javascript.formatObject(options)});`);
code.push(`await page.screenshot(${javascript.formatObject(options)});`);
const buffer = locator ? await locator.screenshot(options) : await tab.page.screenshot(options);
response.addResult(`Took the ${screenshotTarget} screenshot and saved it as ${fileName}`);
response.addImage({
contentType: fileType === 'png' ? 'image/png' : 'image/jpeg',
data: buffer
});
const includeBase64 = context.clientSupportsImages();
const action = async () => {
const screenshot = locator ? await locator.screenshot(options) : await tab.page.screenshot(options);
return {
content: includeBase64 ? [{
type: 'image' as 'image',
data: screenshot.toString('base64'),
mimeType: fileType === 'png' ? 'image/png' : 'image/jpeg',
}] : []
};
};
return {
code,
action,
captureSnapshot: false,
waitForNetwork: false,
};
}
});

View File

@@ -16,7 +16,7 @@
import { z } from 'zod';
import { defineTabTool, defineTool } from './tool.js';
import { defineTool } from './tool.js';
import * as javascript from '../javascript.js';
import { generateLocator } from './utils.js';
@@ -30,9 +30,14 @@ const snapshot = defineTool({
type: 'readOnly',
},
handle: async (context, params, response) => {
handle: async context => {
await context.ensureTab();
response.setIncludeSnapshot();
return {
code: [`// <internal code to capture accessibility snapshot>`],
captureSnapshot: true,
waitForNetwork: false,
};
},
});
@@ -46,7 +51,7 @@ const clickSchema = elementSchema.extend({
button: z.enum(['left', 'right', 'middle']).optional().describe('Button to click, defaults to left'),
});
const click = defineTabTool({
const click = defineTool({
capability: 'core',
schema: {
name: 'browser_click',
@@ -56,31 +61,31 @@ const click = defineTabTool({
type: 'destructive',
},
handle: async (tab, params, response) => {
response.setIncludeSnapshot();
const locator = await tab.refLocator(params);
handle: async (context, params) => {
const tab = context.currentTabOrDie();
const locator = tab.snapshotOrDie().refLocator(params);
const button = params.button;
const buttonAttr = button ? `{ button: '${button}' }` : '';
const code: string[] = [];
if (params.doubleClick) {
response.addCode(`// Double click ${params.element}`);
response.addCode(`await page.${await generateLocator(locator)}.dblclick(${buttonAttr});`);
code.push(`// Double click ${params.element}`);
code.push(`await page.${await generateLocator(locator)}.dblclick(${buttonAttr});`);
} else {
response.addCode(`// Click ${params.element}`);
response.addCode(`await page.${await generateLocator(locator)}.click(${buttonAttr});`);
code.push(`// Click ${params.element}`);
code.push(`await page.${await generateLocator(locator)}.click(${buttonAttr});`);
}
await tab.waitForCompletion(async () => {
if (params.doubleClick)
await locator.dblclick({ button });
else
await locator.click({ button });
});
return {
code,
action: () => params.doubleClick ? locator.dblclick({ button }) : locator.click({ button }),
captureSnapshot: true,
waitForNetwork: true,
};
},
});
const drag = defineTabTool({
const drag = defineTool({
capability: 'core',
schema: {
name: 'browser_drag',
@@ -95,23 +100,26 @@ const drag = defineTabTool({
type: 'destructive',
},
handle: async (tab, params, response) => {
response.setIncludeSnapshot();
handle: async (context, params) => {
const snapshot = context.currentTabOrDie().snapshotOrDie();
const startLocator = snapshot.refLocator({ ref: params.startRef, element: params.startElement });
const endLocator = snapshot.refLocator({ ref: params.endRef, element: params.endElement });
const [startLocator, endLocator] = await tab.refLocators([
{ ref: params.startRef, element: params.startElement },
{ ref: params.endRef, element: params.endElement },
]);
const code = [
`// Drag ${params.startElement} to ${params.endElement}`,
`await page.${await generateLocator(startLocator)}.dragTo(page.${await generateLocator(endLocator)});`
];
await tab.waitForCompletion(async () => {
await startLocator.dragTo(endLocator);
});
response.addCode(`await page.${await generateLocator(startLocator)}.dragTo(page.${await generateLocator(endLocator)});`);
return {
code,
action: () => startLocator.dragTo(endLocator),
captureSnapshot: true,
waitForNetwork: true,
};
},
});
const hover = defineTabTool({
const hover = defineTool({
capability: 'core',
schema: {
name: 'browser_hover',
@@ -121,15 +129,21 @@ const hover = defineTabTool({
type: 'readOnly',
},
handle: async (tab, params, response) => {
response.setIncludeSnapshot();
handle: async (context, params) => {
const snapshot = context.currentTabOrDie().snapshotOrDie();
const locator = snapshot.refLocator(params);
const locator = await tab.refLocator(params);
response.addCode(`await page.${await generateLocator(locator)}.hover();`);
const code = [
`// Hover over ${params.element}`,
`await page.${await generateLocator(locator)}.hover();`
];
await tab.waitForCompletion(async () => {
await locator.hover();
});
return {
code,
action: () => locator.hover(),
captureSnapshot: true,
waitForNetwork: true,
};
},
});
@@ -137,7 +151,7 @@ const selectOptionSchema = elementSchema.extend({
values: z.array(z.string()).describe('Array of values to select in the dropdown. This can be a single value or multiple values.'),
});
const selectOption = defineTabTool({
const selectOption = defineTool({
capability: 'core',
schema: {
name: 'browser_select_option',
@@ -147,16 +161,21 @@ const selectOption = defineTabTool({
type: 'destructive',
},
handle: async (tab, params, response) => {
response.setIncludeSnapshot();
handle: async (context, params) => {
const snapshot = context.currentTabOrDie().snapshotOrDie();
const locator = snapshot.refLocator(params);
const locator = await tab.refLocator(params);
response.addCode(`// Select options [${params.values.join(', ')}] in ${params.element}`);
response.addCode(`await page.${await generateLocator(locator)}.selectOption(${javascript.formatObject(params.values)});`);
const code = [
`// Select options [${params.values.join(', ')}] in ${params.element}`,
`await page.${await generateLocator(locator)}.selectOption(${javascript.formatObject(params.values)});`
];
await tab.waitForCompletion(async () => {
await locator.selectOption(params.values);
});
return {
code,
action: () => locator.selectOption(params.values).then(() => {}),
captureSnapshot: true,
waitForNetwork: true,
};
},
});

View File

@@ -28,9 +28,19 @@ const listTabs = defineTool({
type: 'readOnly',
},
handle: async (context, params, response) => {
handle: async context => {
await context.ensureTab();
response.setIncludeTabs();
return {
code: [`// <internal code to list tabs>`],
captureSnapshot: false,
waitForNetwork: false,
resultOverride: {
content: [{
type: 'text',
text: await context.listTabsMarkdown(),
}],
},
};
},
});
@@ -47,9 +57,17 @@ const selectTab = defineTool({
type: 'readOnly',
},
handle: async (context, params, response) => {
handle: async (context, params) => {
await context.selectTab(params.index);
response.setIncludeSnapshot();
const code = [
`// <internal code to select tab ${params.index}>`,
];
return {
code,
captureSnapshot: true,
waitForNetwork: false
};
},
});
@@ -66,11 +84,19 @@ const newTab = defineTool({
type: 'readOnly',
},
handle: async (context, params, response) => {
const tab = await context.newTab();
handle: async (context, params) => {
await context.newTab();
if (params.url)
await tab.navigate(params.url);
response.setIncludeSnapshot();
await context.currentTabOrDie().navigate(params.url);
const code = [
`// <internal code to open a new tab>`,
];
return {
code,
captureSnapshot: true,
waitForNetwork: false
};
},
});
@@ -87,9 +113,16 @@ const closeTab = defineTool({
type: 'destructive',
},
handle: async (context, params, response) => {
handle: async (context, params) => {
await context.closeTab(params.index);
response.setIncludeSnapshot();
const code = [
`// <internal code to close tab ${params.index}>`,
];
return {
code,
captureSnapshot: true,
waitForNetwork: false
};
},
});

View File

@@ -14,13 +14,21 @@
* limitations under the License.
*/
import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types.js';
import type { z } from 'zod';
import type { Context } from '../context.js';
import type * as playwright from 'playwright';
import type { ToolCapability } from '../../config.js';
import type { Tab } from '../tab.js';
import type { Response } from '../response.js';
import type { ToolSchema } from '../mcp/server.js';
export type ToolSchema<Input extends InputType> = {
name: string;
title: string;
description: string;
inputSchema: Input;
type: 'readOnly' | 'destructive';
};
type InputType = z.Schema;
export type FileUploadModalState = {
type: 'fileChooser';
@@ -36,34 +44,23 @@ export type DialogModalState = {
export type ModalState = FileUploadModalState | DialogModalState;
export type Tool<Input extends z.Schema = z.Schema> = {
capability: ToolCapability;
schema: ToolSchema<Input>;
handle: (context: Context, params: z.output<Input>, response: Response) => Promise<void>;
export type ToolActionResult = { content?: (ImageContent | TextContent)[] } | undefined | void;
export type ToolResult = {
code: string[];
action?: () => Promise<ToolActionResult>;
captureSnapshot: boolean;
waitForNetwork: boolean;
resultOverride?: ToolActionResult;
};
export function defineTool<Input extends z.Schema>(tool: Tool<Input>): Tool<Input> {
return tool;
}
export type TabTool<Input extends z.Schema = z.Schema> = {
export type Tool<Input extends InputType = InputType> = {
capability: ToolCapability;
schema: ToolSchema<Input>;
clearsModalState?: ModalState['type'];
handle: (tab: Tab, params: z.output<Input>, response: Response) => Promise<void>;
handle: (context: Context, params: z.output<Input>) => Promise<ToolResult>;
};
export function defineTabTool<Input extends z.Schema>(tool: TabTool<Input>): Tool<Input> {
return {
...tool,
handle: async (context, params, response) => {
const tab = context.currentTabOrDie();
const modalStates = tab.modalStates().map(state => state.type);
if (tool.clearsModalState && !modalStates.includes(tool.clearsModalState))
throw new Error(`The tool "${tool.schema.name}" can only be used when there is related modal state present.\n` + tab.modalStatesMarkdown().join('\n'));
if (!tool.clearsModalState && modalStates.length)
throw new Error(`Tool "${tool.schema.name}" does not handle the modal state.\n` + tab.modalStatesMarkdown().join('\n'));
return tool.handle(tab, params, response);
},
};
export function defineTool<Input extends InputType>(tool: Tool<Input>): Tool<Input> {
return tool;
}

View File

@@ -18,9 +18,10 @@
import { asLocator } from 'playwright-core/lib/utils';
import type * as playwright from 'playwright';
import type { Context } from '../context.js';
import type { Tab } from '../tab.js';
export async function waitForCompletion<R>(tab: Tab, callback: () => Promise<R>): Promise<R> {
export async function waitForCompletion<R>(context: Context, tab: Tab, callback: () => Promise<R>): Promise<R> {
const requests = new Set<playwright.Request>();
let frameNavigated = false;
let waitCallback: () => void = () => {};
@@ -64,7 +65,7 @@ export async function waitForCompletion<R>(tab: Tab, callback: () => Promise<R>)
if (!requests.size && !frameNavigated)
waitCallback();
await waitBarrier;
await tab.waitForTimeout(1000);
await context.waitForTimeout(1000);
return result;
} finally {
dispose();

View File

@@ -32,7 +32,7 @@ const wait = defineTool({
type: 'readOnly',
},
handle: async (context, params, response) => {
handle: async (context, params) => {
if (!params.text && !params.textGone && !params.time)
throw new Error('Either time, text or textGone must be provided');
@@ -57,8 +57,11 @@ const wait = defineTool({
await locator.waitFor({ state: 'visible' });
}
response.addResult(`Waited for ${params.text || params.textGone || params.time}`);
response.setIncludeSnapshot();
return {
code,
captureSnapshot: true,
waitForNetwork: false,
};
},
});

View File

@@ -14,34 +14,25 @@
* limitations under the License.
*/
import http from 'http';
import crypto from 'crypto';
import debug from 'debug';
import http from 'node:http';
import assert from 'node:assert';
import crypto from 'node:crypto';
import debug from 'debug';
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { httpAddressToString, startHttpServer } from '../httpServer.js';
import * as mcpServer from './server.js';
import type { ServerBackendFactory } from './server.js';
import type { AddressInfo } from 'node:net';
import type { Server } 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);
export async function startStdioTransport(server: Server) {
await server.createConnection(new StdioServerTransport());
}
const testDebug = debug('pw:mcp:test');
async function handleSSE(serverBackendFactory: ServerBackendFactory, req: http.IncomingMessage, res: http.ServerResponse, url: URL, sessions: Map<string, SSEServerTransport>) {
async function handleSSE(server: Server, req: http.IncomingMessage, res: http.ServerResponse, url: URL, sessions: Map<string, SSEServerTransport>) {
if (req.method === 'POST') {
const sessionId = url.searchParams.get('sessionId');
if (!sessionId) {
@@ -60,10 +51,12 @@ async function handleSSE(serverBackendFactory: ServerBackendFactory, req: http.I
const transport = new SSEServerTransport('/sse', res);
sessions.set(transport.sessionId, transport);
testDebug(`create SSE session: ${transport.sessionId}`);
await mcpServer.connect(serverBackendFactory, transport, false);
const connection = await server.createConnection(transport);
res.on('close', () => {
testDebug(`delete SSE session: ${transport.sessionId}`);
sessions.delete(transport.sessionId);
// eslint-disable-next-line no-console
void connection.close().catch(e => console.error(e));
});
return;
}
@@ -72,7 +65,7 @@ async function handleSSE(serverBackendFactory: ServerBackendFactory, req: http.I
res.end('Method not allowed');
}
async function handleStreamable(serverBackendFactory: ServerBackendFactory, req: http.IncomingMessage, res: http.ServerResponse, sessions: Map<string, StreamableHTTPServerTransport>) {
async function handleStreamable(server: Server, req: http.IncomingMessage, res: http.ServerResponse, sessions: Map<string, StreamableHTTPServerTransport>) {
const sessionId = req.headers['mcp-session-id'] as string | undefined;
if (sessionId) {
const transport = sessions.get(sessionId);
@@ -87,20 +80,22 @@ async function handleStreamable(serverBackendFactory: ServerBackendFactory, req:
if (req.method === 'POST') {
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => crypto.randomUUID(),
onsessioninitialized: async sessionId => {
testDebug(`create http session: ${transport.sessionId}`);
await mcpServer.connect(serverBackendFactory, transport, true);
onsessioninitialized: sessionId => {
sessions.set(sessionId, transport);
}
});
transport.onclose = () => {
if (!transport.sessionId)
return;
if (transport.sessionId)
sessions.delete(transport.sessionId);
testDebug(`delete http session: ${transport.sessionId}`);
};
const connection = await server.createConnection(transport);
// Ensure connection is closed when transport closes
transport.onclose = () => {
if (transport.sessionId)
sessions.delete(transport.sessionId);
// eslint-disable-next-line no-console
void connection.close().catch(e => console.error(e));
};
await transport.handleRequest(req, res);
return;
}
@@ -109,15 +104,28 @@ async function handleStreamable(serverBackendFactory: ServerBackendFactory, req:
res.end('Invalid request');
}
function startHttpTransport(httpServer: http.Server, serverBackendFactory: ServerBackendFactory) {
const sseSessions = new Map();
const streamableSessions = new Map();
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 startHttpTransport(httpServer: http.Server, mcpServer: Server) {
const sseSessions = new Map<string, SSEServerTransport>();
const streamableSessions = new Map<string, StreamableHTTPServerTransport>();
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);
await handleSSE(mcpServer, req, res, url, sseSessions);
else
await handleStreamable(serverBackendFactory, req, res, streamableSessions);
await handleStreamable(mcpServer, req, res, streamableSessions);
});
const url = httpAddressToString(httpServer.address());
const message = [
@@ -135,3 +143,14 @@ function startHttpTransport(httpServer: http.Server, serverBackendFactory: Serve
// eslint-disable-next-line no-console
console.error(message);
}
export function httpAddressToString(address: string | 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}`;
}

View File

@@ -41,11 +41,17 @@ test('cdp server reuse tab', async ({ cdpServer, startClient, server }) => {
element: 'Hello, world!',
ref: 'f0',
},
})).toHaveTextContent(`Error: No open pages available. Use the \"browser_navigate\" tool to navigate to a page first.`);
})).toHaveTextContent(`Error: No current snapshot available. Capture a snapshot or navigate to a new location first.`);
expect(await client.callTool({
name: 'browser_snapshot',
})).toHaveTextContent(`### Page state
})).toHaveTextContent(`
### Ran Playwright code
\`\`\`js
// <internal code to capture accessibility snapshot>
\`\`\`
### Page state
- Page URL: ${server.HELLO_WORLD}
- Page Title: Title
- Page Snapshot:

View File

@@ -61,20 +61,3 @@ test.describe(() => {
})).toContainTextContent(`Firefox`);
});
});
test.describe('sandbox configuration', () => {
test('should enable sandbox by default (no --no-sandbox flag)', async () => {
const { configFromCLIOptions } = await import('../lib/config.js');
const config = configFromCLIOptions({ sandbox: undefined });
// When --no-sandbox is not passed, chromiumSandbox should not be set to false
// This allows the default (true) to be used
expect(config.browser?.launchOptions?.chromiumSandbox).toBeUndefined();
});
test('should disable sandbox when --no-sandbox flag is passed', async () => {
const { configFromCLIOptions } = await import('../lib/config.js');
const config = configFromCLIOptions({ sandbox: false });
// When --no-sandbox is passed, chromiumSandbox should be explicitly set to false
expect(config.browser?.launchOptions?.chromiumSandbox).toBe(false);
});
});

View File

@@ -38,7 +38,6 @@ test('browser_console_messages', async ({ client, server }) => {
name: 'browser_console_messages',
});
expect(resource).toHaveTextContent([
'### Result',
`[LOG] Hello, world! @ ${server.PREFIX}:4`,
`[ERROR] Error @ ${server.PREFIX}:5`,
].join('\n'));

View File

@@ -120,6 +120,64 @@ await page.getByRole('listbox').selectOption(['bar', 'baz']);
`);
});
test('browser_type', async ({ client, server }) => {
server.setContent('/', `
<!DOCTYPE html>
<html>
<input type='keypress' onkeypress="console.log('Key pressed:', event.key, ', Text:', event.target.value)"></input>
</html>
`, 'text/html');
await client.callTool({
name: 'browser_navigate',
arguments: {
url: server.PREFIX,
},
});
await client.callTool({
name: 'browser_type',
arguments: {
element: 'textbox',
ref: 'e2',
text: 'Hi!',
submit: true,
},
});
expect(await client.callTool({
name: 'browser_console_messages',
})).toHaveTextContent(/\[LOG\] Key pressed: Enter , Text: Hi!/);
});
test('browser_type (slowly)', async ({ client, server }) => {
server.setContent('/', `
<input type='text' onkeydown="console.log('Key pressed:', event.key, 'Text:', event.target.value)"></input>
`, 'text/html');
await client.callTool({
name: 'browser_navigate',
arguments: {
url: server.PREFIX,
},
});
await client.callTool({
name: 'browser_type',
arguments: {
element: 'textbox',
ref: 'e2',
text: 'Hi!',
submit: true,
slowly: true,
},
});
const response = await client.callTool({
name: 'browser_console_messages',
});
expect(response).toHaveTextContent(/\[LOG\] Key pressed: H Text: /);
expect(response).toHaveTextContent(/\[LOG\] Key pressed: i Text: H/);
expect(response).toHaveTextContent(/\[LOG\] Key pressed: ! Text: Hi/);
expect(response).toHaveTextContent(/\[LOG\] Key pressed: Enter Text: Hi!/);
});
test('browser_resize', async ({ client, server }) => {
server.setContent('/', `
<title>Resize Test</title>
@@ -184,7 +242,7 @@ test('old locator error message', async ({ client, server }) => {
element: 'Button 2',
ref: 'e3',
},
})).toContainTextContent('Ref e3 not found in the current page snapshot. Try capturing new snapshot.');
})).toContainTextContent('Ref not found');
});
test('visibility: hidden > visible should be shown', { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright-mcp/issues/535' } }, async ({ client, server }) => {

View File

@@ -36,8 +36,7 @@ await page.getByRole('button', { name: 'Button' }).click();
\`\`\`
### Modal state
- ["alert" dialog with message "Alert"]: can be handled by the "browser_handle_dialog" tool
`);
- ["alert" dialog with message "Alert"]: can be handled by the "browser_handle_dialog" tool`);
const result = await client.callTool({
name: 'browser_handle_dialog',
@@ -47,7 +46,17 @@ await page.getByRole('button', { name: 'Button' }).click();
});
expect(result).not.toContainTextContent('### Modal state');
expect(result).toContainTextContent(`Page Snapshot:`);
expect(result).toContainTextContent(`### Ran Playwright code
\`\`\`js
// <internal code to handle "alert" dialog>
\`\`\`
### Page state
- Page URL: ${server.PREFIX}
- Page Title:
- Page Snapshot:
\`\`\`yaml
- button "Button"`);
});
test('two alert dialogs', async ({ client, server }) => {
@@ -76,8 +85,7 @@ await page.getByRole('button', { name: 'Button' }).click();
\`\`\`
### Modal state
- ["alert" dialog with message "Alert 1"]: can be handled by the "browser_handle_dialog" tool
`);
- ["alert" dialog with message "Alert 1"]: can be handled by the "browser_handle_dialog" tool`);
const result = await client.callTool({
name: 'browser_handle_dialog',
@@ -86,9 +94,9 @@ await page.getByRole('button', { name: 'Button' }).click();
},
});
expect(result).toContainTextContent(`### Modal state
- ["alert" dialog with message "Alert 2"]: can be handled by the "browser_handle_dialog" tool
`);
expect(result).toContainTextContent(`
### Modal state
- ["alert" dialog with message "Alert 2"]: can be handled by the "browser_handle_dialog" tool`);
const result2 = await client.callTool({
name: 'browser_handle_dialog',
@@ -130,6 +138,7 @@ test('confirm dialog (true)', async ({ client, server }) => {
});
expect(result).not.toContainTextContent('### Modal state');
expect(result).toContainTextContent('// <internal code to handle "confirm" dialog>');
expect(result).toContainTextContent(`- Page Snapshot:
\`\`\`yaml
- generic [active] [ref=e1]: "true"
@@ -156,8 +165,7 @@ test('confirm dialog (false)', async ({ client, server }) => {
ref: 'e2',
},
})).toContainTextContent(`### Modal state
- ["confirm" dialog with message "Confirm"]: can be handled by the "browser_handle_dialog" tool
`);
- ["confirm" dialog with message "Confirm"]: can be handled by the "browser_handle_dialog" tool`);
const result = await client.callTool({
name: 'browser_handle_dialog',
@@ -192,8 +200,7 @@ test('prompt dialog', async ({ client, server }) => {
ref: 'e2',
},
})).toContainTextContent(`### Modal state
- ["prompt" dialog with message "Prompt"]: can be handled by the "browser_handle_dialog" tool
`);
- ["prompt" dialog with message "Prompt"]: can be handled by the "browser_handle_dialog" tool`);
const result = await client.callTool({
name: 'browser_handle_dialog',
@@ -229,8 +236,7 @@ await page.getByRole('button', { name: 'Button' }).click();
\`\`\`
### Modal state
- ["alert" dialog with message "Alert"]: can be handled by the "browser_handle_dialog" tool
`);
- ["alert" dialog with message "Alert"]: can be handled by the "browser_handle_dialog" tool`);
const result = await client.callTool({
name: 'browser_handle_dialog',
@@ -240,7 +246,12 @@ await page.getByRole('button', { name: 'Button' }).click();
});
expect(result).not.toContainTextContent('### Modal state');
expect(result).toContainTextContent(`### Page state
expect(result).toContainTextContent(`### Ran Playwright code
\`\`\`js
// <internal code to handle "alert" dialog>
\`\`\`
### Page state
- Page URL: ${server.PREFIX}
- Page Title:
- Page Snapshot:

View File

@@ -47,26 +47,5 @@ test('browser_evaluate (element)', async ({ client, server }) => {
element: 'body',
ref: 'e1',
},
})).toContainTextContent(`### Result
"red"`);
});
test('browser_evaluate (error)', async ({ client, server }) => {
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
})).toContainTextContent(`- Page Title: Title`);
const result = await client.callTool({
name: 'browser_evaluate',
arguments: {
function: '() => nonExistentVariable',
},
});
expect(result.isError).toBe(true);
expect(result.content?.[0]?.text).toContain('nonExistentVariable');
// Check for common error patterns across browsers
const errorText = result.content?.[0]?.text || '';
expect(errorText).toMatch(/not defined|Can't find variable/);
})).toContainTextContent(`- Result: "red"`);
});

View File

@@ -14,8 +14,8 @@
* limitations under the License.
*/
import fs from 'fs/promises';
import { test, expect } from './fixtures.js';
import fs from 'fs/promises';
test('browser_file_upload', async ({ client, server }, testInfo) => {
server.setContent('/', `
@@ -38,7 +38,7 @@ test('browser_file_upload', async ({ client, server }, testInfo) => {
name: 'browser_file_upload',
arguments: { paths: [] },
})).toHaveTextContent(`
Error: The tool "browser_file_upload" can only be used when there is related modal state present.
The tool "browser_file_upload" can only be used when there is related modal state present.
### Modal state
- There is no modal state present
`.trim());
@@ -88,7 +88,7 @@ Error: The tool "browser_file_upload" can only be used when there is related mod
},
});
expect(response).toContainTextContent(`Error: Tool "browser_click" does not handle the modal state.
expect(response).toContainTextContent(`Tool "browser_click" does not handle the modal state.
### Modal state
- [File chooser]: can be handled by the "browser_file_upload" tool`);
}
@@ -113,7 +113,8 @@ test('clicking on download link emits download', async ({ startClient, server, m
ref: 'e2',
},
});
await expect.poll(() => client.callTool({ name: 'browser_snapshot' })).toContainTextContent(`### Downloads
await expect.poll(() => client.callTool({ name: 'browser_snapshot' })).toContainTextContent(`
### Downloads
- Downloaded file test.txt to ${testInfo.outputPath('output', 'test.txt')}`);
});
@@ -122,7 +123,7 @@ test('navigating to download link emits download', async ({ startClient, server,
config: { outputDir: testInfo.outputPath('output') },
});
test.skip(mcpBrowser !== 'chromium', 'This test is racy');
test.skip(mcpBrowser === 'webkit' && process.platform === 'linux', 'https://github.com/microsoft/playwright/blob/8e08fdb52c27bb75de9bf87627bf740fadab2122/tests/library/download.spec.ts#L436');
server.route('/download', (req, res) => {
res.writeHead(200, {
'Content-Type': 'text/plain',

View File

@@ -19,6 +19,7 @@ import url from 'node:url';
import { ChildProcess, spawn } from 'node:child_process';
import path from 'node:path';
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
@@ -71,6 +72,7 @@ test('http transport', async ({ serverEndpoint }) => {
const client = new Client({ name: 'test', version: '1.0.0' });
await client.connect(transport);
await client.ping();
expect(transport.sessionId, 'has session support').toBeDefined();
});
test('http transport (config)', async ({ serverEndpoint }) => {
@@ -99,14 +101,6 @@ test('http transport browser lifecycle (isolated)', async ({ serverEndpoint, ser
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
});
/**
* src/client/streamableHttp.ts
* Clients that no longer need a particular session
* (e.g., because the user is leaving the client application) SHOULD send an
* HTTP DELETE to the MCP endpoint with the Mcp-Session-Id header to explicitly
* terminate the session.
*/
await transport1.terminateSession();
await client1.close();
const transport2 = new StreamableHTTPClientTransport(new URL('/mcp', url));
@@ -116,14 +110,10 @@ test('http transport browser lifecycle (isolated)', async ({ serverEndpoint, ser
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
});
await transport2.terminateSession();
await client2.close();
await expect(async () => {
const lines = stderr().split('\n');
expect(lines.filter(line => line.match(/create http session/)).length).toBe(2);
expect(lines.filter(line => line.match(/delete http session/)).length).toBe(2);
expect(lines.filter(line => line.match(/create context/)).length).toBe(2);
expect(lines.filter(line => line.match(/close context/)).length).toBe(2);
@@ -153,7 +143,6 @@ test('http transport browser lifecycle (isolated, multiclient)', async ({ server
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
});
await transport1.terminateSession();
await client1.close();
const transport3 = new StreamableHTTPClientTransport(new URL('/mcp', url));
@@ -164,16 +153,11 @@ test('http transport browser lifecycle (isolated, multiclient)', async ({ server
arguments: { url: server.HELLO_WORLD },
});
await transport2.terminateSession();
await client2.close();
await transport3.terminateSession();
await client3.close();
await expect(async () => {
const lines = stderr().split('\n');
expect(lines.filter(line => line.match(/create http session/)).length).toBe(3);
expect(lines.filter(line => line.match(/delete http session/)).length).toBe(3);
expect(lines.filter(line => line.match(/create context/)).length).toBe(3);
expect(lines.filter(line => line.match(/close context/)).length).toBe(3);
@@ -195,7 +179,6 @@ test('http transport browser lifecycle (persistent)', async ({ serverEndpoint, s
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
});
await transport1.terminateSession();
await client1.close();
const transport2 = new StreamableHTTPClientTransport(new URL('/mcp', url));
@@ -205,14 +188,10 @@ test('http transport browser lifecycle (persistent)', async ({ serverEndpoint, s
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
});
await transport2.terminateSession();
await client2.close();
await expect(async () => {
const lines = stderr().split('\n');
expect(lines.filter(line => line.match(/create http session/)).length).toBe(2);
expect(lines.filter(line => line.match(/delete http session/)).length).toBe(2);
expect(lines.filter(line => line.match(/create context/)).length).toBe(2);
expect(lines.filter(line => line.match(/close context/)).length).toBe(2);
@@ -249,6 +228,14 @@ test('http transport browser lifecycle (persistent, multiclient)', async ({ serv
await client2.close();
});
test('sse transport', async ({ serverEndpoint }) => {
const { url } = await serverEndpoint();
const transport = new SSEClientTransport(new URL('/sse', url));
const client = new Client({ name: 'test', version: '1.0.0' });
await client.connect(transport);
await client.ping();
});
test('http transport (default)', async ({ serverEndpoint }) => {
const { url } = await serverEndpoint();
const transport = new StreamableHTTPClientTransport(url);

View File

@@ -20,5 +20,5 @@ test('browser_install', async ({ client, mcpBrowser }) => {
test.skip(mcpBrowser !== 'chromium', 'Test only chromium');
expect(await client.callTool({
name: 'browser_install',
})).toContainTextContent(`### No open tabs`);
})).toContainTextContent(`No open pages available.`);
});

View File

@@ -27,13 +27,7 @@ test('test reopen browser', async ({ startClient, server, mcpMode }) => {
expect(await client.callTool({
name: 'browser_close',
})).toContainTextContent(`### Ran Playwright code
\`\`\`js
await page.close()
\`\`\`
### No open tabs
Use the "browser_navigate" tool to navigate to a page first.`);
})).toContainTextContent('No open pages available');
expect(await client.callTool({
name: 'browser_navigate',

View File

@@ -13,9 +13,9 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import child_process from 'child_process';
import fs from 'fs/promises';
import { test, expect } from './fixtures.js';
import fs from 'node:fs/promises';
import child_process from 'node:child_process';
test('library can be used from CommonJS', { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright-mcp/issues/456' } }, async ({}, testInfo) => {
const file = testInfo.outputPath('main.cjs');

View File

@@ -40,7 +40,6 @@ test('browser_network_requests', async ({ client, server }) => {
await expect.poll(() => client.callTool({
name: 'browser_network_requests',
})).toHaveTextContent(`### Result
[GET] ${`${server.PREFIX}`} => [200] OK
})).toHaveTextContent(`[GET] ${`${server.PREFIX}`} => [200] OK
[GET] ${`${server.PREFIX}json`} => [200] OK`);
});

View File

@@ -65,7 +65,14 @@ test('save as pdf (filename: output.pdf)', async ({ startClient, mcpBrowser, ser
arguments: {
filename: 'output.pdf',
},
})).toContainTextContent(`output.pdf`);
})).toEqual({
content: [
{
type: 'text',
text: expect.stringContaining(`output.pdf`),
},
],
});
const files = [...fs.readdirSync(outputDir)];

View File

@@ -31,15 +31,15 @@ test('browser_take_screenshot (viewport)', async ({ startClient, server }, testI
name: 'browser_take_screenshot',
})).toEqual({
content: [
{
text: expect.stringContaining(`Screenshot viewport and save it as`),
type: 'text',
},
{
data: expect.any(String),
mimeType: 'image/jpeg',
type: 'image',
},
{
text: expect.stringContaining(`Screenshot viewport and save it as`),
type: 'text',
},
],
});
});
@@ -61,15 +61,15 @@ test('browser_take_screenshot (element)', async ({ startClient, server }, testIn
},
})).toEqual({
content: [
{
text: expect.stringContaining(`page.getByText('Hello, world!').screenshot`),
type: 'text',
},
{
data: expect.any(String),
mimeType: 'image/jpeg',
type: 'image',
},
{
text: expect.stringContaining(`page.getByText('Hello, world!').screenshot`),
type: 'text',
},
],
});
});
@@ -111,17 +111,17 @@ for (const raw of [undefined, true]) {
arguments: { raw },
})).toEqual({
content: [
{
data: expect.any(String),
mimeType: `image/${ext}`,
type: 'image',
},
{
text: expect.stringMatching(
new RegExp(`page-\\d{4}-\\d{2}-\\d{2}T\\d{2}-\\d{2}-\\d{2}\\-\\d{3}Z\\.${ext}`)
),
type: 'text',
},
{
data: expect.any(String),
mimeType: `image/${ext}`,
type: 'image',
},
],
});
@@ -153,15 +153,15 @@ test('browser_take_screenshot (filename: "output.jpeg")', async ({ startClient,
},
})).toEqual({
content: [
{
text: expect.stringContaining(`output.jpeg`),
type: 'text',
},
{
data: expect.any(String),
mimeType: 'image/jpeg',
type: 'image',
},
{
text: expect.stringContaining(`output.jpeg`),
type: 'text',
},
],
});
@@ -201,80 +201,3 @@ test('browser_take_screenshot (imageResponses=omit)', async ({ startClient, serv
],
});
});
test('browser_take_screenshot (fullPage: true)', async ({ startClient, server }, testInfo) => {
const { client } = await startClient({
config: { outputDir: testInfo.outputPath('output') },
});
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
})).toContainTextContent(`Navigate to http://localhost`);
expect(await client.callTool({
name: 'browser_take_screenshot',
arguments: { fullPage: true },
})).toEqual({
content: [
{
text: expect.stringContaining(`Screenshot full page and save it as`),
type: 'text',
},
{
data: expect.any(String),
mimeType: 'image/jpeg',
type: 'image',
},
],
});
});
test('browser_take_screenshot (fullPage with element should error)', async ({ startClient, server }, testInfo) => {
const { client } = await startClient({
config: { outputDir: testInfo.outputPath('output') },
});
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
})).toContainTextContent(`[ref=e1]`);
const result = await client.callTool({
name: 'browser_take_screenshot',
arguments: {
fullPage: true,
element: 'hello button',
ref: 'e1',
},
});
expect(result.isError).toBe(true);
expect(result.content?.[0]?.text).toContain('fullPage cannot be used with element screenshots');
});
test('browser_take_screenshot (viewport without snapshot)', async ({ startClient, server }, testInfo) => {
const { client } = await startClient({
config: { outputDir: testInfo.outputPath('output') },
});
// Ensure we have a tab but don't navigate anywhere (no snapshot captured)
expect(await client.callTool({
name: 'browser_tab_list',
})).toContainTextContent('about:blank');
// This should work without requiring a snapshot since it's a viewport screenshot
expect(await client.callTool({
name: 'browser_take_screenshot',
})).toEqual({
content: [
{
text: expect.stringContaining(`Screenshot viewport and save it as`),
type: 'text',
},
{
data: expect.any(String),
mimeType: 'image/jpeg',
type: 'image',
},
],
});
});

View File

@@ -1,236 +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 fs from 'node:fs';
import url from 'node:url';
import { ChildProcess, spawn } from 'node:child_process';
import path from 'node:path';
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { test as baseTest, expect } from './fixtures.js';
import type { Config } from '../config.d.ts';
// NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename.
const __filename = url.fileURLToPath(import.meta.url);
const test = baseTest.extend<{ serverEndpoint: (options?: { args?: string[], noPort?: boolean }) => Promise<{ url: URL, stderr: () => string }> }>({
serverEndpoint: async ({ mcpHeadless }, use, testInfo) => {
let cp: ChildProcess | undefined;
const userDataDir = testInfo.outputPath('user-data-dir');
await use(async (options?: { args?: string[], noPort?: boolean }) => {
if (cp)
throw new Error('Process already running');
cp = spawn('node', [
path.join(path.dirname(__filename), '../cli.js'),
...(options?.noPort ? [] : ['--port=0']),
'--user-data-dir=' + userDataDir,
...(mcpHeadless ? ['--headless'] : []),
...(options?.args || []),
], {
stdio: 'pipe',
env: {
...process.env,
DEBUG: 'pw:mcp:test',
DEBUG_COLORS: '0',
DEBUG_HIDE_DATE: '1',
},
});
let stderr = '';
const url = await new Promise<string>(resolve => cp!.stderr?.on('data', data => {
stderr += data.toString();
const match = stderr.match(/Listening on (http:\/\/.*)/);
if (match)
resolve(match[1]);
}));
return { url: new URL(url), stderr: () => stderr };
});
cp?.kill('SIGTERM');
},
});
test('sse transport', async ({ serverEndpoint }) => {
const { url } = await serverEndpoint();
const transport = new SSEClientTransport(new URL('/sse', url));
const client = new Client({ name: 'test', version: '1.0.0' });
await client.connect(transport);
await client.ping();
});
test('sse transport (config)', async ({ serverEndpoint }) => {
const config: Config = {
server: {
port: 0,
}
};
const configFile = test.info().outputPath('config.json');
await fs.promises.writeFile(configFile, JSON.stringify(config, null, 2));
const { url } = await serverEndpoint({ noPort: true, args: ['--config=' + configFile] });
const transport = new SSEClientTransport(new URL('/sse', url));
const client = new Client({ name: 'test', version: '1.0.0' });
await client.connect(transport);
await client.ping();
});
test('sse transport browser lifecycle (isolated)', async ({ serverEndpoint, server }) => {
const { url, stderr } = await serverEndpoint({ args: ['--isolated'] });
const transport1 = new SSEClientTransport(new URL('/sse', url));
const client1 = new Client({ name: 'test', version: '1.0.0' });
await client1.connect(transport1);
await client1.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
});
await client1.close();
const transport2 = new SSEClientTransport(new URL('/sse', url));
const client2 = new Client({ name: 'test', version: '1.0.0' });
await client2.connect(transport2);
await client2.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
});
await client2.close();
await expect(async () => {
const lines = stderr().split('\n');
expect(lines.filter(line => line.match(/create SSE session/)).length).toBe(2);
expect(lines.filter(line => line.match(/delete SSE session/)).length).toBe(2);
expect(lines.filter(line => line.match(/create context/)).length).toBe(2);
expect(lines.filter(line => line.match(/close context/)).length).toBe(2);
expect(lines.filter(line => line.match(/create browser context \(isolated\)/)).length).toBe(2);
expect(lines.filter(line => line.match(/close browser context \(isolated\)/)).length).toBe(2);
expect(lines.filter(line => line.match(/obtain browser \(isolated\)/)).length).toBe(2);
expect(lines.filter(line => line.match(/close browser \(isolated\)/)).length).toBe(2);
}).toPass();
});
test('sse transport browser lifecycle (isolated, multiclient)', async ({ serverEndpoint, server }) => {
const { url, stderr } = await serverEndpoint({ args: ['--isolated'] });
const transport1 = new SSEClientTransport(new URL('/sse', url));
const client1 = new Client({ name: 'test', version: '1.0.0' });
await client1.connect(transport1);
await client1.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
});
const transport2 = new SSEClientTransport(new URL('/sse', url));
const client2 = new Client({ name: 'test', version: '1.0.0' });
await client2.connect(transport2);
await client2.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
});
await client1.close();
const transport3 = new SSEClientTransport(new URL('/sse', url));
const client3 = new Client({ name: 'test', version: '1.0.0' });
await client3.connect(transport3);
await client3.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
});
await client2.close();
await client3.close();
await expect(async () => {
const lines = stderr().split('\n');
expect(lines.filter(line => line.match(/create SSE session/)).length).toBe(3);
expect(lines.filter(line => line.match(/delete SSE session/)).length).toBe(3);
expect(lines.filter(line => line.match(/create context/)).length).toBe(3);
expect(lines.filter(line => line.match(/close context/)).length).toBe(3);
expect(lines.filter(line => line.match(/create browser context \(isolated\)/)).length).toBe(3);
expect(lines.filter(line => line.match(/close browser context \(isolated\)/)).length).toBe(3);
expect(lines.filter(line => line.match(/obtain browser \(isolated\)/)).length).toBe(1);
expect(lines.filter(line => line.match(/close browser \(isolated\)/)).length).toBe(1);
}).toPass();
});
test('sse transport browser lifecycle (persistent)', async ({ serverEndpoint, server }) => {
const { url, stderr } = await serverEndpoint();
const transport1 = new SSEClientTransport(new URL('/sse', url));
const client1 = new Client({ name: 'test', version: '1.0.0' });
await client1.connect(transport1);
await client1.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
});
await client1.close();
const transport2 = new SSEClientTransport(new URL('/sse', url));
const client2 = new Client({ name: 'test', version: '1.0.0' });
await client2.connect(transport2);
await client2.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
});
await client2.close();
await expect(async () => {
const lines = stderr().split('\n');
expect(lines.filter(line => line.match(/create SSE session/)).length).toBe(2);
expect(lines.filter(line => line.match(/delete SSE session/)).length).toBe(2);
expect(lines.filter(line => line.match(/create context/)).length).toBe(2);
expect(lines.filter(line => line.match(/close context/)).length).toBe(2);
expect(lines.filter(line => line.match(/create browser context \(persistent\)/)).length).toBe(2);
expect(lines.filter(line => line.match(/close browser context \(persistent\)/)).length).toBe(2);
expect(lines.filter(line => line.match(/lock user data dir/)).length).toBe(2);
expect(lines.filter(line => line.match(/release user data dir/)).length).toBe(2);
}).toPass();
});
test('sse transport browser lifecycle (persistent, multiclient)', async ({ serverEndpoint, server }) => {
const { url } = await serverEndpoint();
const transport1 = new SSEClientTransport(new URL('/sse', url));
const client1 = new Client({ name: 'test', version: '1.0.0' });
await client1.connect(transport1);
await client1.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
});
const transport2 = new SSEClientTransport(new URL('/sse', url));
const client2 = new Client({ name: 'test', version: '1.0.0' });
await client2.connect(transport2);
const response = await client2.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
});
expect(response.isError).toBe(true);
expect(response.content?.[0].text).toContain('use --isolated to run multiple instances of the same browser');
await client1.close();
await client2.close();
});

View File

@@ -44,14 +44,12 @@ test('list first tab', async ({ client }) => {
});
test('create new tab', async ({ client }) => {
const result = await createTab(client, 'Tab one', 'Body one');
expect(result).toContainTextContent(`### Open tabs
expect(await createTab(client, 'Tab one', 'Body one')).toContainTextContent(`
### Open tabs
- 0: [] (about:blank)
- 1: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)
`);
expect(result).toContainTextContent(`
### Page state
### Current tab
- Page URL: data:text/html,<title>Tab one</title><body>Body one</body>
- Page Title: Tab one
- Page Snapshot:
@@ -59,15 +57,13 @@ test('create new tab', async ({ client }) => {
- generic [active] [ref=e1]: Body one
\`\`\``);
const result2 = await createTab(client, 'Tab two', 'Body two');
expect(result2).toContainTextContent(`### Open tabs
expect(await createTab(client, 'Tab two', 'Body two')).toContainTextContent(`
### Open tabs
- 0: [] (about:blank)
- 1: [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)
- 2: (current) [Tab two] (data:text/html,<title>Tab two</title><body>Body two</body>)
`);
expect(result2).toContainTextContent(`
### Page state
### Current tab
- Page URL: data:text/html,<title>Tab two</title><body>Body two</body>
- Page Title: Tab two
- Page Snapshot:
@@ -79,20 +75,23 @@ test('create new tab', async ({ client }) => {
test('select tab', async ({ client }) => {
await createTab(client, 'Tab one', 'Body one');
await createTab(client, 'Tab two', 'Body two');
const result = await client.callTool({
expect(await client.callTool({
name: 'browser_tab_select',
arguments: {
index: 1,
},
});
expect(result).toContainTextContent(`### Open tabs
})).toHaveTextContent(`
### Ran Playwright code
\`\`\`js
// <internal code to select tab 1>
\`\`\`
### Open tabs
- 0: [] (about:blank)
- 1: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)
- 2: [Tab two] (data:text/html,<title>Tab two</title><body>Body two</body>)`);
- 2: [Tab two] (data:text/html,<title>Tab two</title><body>Body two</body>)
expect(result).toContainTextContent(`
### Page state
### Current tab
- Page URL: data:text/html,<title>Tab one</title><body>Body one</body>
- Page Title: Tab one
- Page Snapshot:
@@ -104,19 +103,22 @@ test('select tab', async ({ client }) => {
test('close tab', async ({ client }) => {
await createTab(client, 'Tab one', 'Body one');
await createTab(client, 'Tab two', 'Body two');
const result = await client.callTool({
expect(await client.callTool({
name: 'browser_tab_close',
arguments: {
index: 2,
},
});
expect(result).toContainTextContent(`### Open tabs
- 0: [] (about:blank)
- 1: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)`);
})).toHaveTextContent(`
### Ran Playwright code
\`\`\`js
// <internal code to close tab 2>
\`\`\`
expect(result).toContainTextContent(`
### Page state
### Open tabs
- 0: [] (about:blank)
- 1: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)
### Current tab
- Page URL: data:text/html,<title>Tab one</title><body>Body one</body>
- Page Title: Tab one
- Page Snapshot:

View File

@@ -1,119 +0,0 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { test, expect } from './fixtures.js';
test('browser_type', async ({ client, server }) => {
server.setContent('/', `
<!DOCTYPE html>
<html>
<input type='keypress' onkeypress="console.log('Key pressed:', event.key, ', Text:', event.target.value)"></input>
</html>
`, 'text/html');
await client.callTool({
name: 'browser_navigate',
arguments: {
url: server.PREFIX,
},
});
{
const response = await client.callTool({
name: 'browser_type',
arguments: {
element: 'textbox',
ref: 'e2',
text: 'Hi!',
submit: true,
},
});
expect(response).toContainTextContent(`fill('Hi!');`);
expect(response).toContainTextContent(`- textbox`);
}
expect(await client.callTool({
name: 'browser_console_messages',
})).toHaveTextContent(/\[LOG\] Key pressed: Enter , Text: Hi!/);
});
test('browser_type (slowly)', async ({ client, server }) => {
server.setContent('/', `
<input type='text' onkeydown="console.log('Key pressed:', event.key, 'Text:', event.target.value)"></input>
`, 'text/html');
await client.callTool({
name: 'browser_navigate',
arguments: {
url: server.PREFIX,
},
});
{
const response = await client.callTool({
name: 'browser_type',
arguments: {
element: 'textbox',
ref: 'e2',
text: 'Hi!',
slowly: true,
},
});
expect(response).toContainTextContent(`pressSequentially('Hi!');`);
expect(response).toContainTextContent(`- textbox`);
}
const response = await client.callTool({
name: 'browser_console_messages',
});
expect(response).toHaveTextContent(/\[LOG\] Key pressed: H Text: /);
expect(response).toHaveTextContent(/\[LOG\] Key pressed: i Text: H/);
expect(response).toHaveTextContent(/\[LOG\] Key pressed: ! Text: Hi/);
});
test('browser_type (no submit)', async ({ client, server }) => {
server.setContent('/', `
<input type='text' oninput="console.log('New value: ' + event.target.value)"></input>
`, 'text/html');
{
const response = await client.callTool({
name: 'browser_navigate',
arguments: {
url: server.PREFIX,
},
});
expect(response).toContainTextContent(`- textbox`);
}
{
const response = await client.callTool({
name: 'browser_type',
arguments: {
element: 'textbox',
ref: 'e2',
text: 'Hi!',
},
});
expect(response).toContainTextContent(`fill('Hi!');`);
// Should yield no snapshot.
expect(response).not.toContainTextContent(`- textbox`);
}
{
const response = await client.callTool({
name: 'browser_console_messages',
});
expect(response).toHaveTextContent(/\[LOG\] New value: Hi!/);
}
});