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
70 changed files with 2610 additions and 2518 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

View File

@@ -44,7 +44,6 @@ jobs:
- name: Login to ACR - name: Login to ACR
run: az acr login --name playwright run: az acr login --name playwright
- name: Build and push Docker image - name: Build and push Docker image
id: build-push
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
with: with:
context: . context: .
@@ -54,17 +53,3 @@ jobs:
tags: | tags: |
playwright.azurecr.io/public/playwright/mcp:${{ github.event.release.tag_name }} playwright.azurecr.io/public/playwright/mcp:${{ github.event.release.tag_name }}
playwright.azurecr.io/public/playwright/mcp:latest 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

551
README.md
View File

@@ -10,7 +10,7 @@ A Model Context Protocol (MCP) server that provides browser automation capabilit
### Requirements ### Requirements
- Node.js 18 or newer - 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: // Generate using:
@@ -19,9 +19,7 @@ node utils/generate-links.js
### Getting started ### Getting started
First, install the Playwright MCP server with your client. First, install the Playwright MCP server with your client. A typical configuration looks like this:
**Standard config** works in most of the tools:
```js ```js
{ {
@@ -39,73 +37,9 @@ First, install the Playwright MCP server with your client.
[<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) [<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)
<details> <details><summary><b>Install in VS Code</b></summary>
<summary>Claude Code</summary>
Use the Claude Code CLI to add the Playwright MCP server: You can also install the Playwright MCP server using the VS Code CLI:
```bash
claude mcp add playwright npx @playwright/mcp@latest
```
</details>
<details>
<summary>Claude Desktop</summary>
Follow the MCP install [guide](https://modelcontextprotocol.io/quickstart/user), use the standard config above.
</details>
<details>
<summary>Cursor</summary>
#### Click the button to install:
[![Install MCP Server](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/install-mcp?name=playwright&config=eyJjb21tYW5kIjoibnB4IEBwbGF5d3JpZ2h0L21jcEBsYXRlc3QifQ%3D%3D)
#### Or install manually:
Go to `Cursor Settings` -> `MCP` -> `Add new MCP Server`. Name to your liking, use `command` type with the command `npx @playwright/mcp`. You can also verify config or add command like arguments via clicking `Edit`.
</details>
<details>
<summary>Gemini CLI</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 the standard config above.
</details>
<details>
<summary>Goose</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>Qodo Gen</summary>
Open [Qodo Gen](https://docs.qodo.ai/qodo-documentation/qodo-gen) chat panel in VSCode or IntelliJ → Connect more tools → + Add new MCP → Paste the standard config above.
Click <code>Save</code>.
</details>
<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:
```bash ```bash
# For VS Code # For VS Code
@@ -116,10 +50,97 @@ After installation, the Playwright MCP server will be available for use with you
</details> </details>
<details> <details>
<summary>Windsurf</summary> <summary><b>Install in Cursor</b></summary>
Follow Windsurf MCP [documentation](https://docs.windsurf.com/windsurf/cascade/mcp). Use the standard config above. #### Click the button to install:
[![Install MCP Server](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/install-mcp?name=playwright&config=eyJjb21tYW5kIjoibnB4IEBwbGF5d3JpZ2h0L21jcEBsYXRlc3QifQ%3D%3D)
#### Or install manually:
Go to `Cursor Settings` -> `MCP` -> `Add new MCP Server`. Name to your liking, use `command` type with the command `npx @playwright/mcp`. You can also verify config or add command like arguments via clicking `Edit`.
```js
{
"mcpServers": {
"playwright": {
"command": "npx",
"args": [
"@playwright/mcp@latest"
]
}
}
}
```
</details>
<details>
<summary><b>Install in Windsurf</b></summary>
Follow Windsuff MCP [documentation](https://docs.windsurf.com/windsurf/cascade/mcp). Use following configuration:
```js
{
"mcpServers": {
"playwright": {
"command": "npx",
"args": [
"@playwright/mcp@latest"
]
}
}
}
```
</details>
<details>
<summary><b>Install in Claude Desktop</b></summary>
Follow the MCP install [guide](https://modelcontextprotocol.io/quickstart/user), use following configuration:
```js
{
"mcpServers": {
"playwright": {
"command": "npx",
"args": [
"@playwright/mcp@latest"
]
}
}
}
```
</details>
<details>
<summary><b>Install in Claude Code</b></summary>
Use the Claude Code CLI to add the Playwright MCP server:
```bash
claude mcp add playwright npx @playwright/mcp@latest
```
</details>
<details>
<summary><b>Install in Qodo Gen</b></summary>
Open [Qodo Gen](https://docs.qodo.ai/qodo-documentation/qodo-gen) chat panel in VSCode or IntelliJ → Connect more tools → + Add new MCP → Paste the following configuration:
```js
{
"mcpServers": {
"playwright": {
"command": "npx",
"args": [
"@playwright/mcp@latest"
]
}
}
}
```
Click <code>Save</code>.
</details> </details>
### Configuration ### Configuration
@@ -140,8 +161,10 @@ Playwright MCP server supports following arguments. They can be provided in the
--block-service-workers block service workers --block-service-workers block service workers
--browser <browser> browser or chrome channel to use, possible --browser <browser> browser or chrome channel to use, possible
values: chrome, firefox, webkit, msedge. values: chrome, firefox, webkit, msedge.
--caps <caps> comma-separated list of additional capabilities --browser-agent <endpoint> Use browser agent (experimental).
to enable, possible values: vision, pdf. --caps <caps> comma-separated list of capabilities to enable,
possible values: tabs, pdf, history, wait, files,
install. Default is all.
--cdp-endpoint <endpoint> CDP endpoint to connect to. --cdp-endpoint <endpoint> CDP endpoint to connect to.
--config <path> path to the configuration file. --config <path> path to the configuration file.
--device <device> device to emulate, for example: "iPhone 15" --device <device> device to emulate, for example: "iPhone 15"
@@ -153,7 +176,9 @@ Playwright MCP server supports following arguments. They can be provided in the
--isolated keep the browser profile in memory, do not save --isolated keep the browser profile in memory, do not save
it to disk. it to disk.
--image-responses <mode> whether to send image responses to the client. --image-responses <mode> whether to send image responses to the client.
Can be "allow" or "omit", Defaults to "allow". Can be "allow", "omit", or "auto". Defaults to
"auto", which sends images if the client can
display them.
--no-sandbox disable the sandbox for all process types that --no-sandbox disable the sandbox for all process types that
are normally sandboxed. are normally sandboxed.
--output-dir <path> path to the directory for output files. --output-dir <path> path to the directory for output files.
@@ -164,8 +189,6 @@ Playwright MCP server supports following arguments. They can be provided in the
"http://myproxy:3128" or "socks5://myproxy:8080" "http://myproxy:3128" or "socks5://myproxy:8080"
--save-trace Whether to save the Playwright Trace of the --save-trace Whether to save the Playwright Trace of the
session into the output directory. session into the output directory.
--save-session Whether to save the session log with tool calls
and snapshots into the output directory.
--storage-state <path> path to the storage state file for isolated --storage-state <path> path to the storage state file for isolated
sessions. sessions.
--user-agent <ua string> specify user agent string --user-agent <ua string> specify user agent string
@@ -173,6 +196,8 @@ Playwright MCP server supports following arguments. They can be provided in the
specified, a temporary directory will be created. specified, a temporary directory will be created.
--viewport-size <size> specify browser viewport size in pixels, for --viewport-size <size> specify browser viewport size in pixels, for
example "1280, 720" example "1280, 720"
--vision Run server that uses screenshots (Aria snapshots
are used by default)
``` ```
<!--- End of options generated section --> <!--- End of options generated section -->
@@ -273,14 +298,21 @@ npx @playwright/mcp@latest --config path/to/config.json
host?: string; // Host to bind to (default: localhost) host?: string; // Host to bind to (default: localhost)
}, },
// List of additional capabilities // List of enabled capabilities
capabilities?: Array< capabilities?: Array<
'core' | // Core browser automation
'tabs' | // Tab management 'tabs' | // Tab management
'install' | // Browser installation
'pdf' | // PDF generation 'pdf' | // PDF generation
'vision' | // Coordinate-based interactions 'history' | // Browser history
'wait' | // Wait utilities
'files' | // File handling
'install' | // Browser installation
'testing' // Testing
>; >;
// Enable vision mode (screenshots instead of accessibility snapshots)
vision?: boolean;
// Directory for output files // Directory for output files
outputDir?: string; outputDir?: string;
@@ -294,10 +326,9 @@ npx @playwright/mcp@latest --config path/to/config.json
}; };
/** /**
* Whether to send image responses to the client. Can be "allow" or "omit". * Do not send image responses to the client.
* Defaults to "allow".
*/ */
imageResponses?: 'allow' | 'omit'; noImageResponses?: boolean;
} }
``` ```
</details> </details>
@@ -305,19 +336,19 @@ npx @playwright/mcp@latest --config path/to/config.json
### Standalone MCP server ### Standalone MCP server
When running headed browser on system w/o display or from worker processes of the IDEs, When running headed browser on system w/o display or from worker processes of the IDEs,
run the MCP server from environment with the DISPLAY and pass the `--port` flag to enable HTTP transport. run the MCP server from environment with the DISPLAY and pass the `--port` flag to enable SSE transport.
```bash ```bash
npx @playwright/mcp@latest --port 8931 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 SSE endpoint:
```js ```js
{ {
"mcpServers": { "mcpServers": {
"playwright": { "playwright": {
"url": "http://localhost:8931/mcp" "url": "http://localhost:8931/sse"
} }
} }
} }
@@ -370,10 +401,42 @@ http.createServer(async (req, res) => {
### Tools ### Tools
The tools are available in two modes:
1. **Snapshot Mode** (default): Uses accessibility snapshots for better performance and reliability
2. **Vision Mode**: Uses screenshots for visual-based interactions
To use Vision Mode, add the `--vision` flag when starting the server:
```js
{
"mcpServers": {
"playwright": {
"command": "npx",
"args": [
"@playwright/mcp@latest",
"--vision"
]
}
}
}
```
Vision Mode works best with the computer use models that are able to interact with elements using
X Y coordinate space, based on the provided screenshot.
<!--- Tools generated by update-readme.js --> <!--- Tools generated by update-readme.js -->
<details> <details>
<summary><b>Core automation</b></summary> <summary><b>Interactions</b></summary>
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_snapshot**
- Title: Page snapshot
- Description: Capture accessibility snapshot of the current page, this is better than screenshot
- Parameters: None
- Read-only: **true**
<!-- NOTE: This has been generated via update-readme.js --> <!-- NOTE: This has been generated via update-readme.js -->
@@ -383,28 +446,10 @@ http.createServer(async (req, res) => {
- Parameters: - Parameters:
- `element` (string): Human-readable element description used to obtain permission to interact with the element - `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 - `ref` (string): Exact target element reference from the page snapshot
- `doubleClick` (boolean, optional): Whether to perform a double click instead of a single click
- `button` (string, optional): Button to click, defaults to left
- Read-only: **false** - Read-only: **false**
<!-- NOTE: This has been generated via update-readme.js --> <!-- NOTE: This has been generated via update-readme.js -->
- **browser_close**
- Title: Close browser
- Description: Close the page
- Parameters: None
- Read-only: **true**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_console_messages**
- Title: Get console messages
- Description: Returns all console messages
- Parameters: None
- Read-only: **true**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_drag** - **browser_drag**
- Title: Drag mouse - Title: Drag mouse
- Description: Perform drag and drop between two elements - Description: Perform drag and drop between two elements
@@ -417,17 +462,60 @@ http.createServer(async (req, res) => {
<!-- NOTE: This has been generated via update-readme.js --> <!-- NOTE: This has been generated via update-readme.js -->
- **browser_evaluate** - **browser_hover**
- Title: Evaluate JavaScript - Title: Hover mouse
- Description: Evaluate JavaScript expression on page or element - Description: Hover over element on page
- Parameters: - Parameters:
- `function` (string): () => { /* code */ } or (element) => { /* code */ } when element is provided - `element` (string): Human-readable element description used to obtain permission to interact with the element
- `element` (string, optional): Human-readable element description used to obtain permission to interact with the element - `ref` (string): Exact target element reference from the page snapshot
- `ref` (string, optional): Exact target element reference from the page snapshot - Read-only: **true**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_type**
- Title: Type text
- Description: Type text into editable element
- 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
- `text` (string): Text to type into the element
- `submit` (boolean, optional): Whether to submit entered text (press Enter after)
- `slowly` (boolean, optional): 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.
- Read-only: **false** - Read-only: **false**
<!-- NOTE: This has been generated via update-readme.js --> <!-- NOTE: This has been generated via update-readme.js -->
- **browser_select_option**
- Title: Select option
- Description: Select an option in a dropdown
- 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
- `values` (array): Array of values to select in the dropdown. This can be a single value or multiple values.
- Read-only: **false**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_press_key**
- Title: Press a key
- Description: Press a key on the keyboard
- Parameters:
- `key` (string): Name of the key to press or a character to generate, such as `ArrowLeft` or `a`
- Read-only: **false**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_wait_for**
- Title: Wait for
- Description: Wait for text to appear or disappear or a specified time to pass
- Parameters:
- `time` (number, optional): The time to wait in seconds
- `text` (string, optional): The text to wait for
- `textGone` (string, optional): The text to wait for to disappear
- Read-only: **true**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_file_upload** - **browser_file_upload**
- Title: Upload files - Title: Upload files
- Description: Upload one or multiple files - Description: Upload one or multiple files
@@ -445,15 +533,10 @@ http.createServer(async (req, res) => {
- `promptText` (string, optional): The text of the prompt in case of a prompt dialog. - `promptText` (string, optional): The text of the prompt in case of a prompt dialog.
- Read-only: **false** - Read-only: **false**
<!-- NOTE: This has been generated via update-readme.js --> </details>
- **browser_hover** <details>
- Title: Hover mouse <summary><b>Navigation</b></summary>
- Description: Hover over element on page
- 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
- Read-only: **true**
<!-- NOTE: This has been generated via update-readme.js --> <!-- NOTE: This has been generated via update-readme.js -->
@@ -480,51 +563,10 @@ http.createServer(async (req, res) => {
- Parameters: None - Parameters: None
- Read-only: **true** - Read-only: **true**
<!-- NOTE: This has been generated via update-readme.js --> </details>
- **browser_network_requests** <details>
- Title: List network requests <summary><b>Resources</b></summary>
- Description: Returns all network requests since loading the page
- Parameters: None
- Read-only: **true**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_press_key**
- Title: Press a key
- Description: Press a key on the keyboard
- Parameters:
- `key` (string): Name of the key to press or a character to generate, such as `ArrowLeft` or `a`
- Read-only: **false**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_resize**
- Title: Resize browser window
- Description: Resize the browser window
- Parameters:
- `width` (number): Width of the browser window
- `height` (number): Height of the browser window
- Read-only: **true**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_select_option**
- Title: Select option
- Description: Select an option in a dropdown
- 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
- `values` (array): Array of values to select in the dropdown. This can be a single value or multiple values.
- Read-only: **false**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_snapshot**
- Title: Page snapshot
- Description: Capture accessibility snapshot of the current page, this is better than screenshot
- Parameters: None
- Read-only: **true**
<!-- NOTE: This has been generated via update-readme.js --> <!-- NOTE: This has been generated via update-readme.js -->
@@ -536,49 +578,71 @@ http.createServer(async (req, res) => {
- `filename` (string, optional): File name to save the screenshot to. Defaults to `page-{timestamp}.{png|jpeg}` if not specified. - `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. - `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. - `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** - Read-only: **true**
<!-- NOTE: This has been generated via update-readme.js --> <!-- NOTE: This has been generated via update-readme.js -->
- **browser_type** - **browser_pdf_save**
- Title: Type text - Title: Save as PDF
- Description: Type text into editable element - Description: Save page as PDF
- Parameters: - Parameters:
- `element` (string): Human-readable element description used to obtain permission to interact with the element - `filename` (string, optional): File name to save the pdf to. Defaults to `page-{timestamp}.pdf` if not specified.
- `ref` (string): Exact target element reference from the page snapshot - Read-only: **true**
- `text` (string): Text to type into the element
- `submit` (boolean, optional): Whether to submit entered text (press Enter after)
- `slowly` (boolean, optional): 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.
- Read-only: **false**
<!-- NOTE: This has been generated via update-readme.js --> <!-- NOTE: This has been generated via update-readme.js -->
- **browser_wait_for** - **browser_network_requests**
- Title: Wait for - Title: List network requests
- Description: Wait for text to appear or disappear or a specified time to pass - Description: Returns all network requests since loading the page
- Parameters: - Parameters: None
- `time` (number, optional): The time to wait in seconds - Read-only: **true**
- `text` (string, optional): The text to wait for
- `textGone` (string, optional): The text to wait for to disappear <!-- NOTE: This has been generated via update-readme.js -->
- **browser_console_messages**
- Title: Get console messages
- Description: Returns all console messages
- Parameters: None
- Read-only: **true** - Read-only: **true**
</details> </details>
<details> <details>
<summary><b>Tab management</b></summary> <summary><b>Utilities</b></summary>
<!-- NOTE: This has been generated via update-readme.js --> <!-- NOTE: This has been generated via update-readme.js -->
- **browser_tab_close** - **browser_install**
- Title: Close a tab - Title: Install the browser specified in the config
- Description: Close a tab - Description: Install the browser specified in the config. Call this if you get an error about the browser not being installed.
- Parameters: - Parameters: None
- `index` (number, optional): The index of the tab to close. Closes current tab if not provided.
- Read-only: **false** - Read-only: **false**
<!-- NOTE: This has been generated via update-readme.js --> <!-- NOTE: This has been generated via update-readme.js -->
- **browser_close**
- Title: Close browser
- Description: Close the page
- Parameters: None
- Read-only: **true**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_resize**
- Title: Resize browser window
- Description: Resize the browser window
- Parameters:
- `width` (number): Width of the browser window
- `height` (number): Height of the browser window
- Read-only: **true**
</details>
<details>
<summary><b>Tabs</b></summary>
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_tab_list** - **browser_tab_list**
- Title: List tabs - Title: List tabs
- Description: List browser tabs - Description: List browser tabs
@@ -603,29 +667,60 @@ http.createServer(async (req, res) => {
- `index` (number): The index of the tab to select - `index` (number): The index of the tab to select
- Read-only: **true** - Read-only: **true**
</details>
<details>
<summary><b>Browser installation</b></summary>
<!-- NOTE: This has been generated via update-readme.js --> <!-- NOTE: This has been generated via update-readme.js -->
- **browser_install** - **browser_tab_close**
- Title: Install the browser specified in the config - Title: Close a tab
- Description: Install the browser specified in the config. Call this if you get an error about the browser not being installed. - Description: Close a tab
- Parameters: None - Parameters:
- `index` (number, optional): The index of the tab to close. Closes current tab if not provided.
- Read-only: **false** - Read-only: **false**
</details> </details>
<details> <details>
<summary><b>Coordinate-based (opt-in via --caps=vision)</b></summary> <summary><b>Testing</b></summary>
<!-- NOTE: This has been generated via update-readme.js --> <!-- NOTE: This has been generated via update-readme.js -->
- **browser_mouse_click_xy** - **browser_generate_playwright_test**
- Title: Generate a Playwright test
- Description: Generate a Playwright test for given scenario
- Parameters:
- `name` (string): The name of the test
- `description` (string): The description of the test
- `steps` (array): The steps of the test
- Read-only: **true**
</details>
<details>
<summary><b>Vision mode</b></summary>
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_screen_capture**
- Title: Take a screenshot
- Description: Take a screenshot of the current page
- Parameters: None
- Read-only: **true**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_screen_move_mouse**
- Title: Move mouse
- Description: Move mouse to a given position
- Parameters:
- `element` (string): Human-readable element description used to obtain permission to interact with the element
- `x` (number): X coordinate
- `y` (number): Y coordinate
- Read-only: **true**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_screen_click**
- Title: Click - Title: Click
- Description: Click left mouse button at a given position - Description: Click left mouse button
- Parameters: - Parameters:
- `element` (string): Human-readable element description used to obtain permission to interact with the element - `element` (string): Human-readable element description used to obtain permission to interact with the element
- `x` (number): X coordinate - `x` (number): X coordinate
@@ -634,9 +729,9 @@ http.createServer(async (req, res) => {
<!-- NOTE: This has been generated via update-readme.js --> <!-- NOTE: This has been generated via update-readme.js -->
- **browser_mouse_drag_xy** - **browser_screen_drag**
- Title: Drag mouse - Title: Drag mouse
- Description: Drag left mouse button to a given position - Description: Drag left mouse button
- Parameters: - Parameters:
- `element` (string): Human-readable element description used to obtain permission to interact with the element - `element` (string): Human-readable element description used to obtain permission to interact with the element
- `startX` (number): Start X coordinate - `startX` (number): Start X coordinate
@@ -647,29 +742,53 @@ http.createServer(async (req, res) => {
<!-- NOTE: This has been generated via update-readme.js --> <!-- NOTE: This has been generated via update-readme.js -->
- **browser_mouse_move_xy** - **browser_screen_type**
- Title: Move mouse - Title: Type text
- Description: Move mouse to a given position - Description: Type text
- Parameters: - Parameters:
- `element` (string): Human-readable element description used to obtain permission to interact with the element - `text` (string): Text to type into the element
- `x` (number): X coordinate - `submit` (boolean, optional): Whether to submit entered text (press Enter after)
- `y` (number): Y coordinate - Read-only: **false**
- Read-only: **true**
</details>
<details>
<summary><b>PDF generation (opt-in via --caps=pdf)</b></summary>
<!-- NOTE: This has been generated via update-readme.js --> <!-- NOTE: This has been generated via update-readme.js -->
- **browser_pdf_save** - **browser_press_key**
- Title: Save as PDF - Title: Press a key
- Description: Save page as PDF - Description: Press a key on the keyboard
- Parameters: - Parameters:
- `filename` (string, optional): File name to save the pdf to. Defaults to `page-{timestamp}.pdf` if not specified. - `key` (string): Name of the key to press or a character to generate, such as `ArrowLeft` or `a`
- Read-only: **false**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_wait_for**
- Title: Wait for
- Description: Wait for text to appear or disappear or a specified time to pass
- Parameters:
- `time` (number, optional): The time to wait in seconds
- `text` (string, optional): The text to wait for
- `textGone` (string, optional): The text to wait for to disappear
- Read-only: **true** - Read-only: **true**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_file_upload**
- Title: Upload files
- Description: Upload one or multiple files
- Parameters:
- `paths` (array): The absolute paths to the files to upload. Can be a single file or multiple files.
- Read-only: **false**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_handle_dialog**
- Title: Handle a dialog
- Description: Handle a dialog
- Parameters:
- `accept` (boolean): Whether to accept the dialog.
- `promptText` (string, optional): The text of the prompt in case of a prompt dialog.
- Read-only: **false**
</details> </details>

25
config.d.ts vendored
View File

@@ -16,13 +16,18 @@
import type * as playwright from 'playwright'; import type * as playwright from 'playwright';
export type ToolCapability = 'core' | 'core-tabs' | 'core-install' | 'vision' | 'pdf'; export type ToolCapability = 'core' | 'tabs' | 'pdf' | 'history' | 'wait' | 'files' | 'install' | 'testing';
export type Config = { export type Config = {
/** /**
* The browser to use. * The browser to use.
*/ */
browser?: { browser?: {
/**
* Use browser agent (experimental).
*/
browserAgent?: string;
/** /**
* The type of browser to use. * The type of browser to use.
*/ */
@@ -80,21 +85,25 @@ export type Config = {
/** /**
* List of enabled tool capabilities. Possible values: * List of enabled tool capabilities. Possible values:
* - 'core': Core browser automation features. * - 'core': Core browser automation features.
* - 'tabs': Tab management features.
* - 'pdf': PDF generation and manipulation. * - 'pdf': PDF generation and manipulation.
* - 'vision': Coordinate-based interactions. * - 'history': Browser history access.
* - 'wait': Wait and timing utilities.
* - 'files': File upload/download support.
* - 'install': Browser installation utilities.
*/ */
capabilities?: ToolCapability[]; capabilities?: ToolCapability[];
/**
* Run server that uses screenshots (Aria snapshots are used by default).
*/
vision?: boolean;
/** /**
* Whether to save the Playwright trace of the session into the output directory. * Whether to save the Playwright trace of the session into the output directory.
*/ */
saveTrace?: boolean; saveTrace?: boolean;
/**
* Whether to save the session log with tool calls and snapshots into the output directory.
*/
saveSession?: boolean;
/** /**
* The directory to save output files. * The directory to save output files.
*/ */
@@ -115,5 +124,5 @@ export type Config = {
/** /**
* Whether to send image responses to the client. Can be "allow", "omit", or "auto". Defaults to "auto", which sends images if the client can display them. * Whether to send image responses to the client. Can be "allow", "omit", or "auto". Defaults to "auto", which sends images if the client can display them.
*/ */
imageResponses?: 'allow' | 'omit'; imageResponses?: 'allow' | 'omit' | 'auto';
}; };

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();

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.
-->
<!DOCTYPE html>
<html>
<head>
<title>Playwright MCP extension</title>
</head>
<body>
<div class="header">
<h3>Playwright MCP extension</h3>
</div>
<div id="status-container"></div>
<div class="button-row">
<button id="continue-btn">Continue</button>
<button id="reject-btn">Reject</button>
</div>
<script src="lib/connect.js"></script>
</body>
</html>

View File

@@ -2,8 +2,7 @@
"manifest_version": 3, "manifest_version": 3,
"name": "Playwright MCP Bridge", "name": "Playwright MCP Bridge",
"version": "1.0.0", "version": "1.0.0",
"description": "Share browser tabs with Playwright MCP server", "description": "Share browser tabs with Playwright MCP server through CDP bridge",
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA9nMS2b0WCohjVHPGb8D9qAdkbIngDqoAjTeSccHJijgcONejge+OJxOQOMLu7b0ovt1c9BiEJa5JcpM+EHFVGL1vluBxK71zmBy1m2f9vZF3HG0LSCp7YRkum9rAIEthDwbkxx6XTvpmAY5rjFa/NON6b9Hlbo+8peUSkoOK7HTwYnnI36asZ9eUTiveIf+DMPLojW2UX33vDWG2UKvMVDewzclb4+uLxAYshY7Mx8we/b44xu+Anb/EBLKjOPk9Yh541xJ5Ozc8EiP/5yxOp9c/lRiYUHaRW+4r0HKZyFt0eZ52ti2iM4Nfk7jRXR7an3JPsUIf5deC/1cVM/+1ZQIDAQAB",
"permissions": [ "permissions": [
"debugger", "debugger",
@@ -17,12 +16,13 @@
], ],
"background": { "background": {
"service_worker": "lib/background.js", "service_worker": "background.js",
"type": "module" "type": "module"
}, },
"action": { "action": {
"default_title": "Playwright MCP Bridge", "default_title": "Share tab with Playwright MCP",
"default_popup": "popup.html",
"default_icon": { "default_icon": {
"16": "icons/icon-16.png", "16": "icons/icon-16.png",
"32": "icons/icon-32.png", "32": "icons/icon-32.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();
});

View File

@@ -1,109 +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;
};
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':
const tabId = sender.tab?.id;
if (!tabId) {
sendResponse({ success: false, error: 'No tab id' });
return true;
}
this._connectTab(tabId, 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
}
return false;
}
private async _connectTab(tabId: 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);
connection.setConnectedTabId(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 this._setConnectedTabId(tabId);
debugLog(`Tab ${tabId} connected successfully`);
} catch (error: any) {
debugLog(`Failed to connect tab ${tabId}:`, error.message);
await this._setConnectedTabId(null);
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)
this._activeConnection!.setConnectedTabId(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);
}
}
new TabShareExtension();

View File

@@ -1,70 +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.
*/
document.addEventListener('DOMContentLoaded', async () => {
const statusContainer = document.getElementById('status-container') as HTMLElement;
const continueBtn = document.getElementById('continue-btn') as HTMLButtonElement;
const rejectBtn = document.getElementById('reject-btn') as HTMLButtonElement;
const buttonRow = document.querySelector('.button-row') as HTMLElement;
function showStatus(type: 'connected' | 'error' | 'connecting', message: string) {
const div = document.createElement('div');
div.className = `status ${type}`;
div.textContent = message;
statusContainer.replaceChildren(div);
}
const params = new URLSearchParams(window.location.search);
const mcpRelayUrl = params.get('mcpRelayUrl');
if (!mcpRelayUrl) {
buttonRow.style.display = 'none';
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) {
showStatus('error', 'Failed to parse client version.');
return;
}
showStatus('connecting', `MCP client "${clientInfo}" is trying to connect. Do you want to continue?`);
rejectBtn.addEventListener('click', async () => {
buttonRow.style.display = 'none';
showStatus('error', 'Connection rejected. This tab can be closed.');
});
continueBtn.addEventListener('click', async () => {
buttonRow.style.display = 'none';
try {
const response = await chrome.runtime.sendMessage({
type: 'connectToMCPRelay',
mcpRelayUrl
});
if (response?.success)
showStatus('connected', `MCP client "${clientInfo}" connected.`);
else
showStatus('error', response?.error || `MCP client "${clientInfo}" failed to connect.`);
} catch (e) {
showStatus('error', `MCP client "${clientInfo}" failed to connect: ${e}`);
}
});
});

View File

@@ -1,176 +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 _rootSessionId = '';
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) {
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);
}
setConnectedTabId(tabId: number | null): void {
if (!tabId) {
this._debuggee = { };
this._rootSessionId = '';
return;
}
this._debuggee = { tabId };
this._rootSessionId = `pw-tab-${tabId}`;
}
close(message?: string): void {
chrome.debugger.onEvent.removeListener(this._eventListener);
chrome.debugger.onDetach.removeListener(this._detachListener);
this._ws.close(1000, message || 'Connection closed');
}
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._rootSessionId;
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,
},
});
}
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 {
sessionId: this._rootSessionId,
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 };
// Pass session id, unless it's the root session.
if (sessionId && sessionId !== this._rootSessionId)
debuggerSession.sessionId = 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",
],
}

53
package-lock.json generated
View File

@@ -1,20 +1,19 @@
{ {
"name": "@playwright/mcp", "name": "@playwright/mcp",
"version": "0.0.31", "version": "0.0.29",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@playwright/mcp", "name": "@playwright/mcp",
"version": "0.0.31", "version": "0.0.29",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.16.0", "@modelcontextprotocol/sdk": "^1.11.0",
"commander": "^13.1.0", "commander": "^13.1.0",
"debug": "^4.4.1", "debug": "^4.4.1",
"mime": "^4.0.7", "mime": "^4.0.7",
"playwright": "1.55.0-alpha-1752701791000", "playwright": "1.53.0",
"playwright-core": "1.55.0-alpha-1752701791000",
"ws": "^8.18.1", "ws": "^8.18.1",
"zod-to-json-schema": "^3.24.4" "zod-to-json-schema": "^3.24.4"
}, },
@@ -24,7 +23,7 @@
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.2.0", "@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.19.0", "@eslint/js": "^9.19.0",
"@playwright/test": "1.55.0-alpha-1752701791000", "@playwright/test": "1.53.0",
"@stylistic/eslint-plugin": "^3.0.1", "@stylistic/eslint-plugin": "^3.0.1",
"@types/chrome": "^0.0.315", "@types/chrome": "^0.0.315",
"@types/debug": "^4.1.12", "@types/debug": "^4.1.12",
@@ -234,17 +233,15 @@
} }
}, },
"node_modules/@modelcontextprotocol/sdk": { "node_modules/@modelcontextprotocol/sdk": {
"version": "1.16.0", "version": "1.11.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.16.0.tgz", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.11.0.tgz",
"integrity": "sha512-8ofX7gkZcLj9H9rSd50mCgm3SSF8C7XoclxJuLoV0Cz3rEQ1tv9MZRYYvJtm9n1BiEQQMzSmE/w2AEkNacLYfg==", "integrity": "sha512-k/1pb70eD638anoi0e8wUGAlbMJXyvdV4p62Ko+EZ7eBe1xMx8Uhak1R5DgfoofsK5IBBnRwsYGTaLZl+6/+RQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ajv": "^6.12.6",
"content-type": "^1.0.5", "content-type": "^1.0.5",
"cors": "^2.8.5", "cors": "^2.8.5",
"cross-spawn": "^7.0.5", "cross-spawn": "^7.0.3",
"eventsource": "^3.0.2", "eventsource": "^3.0.2",
"eventsource-parser": "^3.0.0",
"express": "^5.0.1", "express": "^5.0.1",
"express-rate-limit": "^7.5.0", "express-rate-limit": "^7.5.0",
"pkce-challenge": "^5.0.0", "pkce-challenge": "^5.0.0",
@@ -295,13 +292,12 @@
} }
}, },
"node_modules/@playwright/test": { "node_modules/@playwright/test": {
"version": "1.55.0-alpha-1752701791000", "version": "1.53.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.55.0-alpha-1752701791000.tgz", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.53.0.tgz",
"integrity": "sha512-mnitdsjXKPyKTjQQDJ78Or1xZSGcaoDzZVD/0BWFCvygn3nyNmGmiias/Mlfvzvgz9UWBbPeZYxU/bd2Lu+OrQ==", "integrity": "sha512-15hjKreZDcp7t6TL/7jkAo6Df5STZN09jGiv5dbP9A6vMVncXRqE7/B2SncsyOwrkZRBH2i6/TPOL8BVmm3c7w==",
"dev": true, "dev": true,
"license": "Apache-2.0",
"dependencies": { "dependencies": {
"playwright": "1.55.0-alpha-1752701791000" "playwright": "1.53.0"
}, },
"bin": { "bin": {
"playwright": "cli.js" "playwright": "cli.js"
@@ -715,6 +711,7 @@
"version": "6.12.6", "version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"fast-deep-equal": "^3.1.1", "fast-deep-equal": "^3.1.1",
@@ -1851,6 +1848,7 @@
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-glob": { "node_modules/fast-glob": {
@@ -1887,6 +1885,7 @@
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
"integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-levenshtein": { "node_modules/fast-levenshtein": {
@@ -2033,7 +2032,6 @@
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"darwin" "darwin"
@@ -2807,6 +2805,7 @@
"version": "0.4.1", "version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/json-stable-stringify-without-jsonify": { "node_modules/json-stable-stringify-without-jsonify": {
@@ -3299,12 +3298,11 @@
} }
}, },
"node_modules/playwright": { "node_modules/playwright": {
"version": "1.55.0-alpha-1752701791000", "version": "1.53.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.0-alpha-1752701791000.tgz", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.53.0.tgz",
"integrity": "sha512-PA3TvDz7uQ+Pde0uaii5/WpU5vntRJsYFsaSPoBzywIqzYFO1ugk1ZZ0q6z4/xHq0ha1UClvsv3P77B+u1fi+w==", "integrity": "sha512-ghGNnIEYZC4E+YtclRn4/p6oYbdPiASELBIYkBXfaTVKreQUYbMUYQDwS12a8F0/HtIjr/CkGjtwABeFPGcS4Q==",
"license": "Apache-2.0",
"dependencies": { "dependencies": {
"playwright-core": "1.55.0-alpha-1752701791000" "playwright-core": "1.53.0"
}, },
"bin": { "bin": {
"playwright": "cli.js" "playwright": "cli.js"
@@ -3317,10 +3315,9 @@
} }
}, },
"node_modules/playwright-core": { "node_modules/playwright-core": {
"version": "1.55.0-alpha-1752701791000", "version": "1.53.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.0-alpha-1752701791000.tgz", "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.53.0.tgz",
"integrity": "sha512-mQhzhjJMiqnGNnYZv7M4yk1OcNTt1E72jrTLO7EqZuoeat4+qpcU0/mbK+RcTEass5a9YheoVFh6OIhruFMGVg==", "integrity": "sha512-mGLg8m0pm4+mmtB7M89Xw/GSqoNC+twivl8ITteqvAndachozYe2ZA7srU6uleV1vEdAHYqjq+SV8SNxRRFYBw==",
"license": "Apache-2.0",
"bin": { "bin": {
"playwright-core": "cli.js" "playwright-core": "cli.js"
}, },
@@ -3365,6 +3362,7 @@
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=6" "node": ">=6"
@@ -4158,6 +4156,7 @@
"version": "4.4.1", "version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
"integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
"dev": true,
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"dependencies": { "dependencies": {
"punycode": "^2.1.0" "punycode": "^2.1.0"

View File

@@ -1,6 +1,6 @@
{ {
"name": "@playwright/mcp", "name": "@playwright/mcp",
"version": "0.0.31", "version": "0.0.29",
"description": "Playwright Tools for MCP", "description": "Playwright Tools for MCP",
"type": "module", "type": "module",
"repository": { "repository": {
@@ -17,17 +17,16 @@
"license": "Apache-2.0", "license": "Apache-2.0",
"scripts": { "scripts": {
"build": "tsc", "build": "tsc",
"build:extension": "tsc --project extension",
"lint": "npm run update-readme && eslint . && tsc --noEmit", "lint": "npm run update-readme && eslint . && tsc --noEmit",
"update-readme": "node utils/update-readme.js", "update-readme": "node utils/update-readme.js",
"watch": "tsc --watch", "watch": "tsc --watch",
"watch:extension": "tsc --watch --project extension",
"test": "playwright test", "test": "playwright test",
"ctest": "playwright test --project=chrome", "ctest": "playwright test --project=chrome",
"ftest": "playwright test --project=firefox", "ftest": "playwright test --project=firefox",
"wtest": "playwright test --project=webkit", "wtest": "playwright test --project=webkit",
"etest": "playwright test --project=chromium-extension",
"run-server": "node lib/browserServer.js", "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" "npm-publish": "npm run clean && npm run build && npm run test && npm publish"
}, },
"exports": { "exports": {
@@ -38,19 +37,18 @@
} }
}, },
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.16.0", "@modelcontextprotocol/sdk": "^1.11.0",
"commander": "^13.1.0", "commander": "^13.1.0",
"debug": "^4.4.1", "debug": "^4.4.1",
"mime": "^4.0.7", "mime": "^4.0.7",
"playwright": "1.55.0-alpha-1752701791000", "playwright": "1.53.0",
"playwright-core": "1.55.0-alpha-1752701791000",
"ws": "^8.18.1", "ws": "^8.18.1",
"zod-to-json-schema": "^3.24.4" "zod-to-json-schema": "^3.24.4"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.2.0", "@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.19.0", "@eslint/js": "^9.19.0",
"@playwright/test": "1.55.0-alpha-1752701791000", "@playwright/test": "1.53.0",
"@stylistic/eslint-plugin": "^3.0.1", "@stylistic/eslint-plugin": "^3.0.1",
"@types/chrome": "^0.0.315", "@types/chrome": "^0.0.315",
"@types/debug": "^4.1.12", "@types/debug": "^4.1.12",

View File

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

View File

@@ -19,11 +19,14 @@ import net from 'node:net';
import path from 'node:path'; import path from 'node:path';
import os from 'node:os'; import os from 'node:os';
import debug from 'debug';
import * as playwright from 'playwright'; import * as playwright from 'playwright';
import { userDataDir } from './fileUtils.js';
import { logUnhandledError, testDebug } from './log.js';
import type { FullConfig } from './config.js'; import type { FullConfig } from './config.js';
import type { BrowserInfo, LaunchBrowserRequest } from './browserServer.js';
const testDebug = debug('pw:mcp:test');
export function contextFactory(browserConfig: FullConfig['browser']): BrowserContextFactory { export function contextFactory(browserConfig: FullConfig['browser']): BrowserContextFactory {
if (browserConfig.remoteEndpoint) if (browserConfig.remoteEndpoint)
@@ -32,11 +35,13 @@ export function contextFactory(browserConfig: FullConfig['browser']): BrowserCon
return new CdpContextFactory(browserConfig); return new CdpContextFactory(browserConfig);
if (browserConfig.isolated) if (browserConfig.isolated)
return new IsolatedContextFactory(browserConfig); return new IsolatedContextFactory(browserConfig);
if (browserConfig.browserAgent)
return new BrowserServerContextFactory(browserConfig);
return new PersistentContextFactory(browserConfig); return new PersistentContextFactory(browserConfig);
} }
export interface BrowserContextFactory { 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 { class BaseContextFactory implements BrowserContextFactory {
@@ -83,10 +88,10 @@ class BaseContextFactory implements BrowserContextFactory {
testDebug(`close browser context (${this.name})`); testDebug(`close browser context (${this.name})`);
if (browser.contexts().length === 1) if (browser.contexts().length === 1)
this._browserPromise = undefined; this._browserPromise = undefined;
await browserContext.close().catch(logUnhandledError); await browserContext.close().catch(() => {});
if (browser.contexts().length === 0) { if (browser.contexts().length === 0) {
testDebug(`close browser (${this.name})`); testDebug(`close browser (${this.name})`);
await browser.close().catch(logUnhandledError); await browser.close().catch(() => {});
} }
} }
} }
@@ -212,6 +217,38 @@ class PersistentContextFactory implements BrowserContextFactory {
} }
} }
export class BrowserServerContextFactory extends BaseContextFactory {
constructor(browserConfig: FullConfig['browser']) {
super('persistent', browserConfig);
}
protected override async _doObtainBrowser(): Promise<playwright.Browser> {
const response = await fetch(new URL(`/json/launch`, this.browserConfig.browserAgent), {
method: 'POST',
body: JSON.stringify({
browserType: this.browserConfig.browserName,
userDataDir: this.browserConfig.userDataDir ?? await this._createUserDataDir(),
launchOptions: this.browserConfig.launchOptions,
contextOptions: this.browserConfig.contextOptions,
} as LaunchBrowserRequest),
});
const info = await response.json() as BrowserInfo;
if (info.error)
throw new Error(info.error);
return await playwright.chromium.connectOverCDP(`http://localhost:${info.cdpPort}/`);
}
protected override async _doCreateContext(browser: playwright.Browser): Promise<playwright.BrowserContext> {
return this.browserConfig.isolated ? await browser.newContext() : browser.contexts()[0];
}
private async _createUserDataDir() {
const dir = await userDataDir(this.browserConfig);
await fs.promises.mkdir(dir, { recursive: true });
return dir;
}
}
async function injectCdpPort(browserConfig: FullConfig['browser']) { async function injectCdpPort(browserConfig: FullConfig['browser']) {
if (browserConfig.browserName === 'chromium') if (browserConfig.browserName === 'chromium')
(browserConfig.launchOptions as any).cdpPort = await findFreePort(); (browserConfig.launchOptions as any).cdpPort = await findFreePort();

197
src/browserServer.ts Normal file
View File

@@ -0,0 +1,197 @@
/**
* 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 net from 'net';
import { program } from 'commander';
import playwright from 'playwright';
import { HttpServer } from './httpServer.js';
import { packageJSON } from './package.js';
import type http from 'http';
export type LaunchBrowserRequest = {
browserType: string;
userDataDir: string;
launchOptions: playwright.LaunchOptions;
contextOptions: playwright.BrowserContextOptions;
};
export type BrowserInfo = {
browserType: string;
userDataDir: string;
cdpPort: number;
launchOptions: playwright.LaunchOptions;
contextOptions: playwright.BrowserContextOptions;
error?: string;
};
type BrowserEntry = {
browser?: playwright.Browser;
info: BrowserInfo;
};
class BrowserServer {
private _server = new HttpServer();
private _entries: BrowserEntry[] = [];
constructor() {
this._setupExitHandler();
}
async start(port: number) {
await this._server.start({ port });
this._server.routePath('/json/list', (req, res) => {
this._handleJsonList(res);
});
this._server.routePath('/json/launch', async (req, res) => {
void this._handleLaunchBrowser(req, res).catch(e => console.error(e));
});
this._setEntries([]);
}
private _handleJsonList(res: http.ServerResponse) {
const list = this._entries.map(browser => browser.info);
res.end(JSON.stringify(list));
}
private async _handleLaunchBrowser(req: http.IncomingMessage, res: http.ServerResponse) {
const request = await readBody<LaunchBrowserRequest>(req);
let info = this._entries.map(entry => entry.info).find(info => info.userDataDir === request.userDataDir);
if (!info || info.error)
info = await this._newBrowser(request);
res.end(JSON.stringify(info));
}
private async _newBrowser(request: LaunchBrowserRequest): Promise<BrowserInfo> {
const cdpPort = await findFreePort();
(request.launchOptions as any).cdpPort = cdpPort;
const info: BrowserInfo = {
browserType: request.browserType,
userDataDir: request.userDataDir,
cdpPort,
launchOptions: request.launchOptions,
contextOptions: request.contextOptions,
};
const browserType = playwright[request.browserType as 'chromium' | 'firefox' | 'webkit'];
const { browser, error } = await browserType.launchPersistentContext(request.userDataDir, {
...request.launchOptions,
...request.contextOptions,
handleSIGINT: false,
handleSIGTERM: false,
}).then(context => {
return { browser: context.browser()!, error: undefined };
}).catch(error => {
return { browser: undefined, error: error.message };
});
this._setEntries([...this._entries, {
browser,
info: {
browserType: request.browserType,
userDataDir: request.userDataDir,
cdpPort,
launchOptions: request.launchOptions,
contextOptions: request.contextOptions,
error,
},
}]);
browser?.on('disconnected', () => {
this._setEntries(this._entries.filter(entry => entry.browser !== browser));
});
return info;
}
private _updateReport() {
// Clear the current line and move cursor to top of screen
process.stdout.write('\x1b[2J\x1b[H');
process.stdout.write(`Playwright Browser Server v${packageJSON.version}\n`);
process.stdout.write(`Listening on ${this._server.urlPrefix('human-readable')}\n\n`);
if (this._entries.length === 0) {
process.stdout.write('No browsers currently running\n');
return;
}
process.stdout.write('Running browsers:\n');
for (const entry of this._entries) {
const status = entry.browser ? 'running' : 'error';
const statusColor = entry.browser ? '\x1b[32m' : '\x1b[31m'; // green for running, red for error
process.stdout.write(`${statusColor}${entry.info.browserType}\x1b[0m (${entry.info.userDataDir}) - ${statusColor}${status}\x1b[0m\n`);
if (entry.info.error)
process.stdout.write(` Error: ${entry.info.error}\n`);
}
}
private _setEntries(entries: BrowserEntry[]) {
this._entries = entries;
this._updateReport();
}
private _setupExitHandler() {
let isExiting = false;
const handleExit = async () => {
if (isExiting)
return;
isExiting = true;
setTimeout(() => process.exit(0), 15000);
for (const entry of this._entries)
await entry.browser?.close().catch(() => {});
process.exit(0);
};
process.stdin.on('close', handleExit);
process.on('SIGINT', handleExit);
process.on('SIGTERM', handleExit);
}
}
program
.name('browser-agent')
.option('-p, --port <port>', 'Port to listen on', '9224')
.action(async options => {
await main(options);
});
void program.parseAsync(process.argv);
async function main(options: { port: string }) {
const server = new BrowserServer();
await server.start(+options.port);
}
function readBody<T>(req: http.IncomingMessage): Promise<T> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
req.on('data', (chunk: Buffer) => chunks.push(chunk));
req.on('end', () => resolve(JSON.parse(Buffer.concat(chunks).toString())));
});
}
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);
});
}

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,16 +19,25 @@ import os from 'os';
import path from 'path'; import path from 'path';
import { devices } from 'playwright'; 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 type { BrowserContextOptions, LaunchOptions } from 'playwright';
import { sanitizeForFilePath } from './tools/utils.js'; 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 = { export type CLIOptions = {
allowedOrigins?: string[]; allowedOrigins?: string[];
blockedOrigins?: string[]; blockedOrigins?: string[];
blockServiceWorkers?: boolean; blockServiceWorkers?: boolean;
browser?: string; browser?: string;
caps?: string[]; browserAgent?: string;
caps?: string;
cdpEndpoint?: string; cdpEndpoint?: string;
config?: string; config?: string;
device?: string; device?: string;
@@ -37,18 +46,19 @@ export type CLIOptions = {
host?: string; host?: string;
ignoreHttpsErrors?: boolean; ignoreHttpsErrors?: boolean;
isolated?: boolean; isolated?: boolean;
imageResponses?: 'allow' | 'omit'; imageResponses?: 'allow' | 'omit' | 'auto';
sandbox?: boolean; sandbox: boolean;
outputDir?: string; outputDir?: string;
port?: number; port?: number;
proxyBypass?: string; proxyBypass?: string;
proxyServer?: string; proxyServer?: string;
saveTrace?: boolean; saveTrace?: boolean;
saveSession?: boolean;
storageState?: string; storageState?: string;
userAgent?: string; userAgent?: string;
userDataDir?: string; userDataDir?: string;
viewportSize?: string; viewportSize?: string;
vision?: boolean;
extension?: boolean;
}; };
const defaultConfig: FullConfig = { const defaultConfig: FullConfig = {
@@ -90,19 +100,22 @@ export async function resolveConfig(config: Config): Promise<FullConfig> {
export async function resolveCLIConfig(cliOptions: CLIOptions): Promise<FullConfig> { export async function resolveCLIConfig(cliOptions: CLIOptions): Promise<FullConfig> {
const configInFile = await loadConfig(cliOptions.config); const configInFile = await loadConfig(cliOptions.config);
const envOverrides = configFromEnv(); const cliOverrides = await configFromCLIOptions(cliOptions);
const cliOverrides = configFromCLIOptions(cliOptions); const result = mergeConfig(mergeConfig(defaultConfig, configInFile), cliOverrides);
let result = defaultConfig;
result = mergeConfig(result, configInFile);
result = mergeConfig(result, envOverrides);
result = mergeConfig(result, cliOverrides);
// Derive artifact output directory from config.outputDir // Derive artifact output directory from config.outputDir
if (result.saveTrace) if (result.saveTrace)
result.browser.launchOptions.tracesDir = path.join(result.outputDir, 'traces'); result.browser.launchOptions.tracesDir = path.join(result.outputDir, 'traces');
return result; return result;
} }
export function configFromCLIOptions(cliOptions: CLIOptions): Config { 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 browserName: 'chromium' | 'firefox' | 'webkit' | undefined;
let channel: string | undefined; let channel: string | undefined;
switch (cliOptions.browser) { switch (cliOptions.browser) {
@@ -134,7 +147,7 @@ export function configFromCLIOptions(cliOptions: CLIOptions): Config {
}; };
// --no-sandbox was passed, disable the sandbox // --no-sandbox was passed, disable the sandbox
if (cliOptions.sandbox === false) if (!cliOptions.sandbox)
launchOptions.chromiumSandbox = false; launchOptions.chromiumSandbox = false;
if (cliOptions.proxyServer) { if (cliOptions.proxyServer) {
@@ -147,6 +160,8 @@ export function configFromCLIOptions(cliOptions: CLIOptions): Config {
if (cliOptions.device && cliOptions.cdpEndpoint) if (cliOptions.device && cliOptions.cdpEndpoint)
throw new Error('Device emulation is not supported with 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 // Context options
const contextOptions: BrowserContextOptions = cliOptions.device ? devices[cliOptions.device] : {}; const contextOptions: BrowserContextOptions = cliOptions.device ? devices[cliOptions.device] : {};
@@ -175,6 +190,7 @@ export function configFromCLIOptions(cliOptions: CLIOptions): Config {
const result: Config = { const result: Config = {
browser: { browser: {
browserAgent: cliOptions.browserAgent ?? process.env.PW_BROWSER_AGENT,
browserName, browserName,
isolated: cliOptions.isolated, isolated: cliOptions.isolated,
userDataDir: cliOptions.userDataDir, userDataDir: cliOptions.userDataDir,
@@ -186,13 +202,14 @@ export function configFromCLIOptions(cliOptions: CLIOptions): Config {
port: cliOptions.port, port: cliOptions.port,
host: cliOptions.host, host: cliOptions.host,
}, },
capabilities: cliOptions.caps as ToolCapability[], capabilities: cliOptions.caps?.split(',').map((c: string) => c.trim() as ToolCapability),
vision: !!cliOptions.vision,
extension: !!cliOptions.extension,
network: { network: {
allowedOrigins: cliOptions.allowedOrigins, allowedOrigins: cliOptions.allowedOrigins,
blockedOrigins: cliOptions.blockedOrigins, blockedOrigins: cliOptions.blockedOrigins,
}, },
saveTrace: cliOptions.saveTrace, saveTrace: cliOptions.saveTrace,
saveSession: cliOptions.saveSession,
outputDir: cliOptions.outputDir, outputDir: cliOptions.outputDir,
imageResponses: cliOptions.imageResponses, imageResponses: cliOptions.imageResponses,
}; };
@@ -200,37 +217,6 @@ export function configFromCLIOptions(cliOptions: CLIOptions): Config {
return result; return result;
} }
function configFromEnv(): Config {
const options: CLIOptions = {};
options.allowedOrigins = semicolonSeparatedList(process.env.PLAYWRIGHT_MCP_ALLOWED_ORIGINS);
options.blockedOrigins = semicolonSeparatedList(process.env.PLAYWRIGHT_MCP_BLOCKED_ORIGINS);
options.blockServiceWorkers = envToBoolean(process.env.PLAYWRIGHT_MCP_BLOCK_SERVICE_WORKERS);
options.browser = envToString(process.env.PLAYWRIGHT_MCP_BROWSER);
options.caps = commaSeparatedList(process.env.PLAYWRIGHT_MCP_CAPS);
options.cdpEndpoint = envToString(process.env.PLAYWRIGHT_MCP_CDP_ENDPOINT);
options.config = envToString(process.env.PLAYWRIGHT_MCP_CONFIG);
options.device = envToString(process.env.PLAYWRIGHT_MCP_DEVICE);
options.executablePath = envToString(process.env.PLAYWRIGHT_MCP_EXECUTABLE_PATH);
options.headless = envToBoolean(process.env.PLAYWRIGHT_MCP_HEADLESS);
options.host = envToString(process.env.PLAYWRIGHT_MCP_HOST);
options.ignoreHttpsErrors = envToBoolean(process.env.PLAYWRIGHT_MCP_IGNORE_HTTPS_ERRORS);
options.isolated = envToBoolean(process.env.PLAYWRIGHT_MCP_ISOLATED);
if (process.env.PLAYWRIGHT_MCP_IMAGE_RESPONSES === 'omit')
options.imageResponses = 'omit';
options.sandbox = envToBoolean(process.env.PLAYWRIGHT_MCP_SANDBOX);
options.outputDir = envToString(process.env.PLAYWRIGHT_MCP_OUTPUT_DIR);
options.port = envToNumber(process.env.PLAYWRIGHT_MCP_PORT);
options.proxyBypass = envToString(process.env.PLAYWRIGHT_MCP_PROXY_BYPASS);
options.proxyServer = envToString(process.env.PLAYWRIGHT_MCP_PROXY_SERVER);
options.saveTrace = envToBoolean(process.env.PLAYWRIGHT_MCP_SAVE_TRACE);
options.saveSession = envToBoolean(process.env.PLAYWRIGHT_MCP_SAVE_SESSION);
options.storageState = envToString(process.env.PLAYWRIGHT_MCP_STORAGE_STATE);
options.userAgent = envToString(process.env.PLAYWRIGHT_MCP_USER_AGENT);
options.userDataDir = envToString(process.env.PLAYWRIGHT_MCP_USER_DATA_DIR);
options.viewportSize = envToString(process.env.PLAYWRIGHT_MCP_VIEWPORT_SIZE);
return configFromCLIOptions(options);
}
async function loadConfig(configFile: string | undefined): Promise<Config> { async function loadConfig(configFile: string | undefined): Promise<Config> {
if (!configFile) if (!configFile)
return {}; return {};
@@ -288,33 +274,3 @@ function mergeConfig(base: FullConfig, overrides: Config): FullConfig {
}, },
} as FullConfig; } as FullConfig;
} }
export function semicolonSeparatedList(value: string | undefined): string[] | undefined {
if (!value)
return undefined;
return value.split(';').map(v => v.trim());
}
export function commaSeparatedList(value: string | undefined): string[] | undefined {
if (!value)
return undefined;
return value.split(',').map(v => v.trim());
}
function envToNumber(value: string | undefined): number | undefined {
if (!value)
return undefined;
return +value;
}
function envToBoolean(value: string | undefined): boolean | undefined {
if (value === 'true' || value === '1')
return true;
if (value === 'false' || value === '0')
return false;
return undefined;
}
function envToString(value: string | undefined): string | undefined {
return value ? value.trim() : undefined;
}

View File

@@ -19,15 +19,17 @@ import { CallToolRequestSchema, ListToolsRequestSchema, Tool as McpTool } from '
import { zodToJsonSchema } from 'zod-to-json-schema'; import { zodToJsonSchema } from 'zod-to-json-schema';
import { Context } from './context.js'; import { Context } from './context.js';
import { allTools } from './tools.js'; import { snapshotTools, visionTools } from './tools.js';
import { packageJSON } from './package.js'; import { packageJSON } from './package.js';
import { FullConfig } from './config.js'; import { FullConfig, validateConfig } from './config.js';
import type { BrowserContextFactory } from './browserContextFactory.js'; import type { BrowserContextFactory } from './browserContextFactory.js';
export function createConnection(config: FullConfig, browserContextFactory: BrowserContextFactory): Connection { export function createConnection(config: FullConfig, browserContextFactory: BrowserContextFactory): Connection {
const tools = allTools.filter(tool => tool.capability.startsWith('core') || config.capabilities?.includes(tool.capability)); 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 context = new Context(tools, config, browserContextFactory);
const server = new McpServer({ name: 'Playwright', version: packageJSON.version }, { const server = new McpServer({ name: 'Playwright', version: packageJSON.version }, {
capabilities: { capabilities: {

View File

@@ -16,14 +16,13 @@
import debug from 'debug'; import debug from 'debug';
import * as playwright from 'playwright'; import * as playwright from 'playwright';
import * as fs from 'fs';
import * as path from 'path';
import { callOnPageNoTrace, waitForCompletion } from './tools/utils.js'; import { callOnPageNoTrace, waitForCompletion } from './tools/utils.js';
import { ManualPromise } from './manualPromise.js'; import { ManualPromise } from './manualPromise.js';
import { Tab } from './tab.js'; import { Tab } from './tab.js';
import { outputFile } from './config.js'; import { outputFile } from './config.js';
import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types.js';
import type { ModalState, Tool, ToolActionResult } from './tools/tool.js'; import type { ModalState, Tool, ToolActionResult } from './tools/tool.js';
import type { FullConfig } from './config.js'; import type { FullConfig } from './config.js';
import type { BrowserContextFactory } from './browserContextFactory.js'; import type { BrowserContextFactory } from './browserContextFactory.js';
@@ -44,8 +43,6 @@ export class Context {
private _modalStates: (ModalState & { tab: Tab })[] = []; private _modalStates: (ModalState & { tab: Tab })[] = [];
private _pendingAction: PendingAction | undefined; private _pendingAction: PendingAction | undefined;
private _downloads: { download: playwright.Download, finished: boolean, outputFile: string }[] = []; private _downloads: { download: playwright.Download, finished: boolean, outputFile: string }[] = [];
private _sessionFile: string | undefined;
private _sessionFileInitialized: Promise<void> | undefined;
clientVersion: { name: string; version: string; } | undefined; clientVersion: { name: string; version: string; } | undefined;
constructor(tools: Tool[], config: FullConfig, browserContextFactory: BrowserContextFactory) { constructor(tools: Tool[], config: FullConfig, browserContextFactory: BrowserContextFactory) {
@@ -53,14 +50,14 @@ export class Context {
this.config = config; this.config = config;
this._browserContextFactory = browserContextFactory; this._browserContextFactory = browserContextFactory;
testDebug('create context'); testDebug('create context');
if (this.config.saveSession)
this._sessionFileInitialized = this._initializeSessionFile();
} }
clientSupportsImages(): boolean { clientSupportsImages(): boolean {
if (this.config.imageResponses === 'allow')
return true;
if (this.config.imageResponses === 'omit') if (this.config.imageResponses === 'omit')
return false; return false;
return true; return !this.clientVersion?.name.includes('cursor');
} }
modalStates(): ModalState[] { modalStates(): ModalState[] {
@@ -135,54 +132,11 @@ export class Context {
return await this.listTabsMarkdown(); return await this.listTabsMarkdown();
} }
private async _initializeSessionFile() {
if (!this.config.saveSession)
return;
const timestamp = new Date().toISOString();
const fileName = `session${timestamp}.yml`;
this._sessionFile = await outputFile(this.config, fileName);
// Initialize empty session file
await fs.promises.writeFile(this._sessionFile, '# Session log started at ' + timestamp + '\n', 'utf8');
}
private async _logSessionEntry(toolName: string, params: Record<string, unknown>, snapshotFile?: string) {
if (!this.config.saveSession)
return;
// Ensure session file is initialized before proceeding
if (this._sessionFileInitialized)
await this._sessionFileInitialized;
// After initialization, session file should always be defined when saveSession is true
if (!this._sessionFile)
throw new Error('Session file not initialized despite saveSession being enabled');
const entry = [
`- ${toolName}:`,
' params:',
];
// Add parameters with proper YAML indentation
for (const [key, value] of Object.entries(params)) {
const yamlValue = typeof value === 'string' ? value : JSON.stringify(value);
entry.push(` ${key}: ${yamlValue}`);
}
// Add snapshot reference if provided
if (snapshotFile)
entry.push(` snapshot: ${path.basename(snapshotFile)}`);
entry.push(''); // Empty line for readability
await fs.promises.appendFile(this._sessionFile, entry.join('\n') + '\n', 'utf8');
}
async run(tool: Tool, params: Record<string, unknown> | undefined) { async run(tool: Tool, params: Record<string, unknown> | undefined) {
// Tab management is done outside of the action() call. // Tab management is done outside of the action() call.
const toolResult = await tool.handle(this, tool.schema.inputSchema.parse(params || {})); const toolResult = await tool.handle(this, tool.schema.inputSchema.parse(params || {}));
const { code, action, waitForNetwork, captureSnapshot, resultOverride } = toolResult; const { code, action, waitForNetwork, captureSnapshot, resultOverride } = toolResult;
const racingAction = action ? () => this._raceAgainstModalDialogs(action) : undefined;
if (resultOverride) if (resultOverride)
return resultOverride; return resultOverride;
@@ -197,41 +151,27 @@ export class Context {
} }
const tab = this.currentTabOrDie(); const tab = this.currentTabOrDie();
let snapshotFile: string | undefined;
// TODO: race against modal dialogs to resolve clicks. // TODO: race against modal dialogs to resolve clicks.
const actionResult = await this._raceAgainstModalDialogs(async () => { let actionResult: { content?: (ImageContent | TextContent)[] } | undefined;
try { try {
if (waitForNetwork) if (waitForNetwork)
return await waitForCompletion(this, tab, async () => action?.()) ?? undefined; actionResult = await waitForCompletion(this, tab, async () => racingAction?.()) ?? undefined;
else else
return await action?.() ?? undefined; actionResult = await racingAction?.() ?? undefined;
} finally { } finally {
if (captureSnapshot && !this._javaScriptBlocked()) { if (captureSnapshot && !this._javaScriptBlocked())
await tab.captureSnapshot(); await tab.captureSnapshot();
// Save snapshot to file if session logging is enabled }
if (this.config.saveSession && tab.hasSnapshot()) {
const timestamp = new Date().toISOString();
const snapshotFileName = `${timestamp}.snapshot.yaml`;
snapshotFile = await outputFile(this.config, snapshotFileName);
await fs.promises.writeFile(snapshotFile, tab.snapshotOrDie().text(), 'utf8');
}
}
}
});
// Log session entry if enabled
if (this.config.saveSession)
await this._logSessionEntry(tool.schema.name, params || {}, snapshotFile);
const result: string[] = []; const result: string[] = [];
result.push(`### Ran Playwright code result.push(`- Ran Playwright code:
\`\`\`js \`\`\`js
${code.join('\n')} ${code.join('\n')}
\`\`\``); \`\`\`
`);
if (this.modalStates().length) { if (this.modalStates().length) {
result.push('', ...this.modalStatesMarkdown()); result.push(...this.modalStatesMarkdown());
return { return {
content: [{ content: [{
type: 'text', type: 'text',
@@ -240,13 +180,6 @@ ${code.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) { if (this._downloads.length) {
result.push('', '### Downloads'); result.push('', '### Downloads');
for (const entry of this._downloads) { for (const entry of this._downloads) {
@@ -255,23 +188,22 @@ ${code.join('\n')}
else else
result.push(`- Downloading file ${entry.download.suggestedFilename()} ...`); result.push(`- Downloading file ${entry.download.suggestedFilename()} ...`);
} }
result.push('');
} }
if (captureSnapshot && tab.hasSnapshot()) { if (this.tabs().length > 1)
if (this.tabs().length > 1) result.push(await this.listTabsMarkdown(), '');
result.push('', await this.listTabsMarkdown());
if (this.tabs().length > 1) if (this.tabs().length > 1)
result.push('', '### Current tab'); result.push('### Current tab');
else
result.push('', '### Page state');
result.push( result.push(
`- Page URL: ${tab.page.url()}`, `- Page URL: ${tab.page.url()}`,
`- Page Title: ${await tab.title()}` `- Page Title: ${await tab.title()}`
); );
if (captureSnapshot && tab.hasSnapshot())
result.push(tab.snapshotOrDie().text()); result.push(tab.snapshotOrDie().text());
}
const content = actionResult?.content ?? []; const content = actionResult?.content ?? [];
@@ -400,7 +332,7 @@ ${code.join('\n')}
private async _setupBrowserContext(): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> { private async _setupBrowserContext(): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
// TODO: move to the browser context factory to make it based on isolation mode. // TODO: move to the browser context factory to make it based on isolation mode.
const result = await this._browserContextFactory.createContext(this.clientVersion!); const result = await this._browserContextFactory.createContext();
const { browserContext } = result; const { browserContext } = result;
await this._setupRequestInterception(browserContext); await this._setupRequestInterception(browserContext);
for (const page of browserContext.pages()) for (const page of browserContext.pages())
@@ -417,9 +349,3 @@ ${code.join('\n')}
return result; return result;
} }
} }
function trim(text: string, maxLength: number) {
if (text.length <= maxLength)
return text;
return text.slice(0, maxLength) + '...';
}

View File

@@ -1,397 +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 { WebSocket, WebSocketServer } from 'ws';
import type websocket from 'ws';
import http from 'node:http';
import debug from 'debug';
import { promisify } from 'node:util';
import { exec } from 'node:child_process';
import { httpAddressToString, startHttpServer } from '../transport.js';
import { BrowserContextFactory } from '../browserContextFactory.js';
import { Browser, chromium, type BrowserContext } from 'playwright';
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 _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 _extensionConnectionPromise: Promise<void>;
private _extensionConnectionResolve: (() => void) | null = null;
constructor(server: http.Server) {
this._wsHost = httpAddressToString(server.address()).replace(/^http/, 'ws');
const uuid = crypto.randomUUID();
this._cdpPath = `/cdp/${uuid}`;
this._extensionPath = `/extension/${uuid}`;
this._extensionConnectionPromise = new Promise(resolve => {
this._extensionConnectionResolve = resolve;
});
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 }) {
if (this._extensionConnection)
return;
await this._connectBrowser(clientInfo);
await this._extensionConnectionPromise;
}
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 command = `'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' '${href}'`;
try {
await promisify(exec)(command);
} catch (err) {
debugLogger('Failed to run command:', err);
}
}
stop(): void {
this._playwrightConnection?.close();
this._extensionConnection?.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 === 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 {
this._playwrightConnection = ws;
ws.on('message', async data => {
try {
const message = JSON.parse(data.toString());
await this._handlePlaywrightMessage(message);
} catch (error) {
debugLogger('Error parsing Playwright message:', error);
}
});
ws.on('close', () => {
if (this._playwrightConnection === ws) {
this._playwrightConnection = null;
this._closeExtensionConnection();
debugLogger('Playwright MCP disconnected');
}
});
ws.on('error', error => {
debugLogger('Playwright WebSocket error:', error);
});
debugLogger('Playwright MCP connected');
}
private _closeExtensionConnection() {
this._connectedTabInfo = undefined;
this._extensionConnection?.close();
this._extensionConnection = null;
this._extensionConnectionPromise = new Promise(resolve => {
this._extensionConnectionResolve = resolve;
});
}
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 => {
if (this._extensionConnection === c)
this._extensionConnection = null;
};
this._extensionConnection.onmessage = this._handleExtensionMessage.bind(this);
this._extensionConnectionResolve?.();
}
private _handleExtensionMessage(method: string, params: any) {
switch (method) {
case 'forwardCDPEvent':
this._sendToPlaywright({
sessionId: params.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})`);
if (!this._extensionConnection) {
debugLogger('Extension not connected, sending error to Playwright');
this._sendToPlaywright({
id: message.id,
error: { message: 'Extension not connected' }
});
return;
}
if (await this._interceptCDPCommand(message))
return;
await this._forwardToExtension(message);
}
private async _interceptCDPCommand(message: CDPCommand): Promise<boolean> {
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',
}
});
return true;
}
case 'Browser.setDownloadBehavior': {
this._sendToPlaywright({
id: message.id
});
return true;
}
case 'Target.setAutoAttach': {
// Simulate auto-attach behavior with real target info
if (!message.sessionId) {
this._connectedTabInfo = await this._extensionConnection!.send('attachToTab');
debugLogger('Simulating auto-attach for target:', message);
this._sendToPlaywright({
method: 'Target.attachedToTarget',
params: {
sessionId: this._connectedTabInfo!.sessionId,
targetInfo: {
...this._connectedTabInfo!.targetInfo,
attached: true,
},
waitingForDebugger: false
}
});
this._sendToPlaywright({
id: message.id
});
} else {
await this._forwardToExtension(message);
}
return true;
}
case 'Target.getTargetInfo': {
debugLogger('Target.getTargetInfo', message);
this._sendToPlaywright({
id: message.id,
result: this._connectedTabInfo?.targetInfo
});
return true;
}
}
return false;
}
private async _forwardToExtension(message: CDPCommand): Promise<void> {
try {
if (!this._extensionConnection)
throw new Error('Extension not connected');
const { id, sessionId, method, params } = message;
const result = await this._extensionConnection.send('forwardCDPCommand', { sessionId, method, params });
this._sendToPlaywright({ id, sessionId, result });
} catch (e) {
debugLogger('Error in the extension:', e);
this._sendToPlaywright({
id: message.id,
sessionId: message.sessionId,
error: { message: (e as Error).message }
});
}
}
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<Browser> | undefined;
constructor(relay: CDPRelayServer) {
this._relay = relay;
}
async createContext(clientInfo: { name: string, version: string }): Promise<{ browserContext: 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 () => {}
};
}
private async _obtainBrowser(clientInfo: { name: string, version: string }): Promise<Browser> {
await this._relay.ensureExtensionConnectionForMCPContext(clientInfo);
return await chromium.connectOverCDP(this._relay.cdpEndpoint());
}
}
export async function startCDPRelayServer(port: number) {
const httpServer = await startHttpServer({ port });
const cdpRelayServer = new CDPRelayServer(httpServer);
process.on('exit', () => cdpRelayServer.stop());
debugLogger(`CDP relay server started, extension endpoint: ${cdpRelayServer.extensionEndpoint()}.`);
return new ExtensionContextFactory(cdpRelayServer);
}
class ExtensionConnection {
private readonly _ws: WebSocket;
private readonly _callbacks = new Map<number, { resolve: (o: any) => void, reject: (e: Error) => void }>();
private _lastId = 0;
onmessage?: (method: string, params: any) => void;
onclose?: (self: ExtensionConnection) => 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('WebSocket closed');
const id = ++this._lastId;
this._ws.send(JSON.stringify({ id, method, params, sessionId }));
return new Promise((resolve, reject) => {
this._callbacks.set(id, { resolve, reject });
});
}
close(message?: string) {
debugLogger('closing extension connection:', message);
this._ws.close(1000, message ?? 'Connection closed');
this.onclose?.(this);
}
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: any) {
if (object.id && this._callbacks.has(object.id)) {
const callback = this._callbacks.get(object.id)!;
this._callbacks.delete(object.id);
if (object.error)
callback.reject(new Error(object.error.message));
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();
}
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,35 +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 { resolveCLIConfig } from '../config.js';
import { startHttpServer, startHttpTransport, startStdioTransport } from '../transport.js';
import { Server } from '../server.js';
import { startCDPRelayServer } from './cdpRelay.js';
export async function runWithExtension(options: any) {
const config = await resolveCLIConfig({ });
const contextFactory = await startCDPRelayServer(9225);
const server = new Server(config, contextFactory);
server.setupExitWatchdog();
if (options.port !== undefined) {
const httpServer = await startHttpServer({ port: options.port });
startHttpTransport(httpServer, server);
} else {
await startStdioTransport(server);
}
}

View File

@@ -42,7 +42,7 @@ export class PageSnapshot {
private async _build() { private async _build() {
const snapshot = await callOnPageNoTrace(this._page, page => (page as PageEx)._snapshotForAI()); const snapshot = await callOnPageNoTrace(this._page, page => (page as PageEx)._snapshotForAI());
this._text = [ this._text = [
`- Page Snapshot:`, `- Page Snapshot`,
'```yaml', '```yaml',
snapshot, snapshot,
'```', '```',

View File

@@ -14,15 +14,15 @@
* limitations under the License. * limitations under the License.
*/ */
import { program, Option } from 'commander'; import { Option, program } from 'commander';
// @ts-ignore // @ts-ignore
import { startTraceViewerServer } from 'playwright-core/lib/server'; import { startTraceViewerServer } from 'playwright-core/lib/server';
import { startHttpServer, startHttpTransport, startStdioTransport } from './transport.js'; import { startHttpServer, startHttpTransport, startStdioTransport } from './transport.js';
import { commaSeparatedList, resolveCLIConfig, semicolonSeparatedList } from './config.js'; import { resolveCLIConfig } from './config.js';
import { Server } from './server.js'; import { Server } from './server.js';
import { packageJSON } from './package.js'; import { packageJSON } from './package.js';
import { runWithExtension } from './extension/main.js'; import { startCDPRelayServer } from './cdpRelay.js';
program program
.version('Version ' + packageJSON.version) .version('Version ' + packageJSON.version)
@@ -31,7 +31,8 @@ program
.option('--blocked-origins <origins>', 'semicolon-separated list of origins to block the browser from requesting. Blocklist is evaluated before allowlist. If used without the allowlist, requests not matching the blocklist are still allowed.', semicolonSeparatedList) .option('--blocked-origins <origins>', 'semicolon-separated list of origins to block the browser from requesting. Blocklist is evaluated before allowlist. If used without the allowlist, requests not matching the blocklist are still allowed.', semicolonSeparatedList)
.option('--block-service-workers', 'block service workers') .option('--block-service-workers', 'block service workers')
.option('--browser <browser>', 'browser or chrome channel to use, possible values: chrome, firefox, webkit, msedge.') .option('--browser <browser>', 'browser or chrome channel to use, possible values: chrome, firefox, webkit, msedge.')
.option('--caps <caps>', 'comma-separated list of additional capabilities to enable, possible values: vision, pdf.', commaSeparatedList) .option('--browser-agent <endpoint>', 'Use browser agent (experimental).')
.option('--caps <caps>', 'comma-separated list of capabilities to enable, possible values: tabs, pdf, history, wait, files, install. Default is all.')
.option('--cdp-endpoint <endpoint>', 'CDP endpoint to connect to.') .option('--cdp-endpoint <endpoint>', 'CDP endpoint to connect to.')
.option('--config <path>', 'path to the configuration file.') .option('--config <path>', 'path to the configuration file.')
.option('--device <device>', 'device to emulate, for example: "iPhone 15"') .option('--device <device>', 'device to emulate, for example: "iPhone 15"')
@@ -40,42 +41,36 @@ program
.option('--host <host>', 'host to bind server to. Default is localhost. Use 0.0.0.0 to bind to all interfaces.') .option('--host <host>', 'host to bind server to. Default is localhost. Use 0.0.0.0 to bind to all interfaces.')
.option('--ignore-https-errors', 'ignore https errors') .option('--ignore-https-errors', 'ignore https errors')
.option('--isolated', 'keep the browser profile in memory, do not save it to disk.') .option('--isolated', 'keep the browser profile in memory, do not save it to disk.')
.option('--image-responses <mode>', 'whether to send image responses to the client. Can be "allow" or "omit", Defaults to "allow".') .option('--image-responses <mode>', 'whether to send image responses to the client. Can be "allow", "omit", or "auto". Defaults to "auto", which sends images if the client can display them.')
.option('--no-sandbox', 'disable the sandbox for all process types that are normally sandboxed.') .option('--no-sandbox', 'disable the sandbox for all process types that are normally sandboxed.')
.option('--output-dir <path>', 'path to the directory for output files.') .option('--output-dir <path>', 'path to the directory for output files.')
.option('--port <port>', 'port to listen on for SSE transport.') .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-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('--proxy-server <proxy>', 'specify proxy server, for example "http://myproxy:3128" or "socks5://myproxy:8080"')
.option('--save-trace', 'Whether to save the Playwright Trace of the session into the output directory.') .option('--save-trace', 'Whether to save the Playwright Trace of the session into the output directory.')
.option('--save-session', 'Whether to save the session log with tool calls and snapshots into the output directory.')
.option('--storage-state <path>', 'path to the storage state file for isolated sessions.') .option('--storage-state <path>', 'path to the storage state file for isolated sessions.')
.option('--user-agent <ua string>', 'specify user agent string') .option('--user-agent <ua string>', 'specify user agent string')
.option('--user-data-dir <path>', 'path to the user data directory. If not specified, a temporary directory will be created.') .option('--user-data-dir <path>', 'path to the user data directory. If not specified, a temporary directory will be created.')
.option('--viewport-size <size>', 'specify browser viewport size in pixels, for example "1280, 720"') .option('--viewport-size <size>', 'specify browser viewport size in pixels, for example "1280, 720"')
.addOption(new Option('--extension', 'Connect to a running browser instance (Edge/Chrome only). Requires the "Playwright MCP Bridge" browser extension to be installed.').hideHelp()) .option('--vision', 'Run server that uses screenshots (Aria snapshots are used by default)')
.addOption(new Option('--vision', 'Legacy option, use --caps=vision instead').hideHelp()) .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 => { .action(async options => {
if (options.extension) {
await runWithExtension(options);
return;
}
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 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); const server = new Server(config);
server.setupExitWatchdog(); server.setupExitWatchdog();
if (config.server.port !== undefined) { if (httpServer)
const httpServer = await startHttpServer(config.server); await startHttpTransport(httpServer, server);
startHttpTransport(httpServer, server); else
} else {
await startStdioTransport(server); await startStdioTransport(server);
}
if (config.saveTrace) { if (config.saveTrace) {
const server = await startTraceViewerServer(); const server = await startTraceViewerServer();
@@ -86,4 +81,8 @@ program
} }
}); });
function semicolonSeparatedList(value: string): string[] {
return value.split(';').map(v => v.trim());
}
void program.parseAsync(process.argv); void program.parseAsync(process.argv);

View File

@@ -14,12 +14,23 @@
* limitations under the License. * limitations under the License.
*/ */
import debug from 'debug'; import type { Context } from '../context.js';
const errorsDebug = debug('pw:mcp:errors'); export type ResourceSchema = {
uri: string;
name: string;
description?: string;
mimeType?: string;
};
export function logUnhandledError(error: unknown) { export type ResourceResult = {
errorsDebug(error); uri: string;
} mimeType?: string;
text?: string;
blob?: string;
};
export const testDebug = debug('pw:mcp:test'); export type Resource = {
schema: ResourceSchema;
read: (context: Context, uri: string) => Promise<ResourceResult[]>;
};

View File

@@ -15,7 +15,7 @@
*/ */
import { createConnection } from './connection.js'; import { createConnection } from './connection.js';
import { contextFactory as defaultContextFactory } from './browserContextFactory.js'; import { contextFactory } from './browserContextFactory.js';
import type { FullConfig } from './config.js'; import type { FullConfig } from './config.js';
import type { Connection } from './connection.js'; import type { Connection } from './connection.js';
@@ -28,10 +28,10 @@ export class Server {
private _browserConfig: FullConfig['browser']; private _browserConfig: FullConfig['browser'];
private _contextFactory: BrowserContextFactory; private _contextFactory: BrowserContextFactory;
constructor(config: FullConfig, contextFactory?: BrowserContextFactory) { constructor(config: FullConfig) {
this.config = config; this.config = config;
this._browserConfig = config.browser; this._browserConfig = config.browser;
this._contextFactory = contextFactory ?? defaultContextFactory(this._browserConfig); this._contextFactory = contextFactory(this._browserConfig);
} }
async createConnection(transport: Transport): Promise<Connection> { async createConnection(transport: Transport): Promise<Connection> {

View File

@@ -17,16 +17,14 @@
import * as playwright from 'playwright'; import * as playwright from 'playwright';
import { PageSnapshot } from './pageSnapshot.js'; import { PageSnapshot } from './pageSnapshot.js';
import { callOnPageNoTrace } from './tools/utils.js';
import { logUnhandledError } from './log.js';
import type { Context } from './context.js'; import type { Context } from './context.js';
import { callOnPageNoTrace } from './tools/utils.js';
export class Tab { export class Tab {
readonly context: Context; readonly context: Context;
readonly page: playwright.Page; readonly page: playwright.Page;
private _consoleMessages: ConsoleMessage[] = []; private _consoleMessages: playwright.ConsoleMessage[] = [];
private _recentConsoleMessages: ConsoleMessage[] = [];
private _requests: Map<playwright.Request, playwright.Response | null> = new Map(); private _requests: Map<playwright.Request, playwright.Response | null> = new Map();
private _snapshot: PageSnapshot | undefined; private _snapshot: PageSnapshot | undefined;
private _onPageClose: (tab: Tab) => void; private _onPageClose: (tab: Tab) => void;
@@ -35,8 +33,7 @@ export class Tab {
this.context = context; this.context = context;
this.page = page; this.page = page;
this._onPageClose = onPageClose; this._onPageClose = onPageClose;
page.on('console', event => this._handleConsoleMessage(messageToConsoleMessage(event))); page.on('console', event => this._consoleMessages.push(event));
page.on('pageerror', error => this._handleConsoleMessage(pageErrorToConsoleMessage(error)));
page.on('request', request => this._requests.set(request, null)); page.on('request', request => this._requests.set(request, null));
page.on('response', response => this._requests.set(response.request(), response)); page.on('response', response => this._requests.set(response.request(), response));
page.on('close', () => this._onClose()); page.on('close', () => this._onClose());
@@ -57,15 +54,9 @@ export class Tab {
private _clearCollectedArtifacts() { private _clearCollectedArtifacts() {
this._consoleMessages.length = 0; this._consoleMessages.length = 0;
this._recentConsoleMessages.length = 0;
this._requests.clear(); this._requests.clear();
} }
private _handleConsoleMessage(message: ConsoleMessage) {
this._consoleMessages.push(message);
this._recentConsoleMessages.push(message);
}
private _onClose() { private _onClose() {
this._clearCollectedArtifacts(); this._clearCollectedArtifacts();
this._onPageClose(this); this._onPageClose(this);
@@ -76,13 +67,13 @@ export class Tab {
} }
async waitForLoadState(state: 'load', options?: { timeout?: number }): Promise<void> { async waitForLoadState(state: 'load', options?: { timeout?: number }): Promise<void> {
await callOnPageNoTrace(this.page, page => page.waitForLoadState(state, options).catch(logUnhandledError)); await callOnPageNoTrace(this.page, page => page.waitForLoadState(state, options).catch(() => {}));
} }
async navigate(url: string) { async navigate(url: string) {
this._clearCollectedArtifacts(); this._clearCollectedArtifacts();
const downloadEvent = callOnPageNoTrace(this.page, page => page.waitForEvent('download').catch(logUnhandledError)); const downloadEvent = callOnPageNoTrace(this.page, page => page.waitForEvent('download').catch(() => {}));
try { try {
await this.page.goto(url, { waitUntil: 'domcontentloaded' }); await this.page.goto(url, { waitUntil: 'domcontentloaded' });
} catch (_e: unknown) { } catch (_e: unknown) {
@@ -115,7 +106,7 @@ export class Tab {
return this._snapshot; return this._snapshot;
} }
consoleMessages(): ConsoleMessage[] { consoleMessages(): playwright.ConsoleMessage[] {
return this._consoleMessages; return this._consoleMessages;
} }
@@ -126,39 +117,4 @@ export class Tab {
async captureSnapshot() { async captureSnapshot() {
this._snapshot = await PageSnapshot.create(this.page); this._snapshot = await PageSnapshot.create(this.page);
} }
takeRecentConsoleMessages(): ConsoleMessage[] {
const result = this._recentConsoleMessages.slice();
this._recentConsoleMessages.length = 0;
return result;
}
}
export type ConsoleMessage = {
type: ReturnType<playwright.ConsoleMessage['type']> | undefined;
text: string;
toString(): string;
};
function messageToConsoleMessage(message: playwright.ConsoleMessage): ConsoleMessage {
return {
type: message.type(),
text: message.text(),
toString: () => `[${message.type().toUpperCase()}] ${message.text()} @ ${message.location().url}:${message.location().lineNumber}`,
};
}
function pageErrorToConsoleMessage(errorOrValue: Error | any): ConsoleMessage {
if (errorOrValue instanceof Error) {
return {
type: undefined,
text: errorOrValue.message,
toString: () => errorOrValue.stack || errorOrValue.message,
};
}
return {
type: undefined,
text: String(errorOrValue),
toString: () => String(errorOrValue),
};
} }

View File

@@ -17,7 +17,6 @@
import common from './tools/common.js'; import common from './tools/common.js';
import console from './tools/console.js'; import console from './tools/console.js';
import dialogs from './tools/dialogs.js'; import dialogs from './tools/dialogs.js';
import evaluate from './tools/evaluate.js';
import files from './tools/files.js'; import files from './tools/files.js';
import install from './tools/install.js'; import install from './tools/install.js';
import keyboard from './tools/keyboard.js'; import keyboard from './tools/keyboard.js';
@@ -27,25 +26,41 @@ import pdf from './tools/pdf.js';
import snapshot from './tools/snapshot.js'; import snapshot from './tools/snapshot.js';
import tabs from './tools/tabs.js'; import tabs from './tools/tabs.js';
import screenshot from './tools/screenshot.js'; import screenshot from './tools/screenshot.js';
import testing from './tools/testing.js';
import vision from './tools/vision.js';
import wait from './tools/wait.js'; import wait from './tools/wait.js';
import mouse from './tools/mouse.js';
import type { Tool } from './tools/tool.js'; import type { Tool } from './tools/tool.js';
export const allTools: Tool<any>[] = [ export const snapshotTools: Tool<any>[] = [
...common, ...common(true),
...console, ...console,
...dialogs, ...dialogs(true),
...evaluate, ...files(true),
...files,
...install, ...install,
...keyboard, ...keyboard(true),
...navigate, ...navigate(true),
...network, ...network,
...mouse,
...pdf, ...pdf,
...screenshot, ...screenshot,
...snapshot, ...snapshot,
...tabs, ...tabs(true),
...wait, ...testing,
...wait(true),
];
export const visionTools: Tool<any>[] = [
...common(false),
...console,
...dialogs(false),
...files(false),
...install,
...keyboard(false),
...navigate(false),
...network,
...pdf,
...tabs(false),
...testing,
...vision,
...wait(false),
]; ];

View File

@@ -15,7 +15,7 @@
*/ */
import { z } from 'zod'; import { z } from 'zod';
import { defineTool } from './tool.js'; import { defineTool, type ToolFactory } from './tool.js';
const close = defineTool({ const close = defineTool({
capability: 'core', capability: 'core',
@@ -38,7 +38,7 @@ const close = defineTool({
}, },
}); });
const resize = defineTool({ const resize: ToolFactory = captureSnapshot => defineTool({
capability: 'core', capability: 'core',
schema: { schema: {
name: 'browser_resize', name: 'browser_resize',
@@ -66,13 +66,13 @@ const resize = defineTool({
return { return {
code, code,
action, action,
captureSnapshot: true, captureSnapshot,
waitForNetwork: true waitForNetwork: true
}; };
}, },
}); });
export default [ export default (captureSnapshot: boolean) => [
close, close,
resize resize(captureSnapshot)
]; ];

View File

@@ -28,7 +28,7 @@ const console = defineTool({
}, },
handle: async context => { handle: async context => {
const messages = context.currentTabOrDie().consoleMessages(); const messages = context.currentTabOrDie().consoleMessages();
const log = messages.map(message => message.toString()).join('\n'); const log = messages.map(message => `[${message.type().toUpperCase()}] ${message.text()}`).join('\n');
return { return {
code: [`// <internal code to get console messages>`], code: [`// <internal code to get console messages>`],
action: async () => { action: async () => {

View File

@@ -15,9 +15,9 @@
*/ */
import { z } from 'zod'; import { z } from 'zod';
import { defineTool } from './tool.js'; import { defineTool, type ToolFactory } from './tool.js';
const handleDialog = defineTool({ const handleDialog: ToolFactory = captureSnapshot => defineTool({
capability: 'core', capability: 'core',
schema: { schema: {
@@ -49,7 +49,7 @@ const handleDialog = defineTool({
return { return {
code, code,
captureSnapshot: true, captureSnapshot,
waitForNetwork: false, waitForNetwork: false,
}; };
}, },
@@ -57,6 +57,6 @@ const handleDialog = defineTool({
clearsModalState: 'dialog', clearsModalState: 'dialog',
}); });
export default [ export default (captureSnapshot: boolean) => [
handleDialog, handleDialog(captureSnapshot),
]; ];

View File

@@ -1,71 +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';
import * as javascript from '../javascript.js';
import { generateLocator } from './utils.js';
import type * as playwright from 'playwright';
const evaluateSchema = z.object({
function: z.string().describe('() => { /* code */ } or (element) => { /* code */ } when element is provided'),
element: z.string().optional().describe('Human-readable element description used to obtain permission to interact with the element'),
ref: z.string().optional().describe('Exact target element reference from the page snapshot'),
});
const evaluate = defineTool({
capability: 'core',
schema: {
name: 'browser_evaluate',
title: 'Evaluate JavaScript',
description: 'Evaluate JavaScript expression on page or element',
inputSchema: evaluateSchema,
type: 'destructive',
},
handle: async (context, params) => {
const tab = context.currentTabOrDie();
const code: string[] = [];
let locator: playwright.Locator | undefined;
if (params.ref && params.element) {
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 {
code.push(`await page.evaluate(${javascript.quote(params.function)});`);
}
return {
code,
action: async () => {
const receiver = locator ?? tab.page as any;
const result = await receiver._evaluateFunction(params.function);
return {
content: [{ type: 'text', text: '- Result: ' + (JSON.stringify(result, null, 2) || 'undefined') }],
};
},
captureSnapshot: false,
waitForNetwork: false,
};
},
});
export default [
evaluate,
];

View File

@@ -15,10 +15,10 @@
*/ */
import { z } from 'zod'; import { z } from 'zod';
import { defineTool } from './tool.js'; import { defineTool, type ToolFactory } from './tool.js';
const uploadFile = defineTool({ const uploadFile: ToolFactory = captureSnapshot => defineTool({
capability: 'core', capability: 'files',
schema: { schema: {
name: 'browser_file_upload', name: 'browser_file_upload',
@@ -47,13 +47,13 @@ const uploadFile = defineTool({
return { return {
code, code,
action, action,
captureSnapshot: true, captureSnapshot,
waitForNetwork: true, waitForNetwork: true,
}; };
}, },
clearsModalState: 'fileChooser', clearsModalState: 'fileChooser',
}); });
export default [ export default (captureSnapshot: boolean) => [
uploadFile, uploadFile(captureSnapshot),
]; ];

View File

@@ -23,7 +23,7 @@ import { defineTool } from './tool.js';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
const install = defineTool({ const install = defineTool({
capability: 'core-install', capability: 'install',
schema: { schema: {
name: 'browser_install', name: 'browser_install',
title: 'Install the browser specified in the config', title: 'Install the browser specified in the config',

View File

@@ -15,13 +15,9 @@
*/ */
import { z } from 'zod'; import { z } from 'zod';
import { defineTool, type ToolFactory } from './tool.js';
import { defineTool } from './tool.js'; const pressKey: ToolFactory = captureSnapshot => defineTool({
import { elementSchema } from './snapshot.js';
import { generateLocator } from './utils.js';
import * as javascript from '../javascript.js';
const pressKey = defineTool({
capability: 'core', capability: 'core',
schema: { schema: {
@@ -47,61 +43,12 @@ const pressKey = defineTool({
return { return {
code, code,
action, action,
captureSnapshot: true, captureSnapshot,
waitForNetwork: true waitForNetwork: true
}; };
}, },
}); });
const typeSchema = elementSchema.extend({ export default (captureSnapshot: boolean) => [
text: z.string().describe('Text to type into the element'), pressKey(captureSnapshot),
submit: z.boolean().optional().describe('Whether to submit entered text (press Enter after)'),
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 = defineTool({
capability: 'core',
schema: {
name: 'browser_type',
title: 'Type text',
description: 'Type text into editable element',
inputSchema: typeSchema,
type: 'destructive',
},
handle: async (context, params) => {
const snapshot = context.currentTabOrDie().snapshotOrDie();
const locator = snapshot.refLocator(params);
const code: string[] = [];
const steps: (() => Promise<void>)[] = [];
if (params.slowly) {
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 {
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) {
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,
};
},
});
export default [
pressKey,
type,
]; ];

View File

@@ -15,9 +15,9 @@
*/ */
import { z } from 'zod'; import { z } from 'zod';
import { defineTool } from './tool.js'; import { defineTool, type ToolFactory } from './tool.js';
const navigate = defineTool({ const navigate: ToolFactory = captureSnapshot => defineTool({
capability: 'core', capability: 'core',
schema: { schema: {
@@ -41,14 +41,14 @@ const navigate = defineTool({
return { return {
code, code,
captureSnapshot: true, captureSnapshot,
waitForNetwork: false, waitForNetwork: false,
}; };
}, },
}); });
const goBack = defineTool({ const goBack: ToolFactory = captureSnapshot => defineTool({
capability: 'core', capability: 'history',
schema: { schema: {
name: 'browser_navigate_back', name: 'browser_navigate_back',
title: 'Go back', title: 'Go back',
@@ -67,14 +67,14 @@ const goBack = defineTool({
return { return {
code, code,
captureSnapshot: true, captureSnapshot,
waitForNetwork: false, waitForNetwork: false,
}; };
}, },
}); });
const goForward = defineTool({ const goForward: ToolFactory = captureSnapshot => defineTool({
capability: 'core', capability: 'history',
schema: { schema: {
name: 'browser_navigate_forward', name: 'browser_navigate_forward',
title: 'Go forward', title: 'Go forward',
@@ -91,14 +91,14 @@ const goForward = defineTool({
]; ];
return { return {
code, code,
captureSnapshot: true, captureSnapshot,
waitForNetwork: false, waitForNetwork: false,
}; };
}, },
}); });
export default [ export default (captureSnapshot: boolean) => [
navigate, navigate(captureSnapshot),
goBack, goBack(captureSnapshot),
goForward, goForward(captureSnapshot),
]; ];

View File

@@ -28,17 +28,11 @@ 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.'), 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.'), 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.'), 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 => { }).refine(data => {
return !!data.element === !!data.ref; return !!data.element === !!data.ref;
}, { }, {
message: 'Both element and ref must be provided or neither.', message: 'Both element and ref must be provided or neither.',
path: ['ref', 'element'] path: ['ref', 'element']
}).refine(data => {
return !(data.fullPage && (data.element || data.ref));
}, {
message: 'fullPage cannot be used with element screenshots.',
path: ['fullPage']
}); });
const screenshot = defineTool({ const screenshot = defineTool({
@@ -56,18 +50,11 @@ const screenshot = defineTool({
const snapshot = tab.snapshotOrDie(); const snapshot = tab.snapshotOrDie();
const fileType = params.raw ? 'png' : 'jpeg'; const fileType = params.raw ? 'png' : 'jpeg';
const fileName = await outputFile(context.config, params.filename ?? `page-${new Date().toISOString()}.${fileType}`); const fileName = await outputFile(context.config, params.filename ?? `page-${new Date().toISOString()}.${fileType}`);
const options: playwright.PageScreenshotOptions = { const options: playwright.PageScreenshotOptions = { type: fileType, quality: fileType === 'png' ? undefined : 50, scale: 'css', path: fileName };
type: fileType,
quality: fileType === 'png' ? undefined : 50,
scale: 'css',
path: fileName,
...(params.fullPage !== undefined && { fullPage: params.fullPage })
};
const isElementScreenshot = params.element && params.ref; const isElementScreenshot = params.element && params.ref;
const screenshotTarget = isElementScreenshot ? params.element : (params.fullPage ? 'full page' : 'viewport');
const code = [ const code = [
`// Screenshot ${screenshotTarget} and save it as ${fileName}`, `// Screenshot ${isElementScreenshot ? params.element : 'viewport'} and save it as ${fileName}`,
]; ];
const locator = params.ref ? snapshot.refLocator({ element: params.element || '', ref: params.ref }) : null; const locator = params.ref ? snapshot.refLocator({ element: params.element || '', ref: params.ref }) : null;
@@ -92,7 +79,7 @@ const screenshot = defineTool({
return { return {
code, code,
action, action,
captureSnapshot: false, captureSnapshot: true,
waitForNetwork: false, waitForNetwork: false,
}; };
} }

View File

@@ -41,44 +41,33 @@ const snapshot = defineTool({
}, },
}); });
export const elementSchema = z.object({ const elementSchema = z.object({
element: z.string().describe('Human-readable element description used to obtain permission to interact with the element'), element: z.string().describe('Human-readable element description used to obtain permission to interact with the element'),
ref: z.string().describe('Exact target element reference from the page snapshot'), 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'),
button: z.enum(['left', 'right', 'middle']).optional().describe('Button to click, defaults to left'),
});
const click = defineTool({ const click = defineTool({
capability: 'core', capability: 'core',
schema: { schema: {
name: 'browser_click', name: 'browser_click',
title: 'Click', title: 'Click',
description: 'Perform click on a web page', description: 'Perform click on a web page',
inputSchema: clickSchema, inputSchema: elementSchema,
type: 'destructive', type: 'destructive',
}, },
handle: async (context, params) => { handle: async (context, params) => {
const tab = context.currentTabOrDie(); const tab = context.currentTabOrDie();
const locator = tab.snapshotOrDie().refLocator(params); const locator = tab.snapshotOrDie().refLocator(params);
const button = params.button;
const buttonAttr = button ? `{ button: '${button}' }` : '';
const code: string[] = []; const code = [
if (params.doubleClick) { `// Click ${params.element}`,
code.push(`// Double click ${params.element}`); `await page.${await generateLocator(locator)}.click();`
code.push(`await page.${await generateLocator(locator)}.dblclick(${buttonAttr});`); ];
} else {
code.push(`// Click ${params.element}`);
code.push(`await page.${await generateLocator(locator)}.click(${buttonAttr});`);
}
return { return {
code, code,
action: () => params.doubleClick ? locator.dblclick({ button }) : locator.click({ button }), action: () => locator.click(),
captureSnapshot: true, captureSnapshot: true,
waitForNetwork: true, waitForNetwork: true,
}; };
@@ -147,6 +136,54 @@ const hover = defineTool({
}, },
}); });
const typeSchema = elementSchema.extend({
text: z.string().describe('Text to type into the element'),
submit: z.boolean().optional().describe('Whether to submit entered text (press Enter after)'),
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 = defineTool({
capability: 'core',
schema: {
name: 'browser_type',
title: 'Type text',
description: 'Type text into editable element',
inputSchema: typeSchema,
type: 'destructive',
},
handle: async (context, params) => {
const snapshot = context.currentTabOrDie().snapshotOrDie();
const locator = snapshot.refLocator(params);
const code: string[] = [];
const steps: (() => Promise<void>)[] = [];
if (params.slowly) {
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 {
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) {
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,
};
},
});
const selectOptionSchema = elementSchema.extend({ 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.'), values: z.array(z.string()).describe('Array of values to select in the dropdown. This can be a single value or multiple values.'),
}); });
@@ -184,5 +221,6 @@ export default [
click, click,
drag, drag,
hover, hover,
type,
selectOption, selectOption,
]; ];

View File

@@ -15,10 +15,10 @@
*/ */
import { z } from 'zod'; import { z } from 'zod';
import { defineTool } from './tool.js'; import { defineTool, type ToolFactory } from './tool.js';
const listTabs = defineTool({ const listTabs = defineTool({
capability: 'core-tabs', capability: 'tabs',
schema: { schema: {
name: 'browser_tab_list', name: 'browser_tab_list',
@@ -44,8 +44,8 @@ const listTabs = defineTool({
}, },
}); });
const selectTab = defineTool({ const selectTab: ToolFactory = captureSnapshot => defineTool({
capability: 'core-tabs', capability: 'tabs',
schema: { schema: {
name: 'browser_tab_select', name: 'browser_tab_select',
@@ -65,14 +65,14 @@ const selectTab = defineTool({
return { return {
code, code,
captureSnapshot: true, captureSnapshot,
waitForNetwork: false waitForNetwork: false
}; };
}, },
}); });
const newTab = defineTool({ const newTab: ToolFactory = captureSnapshot => defineTool({
capability: 'core-tabs', capability: 'tabs',
schema: { schema: {
name: 'browser_tab_new', name: 'browser_tab_new',
@@ -94,14 +94,14 @@ const newTab = defineTool({
]; ];
return { return {
code, code,
captureSnapshot: true, captureSnapshot,
waitForNetwork: false waitForNetwork: false
}; };
}, },
}); });
const closeTab = defineTool({ const closeTab: ToolFactory = captureSnapshot => defineTool({
capability: 'core-tabs', capability: 'tabs',
schema: { schema: {
name: 'browser_tab_close', name: 'browser_tab_close',
@@ -120,15 +120,15 @@ const closeTab = defineTool({
]; ];
return { return {
code, code,
captureSnapshot: true, captureSnapshot,
waitForNetwork: false waitForNetwork: false
}; };
}, },
}); });
export default [ export default (captureSnapshot: boolean) => [
listTabs, listTabs,
newTab, newTab(captureSnapshot),
selectTab, selectTab(captureSnapshot),
closeTab, closeTab(captureSnapshot),
]; ];

67
src/tools/testing.ts Normal file
View File

@@ -0,0 +1,67 @@
/**
* 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 generateTestSchema = z.object({
name: z.string().describe('The name of the test'),
description: z.string().describe('The description of the test'),
steps: z.array(z.string()).describe('The steps of the test'),
});
const generateTest = defineTool({
capability: 'testing',
schema: {
name: 'browser_generate_playwright_test',
title: 'Generate a Playwright test',
description: 'Generate a Playwright test for given scenario',
inputSchema: generateTestSchema,
type: 'readOnly',
},
handle: async (context, params) => {
return {
resultOverride: {
content: [{
type: 'text',
text: instructions(params),
}],
},
code: [],
captureSnapshot: false,
waitForNetwork: false,
};
},
});
const instructions = (params: { name: string, description: string, steps: string[] }) => [
`## Instructions`,
`- You are a playwright test generator.`,
`- You are given a scenario and you need to generate a playwright test for it.`,
'- DO NOT generate test code based on the scenario alone. DO run steps one by one using the tools provided instead.',
'- Only after all steps are completed, emit a Playwright TypeScript test that uses @playwright/test based on message history',
'- Save generated test file in the tests directory',
`Test name: ${params.name}`,
`Description: ${params.description}`,
`Steps:`,
...params.steps.map((step, index) => `- ${index + 1}. ${step}`),
].join('\n');
export default [
generateTest,
];

View File

@@ -61,6 +61,8 @@ export type Tool<Input extends InputType = InputType> = {
handle: (context: Context, params: z.output<Input>) => Promise<ToolResult>; handle: (context: Context, params: z.output<Input>) => Promise<ToolResult>;
}; };
export type ToolFactory = (snapshot: boolean) => Tool<any>;
export function defineTool<Input extends InputType>(tool: Tool<Input>): Tool<Input> { export function defineTool<Input extends InputType>(tool: Tool<Input>): Tool<Input> {
return tool; return tool;
} }

View File

@@ -14,9 +14,6 @@
* limitations under the License. * limitations under the License.
*/ */
// @ts-ignore
import { asLocator } from 'playwright-core/lib/utils';
import type * as playwright from 'playwright'; import type * as playwright from 'playwright';
import type { Context } from '../context.js'; import type { Context } from '../context.js';
import type { Tab } from '../tab.js'; import type { Tab } from '../tab.js';
@@ -82,10 +79,11 @@ export function sanitizeForFilePath(s: string) {
export async function generateLocator(locator: playwright.Locator): Promise<string> { export async function generateLocator(locator: playwright.Locator): Promise<string> {
try { try {
const { resolvedSelector } = await (locator as any)._resolveSelector(); return await (locator as any)._generateLocatorString();
return asLocator('javascript', resolvedSelector);
} catch (e) { } catch (e) {
throw new Error('Ref not found, likely because element was removed. Use browser_snapshot to see what elements are currently on the page.'); 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

@@ -17,14 +17,50 @@
import { z } from 'zod'; import { z } from 'zod';
import { defineTool } from './tool.js'; import { defineTool } from './tool.js';
import * as javascript from '../javascript.js';
const elementSchema = z.object({ const elementSchema = z.object({
element: z.string().describe('Human-readable element description used to obtain permission to interact with the element'), element: z.string().describe('Human-readable element description used to obtain permission to interact with the element'),
}); });
const mouseMove = defineTool({ const screenshot = defineTool({
capability: 'vision', capability: 'core',
schema: { schema: {
name: 'browser_mouse_move_xy', name: 'browser_screen_capture',
title: 'Take a screenshot',
description: 'Take a screenshot of the current page',
inputSchema: z.object({}),
type: 'readOnly',
},
handle: async context => {
const tab = await context.ensureTab();
const options = { type: 'jpeg' as 'jpeg', quality: 50, scale: 'css' as 'css' };
const code = [
`// Take a screenshot of the current page`,
`await page.screenshot(${javascript.formatObject(options)});`,
];
const action = () => tab.page.screenshot(options).then(buffer => {
return {
content: [{ type: 'image' as 'image', data: buffer.toString('base64'), mimeType: 'image/jpeg' }],
};
});
return {
code,
action,
captureSnapshot: false,
waitForNetwork: false
};
},
});
const moveMouse = defineTool({
capability: 'core',
schema: {
name: 'browser_screen_move_mouse',
title: 'Move mouse', title: 'Move mouse',
description: 'Move mouse to a given position', description: 'Move mouse to a given position',
inputSchema: elementSchema.extend({ inputSchema: elementSchema.extend({
@@ -50,12 +86,12 @@ const mouseMove = defineTool({
}, },
}); });
const mouseClick = defineTool({ const click = defineTool({
capability: 'vision', capability: 'core',
schema: { schema: {
name: 'browser_mouse_click_xy', name: 'browser_screen_click',
title: 'Click', title: 'Click',
description: 'Click left mouse button at a given position', description: 'Click left mouse button',
inputSchema: elementSchema.extend({ inputSchema: elementSchema.extend({
x: z.number().describe('X coordinate'), x: z.number().describe('X coordinate'),
y: z.number().describe('Y coordinate'), y: z.number().describe('Y coordinate'),
@@ -85,12 +121,12 @@ const mouseClick = defineTool({
}, },
}); });
const mouseDrag = defineTool({ const drag = defineTool({
capability: 'vision', capability: 'core',
schema: { schema: {
name: 'browser_mouse_drag_xy', name: 'browser_screen_drag',
title: 'Drag mouse', title: 'Drag mouse',
description: 'Drag left mouse button to a given position', description: 'Drag left mouse button',
inputSchema: elementSchema.extend({ inputSchema: elementSchema.extend({
startX: z.number().describe('Start X coordinate'), startX: z.number().describe('Start X coordinate'),
startY: z.number().describe('Start Y coordinate'), startY: z.number().describe('Start Y coordinate'),
@@ -127,8 +163,51 @@ const mouseDrag = defineTool({
}, },
}); });
const type = defineTool({
capability: 'core',
schema: {
name: 'browser_screen_type',
title: 'Type text',
description: 'Type text',
inputSchema: z.object({
text: z.string().describe('Text to type into the element'),
submit: z.boolean().optional().describe('Whether to submit entered text (press Enter after)'),
}),
type: 'destructive',
},
handle: async (context, params) => {
const tab = context.currentTabOrDie();
const code = [
`// Type ${params.text}`,
`await page.keyboard.type('${params.text}');`,
];
const action = async () => {
await tab.page.keyboard.type(params.text);
if (params.submit)
await tab.page.keyboard.press('Enter');
};
if (params.submit) {
code.push(`// Submit text`);
code.push(`await page.keyboard.press('Enter');`);
}
return {
code,
action,
captureSnapshot: false,
waitForNetwork: true,
};
},
});
export default [ export default [
mouseMove, screenshot,
mouseClick, moveMouse,
mouseDrag, click,
drag,
type,
]; ];

View File

@@ -15,10 +15,10 @@
*/ */
import { z } from 'zod'; import { z } from 'zod';
import { defineTool } from './tool.js'; import { defineTool, type ToolFactory } from './tool.js';
const wait = defineTool({ const wait: ToolFactory = captureSnapshot => defineTool({
capability: 'core', capability: 'wait',
schema: { schema: {
name: 'browser_wait_for', name: 'browser_wait_for',
@@ -40,7 +40,7 @@ const wait = defineTool({
if (params.time) { if (params.time) {
code.push(`await new Promise(f => setTimeout(f, ${params.time!} * 1000));`); code.push(`await new Promise(f => setTimeout(f, ${params.time!} * 1000));`);
await new Promise(f => setTimeout(f, Math.min(30000, params.time! * 1000))); await new Promise(f => setTimeout(f, Math.min(10000, params.time! * 1000)));
} }
const tab = context.currentTabOrDie(); const tab = context.currentTabOrDie();
@@ -59,12 +59,12 @@ const wait = defineTool({
return { return {
code, code,
captureSnapshot: true, captureSnapshot,
waitForNetwork: false, waitForNetwork: false,
}; };
}, },
}); });
export default [ export default (captureSnapshot: boolean) => [
wait, wait(captureSnapshot),
]; ];

View File

@@ -23,11 +23,8 @@ import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { logUnhandledError } from './log.js';
import type { AddressInfo } from 'node:net'; import type { AddressInfo } from 'node:net';
import type { Server } from './server.js'; import type { Server } from './server.js';
import type { Connection } from './connection.js';
export async function startStdioTransport(server: Server) { export async function startStdioTransport(server: Server) {
await server.createConnection(new StdioServerTransport()); await server.createConnection(new StdioServerTransport());
@@ -58,7 +55,8 @@ async function handleSSE(server: Server, req: http.IncomingMessage, res: http.Se
res.on('close', () => { res.on('close', () => {
testDebug(`delete SSE session: ${transport.sessionId}`); testDebug(`delete SSE session: ${transport.sessionId}`);
sessions.delete(transport.sessionId); sessions.delete(transport.sessionId);
void connection.close().catch(logUnhandledError); // eslint-disable-next-line no-console
void connection.close().catch(e => console.error(e));
}); });
return; return;
} }
@@ -67,10 +65,10 @@ async function handleSSE(server: Server, req: http.IncomingMessage, res: http.Se
res.end('Method not allowed'); res.end('Method not allowed');
} }
async function handleStreamable(server: Server, req: http.IncomingMessage, res: http.ServerResponse, sessions: Map<string, { transport: StreamableHTTPServerTransport, connection: Connection }>) { 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; const sessionId = req.headers['mcp-session-id'] as string | undefined;
if (sessionId) { if (sessionId) {
const { transport } = sessions.get(sessionId) ?? {}; const transport = sessions.get(sessionId);
if (!transport) { if (!transport) {
res.statusCode = 404; res.statusCode = 404;
res.end('Session not found'); res.end('Session not found');
@@ -82,22 +80,15 @@ async function handleStreamable(server: Server, req: http.IncomingMessage, res:
if (req.method === 'POST') { if (req.method === 'POST') {
const transport = new StreamableHTTPServerTransport({ const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => crypto.randomUUID(), sessionIdGenerator: () => crypto.randomUUID(),
onsessioninitialized: async sessionId => { onsessioninitialized: sessionId => {
testDebug(`create http session: ${transport.sessionId}`); sessions.set(sessionId, transport);
const connection = await server.createConnection(transport);
sessions.set(sessionId, { transport, connection });
} }
}); });
transport.onclose = () => { transport.onclose = () => {
const result = transport.sessionId ? sessions.get(transport.sessionId) : undefined; if (transport.sessionId)
if (!result) sessions.delete(transport.sessionId);
return;
sessions.delete(result.transport.sessionId!);
testDebug(`delete http session: ${transport.sessionId}`);
result.connection.close().catch(logUnhandledError);
}; };
await server.createConnection(transport);
await transport.handleRequest(req, res); await transport.handleRequest(req, res);
return; return;
} }
@@ -121,13 +112,13 @@ export async function startHttpServer(config: { host?: string, port?: number }):
export function startHttpTransport(httpServer: http.Server, mcpServer: Server) { export function startHttpTransport(httpServer: http.Server, mcpServer: Server) {
const sseSessions = new Map<string, SSEServerTransport>(); const sseSessions = new Map<string, SSEServerTransport>();
const streamableSessions = new Map(); const streamableSessions = new Map<string, StreamableHTTPServerTransport>();
httpServer.on('request', async (req, res) => { httpServer.on('request', async (req, res) => {
const url = new URL(`http://localhost${req.url}`); const url = new URL(`http://localhost${req.url}`);
if (url.pathname.startsWith('/sse')) if (url.pathname.startsWith('/mcp'))
await handleSSE(mcpServer, req, res, url, sseSessions);
else
await handleStreamable(mcpServer, req, res, streamableSessions); await handleStreamable(mcpServer, req, res, streamableSessions);
else
await handleSSE(mcpServer, req, res, url, sseSessions);
}); });
const url = httpAddressToString(httpServer.address()); const url = httpAddressToString(httpServer.address());
const message = [ const message = [
@@ -136,11 +127,11 @@ export function startHttpTransport(httpServer: http.Server, mcpServer: Server) {
JSON.stringify({ JSON.stringify({
'mcpServers': { 'mcpServers': {
'playwright': { 'playwright': {
'url': `${url}/mcp` 'url': `${url}/sse`
} }
} }
}, undefined, 2), }, undefined, 2),
'For legacy SSE transport support, you can use the /sse endpoint instead.', 'If your client supports streamable HTTP, you can use the /mcp endpoint instead.',
].join('\n'); ].join('\n');
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.error(message); console.error(message);

View File

@@ -0,0 +1,77 @@
/**
* 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 path from 'path';
import url from 'node:url';
import { spawn } from 'child_process';
import { test as baseTest, expect } from './fixtures.js';
import type { ChildProcess } from 'child_process';
const __filename = url.fileURLToPath(import.meta.url);
const test = baseTest.extend<{ agentEndpoint: (options?: { args?: string[] }) => Promise<{ url: URL, stdout: () => string }> }>({
agentEndpoint: async ({}, use) => {
let cp: ChildProcess | undefined;
await use(async (options?: { args?: string[] }) => {
if (cp)
throw new Error('Process already running');
cp = spawn('node', [
path.join(path.dirname(__filename), '../lib/browserServer.js'),
...(options?.args || []),
], {
stdio: 'pipe',
env: {
...process.env,
DEBUG: 'pw:mcp:test',
DEBUG_COLORS: '0',
DEBUG_HIDE_DATE: '1',
},
});
let stdout = '';
const url = await new Promise<string>(resolve => cp!.stdout?.on('data', data => {
stdout += data.toString();
const match = stdout.match(/Listening on (http:\/\/.*)/);
if (match)
resolve(match[1]);
}));
return { url: new URL(url), stdout: () => stdout };
});
cp?.kill('SIGTERM');
},
});
test.skip(({ mcpBrowser }) => mcpBrowser !== 'chrome', 'Agent is CDP-only for now');
test('browser lifecycle', async ({ agentEndpoint, startClient, server }) => {
const { url: agentUrl } = await agentEndpoint();
const { client: client1 } = await startClient({ args: ['--browser-agent', agentUrl.toString()] });
expect(await client1.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
})).toContainTextContent('Hello, world!');
const { client: client2 } = await startClient({ args: ['--browser-agent', agentUrl.toString()] });
expect(await client2.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
})).toContainTextContent('Hello, world!');
await client1.close();
await client2.close();
});

View File

@@ -22,8 +22,8 @@ test('test snapshot tool list', async ({ client }) => {
'browser_click', 'browser_click',
'browser_console_messages', 'browser_console_messages',
'browser_drag', 'browser_drag',
'browser_evaluate',
'browser_file_upload', 'browser_file_upload',
'browser_generate_playwright_test',
'browser_handle_dialog', 'browser_handle_dialog',
'browser_hover', 'browser_hover',
'browser_select_option', 'browser_select_option',
@@ -34,6 +34,7 @@ test('test snapshot tool list', async ({ client }) => {
'browser_navigate_forward', 'browser_navigate_forward',
'browser_navigate', 'browser_navigate',
'browser_network_requests', 'browser_network_requests',
'browser_pdf_save',
'browser_press_key', 'browser_press_key',
'browser_resize', 'browser_resize',
'browser_snapshot', 'browser_snapshot',
@@ -46,33 +47,46 @@ test('test snapshot tool list', async ({ client }) => {
])); ]));
}); });
test('test capabilities (pdf)', async ({ startClient }) => { test('test vision tool list', async ({ visionClient }) => {
const { client } = await startClient({ const { tools: visionTools } = await visionClient.listTools();
args: ['--caps=pdf'], expect(new Set(visionTools.map(t => t.name))).toEqual(new Set([
}); 'browser_close',
const { tools } = await client.listTools(); 'browser_console_messages',
const toolNames = tools.map(t => t.name); 'browser_file_upload',
expect(toolNames).toContain('browser_pdf_save'); 'browser_generate_playwright_test',
'browser_handle_dialog',
'browser_install',
'browser_navigate_back',
'browser_navigate_forward',
'browser_navigate',
'browser_network_requests',
'browser_pdf_save',
'browser_press_key',
'browser_resize',
'browser_screen_capture',
'browser_screen_click',
'browser_screen_drag',
'browser_screen_move_mouse',
'browser_screen_type',
'browser_tab_close',
'browser_tab_list',
'browser_tab_new',
'browser_tab_select',
'browser_wait_for',
]));
}); });
test('test capabilities (vision)', async ({ startClient }) => { test('test capabilities', async ({ startClient }) => {
const { client } = await startClient({ const { client } = await startClient({
args: ['--caps=vision'], args: ['--caps="core"'],
}); });
const { tools } = await client.listTools(); const { tools } = await client.listTools();
const toolNames = tools.map(t => t.name); const toolNames = tools.map(t => t.name);
expect(toolNames).toContain('browser_mouse_move_xy'); expect(toolNames).not.toContain('browser_file_upload');
expect(toolNames).toContain('browser_mouse_click_xy'); expect(toolNames).not.toContain('browser_pdf_save');
expect(toolNames).toContain('browser_mouse_drag_xy'); expect(toolNames).not.toContain('browser_screen_capture');
}); expect(toolNames).not.toContain('browser_screen_click');
expect(toolNames).not.toContain('browser_screen_drag');
test('support for legacy --vision option', async ({ startClient }) => { expect(toolNames).not.toContain('browser_screen_move_mouse');
const { client } = await startClient({ expect(toolNames).not.toContain('browser_screen_type');
args: ['--vision'],
});
const { tools } = await client.listTools();
const toolNames = tools.map(t => t.name);
expect(toolNames).toContain('browser_mouse_move_xy');
expect(toolNames).toContain('browser_mouse_click_xy');
expect(toolNames).toContain('browser_mouse_drag_xy');
}); });

View File

@@ -19,13 +19,15 @@ import path from 'node:path';
import { spawnSync } from 'node:child_process'; import { spawnSync } from 'node:child_process';
import { test, expect } from './fixtures.js'; 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 }) => { test('cdp server', async ({ cdpServer, startClient, server }) => {
await cdpServer.start(); await cdpServer.start();
const { client } = await startClient({ args: [`--cdp-endpoint=${cdpServer.endpoint}`] }); const { client } = await startClient({ args: [`--cdp-endpoint=${cdpServer.endpoint}`] });
expect(await client.callTool({ expect(await client.callTool({
name: 'browser_navigate', name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD }, 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 }) => { test('cdp server reuse tab', async ({ cdpServer, startClient, server }) => {
@@ -46,17 +48,16 @@ test('cdp server reuse tab', async ({ cdpServer, startClient, server }) => {
expect(await client.callTool({ expect(await client.callTool({
name: 'browser_snapshot', name: 'browser_snapshot',
})).toHaveTextContent(` })).toHaveTextContent(`
### Ran Playwright code - Ran Playwright code:
\`\`\`js \`\`\`js
// <internal code to capture accessibility snapshot> // <internal code to capture accessibility snapshot>
\`\`\` \`\`\`
### Page state
- Page URL: ${server.HELLO_WORLD} - Page URL: ${server.HELLO_WORLD}
- Page Title: Title - Page Title: Title
- Page Snapshot: - Page Snapshot
\`\`\`yaml \`\`\`yaml
- generic [active] [ref=e1]: Hello, world! - generic [ref=e1]: Hello, world!
\`\`\` \`\`\`
`); `);
}); });
@@ -77,7 +78,7 @@ test('should throw connection error and allow re-connecting', async ({ cdpServer
expect(await client.callTool({ expect(await client.callTool({
name: 'browser_navigate', name: 'browser_navigate',
arguments: { url: server.PREFIX }, 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. // NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename.

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_click', async ({ client, server, mcpBrowser }) => {
server.setContent('/', `
<title>Title</title>
<button>Submit</button>
`, 'text/html');
await client.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
});
expect(await client.callTool({
name: 'browser_click',
arguments: {
element: 'Submit button',
ref: 'e2',
},
})).toHaveTextContent(`
### Ran Playwright code
\`\`\`js
// Click Submit button
await page.getByRole('button', { name: 'Submit' }).click();
\`\`\`
### Page state
- Page URL: ${server.PREFIX}
- 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 state
- Page URL: ${server.PREFIX}
- Page Title: Title
- Page Snapshot:
\`\`\`yaml
- heading "Double clicked" [level=1] [ref=e3]
\`\`\`
`);
});
test('browser_click (right)', async ({ client, server }) => {
server.setContent('/', `
<button oncontextmenu="handle">Menu</button>
<script>
document.addEventListener('contextmenu', event => {
event.preventDefault();
document.querySelector('button').textContent = 'Right clicked';
});
</script>
`, 'text/html');
await client.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
});
const result = await client.callTool({
name: 'browser_click',
arguments: {
element: 'Menu',
ref: 'e2',
button: 'right',
},
});
expect(result).toContainTextContent(`await page.getByRole('button', { name: 'Menu' }).click({ button: 'right' });`);
expect(result).toContainTextContent(`- button "Right clicked"`);
});

View File

@@ -20,6 +20,7 @@ import { Config } from '../config.js';
import { test, expect } from './fixtures.js'; import { test, expect } from './fixtures.js';
test('config user data dir', async ({ startClient, server, mcpMode }, testInfo) => { 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('/', ` server.setContent('/', `
<title>Title</title> <title>Title</title>
<body>Hello, world!</body> <body>Hello, world!</body>
@@ -46,6 +47,7 @@ test('config user data dir', async ({ startClient, server, mcpMode }, testInfo)
test.describe(() => { test.describe(() => {
test.use({ mcpBrowser: '' }); test.use({ mcpBrowser: '' });
test('browserName', { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright-mcp/issues/458' } }, async ({ startClient, mcpMode }, testInfo) => { 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 = { const config: Config = {
browser: { browser: {
browserName: 'firefox', browserName: 'firefox',
@@ -61,20 +63,3 @@ test.describe(() => {
})).toContainTextContent(`Firefox`); })).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,59 +38,7 @@ test('browser_console_messages', async ({ client, server }) => {
name: 'browser_console_messages', name: 'browser_console_messages',
}); });
expect(resource).toHaveTextContent([ expect(resource).toHaveTextContent([
`[LOG] Hello, world! @ ${server.PREFIX}:4`, '[LOG] Hello, world!',
`[ERROR] Error @ ${server.PREFIX}:5`, '[ERROR] Error',
].join('\n')); ].join('\n'));
}); });
test('browser_console_messages (page error)', async ({ client, server }) => {
server.setContent('/', `
<!DOCTYPE html>
<html>
<script>
throw new Error("Error in script");
</script>
</html>
`, 'text/html');
await client.callTool({
name: 'browser_navigate',
arguments: {
url: server.PREFIX,
},
});
const resource = await client.callTool({
name: 'browser_console_messages',
});
expect(resource).toHaveTextContent(/Error: Error in script/);
expect(resource).toHaveTextContent(new RegExp(server.PREFIX));
});
test('recent console messages', async ({ client, server }) => {
server.setContent('/', `
<!DOCTYPE html>
<html>
<button onclick="console.log('Hello, world!');">Click me</button>
</html>
`, 'text/html');
await client.callTool({
name: 'browser_navigate',
arguments: {
url: server.PREFIX,
},
});
const response = await client.callTool({
name: 'browser_click',
arguments: {
element: 'Click me',
ref: 'e2',
},
});
expect(response).toContainTextContent(`
### New console messages
- [LOG] Hello, world! @`);
});

View File

@@ -21,23 +21,55 @@ test('browser_navigate', async ({ client, server }) => {
name: 'browser_navigate', name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD }, arguments: { url: server.HELLO_WORLD },
})).toHaveTextContent(` })).toHaveTextContent(`
### Ran Playwright code - Ran Playwright code:
\`\`\`js \`\`\`js
// Navigate to ${server.HELLO_WORLD} // Navigate to ${server.HELLO_WORLD}
await page.goto('${server.HELLO_WORLD}'); await page.goto('${server.HELLO_WORLD}');
\`\`\` \`\`\`
### Page state
- Page URL: ${server.HELLO_WORLD} - Page URL: ${server.HELLO_WORLD}
- Page Title: Title - Page Title: Title
- Page Snapshot: - Page Snapshot
\`\`\`yaml \`\`\`yaml
- generic [active] [ref=e1]: Hello, world! - generic [ref=e1]: Hello, world!
\`\`\` \`\`\`
` `
); );
}); });
test('browser_click', async ({ client, server }) => {
server.setContent('/', `
<title>Title</title>
<button>Submit</button>
`, 'text/html');
await client.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
});
expect(await client.callTool({
name: 'browser_click',
arguments: {
element: 'Submit button',
ref: 'e2',
},
})).toHaveTextContent(`
- Ran Playwright code:
\`\`\`js
// Click Submit button
await page.getByRole('button', { name: 'Submit' }).click();
\`\`\`
- Page URL: ${server.PREFIX}
- Page Title: Title
- Page Snapshot
\`\`\`yaml
- button "Submit" [ref=e2]
\`\`\`
`);
});
test('browser_select_option', async ({ client, server }) => { test('browser_select_option', async ({ client, server }) => {
server.setContent('/', ` server.setContent('/', `
<title>Title</title> <title>Title</title>
@@ -60,16 +92,15 @@ test('browser_select_option', async ({ client, server }) => {
values: ['bar'], values: ['bar'],
}, },
})).toHaveTextContent(` })).toHaveTextContent(`
### Ran Playwright code - Ran Playwright code:
\`\`\`js \`\`\`js
// Select options [bar] in Select // Select options [bar] in Select
await page.getByRole('combobox').selectOption(['bar']); await page.getByRole('combobox').selectOption(['bar']);
\`\`\` \`\`\`
### Page state
- Page URL: ${server.PREFIX} - Page URL: ${server.PREFIX}
- Page Title: Title - Page Title: Title
- Page Snapshot: - Page Snapshot
\`\`\`yaml \`\`\`yaml
- combobox [ref=e2]: - combobox [ref=e2]:
- option "Foo" - option "Foo"
@@ -101,16 +132,15 @@ test('browser_select_option (multiple)', async ({ client, server }) => {
values: ['bar', 'baz'], values: ['bar', 'baz'],
}, },
})).toHaveTextContent(` })).toHaveTextContent(`
### Ran Playwright code - Ran Playwright code:
\`\`\`js \`\`\`js
// Select options [bar, baz] in Select // Select options [bar, baz] in Select
await page.getByRole('listbox').selectOption(['bar', 'baz']); await page.getByRole('listbox').selectOption(['bar', 'baz']);
\`\`\` \`\`\`
### Page state
- Page URL: ${server.PREFIX} - Page URL: ${server.PREFIX}
- Page Title: Title - Page Title: Title
- Page Snapshot: - Page Snapshot
\`\`\`yaml \`\`\`yaml
- listbox [ref=e2]: - listbox [ref=e2]:
- option "Foo" [ref=e3] - option "Foo" [ref=e3]
@@ -145,7 +175,7 @@ test('browser_type', async ({ client, server }) => {
}); });
expect(await client.callTool({ expect(await client.callTool({
name: 'browser_console_messages', name: 'browser_console_messages',
})).toHaveTextContent(/\[LOG\] Key pressed: Enter , Text: Hi!/); })).toHaveTextContent('[LOG] Key pressed: Enter , Text: Hi!');
}); });
test('browser_type (slowly)', async ({ client, server }) => { test('browser_type (slowly)', async ({ client, server }) => {
@@ -169,13 +199,14 @@ test('browser_type (slowly)', async ({ client, server }) => {
slowly: true, slowly: true,
}, },
}); });
const response = await client.callTool({ expect(await client.callTool({
name: 'browser_console_messages', name: 'browser_console_messages',
}); })).toHaveTextContent([
expect(response).toHaveTextContent(/\[LOG\] Key pressed: H Text: /); '[LOG] Key pressed: H Text: ',
expect(response).toHaveTextContent(/\[LOG\] Key pressed: i Text: H/); '[LOG] Key pressed: i Text: H',
expect(response).toHaveTextContent(/\[LOG\] Key pressed: ! Text: Hi/); '[LOG] Key pressed: ! Text: Hi',
expect(response).toHaveTextContent(/\[LOG\] Key pressed: Enter Text: Hi!/); '[LOG] Key pressed: Enter Text: Hi!',
].join('\n'));
}); });
test('browser_resize', async ({ client, server }) => { test('browser_resize', async ({ client, server }) => {
@@ -199,7 +230,7 @@ test('browser_resize', async ({ client, server }) => {
height: 780, height: 780,
}, },
}); });
expect(response).toContainTextContent(`### Ran Playwright code expect(response).toContainTextContent(`- Ran Playwright code:
\`\`\`js \`\`\`js
// Resize browser window to 390x780 // Resize browser window to 390x780
await page.setViewportSize({ width: 390, height: 780 }); await page.setViewportSize({ width: 390, height: 780 });
@@ -244,22 +275,3 @@ test('old locator error message', async ({ client, server }) => {
}, },
})).toContainTextContent('Ref not found'); })).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'; import { test, expect } from './fixtures.js';
test('--device should work', async ({ startClient, server, mcpMode }) => { 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({ const { client } = await startClient({
args: ['--device', 'iPhone 15'], args: ['--device', 'iPhone 15'],
}); });

View File

@@ -16,6 +16,9 @@
import { test, expect } from './fixtures.js'; import { test, expect } from './fixtures.js';
// https://github.com/microsoft/playwright/issues/35663
test.skip(({ mcpBrowser, mcpHeadless }) => mcpBrowser === 'webkit' && mcpHeadless);
test('alert dialog', async ({ client, server }) => { test('alert dialog', async ({ client, server }) => {
server.setContent('/', `<button onclick="alert('Alert')">Button</button>`, 'text/html'); server.setContent('/', `<button onclick="alert('Alert')">Button</button>`, 'text/html');
expect(await client.callTool({ expect(await client.callTool({
@@ -29,7 +32,7 @@ test('alert dialog', async ({ client, server }) => {
element: 'Button', element: 'Button',
ref: 'e2', ref: 'e2',
}, },
})).toHaveTextContent(`### Ran Playwright code })).toHaveTextContent(`- Ran Playwright code:
\`\`\`js \`\`\`js
// Click Button // Click Button
await page.getByRole('button', { name: 'Button' }).click(); await page.getByRole('button', { name: 'Button' }).click();
@@ -46,20 +49,23 @@ await page.getByRole('button', { name: 'Button' }).click();
}); });
expect(result).not.toContainTextContent('### Modal state'); expect(result).not.toContainTextContent('### Modal state');
expect(result).toContainTextContent(`### Ran Playwright code expect(result).toHaveTextContent(`- Ran Playwright code:
\`\`\`js \`\`\`js
// <internal code to handle "alert" dialog> // <internal code to handle "alert" dialog>
\`\`\` \`\`\`
### Page state
- Page URL: ${server.PREFIX} - Page URL: ${server.PREFIX}
- Page Title: - Page Title:
- Page Snapshot: - Page Snapshot
\`\`\`yaml \`\`\`yaml
- button "Button"`); - button "Button" [ref=e2]
\`\`\`
`);
}); });
test('two alert dialogs', async ({ client, server }) => { test('two alert dialogs', async ({ client, server }) => {
test.fixme(true, 'Race between the dialog and ariaSnapshot');
server.setContent('/', ` server.setContent('/', `
<title>Title</title> <title>Title</title>
<body> <body>
@@ -78,7 +84,7 @@ test('two alert dialogs', async ({ client, server }) => {
element: 'Button', element: 'Button',
ref: 'e2', ref: 'e2',
}, },
})).toHaveTextContent(`### Ran Playwright code })).toHaveTextContent(`- Ran Playwright code:
\`\`\`js \`\`\`js
// Click Button // Click Button
await page.getByRole('button', { name: 'Button' }).click(); await page.getByRole('button', { name: 'Button' }).click();
@@ -94,18 +100,7 @@ await page.getByRole('button', { name: 'Button' }).click();
}, },
}); });
expect(result).toContainTextContent(` expect(result).not.toContainTextContent('### Modal state');
### 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',
arguments: {
accept: true,
},
});
expect(result2).not.toContainTextContent('### Modal state');
}); });
test('confirm dialog (true)', async ({ client, server }) => { test('confirm dialog (true)', async ({ client, server }) => {
@@ -139,9 +134,9 @@ test('confirm dialog (true)', async ({ client, server }) => {
expect(result).not.toContainTextContent('### Modal state'); expect(result).not.toContainTextContent('### Modal state');
expect(result).toContainTextContent('// <internal code to handle "confirm" dialog>'); expect(result).toContainTextContent('// <internal code to handle "confirm" dialog>');
expect(result).toContainTextContent(`- Page Snapshot: expect(result).toContainTextContent(`- Page Snapshot
\`\`\`yaml \`\`\`yaml
- generic [active] [ref=e1]: "true" - generic [ref=e1]: "true"
\`\`\``); \`\`\``);
}); });
@@ -174,9 +169,9 @@ test('confirm dialog (false)', async ({ client, server }) => {
}, },
}); });
expect(result).toContainTextContent(`- Page Snapshot: expect(result).toContainTextContent(`- Page Snapshot
\`\`\`yaml \`\`\`yaml
- generic [active] [ref=e1]: "false" - generic [ref=e1]: "false"
\`\`\``); \`\`\``);
}); });
@@ -210,51 +205,8 @@ test('prompt dialog', async ({ client, server }) => {
}, },
}); });
expect(result).toContainTextContent(`- Page Snapshot: expect(result).toContainTextContent(`- Page Snapshot
\`\`\`yaml \`\`\`yaml
- generic [active] [ref=e1]: Answer - generic [ref=e1]: Answer
\`\`\``); \`\`\``);
}); });
test('alert dialog w/ race', async ({ client, server }) => {
server.setContent('/', `<button onclick="setTimeout(() => alert('Alert'), 100)">Button</button>`, 'text/html');
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
})).toContainTextContent('- button "Button" [ref=e2]');
expect(await client.callTool({
name: 'browser_click',
arguments: {
element: 'Button',
ref: 'e2',
},
})).toHaveTextContent(`### Ran Playwright code
\`\`\`js
// Click Button
await page.getByRole('button', { name: 'Button' }).click();
\`\`\`
### Modal state
- ["alert" dialog with message "Alert"]: can be handled by the "browser_handle_dialog" tool`);
const result = await client.callTool({
name: 'browser_handle_dialog',
arguments: {
accept: true,
},
});
expect(result).not.toContainTextContent('### Modal state');
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"`);
});

View File

@@ -1,71 +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_evaluate', 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: '() => document.title',
},
});
expect(result).toContainTextContent(`"Title"`);
});
test('browser_evaluate (element)', async ({ client, server }) => {
server.setContent('/', `
<body style="background-color: red">Hello, world!</body>
`, 'text/html');
await client.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
});
expect(await client.callTool({
name: 'browser_evaluate',
arguments: {
function: 'element => element.style.backgroundColor',
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/);
});

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 }, arguments: { url: server.PREFIX },
})).toContainTextContent(` })).toContainTextContent(`
\`\`\`yaml \`\`\`yaml
- generic [active] [ref=e1]: - generic [ref=e1]:
- button "Choose File" [ref=e2] - button "Choose File" [ref=e2]
- button "Button" [ref=e3] - 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).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('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({ const { client } = await startClient({
config: { outputDir: testInfo.outputPath('output') }, 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('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({ const { client } = await startClient({
config: { outputDir: testInfo.outputPath('output') }, config: { outputDir: testInfo.outputPath('output') },
}); });

View File

@@ -17,12 +17,16 @@
import fs from 'fs'; import fs from 'fs';
import url from 'url'; import url from 'url';
import path from 'path'; import path from 'path';
import net from 'net';
import { chromium } from 'playwright'; import { chromium } from 'playwright';
import { fork } from 'child_process';
import { test as baseTest, expect as baseExpect } from '@playwright/test'; import { test as baseTest, expect as baseExpect } from '@playwright/test';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; 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 { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { TestServer } from './testserver/index.ts'; import { TestServer } from './testserver/index.ts';
import { ManualPromise } from '../src/manualPromise.js';
import type { Config } from '../config'; import type { Config } from '../config';
import type { BrowserContext } from 'playwright'; import type { BrowserContext } from 'playwright';
@@ -31,7 +35,7 @@ import type { Stream } from 'stream';
export type TestOptions = { export type TestOptions = {
mcpBrowser: string | undefined; mcpBrowser: string | undefined;
mcpMode: 'docker' | undefined; mcpMode: 'docker' | 'extension' | undefined;
}; };
type CDPServer = { type CDPServer = {
@@ -41,12 +45,14 @@ type CDPServer = {
type TestFixtures = { type TestFixtures = {
client: Client; client: Client;
visionClient: Client;
startClient: (options?: { clientName?: string, args?: string[], config?: Config }) => Promise<{ client: Client, stderr: () => string }>; startClient: (options?: { clientName?: string, args?: string[], config?: Config }) => Promise<{ client: Client, stderr: () => string }>;
wsEndpoint: string; wsEndpoint: string;
cdpServer: CDPServer; cdpServer: CDPServer;
server: TestServer; server: TestServer;
httpsServer: TestServer; httpsServer: TestServer;
mcpHeadless: boolean; mcpHeadless: boolean;
startMcpExtension: (relayServerURL: string) => Promise<void>;
}; };
type WorkerFixtures = { type WorkerFixtures = {
@@ -60,7 +66,12 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
await use(client); await use(client);
}, },
startClient: async ({ mcpHeadless, mcpBrowser, mcpMode }, use, testInfo) => { visionClient: async ({ startClient }, use) => {
const { client } = await startClient({ args: ['--vision'] });
await use(client);
},
startClient: async ({ mcpHeadless, mcpBrowser, mcpMode, startMcpExtension }, use, testInfo) => {
const userDataDir = mcpMode !== 'docker' ? testInfo.outputPath('user-data-dir') : undefined; const userDataDir = mcpMode !== 'docker' ? testInfo.outputPath('user-data-dir') : undefined;
const configDir = path.dirname(test.info().config.configFile!); const configDir = path.dirname(test.info().config.configFile!);
let client: Client | undefined; let client: Client | undefined;
@@ -84,7 +95,7 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
} }
client = new Client({ name: options?.clientName ?? 'test', version: '1.0.0' }); 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 = ''; let stderrBuffer = '';
stderr?.on('data', data => { stderr?.on('data', data => {
if (process.env.PWMCP_DEBUG) if (process.env.PWMCP_DEBUG)
@@ -92,6 +103,8 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
stderrBuffer += data.toString(); stderrBuffer += data.toString();
}); });
await client.connect(transport); await client.connect(transport);
if (mcpMode === 'extension')
await startMcpExtension(relayServerURL!);
await client.ping(); await client.ping();
return { client, stderr: () => stderrBuffer }; return { client, stderr: () => stderrBuffer };
}); });
@@ -134,6 +147,38 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
mcpMode: [undefined, { option: true }], 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) => { _workerServers: [async ({ }, use, workerInfo) => {
const port = 8907 + workerInfo.workerIndex * 4; const port = 8907 + workerInfo.workerIndex * 4;
const server = await TestServer.create(port); const server = await TestServer.create(port);
@@ -163,6 +208,7 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
async function createTransport(args: string[], mcpMode: TestOptions['mcpMode']): Promise<{ async function createTransport(args: string[], mcpMode: TestOptions['mcpMode']): Promise<{
transport: Transport, transport: Transport,
stderr: Stream | null, stderr: Stream | null,
relayServerURL?: string,
}> { }> {
// NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename. // 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 __filename = url.fileURLToPath(import.meta.url);
@@ -177,6 +223,42 @@ async function createTransport(args: string[], mcpMode: TestOptions['mcpMode']):
stderr: transport.stderr, 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({ const transport = new StdioClientTransport({
command: 'node', command: 'node',
@@ -226,14 +308,17 @@ export const expect = baseExpect.extend({
}; };
}, },
toContainTextContent(response: Response, content: string) { toContainTextContent(response: Response, content: string | string[]) {
const isNot = this.isNot; const isNot = this.isNot;
try { try {
const texts = (response.content as any).map(c => c.text).join('\n'); content = Array.isArray(content) ? content : [content];
if (isNot) const texts = (response.content as any).map(c => c.text);
expect(texts).not.toContain(content); for (let i = 0; i < texts.length; i++) {
else if (isNot)
expect(texts).toContain(content); expect(texts[i]).not.toContain(content[i]);
else
expect(texts[i]).toContain(content[i]);
}
} catch (e) { } catch (e) {
return { return {
pass: isNot, pass: isNot,
@@ -247,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[] { 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); return output.split('\n').map(line => line.replace(/^pw:mcp:test /, '').replace(/user data dir.*/, 'user data dir').trim()).filter(Boolean);
} }

View File

@@ -1,259 +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 { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.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('http transport', async ({ serverEndpoint }) => {
const { url } = await serverEndpoint();
const transport = new StreamableHTTPClientTransport(new URL('/mcp', url));
const client = new Client({ name: 'test', version: '1.0.0' });
await client.connect(transport);
await client.ping();
});
test('http 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 StreamableHTTPClientTransport(new URL('/mcp', url));
const client = new Client({ name: 'test', version: '1.0.0' });
await client.connect(transport);
await client.ping();
});
test('http transport browser lifecycle (isolated)', async ({ serverEndpoint, server }) => {
const { url, stderr } = await serverEndpoint({ args: ['--isolated'] });
const transport1 = new StreamableHTTPClientTransport(new URL('/mcp', 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 },
});
/**
* 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));
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 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);
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('http transport browser lifecycle (isolated, multiclient)', async ({ serverEndpoint, server }) => {
const { url, stderr } = await serverEndpoint({ args: ['--isolated'] });
const transport1 = new StreamableHTTPClientTransport(new URL('/mcp', 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 StreamableHTTPClientTransport(new URL('/mcp', 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 transport1.terminateSession();
await client1.close();
const transport3 = new StreamableHTTPClientTransport(new URL('/mcp', 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 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);
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('http transport browser lifecycle (persistent)', async ({ serverEndpoint, server }) => {
const { url, stderr } = await serverEndpoint();
const transport1 = new StreamableHTTPClientTransport(new URL('/mcp', 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 transport1.terminateSession();
await client1.close();
const transport2 = new StreamableHTTPClientTransport(new URL('/mcp', 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 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);
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('http transport browser lifecycle (persistent, multiclient)', async ({ serverEndpoint, server }) => {
const { url } = await serverEndpoint();
const transport1 = new StreamableHTTPClientTransport(new URL('/mcp', 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 StreamableHTTPClientTransport(new URL('/mcp', 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();
});
test('http transport (default)', async ({ serverEndpoint }) => {
const { url } = await serverEndpoint();
const transport = new StreamableHTTPClientTransport(url);
const client = new Client({ name: 'test', version: '1.0.0' });
await client.connect(transport);
await client.ping();
expect(transport.sessionId, 'has session support').toBeDefined();
});

View File

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

View File

@@ -18,6 +18,8 @@ import fs from 'fs';
import { test, expect, formatOutput } from './fixtures.js'; 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 }) => { test('test reopen browser', async ({ startClient, server, mcpMode }) => {
const { client, stderr } = await startClient(); const { client, stderr } = await startClient();
await client.callTool({ await client.callTool({
@@ -32,7 +34,7 @@ test('test reopen browser', async ({ startClient, server, mcpMode }) => {
expect(await client.callTool({ expect(await client.callTool({
name: 'browser_navigate', name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD }, arguments: { url: server.HELLO_WORLD },
})).toContainTextContent(`- generic [active] [ref=e1]: Hello, world!`); })).toContainTextContent(`- generic [ref=e1]: Hello, world!`);
await client.close(); await client.close();

View File

@@ -19,7 +19,7 @@ import fs from 'fs';
import { test, expect } from './fixtures.js'; import { test, expect } from './fixtures.js';
test('save as pdf unavailable', async ({ startClient, server }) => { test('save as pdf unavailable', async ({ startClient, server }) => {
const { client } = await startClient(); const { client } = await startClient({ args: ['--caps="no-pdf"'] });
await client.callTool({ await client.callTool({
name: 'browser_navigate', name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD }, arguments: { url: server.HELLO_WORLD },
@@ -32,7 +32,7 @@ test('save as pdf unavailable', async ({ startClient, server }) => {
test('save as pdf', async ({ startClient, mcpBrowser, server }, testInfo) => { test('save as pdf', async ({ startClient, mcpBrowser, server }, testInfo) => {
const { client } = await startClient({ const { client } = await startClient({
config: { outputDir: testInfo.outputPath('output'), capabilities: ['pdf'] }, config: { outputDir: testInfo.outputPath('output') },
}); });
test.skip(!!mcpBrowser && !['chromium', 'chrome', 'msedge'].includes(mcpBrowser), 'Save as PDF is only supported in Chromium.'); test.skip(!!mcpBrowser && !['chromium', 'chrome', 'msedge'].includes(mcpBrowser), 'Save as PDF is only supported in Chromium.');
@@ -40,7 +40,7 @@ test('save as pdf', async ({ startClient, mcpBrowser, server }, testInfo) => {
expect(await client.callTool({ expect(await client.callTool({
name: 'browser_navigate', name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD }, arguments: { url: server.HELLO_WORLD },
})).toContainTextContent(`- generic [active] [ref=e1]: Hello, world!`); })).toContainTextContent(`- generic [ref=e1]: Hello, world!`);
const response = await client.callTool({ const response = await client.callTool({
name: 'browser_pdf_save', name: 'browser_pdf_save',
@@ -52,13 +52,13 @@ test('save as pdf (filename: output.pdf)', async ({ startClient, mcpBrowser, ser
const outputDir = testInfo.outputPath('output'); const outputDir = testInfo.outputPath('output');
test.skip(!!mcpBrowser && !['chromium', 'chrome', 'msedge'].includes(mcpBrowser), 'Save as PDF is only supported in Chromium.'); test.skip(!!mcpBrowser && !['chromium', 'chrome', 'msedge'].includes(mcpBrowser), 'Save as PDF is only supported in Chromium.');
const { client } = await startClient({ const { client } = await startClient({
config: { outputDir, capabilities: ['pdf'] }, config: { outputDir },
}); });
expect(await client.callTool({ expect(await client.callTool({
name: 'browser_navigate', name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD }, arguments: { url: server.HELLO_WORLD },
})).toContainTextContent(`- generic [active] [ref=e1]: Hello, world!`); })).toContainTextContent(`- generic [ref=e1]: Hello, world!`);
expect(await client.callTool({ expect(await client.callTool({
name: 'browser_pdf_save', name: 'browser_pdf_save',

View File

@@ -202,51 +202,31 @@ test('browser_take_screenshot (imageResponses=omit)', async ({ startClient, serv
}); });
}); });
test('browser_take_screenshot (fullPage: true)', async ({ startClient, server }, testInfo) => { test('browser_take_screenshot (cursor)', async ({ startClient, server }, testInfo) => {
const outputDir = testInfo.outputPath('output');
const { client } = await startClient({ const { client } = await startClient({
config: { outputDir: testInfo.outputPath('output') }, clientName: 'cursor:vscode',
config: { outputDir },
}); });
expect(await client.callTool({ expect(await client.callTool({
name: 'browser_navigate', name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD }, arguments: { url: server.HELLO_WORLD },
})).toContainTextContent(`Navigate to http://localhost`); })).toContainTextContent(`Navigate to http://localhost`);
await client.callTool({
name: 'browser_take_screenshot',
});
expect(await client.callTool({ expect(await client.callTool({
name: 'browser_take_screenshot', name: 'browser_take_screenshot',
arguments: { fullPage: true },
})).toEqual({ })).toEqual({
content: [ content: [
{ {
data: expect.any(String), text: expect.stringContaining(`Screenshot viewport and save it as`),
mimeType: 'image/jpeg',
type: 'image',
},
{
text: expect.stringContaining(`Screenshot full page and save it as`),
type: 'text', type: 'text',
}, },
], ],
}); });
}); });
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');
});

View File

@@ -1,78 +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 { test, expect } from './fixtures.js';
test('check that session is saved', async ({ startClient, server, mcpMode }, testInfo) => {
const outputDir = testInfo.outputPath('output');
const { client } = await startClient({
args: ['--save-session', `--output-dir=${outputDir}`],
});
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
})).toContainTextContent(`Navigate to http://localhost`);
// Check that session file exists
const files = fs.readdirSync(outputDir);
const sessionFiles = files.filter(f => f.startsWith('session') && f.endsWith('.yml'));
expect(sessionFiles.length).toBe(1);
// Check session file content
const sessionContent = fs.readFileSync(path.join(outputDir, sessionFiles[0]), 'utf8');
expect(sessionContent).toContain('- browser_navigate:');
expect(sessionContent).toContain('params:');
expect(sessionContent).toContain('url: ' + server.HELLO_WORLD);
expect(sessionContent).toContain('snapshot:');
});
test('check that session includes multiple tool calls', async ({ startClient, server, mcpMode }, testInfo) => {
const outputDir = testInfo.outputPath('output');
const { client } = await startClient({
args: ['--save-session', `--output-dir=${outputDir}`],
});
// Navigate to a page
await client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
});
// Take a snapshot
await client.callTool({
name: 'browser_snapshot',
arguments: {},
});
// Check that session file exists and contains both calls
const files = fs.readdirSync(outputDir);
const sessionFiles = files.filter(f => f.startsWith('session') && f.endsWith('.yml'));
expect(sessionFiles.length).toBe(1);
const sessionContent = fs.readFileSync(path.join(outputDir, sessionFiles[0]), 'utf8');
expect(sessionContent).toContain('- browser_navigate:');
expect(sessionContent).toContain('- browser_snapshot:');
// Check that snapshot files exist
const snapshotFiles = files.filter(f => f.includes('snapshot.yaml'));
expect(snapshotFiles.length).toBeGreaterThan(0);
});

View File

@@ -20,6 +20,7 @@ import url from 'node:url';
import { ChildProcess, spawn } from 'node:child_process'; import { ChildProcess, spawn } from 'node:child_process';
import path from 'node:path'; import path from 'node:path';
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { test as baseTest, expect } from './fixtures.js'; import { test as baseTest, expect } from './fixtures.js';
@@ -28,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. // 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 __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 }> }>({ const test = baseTest.extend<{ serverEndpoint: (options?: { args?: string[], noPort?: boolean }) => Promise<{ url: URL, stderr: () => string }> }>({
serverEndpoint: async ({ mcpHeadless }, use, testInfo) => { serverEndpoint: async ({ mcpHeadless }, use, testInfo) => {
let cp: ChildProcess | undefined; let cp: ChildProcess | undefined;
@@ -67,7 +70,7 @@ const test = baseTest.extend<{ serverEndpoint: (options?: { args?: string[], noP
test('sse transport', async ({ serverEndpoint }) => { test('sse transport', async ({ serverEndpoint }) => {
const { url } = await serverEndpoint(); const { url } = await serverEndpoint();
const transport = new SSEClientTransport(new URL('/sse', url)); const transport = new SSEClientTransport(url);
const client = new Client({ name: 'test', version: '1.0.0' }); const client = new Client({ name: 'test', version: '1.0.0' });
await client.connect(transport); await client.connect(transport);
await client.ping(); await client.ping();
@@ -83,7 +86,7 @@ test('sse transport (config)', async ({ serverEndpoint }) => {
await fs.promises.writeFile(configFile, JSON.stringify(config, null, 2)); await fs.promises.writeFile(configFile, JSON.stringify(config, null, 2));
const { url } = await serverEndpoint({ noPort: true, args: ['--config=' + configFile] }); const { url } = await serverEndpoint({ noPort: true, args: ['--config=' + configFile] });
const transport = new SSEClientTransport(new URL('/sse', url)); const transport = new SSEClientTransport(url);
const client = new Client({ name: 'test', version: '1.0.0' }); const client = new Client({ name: 'test', version: '1.0.0' });
await client.connect(transport); await client.connect(transport);
await client.ping(); await client.ping();
@@ -92,7 +95,7 @@ test('sse transport (config)', async ({ serverEndpoint }) => {
test('sse transport browser lifecycle (isolated)', async ({ serverEndpoint, server }) => { test('sse transport browser lifecycle (isolated)', async ({ serverEndpoint, server }) => {
const { url, stderr } = await serverEndpoint({ args: ['--isolated'] }); const { url, stderr } = await serverEndpoint({ args: ['--isolated'] });
const transport1 = new SSEClientTransport(new URL('/sse', url)); const transport1 = new SSEClientTransport(url);
const client1 = new Client({ name: 'test', version: '1.0.0' }); const client1 = new Client({ name: 'test', version: '1.0.0' });
await client1.connect(transport1); await client1.connect(transport1);
await client1.callTool({ await client1.callTool({
@@ -101,7 +104,7 @@ test('sse transport browser lifecycle (isolated)', async ({ serverEndpoint, serv
}); });
await client1.close(); await client1.close();
const transport2 = new SSEClientTransport(new URL('/sse', url)); const transport2 = new SSEClientTransport(url);
const client2 = new Client({ name: 'test', version: '1.0.0' }); const client2 = new Client({ name: 'test', version: '1.0.0' });
await client2.connect(transport2); await client2.connect(transport2);
await client2.callTool({ await client2.callTool({
@@ -129,7 +132,7 @@ test('sse transport browser lifecycle (isolated)', async ({ serverEndpoint, serv
test('sse transport browser lifecycle (isolated, multiclient)', async ({ serverEndpoint, server }) => { test('sse transport browser lifecycle (isolated, multiclient)', async ({ serverEndpoint, server }) => {
const { url, stderr } = await serverEndpoint({ args: ['--isolated'] }); const { url, stderr } = await serverEndpoint({ args: ['--isolated'] });
const transport1 = new SSEClientTransport(new URL('/sse', url)); const transport1 = new SSEClientTransport(url);
const client1 = new Client({ name: 'test', version: '1.0.0' }); const client1 = new Client({ name: 'test', version: '1.0.0' });
await client1.connect(transport1); await client1.connect(transport1);
await client1.callTool({ await client1.callTool({
@@ -137,7 +140,7 @@ test('sse transport browser lifecycle (isolated, multiclient)', async ({ serverE
arguments: { url: server.HELLO_WORLD }, arguments: { url: server.HELLO_WORLD },
}); });
const transport2 = new SSEClientTransport(new URL('/sse', url)); const transport2 = new SSEClientTransport(url);
const client2 = new Client({ name: 'test', version: '1.0.0' }); const client2 = new Client({ name: 'test', version: '1.0.0' });
await client2.connect(transport2); await client2.connect(transport2);
await client2.callTool({ await client2.callTool({
@@ -146,7 +149,7 @@ test('sse transport browser lifecycle (isolated, multiclient)', async ({ serverE
}); });
await client1.close(); await client1.close();
const transport3 = new SSEClientTransport(new URL('/sse', url)); const transport3 = new SSEClientTransport(url);
const client3 = new Client({ name: 'test', version: '1.0.0' }); const client3 = new Client({ name: 'test', version: '1.0.0' });
await client3.connect(transport3); await client3.connect(transport3);
await client3.callTool({ await client3.callTool({
@@ -176,7 +179,7 @@ test('sse transport browser lifecycle (isolated, multiclient)', async ({ serverE
test('sse transport browser lifecycle (persistent)', async ({ serverEndpoint, server }) => { test('sse transport browser lifecycle (persistent)', async ({ serverEndpoint, server }) => {
const { url, stderr } = await serverEndpoint(); const { url, stderr } = await serverEndpoint();
const transport1 = new SSEClientTransport(new URL('/sse', url)); const transport1 = new SSEClientTransport(url);
const client1 = new Client({ name: 'test', version: '1.0.0' }); const client1 = new Client({ name: 'test', version: '1.0.0' });
await client1.connect(transport1); await client1.connect(transport1);
await client1.callTool({ await client1.callTool({
@@ -185,7 +188,7 @@ test('sse transport browser lifecycle (persistent)', async ({ serverEndpoint, se
}); });
await client1.close(); await client1.close();
const transport2 = new SSEClientTransport(new URL('/sse', url)); const transport2 = new SSEClientTransport(url);
const client2 = new Client({ name: 'test', version: '1.0.0' }); const client2 = new Client({ name: 'test', version: '1.0.0' });
await client2.connect(transport2); await client2.connect(transport2);
await client2.callTool({ await client2.callTool({
@@ -213,7 +216,7 @@ test('sse transport browser lifecycle (persistent)', async ({ serverEndpoint, se
test('sse transport browser lifecycle (persistent, multiclient)', async ({ serverEndpoint, server }) => { test('sse transport browser lifecycle (persistent, multiclient)', async ({ serverEndpoint, server }) => {
const { url } = await serverEndpoint(); const { url } = await serverEndpoint();
const transport1 = new SSEClientTransport(new URL('/sse', url)); const transport1 = new SSEClientTransport(url);
const client1 = new Client({ name: 'test', version: '1.0.0' }); const client1 = new Client({ name: 'test', version: '1.0.0' });
await client1.connect(transport1); await client1.connect(transport1);
await client1.callTool({ await client1.callTool({
@@ -221,7 +224,7 @@ test('sse transport browser lifecycle (persistent, multiclient)', async ({ serve
arguments: { url: server.HELLO_WORLD }, arguments: { url: server.HELLO_WORLD },
}); });
const transport2 = new SSEClientTransport(new URL('/sse', url)); const transport2 = new SSEClientTransport(url);
const client2 = new Client({ name: 'test', version: '1.0.0' }); const client2 = new Client({ name: 'test', version: '1.0.0' });
await client2.connect(transport2); await client2.connect(transport2);
const response = await client2.callTool({ const response = await client2.callTool({
@@ -234,3 +237,12 @@ test('sse transport browser lifecycle (persistent, multiclient)', async ({ serve
await client1.close(); await client1.close();
await client2.close(); await client2.close();
}); });
test('streamable http transport', async ({ serverEndpoint }) => {
const { url } = await serverEndpoint();
const transport = new StreamableHTTPClientTransport(new URL('/mcp', url));
const client = new Client({ name: 'test', version: '1.0.0' });
await client.connect(transport);
await client.ping();
expect(transport.sessionId, 'has session support').toBeDefined();
});

View File

@@ -27,6 +27,8 @@ 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 }) => { test('list initial tabs', async ({ client }) => {
expect(await client.callTool({ expect(await client.callTool({
name: 'browser_tab_list', name: 'browser_tab_list',
@@ -44,7 +46,12 @@ test('list first tab', async ({ client }) => {
}); });
test('create new tab', async ({ client }) => { test('create new tab', async ({ client }) => {
expect(await createTab(client, 'Tab one', 'Body one')).toContainTextContent(` expect(await createTab(client, 'Tab one', 'Body one')).toHaveTextContent(`
- Ran Playwright code:
\`\`\`js
// <internal code to open a new tab>
\`\`\`
### Open tabs ### Open tabs
- 0: [] (about:blank) - 0: [] (about:blank)
- 1: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>) - 1: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)
@@ -52,12 +59,17 @@ test('create new tab', async ({ client }) => {
### Current tab ### Current tab
- Page URL: data:text/html,<title>Tab one</title><body>Body one</body> - Page URL: data:text/html,<title>Tab one</title><body>Body one</body>
- Page Title: Tab one - Page Title: Tab one
- Page Snapshot: - Page Snapshot
\`\`\`yaml \`\`\`yaml
- generic [active] [ref=e1]: Body one - generic [ref=e1]: Body one
\`\`\``); \`\`\``);
expect(await createTab(client, 'Tab two', 'Body two')).toContainTextContent(` expect(await createTab(client, 'Tab two', 'Body two')).toHaveTextContent(`
- Ran Playwright code:
\`\`\`js
// <internal code to open a new tab>
\`\`\`
### Open tabs ### Open tabs
- 0: [] (about:blank) - 0: [] (about:blank)
- 1: [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>) - 1: [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)
@@ -66,9 +78,9 @@ test('create new tab', async ({ client }) => {
### Current tab ### Current tab
- Page URL: data:text/html,<title>Tab two</title><body>Body two</body> - Page URL: data:text/html,<title>Tab two</title><body>Body two</body>
- Page Title: Tab two - Page Title: Tab two
- Page Snapshot: - Page Snapshot
\`\`\`yaml \`\`\`yaml
- generic [active] [ref=e1]: Body two - generic [ref=e1]: Body two
\`\`\``); \`\`\``);
}); });
@@ -81,7 +93,7 @@ test('select tab', async ({ client }) => {
index: 1, index: 1,
}, },
})).toHaveTextContent(` })).toHaveTextContent(`
### Ran Playwright code - Ran Playwright code:
\`\`\`js \`\`\`js
// <internal code to select tab 1> // <internal code to select tab 1>
\`\`\` \`\`\`
@@ -94,9 +106,9 @@ test('select tab', async ({ client }) => {
### Current tab ### Current tab
- Page URL: data:text/html,<title>Tab one</title><body>Body one</body> - Page URL: data:text/html,<title>Tab one</title><body>Body one</body>
- Page Title: Tab one - Page Title: Tab one
- Page Snapshot: - Page Snapshot
\`\`\`yaml \`\`\`yaml
- generic [active] [ref=e1]: Body one - generic [ref=e1]: Body one
\`\`\``); \`\`\``);
}); });
@@ -109,7 +121,7 @@ test('close tab', async ({ client }) => {
index: 2, index: 2,
}, },
})).toHaveTextContent(` })).toHaveTextContent(`
### Ran Playwright code - Ran Playwright code:
\`\`\`js \`\`\`js
// <internal code to close tab 2> // <internal code to close tab 2>
\`\`\` \`\`\`
@@ -121,9 +133,9 @@ test('close tab', async ({ client }) => {
### Current tab ### Current tab
- Page URL: data:text/html,<title>Tab one</title><body>Body one</body> - Page URL: data:text/html,<title>Tab one</title><body>Body one</body>
- Page Title: Tab one - Page Title: Tab one
- Page Snapshot: - Page Snapshot
\`\`\`yaml \`\`\`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'; import { test, expect } from './fixtures.js';
test('check that trace is saved', async ({ startClient, server, mcpMode }, testInfo) => { 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 outputDir = testInfo.outputPath('output');
const { client } = await startClient({ const { client } = await startClient({

View File

@@ -20,20 +20,60 @@ import fs from 'node:fs'
import path from 'node:path' import path from 'node:path'
import url from 'node:url' import url from 'node:url'
import zodToJsonSchema from 'zod-to-json-schema' import zodToJsonSchema from 'zod-to-json-schema'
import commonTools from '../lib/tools/common.js';
import consoleTools from '../lib/tools/console.js';
import dialogsTools from '../lib/tools/dialogs.js';
import filesTools from '../lib/tools/files.js';
import installTools from '../lib/tools/install.js';
import keyboardTools from '../lib/tools/keyboard.js';
import navigateTools from '../lib/tools/navigate.js';
import networkTools from '../lib/tools/network.js';
import pdfTools from '../lib/tools/pdf.js';
import snapshotTools from '../lib/tools/snapshot.js';
import tabsTools from '../lib/tools/tabs.js';
import screenshotTools from '../lib/tools/screenshot.js';
import testTools from '../lib/tools/testing.js';
import visionTools from '../lib/tools/vision.js';
import waitTools from '../lib/tools/wait.js';
import { execSync } from 'node:child_process'; import { execSync } from 'node:child_process';
import { allTools } from '../lib/tools.js'; const categories = {
'Interactions': [
const capabilities = { ...snapshotTools,
'core': 'Core automation', ...keyboardTools(true),
'core-tabs': 'Tab management', ...waitTools(true),
'core-install': 'Browser installation', ...filesTools(true),
'vision': 'Coordinate-based (opt-in via --caps=vision)', ...dialogsTools(true),
'pdf': 'PDF generation (opt-in via --caps=pdf)', ],
'Navigation': [
...navigateTools(true),
],
'Resources': [
...screenshotTools,
...pdfTools,
...networkTools,
...consoleTools,
],
'Utilities': [
...installTools,
...commonTools(true),
],
'Tabs': [
...tabsTools(true),
],
'Testing': [
...testTools,
],
'Vision mode': [
...visionTools,
...keyboardTools(),
...waitTools(false),
...filesTools(false),
...dialogsTools(false),
],
}; };
const toolsByCapability = Object.fromEntries(Object.entries(capabilities).map(([capability, title]) => [title, allTools.filter(tool => tool.capability === capability).sort((a, b) => a.schema.name.localeCompare(b.schema.name))]));
// NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename. // 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 __filename = url.fileURLToPath(import.meta.url);
@@ -99,12 +139,14 @@ async function updateSection(content, startMarker, endMarker, generatedLines) {
async function updateTools(content) { async function updateTools(content) {
console.log('Loading tool information from compiled modules...'); console.log('Loading tool information from compiled modules...');
const totalTools = Object.values(categories).flat().length;
console.log(`Found ${totalTools} tools`);
const generatedLines = /** @type {string[]} */ ([]); const generatedLines = /** @type {string[]} */ ([]);
for (const [capability, tools] of Object.entries(toolsByCapability)) { for (const [category, categoryTools] of Object.entries(categories)) {
console.log('Updating tools for capability:', capability); generatedLines.push(`<details>\n<summary><b>${category}</b></summary>`);
generatedLines.push(`<details>\n<summary><b>${capability}</b></summary>`);
generatedLines.push(''); generatedLines.push('');
for (const tool of tools) for (const tool of categoryTools)
generatedLines.push(...formatToolForReadme(tool.schema)); generatedLines.push(...formatToolForReadme(tool.schema));
generatedLines.push(`</details>`); generatedLines.push(`</details>`);
generatedLines.push(''); generatedLines.push('');