5 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
2a233080f7 Update listTabsMarkdown to use 0-based indexing consistently
Co-authored-by: dgozman <9881434+dgozman@users.noreply.github.com>
2025-06-20 11:55:50 +00:00
copilot-swe-agent[bot]
f4bc6447eb Revert description changes to remove explicit 0-based indexing mentions
Co-authored-by: Skn0tt <14912729+Skn0tt@users.noreply.github.com>
2025-06-19 09:50:55 +00:00
copilot-swe-agent[bot]
708aa6d6a5 Change tab selection to use 0-based indexing instead of 1-based
Co-authored-by: Skn0tt <14912729+Skn0tt@users.noreply.github.com>
2025-06-19 09:38:10 +00:00
copilot-swe-agent[bot]
c82a17ddfd Clarify 1-based indexing in browser_tab_select and browser_tab_close tools
Co-authored-by: Skn0tt <14912729+Skn0tt@users.noreply.github.com>
2025-06-19 09:25:48 +00:00
copilot-swe-agent[bot]
8e0ccf770b Initial plan for issue 2025-06-19 08:34:03 +00:00
35 changed files with 1395 additions and 194 deletions

View File

@@ -44,7 +44,6 @@ jobs:
- name: Login to ACR
run: az acr login --name playwright
- name: Build and push Docker image
id: build-push
uses: docker/build-push-action@v6
with:
context: .
@@ -54,17 +53,3 @@ jobs:
tags: |
playwright.azurecr.io/public/playwright/mcp:${{ github.event.release.tag_name }}
playwright.azurecr.io/public/playwright/mcp:latest
- uses: oras-project/setup-oras@v1
- name: Set oras tags
run: |
attach_eol_manifest() {
local image="$1"
local today=$(date -u +'%Y-%m-%d')
# oras is re-using Docker credentials, so we don't need to login.
# Following the advice in https://portal.microsofticm.com/imp/v3/incidents/incident/476783820/summary
oras attach --artifact-type application/vnd.microsoft.artifact.lifecycle --annotation "vnd.microsoft.artifact.lifecycle.end-of-life.date=$today" $image
}
# for each tag, attach the eol manifest
for tag in $(echo ${{ steps.build-push.outputs.metadata['image.name'] }} | tr ',' '\n'); do
attach_eol_manifest $tag
done

View File

@@ -10,7 +10,7 @@ A Model Context Protocol (MCP) server that provides browser automation capabilit
### Requirements
- Node.js 18 or newer
- VS Code, Cursor, Windsurf, Claude Desktop, Goose or any other MCP client
- VS Code, Cursor, Windsurf, Claude Desktop or any other MCP client
<!--
// Generate using:
@@ -77,7 +77,7 @@ Go to `Cursor Settings` -> `MCP` -> `Add new MCP Server`. Name to your liking, u
<details>
<summary><b>Install in Windsurf</b></summary>
Follow Windsurf MCP [documentation](https://docs.windsurf.com/windsurf/cascade/mcp). Use following configuration:
Follow Windsuff MCP [documentation](https://docs.windsurf.com/windsurf/cascade/mcp). Use following configuration:
```js
{
@@ -122,18 +122,6 @@ claude mcp add playwright npx @playwright/mcp@latest
```
</details>
<details>
<summary><b>Install in Goose</b></summary>
#### Click the button to install:
[![Install in Goose](https://block.github.io/goose/img/extension-install-dark.svg)](https://block.github.io/goose/extension?cmd=npx&arg=%40playwright%2Fmcp%40latest&id=playwright&name=Playwright&description=Interact%20with%20web%20pages%20through%20structured%20accessibility%20snapshots%20using%20Playwright)
#### Or install manually:
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><b>Install in Qodo Gen</b></summary>
@@ -155,25 +143,6 @@ Open [Qodo Gen](https://docs.qodo.ai/qodo-documentation/qodo-gen) chat panel in
Click <code>Save</code>.
</details>
<details>
<summary><b>Install in Gemini CLI</b></summary>
Follow the MCP install [guide](https://github.com/google-gemini/gemini-cli/blob/main/docs/tools/mcp-server.md#configure-the-mcp-server-in-settingsjson), use following configuration:
```js
{
"mcpServers": {
"playwright": {
"command": "npx",
"args": [
"@playwright/mcp@latest"
]
}
}
}
```
</details>
### Configuration
Playwright MCP server supports following arguments. They can be provided in the JSON configuration above, as a part of the `"args"` list:
@@ -357,10 +326,9 @@ npx @playwright/mcp@latest --config path/to/config.json
};
/**
* Whether to send image responses to the client. Can be "allow", "omit", or "auto".
* Defaults to "auto", images are omitted for Cursor clients and sent for all other clients.
* Do not send image responses to the client.
*/
imageResponses?: 'allow' | 'omit' | 'auto';
noImageResponses?: boolean;
}
```
</details>
@@ -478,7 +446,6 @@ X Y coordinate space, based on the provided screenshot.
- Parameters:
- `element` (string): Human-readable element description used to obtain permission to interact with the element
- `ref` (string): Exact target element reference from the page snapshot
- `doubleClick` (boolean, optional): Whether to perform a double click instead of a single click
- Read-only: **false**
<!-- NOTE: This has been generated via update-readme.js -->

344
extension/background.js Normal file
View File

@@ -0,0 +1,344 @@
/**
* 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.
*/
/**
* Simple Chrome Extension that pumps CDP messages between chrome.debugger and WebSocket
*/
// @ts-check
function debugLog(...args) {
const enabled = false;
if (enabled) {
console.log('[Extension]', ...args);
}
}
class TabShareExtension {
constructor() {
this.activeConnections = new Map(); // tabId -> connection info
// Remove page action click handler since we now use popup
chrome.tabs.onRemoved.addListener(this.onTabRemoved.bind(this));
// Handle messages from popup
chrome.runtime.onMessage.addListener(this.onMessage.bind(this));
}
/**
* Handle messages from popup
* @param {any} message
* @param {chrome.runtime.MessageSender} sender
* @param {Function} sendResponse
*/
onMessage(message, sender, sendResponse) {
switch (message.type) {
case 'getStatus':
this.getStatus(message.tabId, sendResponse);
return true; // Will respond asynchronously
case 'connect':
this.connectTab(message.tabId, message.bridgeUrl).then(
() => sendResponse({ success: true }),
(error) => sendResponse({ success: false, error: error.message })
);
return true; // Will respond asynchronously
case 'disconnect':
this.disconnectTab(message.tabId).then(
() => sendResponse({ success: true }),
(error) => sendResponse({ success: false, error: error.message })
);
return true; // Will respond asynchronously
}
return false;
}
/**
* Get connection status for popup
* @param {number} requestedTabId
* @param {Function} sendResponse
*/
getStatus(requestedTabId, sendResponse) {
const isConnected = this.activeConnections.size > 0;
let activeTabId = null;
let activeTabInfo = null;
if (isConnected) {
const [tabId, connection] = this.activeConnections.entries().next().value;
activeTabId = tabId;
// Get tab info
chrome.tabs.get(tabId, (tab) => {
if (chrome.runtime.lastError) {
sendResponse({
isConnected: false,
error: 'Active tab not found'
});
} else {
sendResponse({
isConnected: true,
activeTabId,
activeTabInfo: {
title: tab.title,
url: tab.url
}
});
}
});
} else {
sendResponse({
isConnected: false,
activeTabId: null,
activeTabInfo: null
});
}
}
/**
* Connect a tab to the bridge server
* @param {number} tabId
* @param {string} bridgeUrl
*/
async connectTab(tabId, bridgeUrl) {
try {
debugLog(`Connecting tab ${tabId} to bridge at ${bridgeUrl}`);
// Attach chrome debugger
const debuggee = { tabId };
await chrome.debugger.attach(debuggee, '1.3');
if (chrome.runtime.lastError)
throw new Error(chrome.runtime.lastError.message);
const targetInfo = /** @type {any} */ (await chrome.debugger.sendCommand(debuggee, 'Target.getTargetInfo'));
debugLog('Target info:', targetInfo);
// Connect to bridge server
const socket = new WebSocket(bridgeUrl);
const connection = {
debuggee,
socket,
tabId,
sessionId: `pw-tab-${tabId}`
};
await new Promise((resolve, reject) => {
socket.onopen = () => {
debugLog(`WebSocket connected for tab ${tabId}`);
// Send initial connection info to bridge
socket.send(JSON.stringify({
type: 'connection_info',
sessionId: connection.sessionId,
targetInfo: targetInfo?.targetInfo
}));
resolve(undefined);
};
socket.onerror = reject;
setTimeout(() => reject(new Error('Connection timeout')), 5000);
});
// Set up message handling
this.setupMessageHandling(connection);
// Store connection
this.activeConnections.set(tabId, connection);
// Update UI
chrome.action.setBadgeText({ tabId, text: '●' });
chrome.action.setBadgeBackgroundColor({ tabId, color: '#4CAF50' });
chrome.action.setTitle({ tabId, title: 'Disconnect from Playwright MCP' });
debugLog(`Tab ${tabId} connected successfully`);
} catch (error) {
debugLog(`Failed to connect tab ${tabId}:`, error.message);
await this.cleanupConnection(tabId);
// Show error to user
chrome.action.setBadgeText({ tabId, text: '!' });
chrome.action.setBadgeBackgroundColor({ tabId, color: '#F44336' });
chrome.action.setTitle({ tabId, title: `Connection failed: ${error.message}` });
throw error; // Re-throw for popup to handle
}
}
/**
* Set up bidirectional message handling between debugger and WebSocket
* @param {Object} connection
*/
setupMessageHandling(connection) {
const { debuggee, socket, tabId, sessionId: rootSessionId } = connection;
// WebSocket -> chrome.debugger
socket.onmessage = async (event) => {
let message;
try {
message = JSON.parse(event.data);
} catch (error) {
debugLog('Error parsing message:', error);
socket.send(JSON.stringify({
error: {
code: -32700,
message: `Error parsing message: ${error.message}`
}
}));
return;
}
try {
debugLog('Received from bridge:', message);
const debuggerSession = { ...debuggee };
const sessionId = message.sessionId;
// Pass session id, unless it's the root session.
if (sessionId && sessionId !== rootSessionId)
debuggerSession.sessionId = sessionId;
// Forward CDP command to chrome.debugger
const result = await chrome.debugger.sendCommand(
debuggerSession,
message.method,
message.params || {}
);
// Send response back to bridge
const response = {
id: message.id,
sessionId,
result
};
if (chrome.runtime.lastError) {
response.error = {
code: -32000,
message: chrome.runtime.lastError.message,
};
}
socket.send(JSON.stringify(response));
} catch (error) {
debugLog('Error processing WebSocket message:', error);
const response = {
id: message.id,
sessionId: message.sessionId,
error: {
code: -32000,
message: error.message,
},
};
socket.send(JSON.stringify(response));
}
};
// chrome.debugger events -> WebSocket
const eventListener = (source, method, params) => {
if (source.tabId === tabId && socket.readyState === WebSocket.OPEN) {
// If the sessionId is not provided, use the root sessionId.
const event = {
sessionId: source.sessionId || rootSessionId,
method,
params,
};
debugLog('Forwarding CDP event:', event);
socket.send(JSON.stringify(event));
}
};
const detachListener = (source, reason) => {
if (source.tabId === tabId) {
debugLog(`Debugger detached from tab ${tabId}, reason: ${reason}`);
this.disconnectTab(tabId);
}
};
// Store listeners for cleanup
connection.eventListener = eventListener;
connection.detachListener = detachListener;
chrome.debugger.onEvent.addListener(eventListener);
chrome.debugger.onDetach.addListener(detachListener);
// Handle WebSocket close
socket.onclose = () => {
debugLog(`WebSocket closed for tab ${tabId}`);
this.disconnectTab(tabId);
};
socket.onerror = (error) => {
debugLog(`WebSocket error for tab ${tabId}:`, error);
this.disconnectTab(tabId);
};
}
/**
* Disconnect a tab from the bridge
* @param {number} tabId
*/
async disconnectTab(tabId) {
await this.cleanupConnection(tabId);
// Update UI
chrome.action.setBadgeText({ tabId, text: '' });
chrome.action.setTitle({ tabId, title: 'Share tab with Playwright MCP' });
debugLog(`Tab ${tabId} disconnected`);
}
/**
* Clean up connection resources
* @param {number} tabId
*/
async cleanupConnection(tabId) {
const connection = this.activeConnections.get(tabId);
if (!connection) return;
// Remove listeners
if (connection.eventListener) {
chrome.debugger.onEvent.removeListener(connection.eventListener);
}
if (connection.detachListener) {
chrome.debugger.onDetach.removeListener(connection.detachListener);
}
// Close WebSocket
if (connection.socket && connection.socket.readyState === WebSocket.OPEN) {
connection.socket.close();
}
// Detach debugger
try {
await chrome.debugger.detach(connection.debuggee);
} catch (error) {
// Ignore detach errors - might already be detached
}
this.activeConnections.delete(tabId);
}
/**
* Handle tab removal
* @param {number} tabId
*/
async onTabRemoved(tabId) {
if (this.activeConnections.has(tabId)) {
await this.cleanupConnection(tabId);
}
}
}
new TabShareExtension();

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

BIN
extension/icons/icon-16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 571 B

BIN
extension/icons/icon-32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
extension/icons/icon-48.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

40
extension/manifest.json Normal file
View File

@@ -0,0 +1,40 @@
{
"manifest_version": 3,
"name": "Playwright MCP Bridge",
"version": "1.0.0",
"description": "Share browser tabs with Playwright MCP server through CDP bridge",
"permissions": [
"debugger",
"activeTab",
"tabs",
"storage"
],
"host_permissions": [
"<all_urls>"
],
"background": {
"service_worker": "background.js",
"type": "module"
},
"action": {
"default_title": "Share tab with Playwright MCP",
"default_popup": "popup.html",
"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"
}
}

173
extension/popup.html Normal file
View File

@@ -0,0 +1,173 @@
<!--
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>
<meta charset="utf-8">
<style>
body {
width: 320px;
padding: 16px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
margin: 0;
}
.header {
margin-bottom: 16px;
text-align: center;
}
.header h3 {
margin: 0 0 8px 0;
color: #333;
}
.section {
margin-bottom: 16px;
}
label {
display: block;
margin-bottom: 4px;
font-weight: 500;
color: #555;
}
input[type="url"] {
width: 100%;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
box-sizing: border-box;
}
input[type="url"]:focus {
outline: none;
border-color: #4CAF50;
box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.2);
}
.button {
background: #4CAF50;
color: white;
border: none;
padding: 10px 16px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
width: 100%;
margin-top: 8px;
}
.button:hover {
background: #45a049;
}
.button:disabled {
background: #cccccc;
cursor: not-allowed;
}
.button.disconnect {
background: #f44336;
}
.button.disconnect:hover {
background: #da190b;
}
.status {
padding: 12px;
border-radius: 4px;
margin-bottom: 16px;
text-align: center;
}
.status.connected {
background: #e8f5e8;
color: #2e7d32;
border: 1px solid #4caf50;
}
.status.error {
background: #ffebee;
color: #c62828;
border: 1px solid #f44336;
}
.status.warning {
background: #fff3e0;
color: #ef6c00;
border: 1px solid #ff9800;
}
.tab-info {
background: #f5f5f5;
padding: 12px;
border-radius: 4px;
margin-bottom: 16px;
}
.tab-title {
font-weight: 500;
margin-bottom: 4px;
color: #333;
}
.tab-url {
font-size: 12px;
color: #666;
word-break: break-all;
}
.focus-button {
background: #2196F3;
margin-top: 8px;
}
.focus-button:hover {
background: #1976D2;
}
.small-text {
font-size: 12px;
color: #666;
margin-top: 8px;
}
</style>
</head>
<body>
<div class="header">
<h3>Playwright MCP Bridge</h3>
</div>
<div id="status-container"></div>
<div class="section">
<label for="bridge-url">Bridge Server URL:</label>
<input type="url" id="bridge-url" disabled placeholder="ws://localhost:9223/extension" />
<div class="small-text">Enter the WebSocket URL of your MCP bridge server</div>
</div>
<div id="action-container">
<button id="connect-btn" class="button">Share This Tab</button>
</div>
<script src="popup.js"></script>
</body>
</html>

228
extension/popup.js Normal file
View File

@@ -0,0 +1,228 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// @ts-check
/**
* Popup script for Playwright MCP Bridge extension
*/
class PopupController {
constructor() {
this.currentTab = null;
this.bridgeUrlInput = /** @type {HTMLInputElement} */ (document.getElementById('bridge-url'));
this.connectBtn = /** @type {HTMLButtonElement} */ (document.getElementById('connect-btn'));
this.statusContainer = /** @type {HTMLElement} */ (document.getElementById('status-container'));
this.actionContainer = /** @type {HTMLElement} */ (document.getElementById('action-container'));
this.init();
}
async init() {
// Get current tab
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
this.currentTab = tab;
// Load saved bridge URL
const result = await chrome.storage.sync.get(['bridgeUrl']);
const savedUrl = result.bridgeUrl || 'ws://localhost:9223/extension';
this.bridgeUrlInput.value = savedUrl;
this.bridgeUrlInput.disabled = false;
// Set up event listeners
this.bridgeUrlInput.addEventListener('input', this.onUrlChange.bind(this));
this.connectBtn.addEventListener('click', this.onConnectClick.bind(this));
// Update UI based on current state
await this.updateUI();
}
async updateUI() {
if (!this.currentTab?.id) return;
// Get connection status from background script
const response = await chrome.runtime.sendMessage({
type: 'getStatus',
tabId: this.currentTab.id
});
const { isConnected, activeTabId, activeTabInfo, error } = response;
if (!this.statusContainer || !this.actionContainer) return;
this.statusContainer.innerHTML = '';
this.actionContainer.innerHTML = '';
if (error) {
this.showStatus('error', `Error: ${error}`);
this.showConnectButton();
} else if (isConnected && activeTabId === this.currentTab.id) {
// Current tab is connected
this.showStatus('connected', 'This tab is currently shared with MCP server');
this.showDisconnectButton();
} else if (isConnected && activeTabId !== this.currentTab.id) {
// Another tab is connected
this.showStatus('warning', 'Another tab is already sharing the CDP session');
this.showActiveTabInfo(activeTabInfo);
this.showFocusButton(activeTabId);
} else {
// No connection
this.showConnectButton();
}
}
showStatus(type, message) {
const statusDiv = document.createElement('div');
statusDiv.className = `status ${type}`;
statusDiv.textContent = message;
this.statusContainer.appendChild(statusDiv);
}
showConnectButton() {
if (!this.actionContainer) return;
this.actionContainer.innerHTML = `
<button id="connect-btn" class="button">Share This Tab</button>
`;
const connectBtn = /** @type {HTMLButtonElement} */ (document.getElementById('connect-btn'));
if (connectBtn) {
connectBtn.addEventListener('click', this.onConnectClick.bind(this));
// Disable if URL is invalid
const isValidUrl = this.bridgeUrlInput ? this.isValidWebSocketUrl(this.bridgeUrlInput.value) : false;
connectBtn.disabled = !isValidUrl;
}
}
showDisconnectButton() {
if (!this.actionContainer) return;
this.actionContainer.innerHTML = `
<button id="disconnect-btn" class="button disconnect">Stop Sharing</button>
`;
const disconnectBtn = /** @type {HTMLButtonElement} */ (document.getElementById('disconnect-btn'));
if (disconnectBtn) {
disconnectBtn.addEventListener('click', this.onDisconnectClick.bind(this));
}
}
showActiveTabInfo(tabInfo) {
if (!tabInfo) return;
const tabDiv = document.createElement('div');
tabDiv.className = 'tab-info';
tabDiv.innerHTML = `
<div class="tab-title">${tabInfo.title || 'Unknown Tab'}</div>
<div class="tab-url">${tabInfo.url || ''}</div>
`;
this.statusContainer.appendChild(tabDiv);
}
showFocusButton(activeTabId) {
if (!this.actionContainer) return;
this.actionContainer.innerHTML = `
<button id="focus-btn" class="button focus-button">Switch to Shared Tab</button>
`;
const focusBtn = /** @type {HTMLButtonElement} */ (document.getElementById('focus-btn'));
if (focusBtn) {
focusBtn.addEventListener('click', () => this.onFocusClick(activeTabId));
}
}
onUrlChange() {
if (!this.bridgeUrlInput) return;
const isValid = this.isValidWebSocketUrl(this.bridgeUrlInput.value);
const connectBtn = /** @type {HTMLButtonElement} */ (document.getElementById('connect-btn'));
if (connectBtn) {
connectBtn.disabled = !isValid;
}
// Save URL to storage
if (isValid) {
chrome.storage.sync.set({ bridgeUrl: this.bridgeUrlInput.value });
}
}
async onConnectClick() {
if (!this.bridgeUrlInput || !this.currentTab?.id) return;
const url = this.bridgeUrlInput.value.trim();
if (!this.isValidWebSocketUrl(url)) {
this.showStatus('error', 'Please enter a valid WebSocket URL');
return;
}
// Save URL to storage
await chrome.storage.sync.set({ bridgeUrl: url });
// Send connect message to background script
const response = await chrome.runtime.sendMessage({
type: 'connect',
tabId: this.currentTab.id,
bridgeUrl: url
});
if (response.success) {
await this.updateUI();
} else {
this.showStatus('error', response.error || 'Failed to connect');
}
}
async onDisconnectClick() {
if (!this.currentTab?.id) return;
const response = await chrome.runtime.sendMessage({
type: 'disconnect',
tabId: this.currentTab.id
});
if (response.success) {
await this.updateUI();
} else {
this.showStatus('error', response.error || 'Failed to disconnect');
}
}
async onFocusClick(activeTabId) {
try {
await chrome.tabs.update(activeTabId, { active: true });
window.close(); // Close popup after switching
} catch (error) {
this.showStatus('error', 'Failed to switch to tab');
}
}
isValidWebSocketUrl(url) {
if (!url) return false;
try {
const parsed = new URL(url);
return parsed.protocol === 'ws:' || parsed.protocol === 'wss:';
} catch {
return false;
}
}
}
// Initialize popup when DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
new PopupController();
});

34
package-lock.json generated
View File

@@ -1,19 +1,19 @@
{
"name": "@playwright/mcp",
"version": "0.0.30",
"version": "0.0.29",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@playwright/mcp",
"version": "0.0.30",
"version": "0.0.29",
"license": "Apache-2.0",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.11.0",
"commander": "^13.1.0",
"debug": "^4.4.1",
"mime": "^4.0.7",
"playwright": "1.54.1",
"playwright": "1.53.0",
"ws": "^8.18.1",
"zod-to-json-schema": "^3.24.4"
},
@@ -23,7 +23,7 @@
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.19.0",
"@playwright/test": "1.54.1",
"@playwright/test": "1.53.0",
"@stylistic/eslint-plugin": "^3.0.1",
"@types/chrome": "^0.0.315",
"@types/debug": "^4.1.12",
@@ -292,13 +292,12 @@
}
},
"node_modules/@playwright/test": {
"version": "1.54.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.54.1.tgz",
"integrity": "sha512-FS8hQ12acieG2dYSksmLOF7BNxnVf2afRJdCuM1eMSxj6QTSE6G4InGF7oApGgDb65MX7AwMVlIkpru0yZA4Xw==",
"version": "1.53.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.53.0.tgz",
"integrity": "sha512-15hjKreZDcp7t6TL/7jkAo6Df5STZN09jGiv5dbP9A6vMVncXRqE7/B2SncsyOwrkZRBH2i6/TPOL8BVmm3c7w==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.54.1"
"playwright": "1.53.0"
},
"bin": {
"playwright": "cli.js"
@@ -2033,7 +2032,6 @@
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
@@ -3300,12 +3298,11 @@
}
},
"node_modules/playwright": {
"version": "1.54.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.54.1.tgz",
"integrity": "sha512-peWpSwIBmSLi6aW2auvrUtf2DqY16YYcCMO8rTVx486jKmDTJg7UAhyrraP98GB8BoPURZP8+nxO7TSd4cPr5g==",
"license": "Apache-2.0",
"version": "1.53.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.53.0.tgz",
"integrity": "sha512-ghGNnIEYZC4E+YtclRn4/p6oYbdPiASELBIYkBXfaTVKreQUYbMUYQDwS12a8F0/HtIjr/CkGjtwABeFPGcS4Q==",
"dependencies": {
"playwright-core": "1.54.1"
"playwright-core": "1.53.0"
},
"bin": {
"playwright": "cli.js"
@@ -3318,10 +3315,9 @@
}
},
"node_modules/playwright-core": {
"version": "1.54.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.54.1.tgz",
"integrity": "sha512-Nbjs2zjj0htNhzgiy5wu+3w09YetDx5pkrpI/kZotDlDUaYk0HVA5xrBVPdow4SAUIlhgKcJeJg4GRKW6xHusA==",
"license": "Apache-2.0",
"version": "1.53.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.53.0.tgz",
"integrity": "sha512-mGLg8m0pm4+mmtB7M89Xw/GSqoNC+twivl8ITteqvAndachozYe2ZA7srU6uleV1vEdAHYqjq+SV8SNxRRFYBw==",
"bin": {
"playwright-core": "cli.js"
},

View File

@@ -1,6 +1,6 @@
{
"name": "@playwright/mcp",
"version": "0.0.30",
"version": "0.0.29",
"description": "Playwright Tools for MCP",
"type": "module",
"repository": {
@@ -24,6 +24,7 @@
"ctest": "playwright test --project=chrome",
"ftest": "playwright test --project=firefox",
"wtest": "playwright test --project=webkit",
"etest": "playwright test --project=chromium-extension",
"run-server": "node lib/browserServer.js",
"clean": "rm -rf lib",
"npm-publish": "npm run clean && npm run build && npm run test && npm publish"
@@ -40,14 +41,14 @@
"commander": "^13.1.0",
"debug": "^4.4.1",
"mime": "^4.0.7",
"playwright": "1.54.1",
"playwright": "1.53.0",
"ws": "^8.18.1",
"zod-to-json-schema": "^3.24.4"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.19.0",
"@playwright/test": "1.54.1",
"@playwright/test": "1.53.0",
"@stylistic/eslint-plugin": "^3.0.1",
"@types/chrome": "^0.0.315",
"@types/debug": "^4.1.12",

View File

@@ -39,5 +39,6 @@ export default defineConfig<TestOptions>({
}] : [],
{ name: 'firefox', use: { mcpBrowser: 'firefox' } },
{ name: 'webkit', use: { mcpBrowser: 'webkit' } },
{ name: 'chromium-extension', use: { mcpBrowser: 'chromium', mcpMode: 'extension' } },
],
});

317
src/cdpRelay.ts Normal file
View File

@@ -0,0 +1,317 @@
/**
* 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.
*/
/**
* Bridge Server - Standalone WebSocket server that bridges Playwright MCP and Chrome Extension
*
* Endpoints:
* - /cdp - Full CDP interface for Playwright MCP
* - /extension - Extension connection for chrome.debugger forwarding
*/
/* eslint-disable no-console */
import { WebSocket, WebSocketServer } from 'ws';
import http from 'node:http';
import { EventEmitter } from 'node:events';
import debug from 'debug';
import { httpAddressToString } from './transport.js';
const debugLogger = debug('pw:mcp:relay');
const CDP_PATH = '/cdp';
const EXTENSION_PATH = '/extension';
export class CDPRelayServer extends EventEmitter {
private _wss: WebSocketServer;
private _playwrightSocket: WebSocket | null = null;
private _extensionSocket: WebSocket | null = null;
private _connectionInfo: {
targetInfo: any;
sessionId: string;
} | undefined;
constructor(server: http.Server) {
super();
this._wss = new WebSocketServer({ server });
this._wss.on('connection', this._onConnection.bind(this));
}
stop(): void {
this._playwrightSocket?.close();
this._extensionSocket?.close();
}
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 === CDP_PATH) {
this._handlePlaywrightConnection(ws);
} else if (url.pathname === EXTENSION_PATH) {
this._handleExtensionConnection(ws);
} else {
debugLogger(`Invalid path: ${url.pathname}`);
ws.close(4004, 'Invalid path');
}
}
/**
* Handle Playwright MCP connection - provides full CDP interface
*/
private _handlePlaywrightConnection(ws: WebSocket): void {
if (this._playwrightSocket?.readyState === WebSocket.OPEN) {
debugLogger('Closing previous Playwright connection');
this._playwrightSocket.close(1000, 'New connection established');
}
this._playwrightSocket = ws;
debugLogger('Playwright MCP connected');
ws.on('message', data => {
try {
const message = JSON.parse(data.toString());
this._handlePlaywrightMessage(message);
} catch (error) {
debugLogger('Error parsing Playwright message:', error);
}
});
ws.on('close', () => {
if (this._playwrightSocket === ws)
this._playwrightSocket = null;
debugLogger('Playwright MCP disconnected');
});
ws.on('error', error => {
debugLogger('Playwright WebSocket error:', error);
});
}
/**
* Handle Extension connection - forwards to chrome.debugger
*/
private _handleExtensionConnection(ws: WebSocket): void {
if (this._extensionSocket?.readyState === WebSocket.OPEN) {
debugLogger('Closing previous extension connection');
this._extensionSocket.close(1000, 'New connection established');
}
this._extensionSocket = ws;
debugLogger('Extension connected');
ws.on('message', data => {
try {
const message = JSON.parse(data.toString());
this._handleExtensionMessage(message);
} catch (error) {
debugLogger('Error parsing extension message:', error);
}
});
ws.on('close', () => {
if (this._extensionSocket === ws)
this._extensionSocket = null;
debugLogger('Extension disconnected');
});
ws.on('error', error => {
debugLogger('Extension WebSocket error:', error);
});
}
/**
* Handle messages from Playwright MCP
*/
private _handlePlaywrightMessage(message: any): void {
debugLogger('← Playwright:', message.method || `response(${message.id})`);
// Handle Browser domain methods locally
if (message.method?.startsWith('Browser.')) {
this._handleBrowserDomainMethod(message);
return;
}
// Handle Target domain methods
if (message.method?.startsWith('Target.')) {
this._handleTargetDomainMethod(message);
return;
}
// Forward other commands to extension
if (message.method)
this._forwardToExtension(message);
}
/**
* Handle messages from Extension
*/
private _handleExtensionMessage(message: any): void {
// Handle connection info from extension
if (message.type === 'connection_info') {
debugLogger('← Extension connected to tab:', message);
this._connectionInfo = {
targetInfo: message.targetInfo,
// Page sessionId that should be used by this connection.
sessionId: message.sessionId
};
return;
}
// CDP event from extension
debugLogger(`← Extension message: ${message.method ?? (message.id && `response(id=${message.id})`) ?? 'unknown'}`);
this._sendToPlaywright(message);
}
/**
* Handle Browser domain methods locally
*/
private _handleBrowserDomainMethod(message: any): void {
switch (message.method) {
case 'Browser.getVersion':
this._sendToPlaywright({
id: message.id,
result: {
protocolVersion: '1.3',
product: 'Chrome/Extension-Bridge',
userAgent: 'CDP-Bridge-Server/1.0.0',
}
});
break;
case 'Browser.setDownloadBehavior':
this._sendToPlaywright({
id: message.id,
result: {}
});
break;
default:
// Forward unknown Browser methods to extension
this._forwardToExtension(message);
}
}
/**
* Handle Target domain methods
*/
private _handleTargetDomainMethod(message: any): void {
switch (message.method) {
case 'Target.setAutoAttach':
// Simulate auto-attach behavior with real target info
if (this._connectionInfo && !message.sessionId) {
debugLogger('Simulating auto-attach for target:', JSON.stringify(message));
this._sendToPlaywright({
method: 'Target.attachedToTarget',
params: {
sessionId: this._connectionInfo.sessionId,
targetInfo: {
...this._connectionInfo.targetInfo,
attached: true,
},
waitingForDebugger: false
}
});
this._sendToPlaywright({
id: message.id,
result: {}
});
} else {
this._forwardToExtension(message);
}
break;
case 'Target.getTargets':
const targetInfos = [];
if (this._connectionInfo) {
targetInfos.push({
...this._connectionInfo.targetInfo,
attached: true,
});
}
this._sendToPlaywright({
id: message.id,
result: { targetInfos }
});
break;
default:
this._forwardToExtension(message);
}
}
/**
* Forward message to extension
*/
private _forwardToExtension(message: any): void {
if (this._extensionSocket?.readyState === WebSocket.OPEN) {
debugLogger('→ Extension:', message.method || `command(${message.id})`);
this._extensionSocket.send(JSON.stringify(message));
} else {
debugLogger('Extension not connected, cannot forward message');
if (message.id) {
this._sendToPlaywright({
id: message.id,
error: { message: 'Extension not connected' }
});
}
}
}
/**
* Forward message to Playwright
*/
private _sendToPlaywright(message: any): void {
if (this._playwrightSocket?.readyState === WebSocket.OPEN) {
debugLogger('→ Playwright:', JSON.stringify(message));
this._playwrightSocket.send(JSON.stringify(message));
}
}
}
export async function startCDPRelayServer(httpServer: http.Server) {
const wsAddress = httpAddressToString(httpServer.address()).replace(/^http/, 'ws');
const cdpRelayServer = new CDPRelayServer(httpServer);
process.on('exit', () => cdpRelayServer.stop());
// eslint-disable-next-line no-console
console.error(`CDP relay server started on ${wsAddress}${EXTENSION_PATH} - Connect to it using the browser extension.`);
const cdpEndpoint = `${wsAddress}${CDP_PATH}`;
return cdpEndpoint;
}
// CLI usage
if (import.meta.url === `file://${process.argv[1]}`) {
const port = parseInt(process.argv[2], 10) || 9223;
const httpServer = http.createServer();
await new Promise<void>(resolve => httpServer.listen(port, resolve));
const server = new CDPRelayServer(httpServer);
console.error(`CDP Bridge Server listening on ws://localhost:${port}`);
console.error(`- Playwright MCP: ws://localhost:${port}${CDP_PATH}`);
console.error(`- Extension: ws://localhost:${port}${EXTENSION_PATH}`);
process.on('SIGINT', () => {
debugLogger('\nShutting down bridge server...');
server.stop();
process.exit(0);
});
}

View File

@@ -19,10 +19,18 @@ import os from 'os';
import path from 'path';
import { devices } from 'playwright';
import type { Config, ToolCapability } from '../config.js';
import type { Config as PublicConfig, ToolCapability } from '../config.js';
import type { BrowserContextOptions, LaunchOptions } from 'playwright';
import { sanitizeForFilePath } from './tools/utils.js';
type Config = PublicConfig & {
/**
* TODO: Move to PublicConfig once we are ready to release this feature.
* Run server that is able to connect to the 'Playwright MCP' Chrome extension.
*/
extension?: boolean;
};
export type CLIOptions = {
allowedOrigins?: string[];
blockedOrigins?: string[];
@@ -50,6 +58,7 @@ export type CLIOptions = {
userDataDir?: string;
viewportSize?: string;
vision?: boolean;
extension?: boolean;
};
const defaultConfig: FullConfig = {
@@ -99,6 +108,13 @@ export async function resolveCLIConfig(cliOptions: CLIOptions): Promise<FullConf
return result;
}
export function validateConfig(config: Config) {
if (config.extension) {
if (config.browser?.browserName !== 'chromium')
throw new Error('Extension mode is only supported for Chromium browsers.');
}
}
export async function configFromCLIOptions(cliOptions: CLIOptions): Promise<Config> {
let browserName: 'chromium' | 'firefox' | 'webkit' | undefined;
let channel: string | undefined;
@@ -144,6 +160,8 @@ export async function configFromCLIOptions(cliOptions: CLIOptions): Promise<Conf
if (cliOptions.device && cliOptions.cdpEndpoint)
throw new Error('Device emulation is not supported with cdpEndpoint.');
if (cliOptions.device && cliOptions.extension)
throw new Error('Device emulation is not supported with extension mode.');
// Context options
const contextOptions: BrowserContextOptions = cliOptions.device ? devices[cliOptions.device] : {};
@@ -186,6 +204,7 @@ export async function configFromCLIOptions(cliOptions: CLIOptions): Promise<Conf
},
capabilities: cliOptions.caps?.split(',').map((c: string) => c.trim() as ToolCapability),
vision: !!cliOptions.vision,
extension: !!cliOptions.extension,
network: {
allowedOrigins: cliOptions.allowedOrigins,
blockedOrigins: cliOptions.blockedOrigins,

View File

@@ -22,13 +22,14 @@ import { Context } from './context.js';
import { snapshotTools, visionTools } from './tools.js';
import { packageJSON } from './package.js';
import { FullConfig } from './config.js';
import { FullConfig, validateConfig } from './config.js';
import type { BrowserContextFactory } from './browserContextFactory.js';
export function createConnection(config: FullConfig, browserContextFactory: BrowserContextFactory): Connection {
const allTools = config.vision ? visionTools : snapshotTools;
const tools = allTools.filter(tool => !config.capabilities || tool.capability === 'core' || config.capabilities.includes(tool.capability));
validateConfig(config);
const context = new Context(tools, config, browserContextFactory);
const server = new McpServer({ name: 'Playwright', version: packageJSON.version }, {
capabilities: {

View File

@@ -101,7 +101,7 @@ export class Context {
}
async selectTab(index: number) {
this._currentTab = this._tabs[index - 1];
this._currentTab = this._tabs[index];
await this._currentTab.page.bringToFront();
}
@@ -121,13 +121,13 @@ export class Context {
const title = await tab.title();
const url = tab.page.url();
const current = tab === this._currentTab ? ' (current)' : '';
lines.push(`- ${i + 1}:${current} [${title}] (${url})`);
lines.push(`- ${i}:${current} [${title}] (${url})`);
}
return lines.join('\n');
}
async closeTab(index: number | undefined) {
const tab = index === undefined ? this._currentTab : this._tabs[index - 1];
const tab = index === undefined ? this._currentTab : this._tabs[index];
await tab?.page.close();
return await this.listTabsMarkdown();
}

View File

@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { program } from 'commander';
import { Option, program } from 'commander';
// @ts-ignore
import { startTraceViewerServer } from 'playwright-core/lib/server';
@@ -22,6 +22,7 @@ import { startHttpServer, startHttpTransport, startStdioTransport } from './tran
import { resolveCLIConfig } from './config.js';
import { Server } from './server.js';
import { packageJSON } from './package.js';
import { startCDPRelayServer } from './cdpRelay.js';
program
.version('Version ' + packageJSON.version)
@@ -52,15 +53,22 @@ program
.option('--user-data-dir <path>', 'path to the user data directory. If not specified, a temporary directory will be created.')
.option('--viewport-size <size>', 'specify browser viewport size in pixels, for example "1280, 720"')
.option('--vision', 'Run server that uses screenshots (Aria snapshots are used by default)')
.addOption(new Option('--extension', 'Allow connecting to a running browser instance (Edge/Chrome only). Requires the \'Playwright MCP\' browser extension to be installed.').hideHelp())
.action(async options => {
const config = await resolveCLIConfig(options);
const httpServer = config.server.port !== undefined ? await startHttpServer(config.server) : undefined;
if (config.extension) {
if (!httpServer)
throw new Error('--port parameter is required for extension mode');
// Point CDP endpoint to the relay server.
config.browser.cdpEndpoint = await startCDPRelayServer(httpServer);
}
const server = new Server(config);
server.setupExitWatchdog();
if (httpServer)
startHttpTransport(httpServer, server);
await startHttpTransport(httpServer, server);
else
await startStdioTransport(server);

36
src/resources/resource.ts Normal file
View File

@@ -0,0 +1,36 @@
/**
* 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 { Context } from '../context.js';
export type ResourceSchema = {
uri: string;
name: string;
description?: string;
mimeType?: string;
};
export type ResourceResult = {
uri: string;
mimeType?: string;
text?: string;
blob?: string;
};
export type Resource = {
schema: ResourceSchema;
read: (context: Context, uri: string) => Promise<ResourceResult[]>;
};

View File

@@ -46,17 +46,13 @@ const elementSchema = z.object({
ref: z.string().describe('Exact target element reference from the page snapshot'),
});
const clickSchema = elementSchema.extend({
doubleClick: z.boolean().optional().describe('Whether to perform a double click instead of a single click'),
});
const click = defineTool({
capability: 'core',
schema: {
name: 'browser_click',
title: 'Click',
description: 'Perform click on a web page',
inputSchema: clickSchema,
inputSchema: elementSchema,
type: 'destructive',
},
@@ -64,18 +60,14 @@ const click = defineTool({
const tab = context.currentTabOrDie();
const locator = tab.snapshotOrDie().refLocator(params);
const code: string[] = [];
if (params.doubleClick) {
code.push(`// Double click ${params.element}`);
code.push(`await page.${await generateLocator(locator)}.dblclick();`);
} else {
code.push(`// Click ${params.element}`);
code.push(`await page.${await generateLocator(locator)}.click();`);
}
const code = [
`// Click ${params.element}`,
`await page.${await generateLocator(locator)}.click();`
];
return {
code,
action: () => params.doubleClick ? locator.dblclick() : locator.click(),
action: () => locator.click(),
captureSnapshot: true,
waitForNetwork: true,
};

View File

@@ -81,7 +81,7 @@ export async function generateLocator(locator: playwright.Locator): Promise<stri
try {
return await (locator as any)._generateLocatorString();
} catch (e) {
if (e instanceof Error && /locator._generateLocatorString: No element matching locator/.test(e.message))
if (e instanceof Error && /locator._generateLocatorString: Timeout .* exceeded/.test(e.message))
throw new Error('Ref not found, likely because element was removed. Use browser_snapshot to see what elements are currently on the page.');
throw e;
}

View File

@@ -19,13 +19,15 @@ import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { test, expect } from './fixtures.js';
test.skip(({ mcpMode }) => mcpMode === 'extension', 'Connecting to CDP server is not supported in combination with --extension');
test('cdp server', async ({ cdpServer, startClient, server }) => {
await cdpServer.start();
const { client } = await startClient({ args: [`--cdp-endpoint=${cdpServer.endpoint}`] });
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
})).toContainTextContent(`- generic [active] [ref=e1]: Hello, world!`);
})).toContainTextContent(`- generic [ref=e1]: Hello, world!`);
});
test('cdp server reuse tab', async ({ cdpServer, startClient, server }) => {
@@ -55,7 +57,7 @@ test('cdp server reuse tab', async ({ cdpServer, startClient, server }) => {
- Page Title: Title
- Page Snapshot
\`\`\`yaml
- generic [active] [ref=e1]: Hello, world!
- generic [ref=e1]: Hello, world!
\`\`\`
`);
});
@@ -76,7 +78,7 @@ test('should throw connection error and allow re-connecting', async ({ cdpServer
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
})).toContainTextContent(`- generic [active] [ref=e1]: Hello, world!`);
})).toContainTextContent(`- generic [ref=e1]: Hello, world!`);
});
// NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename.

View File

@@ -20,6 +20,7 @@ import { Config } from '../config.js';
import { test, expect } from './fixtures.js';
test('config user data dir', async ({ startClient, server, mcpMode }, testInfo) => {
test.skip(mcpMode === 'extension', 'Connecting to CDP server does not use user data dir');
server.setContent('/', `
<title>Title</title>
<body>Hello, world!</body>
@@ -46,6 +47,7 @@ test('config user data dir', async ({ startClient, server, mcpMode }, testInfo)
test.describe(() => {
test.use({ mcpBrowser: '' });
test('browserName', { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright-mcp/issues/458' } }, async ({ startClient, mcpMode }, testInfo) => {
test.skip(mcpMode === 'extension', 'Extension mode only supports Chromium');
const config: Config = {
browser: {
browserName: 'firefox',

View File

@@ -31,13 +31,13 @@ await page.goto('${server.HELLO_WORLD}');
- Page Title: Title
- Page Snapshot
\`\`\`yaml
- generic [active] [ref=e1]: Hello, world!
- generic [ref=e1]: Hello, world!
\`\`\`
`
);
});
test('browser_click', async ({ client, server, mcpBrowser }) => {
test('browser_click', async ({ client, server }) => {
server.setContent('/', `
<title>Title</title>
<button>Submit</button>
@@ -65,46 +65,7 @@ await page.getByRole('button', { name: 'Submit' }).click();
- Page Title: Title
- Page Snapshot
\`\`\`yaml
- button "Submit" ${mcpBrowser !== 'webkit' || process.platform === 'linux' ? '[active] ' : ''}[ref=e2]
\`\`\`
`);
});
test('browser_click (double)', async ({ client, server }) => {
server.setContent('/', `
<title>Title</title>
<script>
function handle() {
document.querySelector('h1').textContent = 'Double clicked';
}
</script>
<h1 ondblclick="handle()">Click me</h1>
`, 'text/html');
await client.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
});
expect(await client.callTool({
name: 'browser_click',
arguments: {
element: 'Click me',
ref: 'e2',
doubleClick: true,
},
})).toHaveTextContent(`
- Ran Playwright code:
\`\`\`js
// Double click Click me
await page.getByRole('heading', { name: 'Click me' }).dblclick();
\`\`\`
- Page URL: ${server.PREFIX}
- Page Title: Title
- Page Snapshot
\`\`\`yaml
- heading "Double clicked" [level=1] [ref=e3]
- button "Submit" [ref=e2]
\`\`\`
`);
});
@@ -314,22 +275,3 @@ test('old locator error message', async ({ client, server }) => {
},
})).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 }) => {
server.setContent('/', `
<div style="visibility: hidden;">
<div style="visibility: visible;">
<button>Button</button>
</div>
</div>
`, 'text/html');
await client.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
});
expect(await client.callTool({
name: 'browser_snapshot'
})).toContainTextContent('- button "Button"');
});

View File

@@ -17,6 +17,7 @@
import { test, expect } from './fixtures.js';
test('--device should work', async ({ startClient, server, mcpMode }) => {
test.skip(mcpMode === 'extension', 'Viewport is not supported when connecting via CDP. There we re-use the browser viewport.');
const { client } = await startClient({
args: ['--device', 'iPhone 15'],
});

View File

@@ -58,7 +58,7 @@ await page.getByRole('button', { name: 'Button' }).click();
- Page Title:
- Page Snapshot
\`\`\`yaml
- button "Button" [active] [ref=e2]
- button "Button" [ref=e2]
\`\`\`
`);
});
@@ -136,7 +136,7 @@ test('confirm dialog (true)', async ({ client, server }) => {
expect(result).toContainTextContent('// <internal code to handle "confirm" dialog>');
expect(result).toContainTextContent(`- Page Snapshot
\`\`\`yaml
- generic [active] [ref=e1]: "true"
- generic [ref=e1]: "true"
\`\`\``);
});
@@ -171,7 +171,7 @@ test('confirm dialog (false)', async ({ client, server }) => {
expect(result).toContainTextContent(`- Page Snapshot
\`\`\`yaml
- generic [active] [ref=e1]: "false"
- generic [ref=e1]: "false"
\`\`\``);
});
@@ -207,6 +207,6 @@ test('prompt dialog', async ({ client, server }) => {
expect(result).toContainTextContent(`- Page Snapshot
\`\`\`yaml
- generic [active] [ref=e1]: Answer
- generic [ref=e1]: Answer
\`\`\``);
});

43
tests/extension.spec.ts Normal file
View File

@@ -0,0 +1,43 @@
/**
* 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 url from 'url';
import path from 'path';
import { spawnSync } from 'child_process';
import { test, expect } from './fixtures.js';
import { createConnection } from '@playwright/mcp';
test.skip(({ mcpMode }) => mcpMode !== 'extension');
test('does not allow --cdp-endpoint', async ({ startClient }) => {
await expect(createConnection({
browser: { browserName: 'firefox' },
...({ extension: true })
})).rejects.toThrow(/Extension mode is only supported for Chromium browsers/);
});
// NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename.
const __filename = url.fileURLToPath(import.meta.url);
test('does not support --device', async () => {
const result = spawnSync('node', [
path.join(__filename, '../../cli.js'), '--device=Pixel 5', '--extension',
]);
expect(result.error).toBeUndefined();
expect(result.status).toBe(1);
expect(result.stderr.toString()).toContain('Device emulation is not supported with extension mode.');
});

View File

@@ -28,7 +28,7 @@ test('browser_file_upload', async ({ client, server }, testInfo) => {
arguments: { url: server.PREFIX },
})).toContainTextContent(`
\`\`\`yaml
- generic [active] [ref=e1]:
- generic [ref=e1]:
- button "Choose File" [ref=e2]
- button "Button" [ref=e3]
\`\`\``);
@@ -65,6 +65,12 @@ The tool "browser_file_upload" can only be used when there is related modal stat
});
expect(response).not.toContainTextContent('### Modal state');
expect(response).toContainTextContent(`
\`\`\`yaml
- generic [ref=e1]:
- button "Choose File" [ref=e2]
- button "Button" [ref=e3]
\`\`\``);
}
{
@@ -95,6 +101,7 @@ The tool "browser_file_upload" can only be used when there is related modal stat
});
test('clicking on download link emits download', async ({ startClient, server, mcpMode }, testInfo) => {
test.fixme(mcpMode === 'extension', 'Downloads are on the Browser CDP domain and not supported with --extension');
const { client } = await startClient({
config: { outputDir: testInfo.outputPath('output') },
});
@@ -119,6 +126,7 @@ test('clicking on download link emits download', async ({ startClient, server, m
});
test('navigating to download link emits download', async ({ startClient, server, mcpBrowser, mcpMode }, testInfo) => {
test.fixme(mcpMode === 'extension', 'Downloads are on the Browser CDP domain and not supported with --extension');
const { client } = await startClient({
config: { outputDir: testInfo.outputPath('output') },
});

View File

@@ -17,12 +17,16 @@
import fs from 'fs';
import url from 'url';
import path from 'path';
import net from 'net';
import { chromium } from 'playwright';
import { fork } from 'child_process';
import { test as baseTest, expect as baseExpect } from '@playwright/test';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { TestServer } from './testserver/index.ts';
import { ManualPromise } from '../src/manualPromise.js';
import type { Config } from '../config';
import type { BrowserContext } from 'playwright';
@@ -31,7 +35,7 @@ import type { Stream } from 'stream';
export type TestOptions = {
mcpBrowser: string | undefined;
mcpMode: 'docker' | undefined;
mcpMode: 'docker' | 'extension' | undefined;
};
type CDPServer = {
@@ -48,6 +52,7 @@ type TestFixtures = {
server: TestServer;
httpsServer: TestServer;
mcpHeadless: boolean;
startMcpExtension: (relayServerURL: string) => Promise<void>;
};
type WorkerFixtures = {
@@ -66,7 +71,7 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
await use(client);
},
startClient: async ({ mcpHeadless, mcpBrowser, mcpMode }, use, testInfo) => {
startClient: async ({ mcpHeadless, mcpBrowser, mcpMode, startMcpExtension }, use, testInfo) => {
const userDataDir = mcpMode !== 'docker' ? testInfo.outputPath('user-data-dir') : undefined;
const configDir = path.dirname(test.info().config.configFile!);
let client: Client | undefined;
@@ -90,7 +95,7 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
}
client = new Client({ name: options?.clientName ?? 'test', version: '1.0.0' });
const { transport, stderr } = await createTransport(args, mcpMode);
const { transport, stderr, relayServerURL } = await createTransport(args, mcpMode);
let stderrBuffer = '';
stderr?.on('data', data => {
if (process.env.PWMCP_DEBUG)
@@ -98,6 +103,8 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
stderrBuffer += data.toString();
});
await client.connect(transport);
if (mcpMode === 'extension')
await startMcpExtension(relayServerURL!);
await client.ping();
return { client, stderr: () => stderrBuffer };
});
@@ -140,6 +147,38 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
mcpMode: [undefined, { option: true }],
startMcpExtension: async ({ mcpMode, mcpHeadless }, use) => {
let context: BrowserContext | undefined;
await use(async (relayServerURL: string) => {
if (mcpMode !== 'extension')
throw new Error('Must be running in MCP extension mode to use this fixture.');
const cdpPort = await findFreePort();
const pathToExtension = path.join(url.fileURLToPath(import.meta.url), '../../extension');
context = await chromium.launchPersistentContext('', {
headless: mcpHeadless,
args: [
`--disable-extensions-except=${pathToExtension}`,
`--load-extension=${pathToExtension}`,
'--enable-features=AllowContentInitiatedDataUrlNavigations',
],
channel: 'chromium',
...{ assistantMode: true, cdpPort },
});
const popupPage = await context.newPage();
const page = context.pages()[0];
await page.bringToFront();
// Do not auto dismiss dialogs.
page.on('dialog', () => { });
await expect.poll(() => context?.serviceWorkers()).toHaveLength(1);
// Connect to the relay server.
await popupPage.goto(new URL('/popup.html', context.serviceWorkers()[0].url()).toString());
await popupPage.getByRole('textbox', { name: 'Bridge Server URL:' }).clear();
await popupPage.getByRole('textbox', { name: 'Bridge Server URL:' }).fill(relayServerURL);
await popupPage.getByRole('button', { name: 'Share This Tab' }).click();
});
await context?.close();
},
_workerServers: [async ({ }, use, workerInfo) => {
const port = 8907 + workerInfo.workerIndex * 4;
const server = await TestServer.create(port);
@@ -169,6 +208,7 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
async function createTransport(args: string[], mcpMode: TestOptions['mcpMode']): Promise<{
transport: Transport,
stderr: Stream | null,
relayServerURL?: string,
}> {
// NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename.
const __filename = url.fileURLToPath(import.meta.url);
@@ -183,6 +223,42 @@ async function createTransport(args: string[], mcpMode: TestOptions['mcpMode']):
stderr: transport.stderr,
};
}
if (mcpMode === 'extension') {
const relay = fork(path.join(__filename, '../../cli.js'), [...args, '--extension', '--port=0'], {
stdio: 'pipe'
});
const cdpRelayServerReady = new ManualPromise<string>();
const sseEndpointPromise = new ManualPromise<string>();
let stderrBuffer = '';
relay.stderr!.on('data', data => {
stderrBuffer += data.toString();
const match = stderrBuffer.match(/Listening on (http:\/\/.*)/);
if (match)
sseEndpointPromise.resolve(match[1].toString());
const extensionMatch = stderrBuffer.match(/CDP relay server started on (ws:\/\/.*\/extension)/);
if (extensionMatch)
cdpRelayServerReady.resolve(extensionMatch[1].toString());
});
relay.on('exit', () => {
sseEndpointPromise.reject(new Error(`Process exited`));
cdpRelayServerReady.reject(new Error(`Process exited`));
});
const relayServerURL = await cdpRelayServerReady;
const sseEndpoint = await sseEndpointPromise;
const transport = new SSEClientTransport(new URL(sseEndpoint));
// We cannot just add transport.onclose here as Client.connect() overrides it.
const origClose = transport.close;
transport.close = async () => {
await origClose.call(transport);
relay.kill();
};
return {
transport,
stderr: relay.stderr!,
relayServerURL,
};
}
const transport = new StdioClientTransport({
command: 'node',
@@ -256,6 +332,17 @@ export const expect = baseExpect.extend({
},
});
async function findFreePort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.listen(0, () => {
const { port } = server.address() as net.AddressInfo;
server.close(() => resolve(port));
});
server.on('error', reject);
});
}
export function formatOutput(output: string): string[] {
return output.split('\n').map(line => line.replace(/^pw:mcp:test /, '').replace(/user data dir.*/, 'user data dir').trim()).filter(Boolean);
}

View File

@@ -24,10 +24,10 @@ test('stitched aria frames', async ({ client }) => {
},
})).toContainTextContent(`
\`\`\`yaml
- generic [active] [ref=e1]:
- generic [ref=e1]:
- heading "Hello" [level=1] [ref=e2]
- iframe [ref=e3]:
- generic [active] [ref=f1e1]:
- generic [ref=f1e1]:
- button "World" [ref=f1e2]
- main [ref=f1e3]:
- iframe [ref=f1e4]:

View File

@@ -18,6 +18,8 @@ import fs from 'fs';
import { test, expect, formatOutput } from './fixtures.js';
test.skip(({ mcpMode }) => mcpMode === 'extension', 'launch scenarios are not supported with --extension - the browser is already launched');
test('test reopen browser', async ({ startClient, server, mcpMode }) => {
const { client, stderr } = await startClient();
await client.callTool({
@@ -32,7 +34,7 @@ test('test reopen browser', async ({ startClient, server, mcpMode }) => {
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
})).toContainTextContent(`- generic [active] [ref=e1]: Hello, world!`);
})).toContainTextContent(`- generic [ref=e1]: Hello, world!`);
await client.close();

View File

@@ -40,7 +40,7 @@ test('save as pdf', async ({ startClient, mcpBrowser, server }, testInfo) => {
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
})).toContainTextContent(`- generic [active] [ref=e1]: Hello, world!`);
})).toContainTextContent(`- generic [ref=e1]: Hello, world!`);
const response = await client.callTool({
name: 'browser_pdf_save',
@@ -58,7 +58,7 @@ test('save as pdf (filename: output.pdf)', async ({ startClient, mcpBrowser, ser
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
})).toContainTextContent(`- generic [active] [ref=e1]: Hello, world!`);
})).toContainTextContent(`- generic [ref=e1]: Hello, world!`);
expect(await client.callTool({
name: 'browser_pdf_save',

View File

@@ -29,6 +29,8 @@ 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);
baseTest.skip(({ mcpMode }) => mcpMode === 'extension', 'Extension tests run via SSE anyways');
const test = baseTest.extend<{ serverEndpoint: (options?: { args?: string[], noPort?: boolean }) => Promise<{ url: URL, stderr: () => string }> }>({
serverEndpoint: async ({ mcpHeadless }, use, testInfo) => {
let cp: ChildProcess | undefined;

View File

@@ -27,11 +27,13 @@ async function createTab(client: Client, title: string, body: string) {
});
}
test.skip(({ mcpMode }) => mcpMode === 'extension', 'Multi-tab scenarios are not supported with --extension');
test('list initial tabs', async ({ client }) => {
expect(await client.callTool({
name: 'browser_tab_list',
})).toHaveTextContent(`### Open tabs
- 1: (current) [] (about:blank)`);
- 0: (current) [] (about:blank)`);
});
test('list first tab', async ({ client }) => {
@@ -39,8 +41,8 @@ test('list first tab', async ({ client }) => {
expect(await client.callTool({
name: 'browser_tab_list',
})).toHaveTextContent(`### Open tabs
- 1: [] (about:blank)
- 2: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)`);
- 0: [] (about:blank)
- 1: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)`);
});
test('create new tab', async ({ client }) => {
@@ -51,15 +53,15 @@ test('create new tab', async ({ client }) => {
\`\`\`
### Open tabs
- 1: [] (about:blank)
- 2: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)
- 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
\`\`\`yaml
- generic [active] [ref=e1]: Body one
- generic [ref=e1]: Body one
\`\`\``);
expect(await createTab(client, 'Tab two', 'Body two')).toHaveTextContent(`
@@ -69,16 +71,16 @@ test('create new tab', async ({ client }) => {
\`\`\`
### Open tabs
- 1: [] (about:blank)
- 2: [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)
- 3: (current) [Tab two] (data:text/html,<title>Tab two</title><body>Body two</body>)
- 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>)
### Current tab
- Page URL: data:text/html,<title>Tab two</title><body>Body two</body>
- Page Title: Tab two
- Page Snapshot
\`\`\`yaml
- generic [active] [ref=e1]: Body two
- generic [ref=e1]: Body two
\`\`\``);
});
@@ -88,25 +90,25 @@ test('select tab', async ({ client }) => {
expect(await client.callTool({
name: 'browser_tab_select',
arguments: {
index: 2,
index: 1,
},
})).toHaveTextContent(`
- Ran Playwright code:
\`\`\`js
// <internal code to select tab 2>
// <internal code to select tab 1>
\`\`\`
### Open tabs
- 1: [] (about:blank)
- 2: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)
- 3: [Tab two] (data:text/html,<title>Tab two</title><body>Body two</body>)
- 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>)
### Current tab
- Page URL: data:text/html,<title>Tab one</title><body>Body one</body>
- Page Title: Tab one
- Page Snapshot
\`\`\`yaml
- generic [active] [ref=e1]: Body one
- generic [ref=e1]: Body one
\`\`\``);
});
@@ -116,24 +118,24 @@ test('close tab', async ({ client }) => {
expect(await client.callTool({
name: 'browser_tab_close',
arguments: {
index: 3,
index: 2,
},
})).toHaveTextContent(`
- Ran Playwright code:
\`\`\`js
// <internal code to close tab 3>
// <internal code to close tab 2>
\`\`\`
### Open tabs
- 1: [] (about:blank)
- 2: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)
- 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
\`\`\`yaml
- generic [active] [ref=e1]: Body one
- generic [ref=e1]: Body one
\`\`\``);
});

View File

@@ -20,6 +20,8 @@ import path from 'path';
import { test, expect } from './fixtures.js';
test('check that trace is saved', async ({ startClient, server, mcpMode }, testInfo) => {
test.fixme(mcpMode === 'extension', 'Tracing is not supported via CDP');
const outputDir = testInfo.outputPath('output');
const { client } = await startClient({