diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 25103c1..2c28167 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -7,29 +7,79 @@ on:
branches: [ main ]
jobs:
- build-and-test:
+ lint:
runs-on: ubuntu-latest
-
steps:
- uses: actions/checkout@v4
-
- name: Use Node.js 18
uses: actions/setup-node@v4
with:
node-version: '18'
cache: 'npm'
-
- name: Install dependencies
run: npm ci
-
- - name: Run linting
+ - run: npm run build
+ - name: Run ESLint
run: npm run lint
+ - name: Ensure no changes
+ run: git diff --exit-code
+ test:
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [ubuntu-latest, macos-latest, windows-latest]
+ runs-on: ${{ matrix.os }}
+ steps:
+ - uses: actions/checkout@v4
+ - name: Use Node.js 18
+ uses: actions/setup-node@v4
+ with:
+ # https://github.com/microsoft/playwright-mcp/issues/344
+ node-version: '18.19'
+ cache: 'npm'
+ - name: Install dependencies
+ run: npm ci
+ - name: Playwright install
+ run: npx playwright install --with-deps
+ - name: Install MS Edge
+ # MS Edge is not preinstalled on macOS runners.
+ if: ${{ matrix.os == 'macos-latest' }}
+ run: npx playwright install msedge
- name: Build
run: npm run build
-
- - name: Install Playwright browsers
- run: npx playwright install --with-deps
-
- name: Run tests
run: npm test
+
+ test_docker:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Use Node.js 18
+ uses: actions/setup-node@v4
+ with:
+ node-version: '18'
+ cache: 'npm'
+ - name: Install dependencies
+ run: npm ci
+ - name: Playwright install
+ run: npx playwright install --with-deps chromium
+ - name: Build
+ run: npm run build
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+ - name: Build and push
+ uses: docker/build-push-action@v6
+ with:
+ tags: playwright-mcp-dev:latest
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
+ load: true
+ - name: Run tests
+ shell: bash
+ run: |
+ # Used for the Docker tests to share the test-results folder with the container.
+ umask 0000
+ npm run test -- --project=chromium-docker
+ env:
+ MCP_IN_DOCKER: 1
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 7e6b02f..503702b 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -5,6 +5,9 @@ on:
jobs:
publish-npm:
runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ id-token: write # Needed for npm provenance
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
@@ -12,9 +15,41 @@ jobs:
node-version: 18
registry-url: https://registry.npmjs.org/
- run: npm ci
+ - run: npx playwright install --with-deps
- run: npm run build
- run: npm run lint
- - run: npm run test
+ - run: npm run ctest
- run: npm publish --provenance
env:
- NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+
+ publish-docker:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ id-token: write # Needed for OIDC login to Azure
+ environment: allow-publishing-docker-to-acr
+ steps:
+ - uses: actions/checkout@v4
+ - name: Set up QEMU # Needed for multi-platform builds (e.g., arm64 on amd64 runner)
+ uses: docker/setup-qemu-action@v3
+ - name: Set up Docker Buildx # Needed for multi-platform builds
+ uses: docker/setup-buildx-action@v3
+ - name: Azure Login via OIDC
+ uses: azure/login@v2
+ with:
+ client-id: ${{ secrets.AZURE_DOCKER_CLIENT_ID }}
+ tenant-id: ${{ secrets.AZURE_DOCKER_TENANT_ID }}
+ subscription-id: ${{ secrets.AZURE_DOCKER_SUBSCRIPTION_ID }}
+ - name: Login to ACR
+ run: az acr login --name playwright
+ - name: Build and push Docker image
+ uses: docker/build-push-action@v6
+ with:
+ context: .
+ file: ./Dockerfile # Adjust path if your Dockerfile is elsewhere
+ platforms: linux/amd64,linux/arm64
+ push: true
+ tags: |
+ playwright.azurecr.io/public/playwright/mcp:${{ github.event.release.tag_name }}
+ playwright.azurecr.io/public/playwright/mcp:latest
diff --git a/.gitignore b/.gitignore
index 7e00484..1089cef 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,8 @@
lib/
node_modules/
test-results/
+playwright-report/
+.vscode/mcp.json
+
+.idea
+.DS_Store
diff --git a/.npmignore b/.npmignore
index f29dce2..a2846ba 100644
--- a/.npmignore
+++ b/.npmignore
@@ -4,3 +4,4 @@ LICENSE
!lib/**/*.js
!cli.js
!index.*
+!config.d.ts
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..556ea6a
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,69 @@
+ARG PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
+
+# ------------------------------
+# Base
+# ------------------------------
+# Base stage: Contains only the minimal dependencies required for runtime
+# (node_modules and Playwright system dependencies)
+FROM node:22-bookworm-slim AS base
+
+ARG PLAYWRIGHT_BROWSERS_PATH
+ENV PLAYWRIGHT_BROWSERS_PATH=${PLAYWRIGHT_BROWSERS_PATH}
+
+# Set the working directory
+WORKDIR /app
+
+RUN --mount=type=cache,target=/root/.npm,sharing=locked,id=npm-cache \
+ --mount=type=bind,source=package.json,target=package.json \
+ --mount=type=bind,source=package-lock.json,target=package-lock.json \
+ npm ci --omit=dev && \
+ # Install system dependencies for playwright
+ npx -y playwright-core install-deps chromium
+
+# ------------------------------
+# Builder
+# ------------------------------
+FROM base AS builder
+
+RUN --mount=type=cache,target=/root/.npm,sharing=locked,id=npm-cache \
+ --mount=type=bind,source=package.json,target=package.json \
+ --mount=type=bind,source=package-lock.json,target=package-lock.json \
+ npm ci
+
+# Copy the rest of the app
+COPY *.json *.js *.ts .
+COPY src src/
+
+# Build the app
+RUN npm run build
+
+# ------------------------------
+# Browser
+# ------------------------------
+# Cache optimization:
+# - Browser is downloaded only when node_modules or Playwright system dependencies change
+# - Cache is reused when only source code changes
+FROM base AS browser
+
+RUN npx -y playwright-core install --no-shell chromium
+
+# ------------------------------
+# Runtime
+# ------------------------------
+FROM base
+
+ARG PLAYWRIGHT_BROWSERS_PATH
+ARG USERNAME=node
+ENV NODE_ENV=production
+
+# Set the correct ownership for the runtime user on production `node_modules`
+RUN chown -R ${USERNAME}:${USERNAME} node_modules
+
+USER ${USERNAME}
+
+COPY --from=browser --chown=${USERNAME}:${USERNAME} ${PLAYWRIGHT_BROWSERS_PATH} ${PLAYWRIGHT_BROWSERS_PATH}
+COPY --chown=${USERNAME}:${USERNAME} cli.js package.json ./
+COPY --from=builder --chown=${USERNAME}:${USERNAME} /app/lib /app/lib
+
+# Run in headless and only with chromium (other browsers need more dependencies not included in this image)
+ENTRYPOINT ["node", "cli.js", "--headless", "--browser", "chromium", "--no-sandbox"]
diff --git a/README.md b/README.md
index 3f2b70e..99e52d9 100644
--- a/README.md
+++ b/README.md
@@ -4,18 +4,22 @@ A Model Context Protocol (MCP) server that provides browser automation capabilit
### Key Features
-- **Fast and lightweight**: Uses Playwright's accessibility tree, not pixel-based input.
-- **LLM-friendly**: No vision models needed, operates purely on structured data.
-- **Deterministic tool application**: Avoids ambiguity common with screenshot-based approaches.
+- **Fast and lightweight**. Uses Playwright's accessibility tree, not pixel-based input.
+- **LLM-friendly**. No vision models needed, operates purely on structured data.
+- **Deterministic tool application**. Avoids ambiguity common with screenshot-based approaches.
-### Use Cases
+### Requirements
+- Node.js 18 or newer
+- VS Code, Cursor, Windsurf, Claude Desktop or any other MCP client
-- Web navigation and form-filling
-- Data extraction from structured content
-- Automated testing driven by LLMs
-- General-purpose browser interaction for agents
+
-### Example config
+### Getting started
+
+First, install the Playwright MCP server with your client. A typical configuration looks like this:
```js
{
@@ -30,51 +34,163 @@ A Model Context Protocol (MCP) server that provides browser automation capabilit
}
```
+[
](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) [
](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)
-#### Installation in VS Code
-
-Install the Playwright MCP server in VS Code using one of these buttons:
-
-
-[
](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-y%2522%252C%2522%2540playwright%252Fmcp%2540latest%2522%255D%257D)
+Install in VS Code
-Alternatively, you can install the Playwright MCP server using the VS Code CLI:
+You can also install the Playwright MCP server using the VS Code CLI:
```bash
# For VS Code
code --add-mcp '{"name":"playwright","command":"npx","args":["@playwright/mcp@latest"]}'
```
-```bash
-# For VS Code Insiders
-code-insiders --add-mcp '{"name":"playwright","command":"npx","args":["@playwright/mcp@latest"]}'
+After installation, the Playwright MCP server will be available for use with your GitHub Copilot agent in VS Code.
+
+
+
+Install in Cursor
+
+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"
+ ]
+ }
+ }
+}
```
+
-After installation, the Playwright MCP server will be available for use with your GitHub Copilot agent in VS Code.
+
+Install in Windsurf
-### User data directory
+Follow Windsuff MCP [documentation](https://docs.windsurf.com/windsurf/cascade/mcp). Use following configuration:
-Playwright MCP will launch Chrome browser with the new profile, located at
+```js
+{
+ "mcpServers": {
+ "playwright": {
+ "command": "npx",
+ "args": [
+ "@playwright/mcp@latest"
+ ]
+ }
+ }
+}
+```
+
+
+
+Install in Claude Desktop
+Follow the MCP install [guide](https://modelcontextprotocol.io/quickstart/user), use following configuration:
+
+```js
+{
+ "mcpServers": {
+ "playwright": {
+ "command": "npx",
+ "args": [
+ "@playwright/mcp@latest"
+ ]
+ }
+ }
+}
```
-- `%USERPROFILE%\AppData\Local\ms-playwright\mcp-chrome-profile` on Windows
-- `~/Library/Caches/ms-playwright/mcp-chrome-profile` on macOS
-- `~/.cache/ms-playwright/mcp-chrome-profile` on Linux
+
+
+### Configuration
+
+Playwright MCP server supports following arguments. They can be provided in the JSON configuration above, as a part of the `"args"` list:
+
+
+
+```
+> npx @playwright/mcp@latest --help
+ --allowed-origins semicolon-separated list of origins to allow the
+ browser to request. Default is to allow all.
+ --blocked-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.
+ --block-service-workers block service workers
+ --browser browser or chrome channel to use, possible
+ values: chrome, firefox, webkit, msedge.
+ --browser-agent Use browser agent (experimental).
+ --caps comma-separated list of capabilities to enable,
+ possible values: tabs, pdf, history, wait, files,
+ install. Default is all.
+ --cdp-endpoint CDP endpoint to connect to.
+ --config path to the configuration file.
+ --device device to emulate, for example: "iPhone 15"
+ --executable-path path to the browser executable.
+ --headless run browser in headless mode, headed by default
+ --host host to bind server to. Default is localhost. Use
+ 0.0.0.0 to bind to all interfaces.
+ --ignore-https-errors ignore https errors
+ --isolated keep the browser profile in memory, do not save
+ it to disk.
+ --image-responses 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.
+ --no-sandbox disable the sandbox for all process types that
+ are normally sandboxed.
+ --output-dir path to the directory for output files.
+ --port port to listen on for SSE transport.
+ --proxy-bypass comma-separated domains to bypass proxy, for
+ example ".com,chromium.org,.domain.com"
+ --proxy-server specify proxy server, for example
+ "http://myproxy:3128" or "socks5://myproxy:8080"
+ --save-trace Whether to save the Playwright Trace of the
+ session into the output directory.
+ --storage-state path to the storage state file for isolated
+ sessions.
+ --user-agent specify user agent string
+ --user-data-dir path to the user data directory. If not
+ specified, a temporary directory will be created.
+ --viewport-size specify browser viewport size in pixels, for
+ example "1280, 720"
+ --vision Run server that uses screenshots (Aria snapshots
+ are used by default)
```
-All the logged in information will be stored in that profile, you can delete it between sessions if you'dlike to clear the offline state.
+
+
+### User profile
+You can run Playwright MCP with persistent profile like a regular browser (default), or in the isolated contexts for the testing sessions.
-### Running headless browser (Browser without GUI).
+**Persistent profile**
-This mode is useful for background or batch operations.
+All the logged in information will be stored in the persistent profile, you can delete it between sessions if you'd like to clear the offline state.
+Persistent profile is located at the following locations and you can override it with the `--user-data-dir` argument.
+
+```bash
+# Windows
+%USERPROFILE%\AppData\Local\ms-playwright\mcp-{channel}-profile
+
+# macOS
+- ~/Library/Caches/ms-playwright/mcp-{channel}-profile
+
+# Linux
+- ~/.cache/ms-playwright/mcp-{channel}-profile
+```
+
+**Isolated**
+
+In the isolated mode, each session is started in the isolated profile. Every time you ask MCP to close the browser,
+the session is closed and all the storage state for this session is lost. You can provide initial storage state
+to the browser via the config's `contextOptions` or via the `--storage-state` argument. Learn more about the storage
+state [here](https://playwright.dev/docs/auth).
```js
{
@@ -83,14 +199,104 @@ This mode is useful for background or batch operations.
"command": "npx",
"args": [
"@playwright/mcp@latest",
- "--headless"
+ "--isolated",
+ "--storage-state={path/to/storage.json}"
]
}
}
}
```
-### Running headed browser on Linux w/o DISPLAY
+### Configuration file
+
+The Playwright MCP server can be configured using a JSON configuration file. You can specify the configuration file
+using the `--config` command line option:
+
+```bash
+npx @playwright/mcp@latest --config path/to/config.json
+```
+
+
+Configuration file schema
+
+```typescript
+{
+ // Browser configuration
+ browser?: {
+ // Browser type to use (chromium, firefox, or webkit)
+ browserName?: 'chromium' | 'firefox' | 'webkit';
+
+ // Keep the browser profile in memory, do not save it to disk.
+ isolated?: boolean;
+
+ // Path to user data directory for browser profile persistence
+ userDataDir?: string;
+
+ // Browser launch options (see Playwright docs)
+ // @see https://playwright.dev/docs/api/class-browsertype#browser-type-launch
+ launchOptions?: {
+ channel?: string; // Browser channel (e.g. 'chrome')
+ headless?: boolean; // Run in headless mode
+ executablePath?: string; // Path to browser executable
+ // ... other Playwright launch options
+ };
+
+ // Browser context options
+ // @see https://playwright.dev/docs/api/class-browser#browser-new-context
+ contextOptions?: {
+ viewport?: { width: number, height: number };
+ // ... other Playwright context options
+ };
+
+ // CDP endpoint for connecting to existing browser
+ cdpEndpoint?: string;
+
+ // Remote Playwright server endpoint
+ remoteEndpoint?: string;
+ },
+
+ // Server configuration
+ server?: {
+ port?: number; // Port to listen on
+ host?: string; // Host to bind to (default: localhost)
+ },
+
+ // List of enabled capabilities
+ capabilities?: Array<
+ 'core' | // Core browser automation
+ 'tabs' | // Tab management
+ 'pdf' | // PDF generation
+ '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
+ outputDir?: string;
+
+ // Network configuration
+ network?: {
+ // List of origins to allow the browser to request. Default is to allow all. Origins matching both `allowedOrigins` and `blockedOrigins` will be blocked.
+ allowedOrigins?: string[];
+
+ // List of origins to block the browser to request. Origins matching both `allowedOrigins` and `blockedOrigins` will be blocked.
+ blockedOrigins?: string[];
+ };
+
+ /**
+ * Do not send image responses to the client.
+ */
+ noImageResponses?: boolean;
+}
+```
+
+
+### Standalone MCP server
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 SSE transport.
@@ -111,7 +317,52 @@ And then in MCP client config, set the `url` to the SSE endpoint:
}
```
-### Tool Modes
+
+Docker
+
+**NOTE:** The Docker implementation only supports headless chromium at the moment.
+
+```js
+{
+ "mcpServers": {
+ "playwright": {
+ "command": "docker",
+ "args": ["run", "-i", "--rm", "--init", "--pull=always", "mcr.microsoft.com/playwright/mcp"]
+ }
+ }
+}
+```
+
+You can build the Docker image yourself.
+
+```
+docker build -t mcr.microsoft.com/playwright/mcp .
+```
+
+
+
+Programmatic usage
+
+```js
+import http from 'http';
+
+import { createConnection } from '@playwright/mcp';
+import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
+
+http.createServer(async (req, res) => {
+ // ...
+
+ // Creates a headless Playwright MCP server with SSE transport
+ const connection = await createConnection({ browser: { launchOptions: { headless: true } } });
+ const transport = new SSEServerTransport('/messages', res);
+ await connection.connect(transport);
+
+ // ...
+});
+```
+
+
+### Tools
The tools are available in two modes:
@@ -137,171 +388,371 @@ To use Vision Mode, add the `--vision` flag when starting the server:
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.
-### Programmatic usage with custom transports
+
-```js
-import { createServer } from '@playwright/mcp';
-
-// ...
+
+Interactions
-const server = createServer({
- launchOptions: { headless: true }
-});
-transport = new SSEServerTransport("/messages", res);
-server.connect(transport);
-```
+
-### Snapshot Mode
-
-The Playwright MCP provides a set of tools for browser automation. Here are all available tools:
-
-- **browser_navigate**
- - Description: Navigate to a URL
- - Parameters:
- - `url` (string): The URL to navigate to
-
-- **browser_go_back**
- - Description: Go back to the previous page
+- **browser_snapshot**
+ - Title: Page snapshot
+ - Description: Capture accessibility snapshot of the current page, this is better than screenshot
- Parameters: None
+ - Read-only: **true**
-- **browser_go_forward**
- - Description: Go forward to the next page
- - Parameters: None
+
- **browser_click**
+ - Title: Click
- Description: Perform click on a web 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: **false**
-- **browser_hover**
- - 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
+
- **browser_drag**
+ - Title: Drag mouse
- Description: Perform drag and drop between two elements
- Parameters:
- - `startElement` (string): Human-readable source element description used to obtain permission to interact with the element
+ - `startElement` (string): Human-readable source element description used to obtain the permission to interact with the element
- `startRef` (string): Exact source element reference from the page snapshot
- - `endElement` (string): Human-readable target element description used to obtain permission to interact with the element
+ - `endElement` (string): Human-readable target element description used to obtain the permission to interact with the element
- `endRef` (string): Exact target element reference from the page snapshot
+ - Read-only: **false**
+
+
+
+- **browser_hover**
+ - Title: Hover mouse
+ - 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**
+
+
- **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): Whether to submit entered text (press Enter after)
+ - `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**
+
+
- **browser_select_option**
- - Description: Select option in a dropdown
+ - 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.
+ - `values` (array): Array of values to select in the dropdown. This can be a single value or multiple values.
+ - Read-only: **false**
-- **browser_choose_file**
- - Description: Choose one or multiple files to upload
- - Parameters:
- - `paths` (array): The absolute paths to the files to upload. Can be a single file or multiple files.
+
- **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**
-- **browser_snapshot**
- - Description: Capture accessibility snapshot of the current page (better than screenshot)
- - Parameters: None
-
-- **browser_save_as_pdf**
- - Description: Save page as PDF
- - Parameters: None
+
-- **browser_take_screenshot**
- - Description: Capture screenshot of the page
+- **browser_wait_for**
+ - Title: Wait for
+ - Description: Wait for text to appear or disappear or a specified time to pass
- Parameters:
- - `raw` (string): Optionally returns lossless PNG screenshot. JPEG by default.
+ - `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**
+
+
-- **browser_wait**
- - Description: Wait for a specified time in seconds
+- **browser_file_upload**
+ - Title: Upload files
+ - Description: Upload one or multiple files
- Parameters:
- - `time` (number): The time to wait in seconds (capped at 10 seconds)
+ - `paths` (array): The absolute paths to the files to upload. Can be a single file or multiple files.
+ - Read-only: **false**
-- **browser_close**
- - Description: Close the page
- - Parameters: None
+
+
+- **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**
+
-### Vision Mode
+
+Navigation
-Vision Mode provides tools for visual-based interactions using screenshots. Here are all available tools:
+
- **browser_navigate**
+ - Title: Navigate to a URL
- Description: Navigate to a URL
- Parameters:
- `url` (string): The URL to navigate to
+ - Read-only: **false**
+
+
-- **browser_go_back**
+- **browser_navigate_back**
+ - Title: Go back
- Description: Go back to the previous page
- Parameters: None
+ - Read-only: **true**
-- **browser_go_forward**
+
+
+- **browser_navigate_forward**
+ - Title: Go forward
- Description: Go forward to the next page
- Parameters: None
+ - Read-only: **true**
+
+
+
+
+Resources
-- **browser_screenshot**
- - Description: Capture screenshot of the current page
+
+
+- **browser_take_screenshot**
+ - Title: Take a screenshot
+ - Description: Take a screenshot of the current page. You can't perform actions based on the screenshot, use browser_snapshot for actions.
+ - Parameters:
+ - `raw` (boolean, optional): Whether to return without compression (in PNG format). Default is false, which returns a JPEG image.
+ - `filename` (string, optional): File name to save the screenshot to. Defaults to `page-{timestamp}.{png|jpeg}` if not specified.
+ - `element` (string, optional): Human-readable element description used to obtain permission to screenshot the element. If not provided, the screenshot will be taken of viewport. If element is provided, ref must be provided too.
+ - `ref` (string, optional): Exact target element reference from the page snapshot. If not provided, the screenshot will be taken of viewport. If ref is provided, element must be provided too.
+ - Read-only: **true**
+
+
+
+- **browser_pdf_save**
+ - Title: Save as PDF
+ - Description: Save page as PDF
+ - Parameters:
+ - `filename` (string, optional): File name to save the pdf to. Defaults to `page-{timestamp}.pdf` if not specified.
+ - Read-only: **true**
+
+
+
+- **browser_network_requests**
+ - Title: List network requests
+ - Description: Returns all network requests since loading the page
- Parameters: None
+ - Read-only: **true**
+
+
+
+- **browser_console_messages**
+ - Title: Get console messages
+ - Description: Returns all console messages
+ - Parameters: None
+ - Read-only: **true**
+
+
+
+
+Utilities
-- **browser_move_mouse**
- - Description: Move mouse to specified coordinates
+
+
+- **browser_install**
+ - Title: Install the browser specified in the config
+ - Description: Install the browser specified in the config. Call this if you get an error about the browser not being installed.
+ - Parameters: None
+ - Read-only: **false**
+
+
+
+- **browser_close**
+ - Title: Close browser
+ - Description: Close the page
+ - Parameters: None
+ - Read-only: **true**
+
+
+
+- **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**
+
+
+
+
+Tabs
+
+
+
+- **browser_tab_list**
+ - Title: List tabs
+ - Description: List browser tabs
+ - Parameters: None
+ - Read-only: **true**
+
+
+
+- **browser_tab_new**
+ - Title: Open a new tab
+ - Description: Open a new tab
+ - Parameters:
+ - `url` (string, optional): The URL to navigate to in the new tab. If not provided, the new tab will be blank.
+ - Read-only: **true**
+
+
+
+- **browser_tab_select**
+ - Title: Select a tab
+ - Description: Select a tab by index
+ - Parameters:
+ - `index` (number): The index of the tab to select
+ - Read-only: **true**
+
+
+
+- **browser_tab_close**
+ - Title: Close a tab
+ - Description: Close a tab
+ - Parameters:
+ - `index` (number, optional): The index of the tab to close. Closes current tab if not provided.
+ - Read-only: **false**
+
+
+
+
+Testing
+
+
+
+- **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**
+
+
+
+
+Vision mode
+
+
+
+- **browser_screen_capture**
+ - Title: Take a screenshot
+ - Description: Take a screenshot of the current page
+ - Parameters: None
+ - Read-only: **true**
+
+
+
+- **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**
-- **browser_click**
- - Description: Click at specified coordinates
+
+
+- **browser_screen_click**
+ - Title: Click
+ - Description: Click left mouse button
- Parameters:
- - `x` (number): X coordinate to click at
- - `y` (number): Y coordinate to click at
+ - `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: **false**
-- **browser_drag**
- - Description: Perform drag and drop operation
+
+
+- **browser_screen_drag**
+ - Title: Drag mouse
+ - Description: Drag left mouse button
- Parameters:
+ - `element` (string): Human-readable element description used to obtain permission to interact with the element
- `startX` (number): Start X coordinate
- `startY` (number): Start Y coordinate
- `endX` (number): End X coordinate
- `endY` (number): End Y coordinate
+ - Read-only: **false**
-- **browser_type**
- - Description: Type text at specified coordinates
+
+
+- **browser_screen_type**
+ - Title: Type text
+ - Description: Type text
- Parameters:
- - `text` (string): Text to type
- - `submit` (boolean): Whether to submit entered text (press Enter after)
+ - `text` (string): Text to type into the element
+ - `submit` (boolean, optional): Whether to submit entered text (press Enter after)
+ - Read-only: **false**
+
+
- **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**
-- **browser_choose_file**
- - Description: Choose one or multiple files to upload
+
+
+- **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**
+
+
+
+- **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**
-- **browser_save_as_pdf**
- - Description: Save page as PDF
- - Parameters: None
+
-- **browser_wait**
- - Description: Wait for a specified time in seconds
+- **browser_handle_dialog**
+ - Title: Handle a dialog
+ - Description: Handle a dialog
- Parameters:
- - `time` (number): The time to wait in seconds (capped at 10 seconds)
+ - `accept` (boolean): Whether to accept the dialog.
+ - `promptText` (string, optional): The text of the prompt in case of a prompt dialog.
+ - Read-only: **false**
-- **browser_close**
- - Description: Close the page
- - Parameters: None
+
+
+
+
diff --git a/cli.js b/cli.js
index 95d4dbd..bbda09e 100755
--- a/cli.js
+++ b/cli.js
@@ -15,4 +15,4 @@
* limitations under the License.
*/
-require('./lib/program');
+import './lib/program.js';
diff --git a/config.d.ts b/config.d.ts
new file mode 100644
index 0000000..a935918
--- /dev/null
+++ b/config.d.ts
@@ -0,0 +1,128 @@
+/**
+ * Copyright (c) Microsoft Corporation.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import type * as playwright from 'playwright';
+
+export type ToolCapability = 'core' | 'tabs' | 'pdf' | 'history' | 'wait' | 'files' | 'install' | 'testing';
+
+export type Config = {
+ /**
+ * The browser to use.
+ */
+ browser?: {
+ /**
+ * Use browser agent (experimental).
+ */
+ browserAgent?: string;
+
+ /**
+ * The type of browser to use.
+ */
+ browserName?: 'chromium' | 'firefox' | 'webkit';
+
+ /**
+ * Keep the browser profile in memory, do not save it to disk.
+ */
+ isolated?: boolean;
+
+ /**
+ * Path to a user data directory for browser profile persistence.
+ * Temporary directory is created by default.
+ */
+ userDataDir?: string;
+
+ /**
+ * Launch options passed to
+ * @see https://playwright.dev/docs/api/class-browsertype#browser-type-launch-persistent-context
+ *
+ * This is useful for settings options like `channel`, `headless`, `executablePath`, etc.
+ */
+ launchOptions?: playwright.LaunchOptions;
+
+ /**
+ * Context options for the browser context.
+ *
+ * This is useful for settings options like `viewport`.
+ */
+ contextOptions?: playwright.BrowserContextOptions;
+
+ /**
+ * Chrome DevTools Protocol endpoint to connect to an existing browser instance in case of Chromium family browsers.
+ */
+ cdpEndpoint?: string;
+
+ /**
+ * Remote endpoint to connect to an existing Playwright server.
+ */
+ remoteEndpoint?: string;
+ },
+
+ server?: {
+ /**
+ * The port to listen on for SSE or MCP transport.
+ */
+ port?: number;
+
+ /**
+ * The host to bind the server to. Default is localhost. Use 0.0.0.0 to bind to all interfaces.
+ */
+ host?: string;
+ },
+
+ /**
+ * List of enabled tool capabilities. Possible values:
+ * - 'core': Core browser automation features.
+ * - 'tabs': Tab management features.
+ * - 'pdf': PDF generation and manipulation.
+ * - 'history': Browser history access.
+ * - 'wait': Wait and timing utilities.
+ * - 'files': File upload/download support.
+ * - 'install': Browser installation utilities.
+ */
+ 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.
+ */
+ saveTrace?: boolean;
+
+ /**
+ * The directory to save output files.
+ */
+ outputDir?: string;
+
+ network?: {
+ /**
+ * List of origins to allow the browser to request. Default is to allow all. Origins matching both `allowedOrigins` and `blockedOrigins` will be blocked.
+ */
+ allowedOrigins?: string[];
+
+ /**
+ * List of origins to block the browser to request. Origins matching both `allowedOrigins` and `blockedOrigins` will be blocked.
+ */
+ blockedOrigins?: string[];
+ };
+
+ /**
+ * 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' | 'auto';
+};
diff --git a/eslint.config.mjs b/eslint.config.mjs
index abedf47..eda1eff 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -33,6 +33,8 @@ const plugins = {
};
export const baseRules = {
+ "import/extensions": ["error", "ignorePackages", {ts: "always"}],
+ "@typescript-eslint/no-floating-promises": "error",
"@typescript-eslint/no-unused-vars": [
2,
{ args: "none", caughtErrors: "none" },
@@ -178,12 +180,16 @@ export const baseRules = {
// react
"react/react-in-jsx-scope": 0,
+ "no-console": 2,
};
const languageOptions = {
parser: tsParser,
ecmaVersion: 9,
sourceType: "module",
+ parserOptions: {
+ project: path.join(fileURLToPath(import.meta.url), "..", "tsconfig.all.json"),
+ }
};
export default [
diff --git a/examples/generate-test.md b/examples/generate-test.md
new file mode 100644
index 0000000..b6d8dd8
--- /dev/null
+++ b/examples/generate-test.md
@@ -0,0 +1,10 @@
+Use Playwright tools to generate test for scenario:
+
+## GitHub PR Checks Navigation Checklist
+
+1. Open the [Microsoft Playwright GitHub repository](https://github.com/microsoft/playwright).
+2. Click on the **Pull requests** tab.
+3. Find and open the pull request titled **"chore: make noWaitAfter a default"**.
+4. Switch to the **Checks** tab for that pull request.
+5. Expand the **infra** check suite to view its jobs.
+6. Click on the **docs & lint** job to view its details.
diff --git a/index.d.ts b/index.d.ts
index c315dfe..551ed55 100644
--- a/index.d.ts
+++ b/index.d.ts
@@ -15,26 +15,16 @@
* limitations under the License.
*/
-import type { LaunchOptions } from 'playwright';
import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
+import type { Config } from './config';
+import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
+import type { BrowserContext } from 'playwright';
-type Options = {
- /**
- * Path to the user data directory.
- */
- userDataDir?: string;
-
- /**
- * Launch options for the browser.
- */
- launchOptions?: LaunchOptions;
-
- /**
- * Use screenshots instead of snapshots. Less accurate, reliable and overall
- * slower, but contains visual representation of the page.
- * @default false
- */
- vision?: boolean;
+export type Connection = {
+ server: Server;
+ connect(transport: Transport): Promise;
+ close(): Promise;
};
-export function createServer(options?: Options): Server;
+export declare function createConnection(config?: Config, contextGetter?: () => Promise): Promise;
+export {};
diff --git a/index.js b/index.js
index faf60b5..69f7ae2 100755
--- a/index.js
+++ b/index.js
@@ -15,5 +15,5 @@
* limitations under the License.
*/
-const { createServer } = require('./lib/index');
-module.exports = { createServer };
+import { createConnection } from './lib/index.js';
+export { createConnection };
diff --git a/package-lock.json b/package-lock.json
index 708cc91..c556e4f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,17 +1,19 @@
{
"name": "@playwright/mcp",
- "version": "0.0.7",
+ "version": "0.0.28",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@playwright/mcp",
- "version": "0.0.7",
+ "version": "0.0.28",
"license": "Apache-2.0",
"dependencies": {
- "@modelcontextprotocol/sdk": "^1.6.1",
+ "@modelcontextprotocol/sdk": "^1.11.0",
"commander": "^13.1.0",
- "playwright": "1.52.0-alpha-1743011787000",
+ "debug": "^4.4.1",
+ "mime": "^4.0.7",
+ "playwright": "1.53.0-alpha-2025-05-27",
"zod-to-json-schema": "^3.24.4"
},
"bin": {
@@ -20,8 +22,9 @@
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.19.0",
- "@playwright/test": "1.52.0-alpha-1743011787000",
+ "@playwright/test": "1.53.0-alpha-2025-05-27",
"@stylistic/eslint-plugin": "^3.0.1",
+ "@types/debug": "^4.1.12",
"@types/node": "^22.13.10",
"@typescript-eslint/eslint-plugin": "^8.26.1",
"@typescript-eslint/parser": "^8.26.1",
@@ -227,17 +230,18 @@
}
},
"node_modules/@modelcontextprotocol/sdk": {
- "version": "1.7.0",
- "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.7.0.tgz",
- "integrity": "sha512-IYPe/FLpvF3IZrd/f5p5ffmWhMc3aEMuM2wGJASDqC2Ge7qatVCdbfPx3n/5xFeb19xN0j/911M2AaFuircsWA==",
+ "version": "1.11.0",
+ "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.11.0.tgz",
+ "integrity": "sha512-k/1pb70eD638anoi0e8wUGAlbMJXyvdV4p62Ko+EZ7eBe1xMx8Uhak1R5DgfoofsK5IBBnRwsYGTaLZl+6/+RQ==",
"license": "MIT",
"dependencies": {
"content-type": "^1.0.5",
"cors": "^2.8.5",
+ "cross-spawn": "^7.0.3",
"eventsource": "^3.0.2",
"express": "^5.0.1",
"express-rate-limit": "^7.5.0",
- "pkce-challenge": "^4.1.0",
+ "pkce-challenge": "^5.0.0",
"raw-body": "^3.0.0",
"zod": "^3.23.8",
"zod-to-json-schema": "^3.24.1"
@@ -285,13 +289,13 @@
}
},
"node_modules/@playwright/test": {
- "version": "1.52.0-alpha-1743011787000",
- "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.52.0-alpha-1743011787000.tgz",
- "integrity": "sha512-ikJR8JXof5IBvErrmIsR3ixov4nKlQe/6PSYK/R6eTEe6eoT+eEXlaNY4z6mn9dF02Z1zYGxzAbb8TvSvuwh4Q==",
+ "version": "1.53.0-alpha-2025-05-27",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.53.0-alpha-2025-05-27.tgz",
+ "integrity": "sha512-G2zG56kEQOWhk3nQyPKH5u41jyQw5jx+Kga5huUi7RjBjPEnNtiCMNXMNGCh6dDYCIyQkLJvz/o1H/QN26HLsg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
- "playwright": "1.52.0-alpha-1743011787000"
+ "playwright": "1.53.0-alpha-2025-05-27"
},
"bin": {
"playwright": "cli.js"
@@ -353,6 +357,16 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
+ "node_modules/@types/debug": {
+ "version": "4.1.12",
+ "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
+ "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/ms": "*"
+ }
+ },
"node_modules/@types/estree": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",
@@ -374,8 +388,17 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/ms": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
+ "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/node": {
"version": "22.13.10",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.10.tgz",
+ "integrity": "sha512-I6LPUvlRH+O6VRUqYOcMudhaIdUVWfsjnZavnsraHvpBwaEyMN29ry+0UVJhImYL16xsscu0aske3yA+uPOWfw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -831,16 +854,16 @@
"license": "MIT"
},
"node_modules/body-parser": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.1.0.tgz",
- "integrity": "sha512-/hPxh61E+ll0Ujp24Ilm64cykicul1ypfwjVttduAiEdtnJFvLePSrIPk+HMImtNv5270wOGCb1Tns2rybMkoQ==",
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz",
+ "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==",
"license": "MIT",
"dependencies": {
"bytes": "^3.1.2",
"content-type": "^1.0.5",
"debug": "^4.4.0",
"http-errors": "^2.0.0",
- "iconv-lite": "^0.5.2",
+ "iconv-lite": "^0.6.3",
"on-finished": "^2.4.1",
"qs": "^6.14.0",
"raw-body": "^3.0.0",
@@ -850,44 +873,6 @@
"node": ">=18"
}
},
- "node_modules/body-parser/node_modules/debug": {
- "version": "4.4.0",
- "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
- "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
- "license": "MIT",
- "dependencies": {
- "ms": "^2.1.3"
- },
- "engines": {
- "node": ">=6.0"
- },
- "peerDependenciesMeta": {
- "supports-color": {
- "optional": true
- }
- }
- },
- "node_modules/body-parser/node_modules/ms": {
- "version": "2.1.3",
- "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
- "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
- "license": "MIT"
- },
- "node_modules/body-parser/node_modules/qs": {
- "version": "6.14.0",
- "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
- "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
- "license": "BSD-3-Clause",
- "dependencies": {
- "side-channel": "^1.1.0"
- },
- "engines": {
- "node": ">=0.6"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
"node_modules/brace-expansion": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
@@ -1018,6 +1003,8 @@
},
"node_modules/commander": {
"version": "13.1.0",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz",
+ "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==",
"license": "MIT",
"engines": {
"node": ">=18"
@@ -1086,7 +1073,6 @@
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
- "dev": true,
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
@@ -1152,12 +1138,12 @@
}
},
"node_modules/debug": {
- "version": "4.3.6",
- "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz",
- "integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==",
+ "version": "4.4.1",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
+ "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
"license": "MIT",
"dependencies": {
- "ms": "2.1.2"
+ "ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
@@ -1220,16 +1206,6 @@
"node": ">= 0.8"
}
},
- "node_modules/destroy": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
- "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
- "license": "MIT",
- "engines": {
- "node": ">= 0.8",
- "npm": "1.2.8000 || >= 1.4.16"
- }
- },
"node_modules/doctrine": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
@@ -1765,46 +1741,45 @@
}
},
"node_modules/express": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/express/-/express-5.0.1.tgz",
- "integrity": "sha512-ORF7g6qGnD+YtUG9yx4DFoqCShNMmUKiXuT5oWMHiOvt/4WFbHC6yCwQMTSBMno7AqntNCAzzcnnjowRkTL9eQ==",
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz",
+ "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==",
"license": "MIT",
"dependencies": {
"accepts": "^2.0.0",
- "body-parser": "^2.0.1",
+ "body-parser": "^2.2.0",
"content-disposition": "^1.0.0",
- "content-type": "~1.0.4",
- "cookie": "0.7.1",
+ "content-type": "^1.0.5",
+ "cookie": "^0.7.1",
"cookie-signature": "^1.2.1",
- "debug": "4.3.6",
- "depd": "2.0.0",
- "encodeurl": "~2.0.0",
- "escape-html": "~1.0.3",
- "etag": "~1.8.1",
- "finalhandler": "^2.0.0",
- "fresh": "2.0.0",
- "http-errors": "2.0.0",
+ "debug": "^4.4.0",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "etag": "^1.8.1",
+ "finalhandler": "^2.1.0",
+ "fresh": "^2.0.0",
+ "http-errors": "^2.0.0",
"merge-descriptors": "^2.0.0",
- "methods": "~1.1.2",
"mime-types": "^3.0.0",
- "on-finished": "2.4.1",
- "once": "1.4.0",
- "parseurl": "~1.3.3",
- "proxy-addr": "~2.0.7",
- "qs": "6.13.0",
- "range-parser": "~1.2.1",
- "router": "^2.0.0",
- "safe-buffer": "5.2.1",
+ "on-finished": "^2.4.1",
+ "once": "^1.4.0",
+ "parseurl": "^1.3.3",
+ "proxy-addr": "^2.0.7",
+ "qs": "^6.14.0",
+ "range-parser": "^1.2.1",
+ "router": "^2.2.0",
"send": "^1.1.0",
- "serve-static": "^2.1.0",
- "setprototypeof": "1.2.0",
- "statuses": "2.0.1",
- "type-is": "^2.0.0",
- "utils-merge": "1.0.1",
- "vary": "~1.1.2"
+ "serve-static": "^2.2.0",
+ "statuses": "^2.0.1",
+ "type-is": "^2.0.1",
+ "vary": "^1.1.2"
},
"engines": {
"node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
}
},
"node_modules/express-rate-limit": {
@@ -1926,29 +1901,6 @@
"node": ">= 0.8"
}
},
- "node_modules/finalhandler/node_modules/debug": {
- "version": "4.4.0",
- "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
- "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
- "license": "MIT",
- "dependencies": {
- "ms": "^2.1.3"
- },
- "engines": {
- "node": ">=6.0"
- },
- "peerDependenciesMeta": {
- "supports-color": {
- "optional": true
- }
- }
- },
- "node_modules/finalhandler/node_modules/ms": {
- "version": "2.1.3",
- "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
- "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
- "license": "MIT"
- },
"node_modules/find-root": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz",
@@ -2308,12 +2260,12 @@
}
},
"node_modules/iconv-lite": {
- "version": "0.5.2",
- "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.5.2.tgz",
- "integrity": "sha512-kERHXvpSaB4aU3eANwidg79K8FlrN77m8G9V+0vOR3HYaRifrlwMEpT7ZBJqLSEIHnEgJTHcWK82wwLwwKwtag==",
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
+ "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"license": "MIT",
"dependencies": {
- "safer-buffer": ">= 2.1.2 < 3"
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
@@ -2781,7 +2733,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
- "dev": true,
"license": "ISC"
},
"node_modules/js-yaml": {
@@ -2925,15 +2876,6 @@
"node": ">= 8"
}
},
- "node_modules/methods": {
- "version": "1.1.2",
- "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
- "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
- "license": "MIT",
- "engines": {
- "node": ">= 0.6"
- }
- },
"node_modules/metric-lcs": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/metric-lcs/-/metric-lcs-0.1.2.tgz",
@@ -2955,6 +2897,21 @@
"node": ">=8.6"
}
},
+ "node_modules/mime": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/mime/-/mime-4.0.7.tgz",
+ "integrity": "sha512-2OfDPL+e03E0LrXaGYOtTFIYhiuzep94NSsuhrNULq+stylcJedcHdzHtz0atMUuGwJfFYs0YL5xeC/Ca2x0eQ==",
+ "funding": [
+ "https://github.com/sponsors/broofa"
+ ],
+ "license": "MIT",
+ "bin": {
+ "mime": "bin/cli.js"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
"node_modules/mime-db": {
"version": "1.54.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
@@ -2965,12 +2922,12 @@
}
},
"node_modules/mime-types": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.0.tgz",
- "integrity": "sha512-XqoSHeCGjVClAmoGFG3lVFqQFRIrTVw2OH3axRqAcfaw+gHWIfnASS92AV+Rl/mk0MupgZTRHQOjxY6YVnzK5w==",
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz",
+ "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==",
"license": "MIT",
"dependencies": {
- "mime-db": "^1.53.0"
+ "mime-db": "^1.54.0"
},
"engines": {
"node": ">= 0.6"
@@ -3000,9 +2957,9 @@
}
},
"node_modules/ms": {
- "version": "2.1.2",
- "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
- "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/natural-compare": {
@@ -3251,7 +3208,6 @@
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -3287,21 +3243,21 @@
}
},
"node_modules/pkce-challenge": {
- "version": "4.1.0",
- "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-4.1.0.tgz",
- "integrity": "sha512-ZBmhE1C9LcPoH9XZSdwiPtbPHZROwAnMy+kIFQVrnMCxY4Cudlz3gBOpzilgc0jOgRaiT3sIWfpMomW2ar2orQ==",
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.0.tgz",
+ "integrity": "sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==",
"license": "MIT",
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/playwright": {
- "version": "1.52.0-alpha-1743011787000",
- "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.52.0-alpha-1743011787000.tgz",
- "integrity": "sha512-wg9Tu4ZDKJWo7hBKpeuD/XLtLOQ7fCCuBfekgUrPLStA12O3224E1fbp/xGFnmi47SF71Y8F6C2Beyd3gYFWlQ==",
+ "version": "1.53.0-alpha-2025-05-27",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.53.0-alpha-2025-05-27.tgz",
+ "integrity": "sha512-CD0BTwV5javEJ3hf3rhFJEvR3ZoWsu4HUQFfLH2mtVVe+grGPCP55FnlOjpDnJ5pP4Kibe/ZcmgPDg56ic/y9g==",
"license": "Apache-2.0",
"dependencies": {
- "playwright-core": "1.52.0-alpha-1743011787000"
+ "playwright-core": "1.53.0-alpha-2025-05-27"
},
"bin": {
"playwright": "cli.js"
@@ -3314,9 +3270,9 @@
}
},
"node_modules/playwright-core": {
- "version": "1.52.0-alpha-1743011787000",
- "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.52.0-alpha-1743011787000.tgz",
- "integrity": "sha512-yOpMfKxTBRqdm50b52cojvTCNttWN+Xk6LXF+KU4ufcGwcRjUud1xdHmHHvQNFFanXM1MBYnDKsMkRvjPsuYOw==",
+ "version": "1.53.0-alpha-2025-05-27",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.53.0-alpha-2025-05-27.tgz",
+ "integrity": "sha512-uVxs7YjENoBMFyQhsZWImIBuo/oX7Mu63djhQN3qFz/NdXA/rOAnP73XzfB+VJNwRMKgIOtqHQgjOG3Rl/lm0A==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
@@ -3369,12 +3325,12 @@
}
},
"node_modules/qs": {
- "version": "6.13.0",
- "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
- "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==",
+ "version": "6.14.0",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
+ "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
"license": "BSD-3-Clause",
"dependencies": {
- "side-channel": "^1.0.6"
+ "side-channel": "^1.1.0"
},
"engines": {
"node": ">=0.6"
@@ -3428,18 +3384,6 @@
"node": ">= 0.8"
}
},
- "node_modules/raw-body/node_modules/iconv-lite": {
- "version": "0.6.3",
- "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
- "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
- "license": "MIT",
- "dependencies": {
- "safer-buffer": ">= 2.1.2 < 3.0.0"
- },
- "engines": {
- "node": ">=0.10.0"
- }
- },
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -3527,11 +3471,13 @@
}
},
"node_modules/router": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/router/-/router-2.1.0.tgz",
- "integrity": "sha512-/m/NSLxeYEgWNtyC+WtNHCF7jbGxOibVWKnn+1Psff4dJGOfoXP+MuC/f2CwSmyiHdOIzYnYFp4W6GxWfekaLA==",
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
+ "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
"license": "MIT",
"dependencies": {
+ "debug": "^4.4.0",
+ "depd": "^2.0.0",
"is-promise": "^4.0.0",
"parseurl": "^1.3.3",
"path-to-regexp": "^8.0.0"
@@ -3659,19 +3605,18 @@
}
},
"node_modules/send": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/send/-/send-1.1.0.tgz",
- "integrity": "sha512-v67WcEouB5GxbTWL/4NeToqcZiAWEq90N888fczVArY8A79J0L4FD7vj5hm3eUMua5EpoQ59wa/oovY6TLvRUA==",
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz",
+ "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==",
"license": "MIT",
"dependencies": {
"debug": "^4.3.5",
- "destroy": "^1.2.0",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"etag": "^1.8.1",
- "fresh": "^0.5.2",
+ "fresh": "^2.0.0",
"http-errors": "^2.0.0",
- "mime-types": "^2.1.35",
+ "mime-types": "^3.0.1",
"ms": "^2.1.3",
"on-finished": "^2.4.1",
"range-parser": "^1.2.1",
@@ -3681,52 +3626,16 @@
"node": ">= 18"
}
},
- "node_modules/send/node_modules/fresh": {
- "version": "0.5.2",
- "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
- "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
- "license": "MIT",
- "engines": {
- "node": ">= 0.6"
- }
- },
- "node_modules/send/node_modules/mime-db": {
- "version": "1.52.0",
- "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
- "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
- "license": "MIT",
- "engines": {
- "node": ">= 0.6"
- }
- },
- "node_modules/send/node_modules/mime-types": {
- "version": "2.1.35",
- "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
- "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
- "license": "MIT",
- "dependencies": {
- "mime-db": "1.52.0"
- },
- "engines": {
- "node": ">= 0.6"
- }
- },
- "node_modules/send/node_modules/ms": {
- "version": "2.1.3",
- "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
- "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
- "license": "MIT"
- },
"node_modules/serve-static": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.1.0.tgz",
- "integrity": "sha512-A3We5UfEjG8Z7VkDv6uItWw6HY2bBSBJT1KtVESn6EOoOr2jAxNhxWCLY3jDE2WcuHXByWju74ck3ZgLwL8xmA==",
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz",
+ "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==",
"license": "MIT",
"dependencies": {
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"parseurl": "^1.3.3",
- "send": "^1.0.0"
+ "send": "^1.2.0"
},
"engines": {
"node": ">= 18"
@@ -3791,7 +3700,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
- "dev": true,
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
@@ -3804,7 +3712,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -4061,9 +3968,9 @@
}
},
"node_modules/type-is": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.0.tgz",
- "integrity": "sha512-gd0sGezQYCbWSbkZr75mln4YBidWUN60+devscpLF5mtRDUpiaTvKpBNrdaCvel1NdR2k6vclXybU5fBd2i+nw==",
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
+ "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
"license": "MIT",
"dependencies": {
"content-type": "^1.0.5",
@@ -4187,6 +4094,8 @@
},
"node_modules/undici-types": {
"version": "6.20.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",
+ "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==",
"dev": true,
"license": "MIT"
},
@@ -4209,15 +4118,6 @@
"punycode": "^2.1.0"
}
},
- "node_modules/utils-merge": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
- "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
- "license": "MIT",
- "engines": {
- "node": ">= 0.4.0"
- }
- },
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
@@ -4231,7 +4131,6 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
- "dev": true,
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
@@ -4363,6 +4262,8 @@
},
"node_modules/zod": {
"version": "3.24.2",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.2.tgz",
+ "integrity": "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
@@ -4370,6 +4271,8 @@
},
"node_modules/zod-to-json-schema": {
"version": "3.24.4",
+ "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.4.tgz",
+ "integrity": "sha512-0uNlcvgabyrni9Ag8Vghj21drk7+7tp7VTwwR7KxxXXc/3pbXz2PHlDgj3cICahgF1kHm4dExBFj7BXrZJXzig==",
"license": "ISC",
"peerDependencies": {
"zod": "^3.24.1"
diff --git a/package.json b/package.json
index c0da749..4492291 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,8 @@
{
"name": "@playwright/mcp",
- "version": "0.0.7",
+ "version": "0.0.28",
"description": "Playwright Tools for MCP",
+ "type": "module",
"repository": {
"type": "git",
"url": "git+https://github.com/microsoft/playwright-mcp.git"
@@ -16,9 +17,13 @@
"license": "Apache-2.0",
"scripts": {
"build": "tsc",
- "lint": "eslint .",
+ "lint": "npm run update-readme && eslint . && tsc --noEmit",
+ "update-readme": "node utils/update-readme.js",
"watch": "tsc --watch",
"test": "playwright test",
+ "ctest": "playwright test --project=chrome",
+ "ftest": "playwright test --project=firefox",
+ "wtest": "playwright test --project=webkit",
"clean": "rm -rf lib",
"npm-publish": "npm run clean && npm run build && npm run test && npm publish"
},
@@ -30,20 +35,23 @@
}
},
"dependencies": {
- "@modelcontextprotocol/sdk": "^1.6.1",
+ "@modelcontextprotocol/sdk": "^1.11.0",
"commander": "^13.1.0",
- "playwright": "1.52.0-alpha-1743011787000",
+ "debug": "^4.4.1",
+ "mime": "^4.0.7",
+ "playwright": "1.53.0-alpha-2025-05-27",
"zod-to-json-schema": "^3.24.4"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.19.0",
- "@playwright/test": "1.52.0-alpha-1743011787000",
+ "@playwright/test": "1.53.0-alpha-2025-05-27",
"@stylistic/eslint-plugin": "^3.0.1",
+ "@types/debug": "^4.1.12",
+ "@types/node": "^22.13.10",
"@typescript-eslint/eslint-plugin": "^8.26.1",
"@typescript-eslint/parser": "^8.26.1",
"@typescript-eslint/utils": "^8.26.1",
- "@types/node": "^22.13.10",
"eslint": "^9.19.0",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-notice": "^1.0.0",
diff --git a/playwright.config.ts b/playwright.config.ts
index dfd128d..9c8ba59 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -16,12 +16,28 @@
import { defineConfig } from '@playwright/test';
-export default defineConfig({
+import type { TestOptions } from './tests/fixtures.js';
+
+export default defineConfig({
testDir: './tests',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'list',
- projects: [{ name: 'default' }],
+ projects: [
+ { name: 'chrome' },
+ { name: 'msedge', use: { mcpBrowser: 'msedge' } },
+ { name: 'chromium', use: { mcpBrowser: 'chromium' } },
+ ...process.env.MCP_IN_DOCKER ? [{
+ name: 'chromium-docker',
+ grep: /browser_navigate|browser_click/,
+ use: {
+ mcpBrowser: 'chromium',
+ mcpMode: 'docker' as const
+ }
+ }] : [],
+ { name: 'firefox', use: { mcpBrowser: 'firefox' } },
+ { name: 'webkit', use: { mcpBrowser: 'webkit' } },
+ ],
});
diff --git a/src/browserAgent.ts b/src/browserAgent.ts
new file mode 100644
index 0000000..67ec6f7
--- /dev/null
+++ b/src/browserAgent.ts
@@ -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 Agent {
+ 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(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 {
+ 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 agent 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 to listen on', '9224')
+ .action(async options => {
+ await main(options);
+ });
+
+void program.parseAsync(process.argv);
+
+async function main(options: { port: string }) {
+ const agent = new Agent();
+ await agent.start(+options.port);
+}
+
+function readBody(req: http.IncomingMessage): Promise {
+ 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 {
+ 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);
+ });
+}
diff --git a/src/browserContextFactory.ts b/src/browserContextFactory.ts
new file mode 100644
index 0000000..92fbf18
--- /dev/null
+++ b/src/browserContextFactory.ts
@@ -0,0 +1,266 @@
+/**
+ * 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 net from 'node:net';
+import path from 'node:path';
+import os from 'node:os';
+
+import debug from 'debug';
+import * as playwright from 'playwright';
+import { userDataDir } from './fileUtils.js';
+
+import type { FullConfig } from './config.js';
+import type { BrowserInfo, LaunchBrowserRequest } from './browserAgent.js';
+
+const testDebug = debug('pw:mcp:test');
+
+export function contextFactory(browserConfig: FullConfig['browser']): BrowserContextFactory {
+ if (browserConfig.remoteEndpoint)
+ return new RemoteContextFactory(browserConfig);
+ if (browserConfig.cdpEndpoint)
+ return new CdpContextFactory(browserConfig);
+ if (browserConfig.isolated)
+ return new IsolatedContextFactory(browserConfig);
+ if (browserConfig.browserAgent)
+ return new AgentContextFactory(browserConfig);
+ return new PersistentContextFactory(browserConfig);
+}
+
+export interface BrowserContextFactory {
+ createContext(): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise }>;
+}
+
+class BaseContextFactory implements BrowserContextFactory {
+ readonly browserConfig: FullConfig['browser'];
+ protected _browserPromise: Promise | undefined;
+ readonly name: string;
+
+ constructor(name: string, browserConfig: FullConfig['browser']) {
+ this.name = name;
+ this.browserConfig = browserConfig;
+ }
+
+ protected async _obtainBrowser(): Promise {
+ if (this._browserPromise)
+ return this._browserPromise;
+ testDebug(`obtain browser (${this.name})`);
+ this._browserPromise = this._doObtainBrowser();
+ void this._browserPromise.then(browser => {
+ browser.on('disconnected', () => {
+ this._browserPromise = undefined;
+ });
+ }).catch(() => {
+ this._browserPromise = undefined;
+ });
+ return this._browserPromise;
+ }
+
+ protected async _doObtainBrowser(): Promise {
+ throw new Error('Not implemented');
+ }
+
+ async createContext(): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise }> {
+ testDebug(`create browser context (${this.name})`);
+ const browser = await this._obtainBrowser();
+ const browserContext = await this._doCreateContext(browser);
+ return { browserContext, close: () => this._closeBrowserContext(browserContext, browser) };
+ }
+
+ protected async _doCreateContext(browser: playwright.Browser): Promise {
+ throw new Error('Not implemented');
+ }
+
+ private async _closeBrowserContext(browserContext: playwright.BrowserContext, browser: playwright.Browser) {
+ testDebug(`close browser context (${this.name})`);
+ if (browser.contexts().length === 1)
+ this._browserPromise = undefined;
+ await browserContext.close().catch(() => {});
+ if (browser.contexts().length === 0) {
+ testDebug(`close browser (${this.name})`);
+ await browser.close().catch(() => {});
+ }
+ }
+}
+
+class IsolatedContextFactory extends BaseContextFactory {
+ constructor(browserConfig: FullConfig['browser']) {
+ super('isolated', browserConfig);
+ }
+
+ protected override async _doObtainBrowser(): Promise {
+ await injectCdpPort(this.browserConfig);
+ const browserType = playwright[this.browserConfig.browserName];
+ return browserType.launch({
+ ...this.browserConfig.launchOptions,
+ handleSIGINT: false,
+ handleSIGTERM: false,
+ }).catch(error => {
+ if (error.message.includes('Executable doesn\'t exist'))
+ throw new Error(`Browser specified in your config is not installed. Either install it (likely) or change the config.`);
+ throw error;
+ });
+ }
+
+ protected override async _doCreateContext(browser: playwright.Browser): Promise {
+ return browser.newContext(this.browserConfig.contextOptions);
+ }
+}
+
+class CdpContextFactory extends BaseContextFactory {
+ constructor(browserConfig: FullConfig['browser']) {
+ super('cdp', browserConfig);
+ }
+
+ protected override async _doObtainBrowser(): Promise {
+ return playwright.chromium.connectOverCDP(this.browserConfig.cdpEndpoint!);
+ }
+
+ protected override async _doCreateContext(browser: playwright.Browser): Promise {
+ return this.browserConfig.isolated ? await browser.newContext() : browser.contexts()[0];
+ }
+}
+
+class RemoteContextFactory extends BaseContextFactory {
+ constructor(browserConfig: FullConfig['browser']) {
+ super('remote', browserConfig);
+ }
+
+ protected override async _doObtainBrowser(): Promise {
+ const url = new URL(this.browserConfig.remoteEndpoint!);
+ url.searchParams.set('browser', this.browserConfig.browserName);
+ if (this.browserConfig.launchOptions)
+ url.searchParams.set('launch-options', JSON.stringify(this.browserConfig.launchOptions));
+ return playwright[this.browserConfig.browserName].connect(String(url));
+ }
+
+ protected override async _doCreateContext(browser: playwright.Browser): Promise {
+ return browser.newContext();
+ }
+}
+
+class PersistentContextFactory implements BrowserContextFactory {
+ readonly browserConfig: FullConfig['browser'];
+ private _userDataDirs = new Set();
+
+ constructor(browserConfig: FullConfig['browser']) {
+ this.browserConfig = browserConfig;
+ }
+
+ async createContext(): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise }> {
+ await injectCdpPort(this.browserConfig);
+ testDebug('create browser context (persistent)');
+ const userDataDir = this.browserConfig.userDataDir ?? await this._createUserDataDir();
+
+ this._userDataDirs.add(userDataDir);
+ testDebug('lock user data dir', userDataDir);
+
+ const browserType = playwright[this.browserConfig.browserName];
+ for (let i = 0; i < 5; i++) {
+ try {
+ const browserContext = await browserType.launchPersistentContext(userDataDir, {
+ ...this.browserConfig.launchOptions,
+ ...this.browserConfig.contextOptions,
+ handleSIGINT: false,
+ handleSIGTERM: false,
+ });
+ const close = () => this._closeBrowserContext(browserContext, userDataDir);
+ return { browserContext, close };
+ } catch (error: any) {
+ if (error.message.includes('Executable doesn\'t exist'))
+ throw new Error(`Browser specified in your config is not installed. Either install it (likely) or change the config.`);
+ if (error.message.includes('ProcessSingleton') || error.message.includes('Invalid URL')) {
+ // User data directory is already in use, try again.
+ await new Promise(resolve => setTimeout(resolve, 1000));
+ continue;
+ }
+ throw error;
+ }
+ }
+ throw new Error(`Browser is already in use for ${userDataDir}, use --isolated to run multiple instances of the same browser`);
+ }
+
+ private async _closeBrowserContext(browserContext: playwright.BrowserContext, userDataDir: string) {
+ testDebug('close browser context (persistent)');
+ testDebug('release user data dir', userDataDir);
+ await browserContext.close().catch(() => {});
+ this._userDataDirs.delete(userDataDir);
+ testDebug('close browser context complete (persistent)');
+ }
+
+ private async _createUserDataDir() {
+ let cacheDirectory: string;
+ if (process.platform === 'linux')
+ cacheDirectory = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
+ else if (process.platform === 'darwin')
+ cacheDirectory = path.join(os.homedir(), 'Library', 'Caches');
+ else if (process.platform === 'win32')
+ cacheDirectory = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
+ else
+ throw new Error('Unsupported platform: ' + process.platform);
+ const result = path.join(cacheDirectory, 'ms-playwright', `mcp-${this.browserConfig.launchOptions?.channel ?? this.browserConfig?.browserName}-profile`);
+ await fs.promises.mkdir(result, { recursive: true });
+ return result;
+ }
+}
+
+export class AgentContextFactory extends BaseContextFactory {
+ constructor(browserConfig: FullConfig['browser']) {
+ super('persistent', browserConfig);
+ }
+
+ protected override async _doObtainBrowser(): Promise {
+ 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 {
+ 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']) {
+ if (browserConfig.browserName === 'chromium')
+ (browserConfig.launchOptions as any).cdpPort = await findFreePort();
+}
+
+async function findFreePort() {
+ 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);
+ });
+}
diff --git a/src/config.ts b/src/config.ts
new file mode 100644
index 0000000..f25e5a2
--- /dev/null
+++ b/src/config.ts
@@ -0,0 +1,254 @@
+/**
+ * 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 os from 'os';
+import path from 'path';
+import { devices } from 'playwright';
+
+import type { Config, ToolCapability } from '../config.js';
+import type { BrowserContextOptions, LaunchOptions } from 'playwright';
+import { sanitizeForFilePath } from './tools/utils.js';
+
+export type CLIOptions = {
+ allowedOrigins?: string[];
+ blockedOrigins?: string[];
+ blockServiceWorkers?: boolean;
+ browser?: string;
+ browserAgent?: string;
+ caps?: string;
+ cdpEndpoint?: string;
+ config?: string;
+ device?: string;
+ executablePath?: string;
+ headless?: boolean;
+ host?: string;
+ ignoreHttpsErrors?: boolean;
+ isolated?: boolean;
+ imageResponses?: 'allow' | 'omit' | 'auto';
+ sandbox: boolean;
+ outputDir?: string;
+ port?: number;
+ proxyBypass?: string;
+ proxyServer?: string;
+ saveTrace?: boolean;
+ storageState?: string;
+ userAgent?: string;
+ userDataDir?: string;
+ viewportSize?: string;
+ vision?: boolean;
+};
+
+const defaultConfig: FullConfig = {
+ browser: {
+ browserName: 'chromium',
+ launchOptions: {
+ channel: 'chrome',
+ headless: os.platform() === 'linux' && !process.env.DISPLAY,
+ chromiumSandbox: true,
+ },
+ contextOptions: {
+ viewport: null,
+ },
+ },
+ network: {
+ allowedOrigins: undefined,
+ blockedOrigins: undefined,
+ },
+ server: {},
+ outputDir: path.join(os.tmpdir(), 'playwright-mcp-output', sanitizeForFilePath(new Date().toISOString())),
+};
+
+type BrowserUserConfig = NonNullable;
+
+export type FullConfig = Config & {
+ browser: Omit & {
+ browserName: 'chromium' | 'firefox' | 'webkit';
+ launchOptions: NonNullable;
+ contextOptions: NonNullable;
+ },
+ network: NonNullable,
+ outputDir: string;
+ server: NonNullable,
+};
+
+export async function resolveConfig(config: Config): Promise {
+ return mergeConfig(defaultConfig, config);
+}
+
+export async function resolveCLIConfig(cliOptions: CLIOptions): Promise {
+ const configInFile = await loadConfig(cliOptions.config);
+ const cliOverrides = await configFromCLIOptions(cliOptions);
+ const result = mergeConfig(mergeConfig(defaultConfig, configInFile), cliOverrides);
+ // Derive artifact output directory from config.outputDir
+ if (result.saveTrace)
+ result.browser.launchOptions.tracesDir = path.join(result.outputDir, 'traces');
+ return result;
+}
+
+export async function configFromCLIOptions(cliOptions: CLIOptions): Promise {
+ let browserName: 'chromium' | 'firefox' | 'webkit' | undefined;
+ let channel: string | undefined;
+ switch (cliOptions.browser) {
+ case 'chrome':
+ case 'chrome-beta':
+ case 'chrome-canary':
+ case 'chrome-dev':
+ case 'chromium':
+ case 'msedge':
+ case 'msedge-beta':
+ case 'msedge-canary':
+ case 'msedge-dev':
+ browserName = 'chromium';
+ channel = cliOptions.browser;
+ break;
+ case 'firefox':
+ browserName = 'firefox';
+ break;
+ case 'webkit':
+ browserName = 'webkit';
+ break;
+ }
+
+ // Launch options
+ const launchOptions: LaunchOptions = {
+ channel,
+ executablePath: cliOptions.executablePath,
+ headless: cliOptions.headless,
+ };
+
+ // --no-sandbox was passed, disable the sandbox
+ if (!cliOptions.sandbox)
+ launchOptions.chromiumSandbox = false;
+
+ if (cliOptions.proxyServer) {
+ launchOptions.proxy = {
+ server: cliOptions.proxyServer
+ };
+ if (cliOptions.proxyBypass)
+ launchOptions.proxy.bypass = cliOptions.proxyBypass;
+ }
+
+ // Context options
+ const contextOptions: BrowserContextOptions = cliOptions.device ? devices[cliOptions.device] : {};
+ if (cliOptions.storageState)
+ contextOptions.storageState = cliOptions.storageState;
+
+ if (cliOptions.userAgent)
+ contextOptions.userAgent = cliOptions.userAgent;
+
+ if (cliOptions.viewportSize) {
+ try {
+ const [width, height] = cliOptions.viewportSize.split(',').map(n => +n);
+ if (isNaN(width) || isNaN(height))
+ throw new Error('bad values');
+ contextOptions.viewport = { width, height };
+ } catch (e) {
+ throw new Error('Invalid viewport size format: use "width,height", for example --viewport-size="800,600"');
+ }
+ }
+
+ if (cliOptions.ignoreHttpsErrors)
+ contextOptions.ignoreHTTPSErrors = true;
+
+ if (cliOptions.blockServiceWorkers)
+ contextOptions.serviceWorkers = 'block';
+
+ const result: Config = {
+ browser: {
+ browserAgent: cliOptions.browserAgent ?? process.env.PW_BROWSER_AGENT,
+ browserName,
+ isolated: cliOptions.isolated,
+ userDataDir: cliOptions.userDataDir,
+ launchOptions,
+ contextOptions,
+ cdpEndpoint: cliOptions.cdpEndpoint,
+ },
+ server: {
+ port: cliOptions.port,
+ host: cliOptions.host,
+ },
+ capabilities: cliOptions.caps?.split(',').map((c: string) => c.trim() as ToolCapability),
+ vision: !!cliOptions.vision,
+ network: {
+ allowedOrigins: cliOptions.allowedOrigins,
+ blockedOrigins: cliOptions.blockedOrigins,
+ },
+ saveTrace: cliOptions.saveTrace,
+ outputDir: cliOptions.outputDir,
+ imageResponses: cliOptions.imageResponses,
+ };
+
+ return result;
+}
+
+async function loadConfig(configFile: string | undefined): Promise {
+ if (!configFile)
+ return {};
+
+ try {
+ return JSON.parse(await fs.promises.readFile(configFile, 'utf8'));
+ } catch (error) {
+ throw new Error(`Failed to load config file: ${configFile}, ${error}`);
+ }
+}
+
+export async function outputFile(config: FullConfig, name: string): Promise {
+ await fs.promises.mkdir(config.outputDir, { recursive: true });
+ const fileName = sanitizeForFilePath(name);
+ return path.join(config.outputDir, fileName);
+}
+
+function pickDefined(obj: T | undefined): Partial {
+ return Object.fromEntries(
+ Object.entries(obj ?? {}).filter(([_, v]) => v !== undefined)
+ ) as Partial;
+}
+
+function mergeConfig(base: FullConfig, overrides: Config): FullConfig {
+ const browser: FullConfig['browser'] = {
+ ...pickDefined(base.browser),
+ ...pickDefined(overrides.browser),
+ browserName: overrides.browser?.browserName ?? base.browser?.browserName ?? 'chromium',
+ isolated: overrides.browser?.isolated ?? base.browser?.isolated ?? false,
+ launchOptions: {
+ ...pickDefined(base.browser?.launchOptions),
+ ...pickDefined(overrides.browser?.launchOptions),
+ ...{ assistantMode: true },
+ },
+ contextOptions: {
+ ...pickDefined(base.browser?.contextOptions),
+ ...pickDefined(overrides.browser?.contextOptions),
+ },
+ };
+
+ if (browser.browserName !== 'chromium' && browser.launchOptions)
+ delete browser.launchOptions.channel;
+
+ return {
+ ...pickDefined(base),
+ ...pickDefined(overrides),
+ browser,
+ network: {
+ ...pickDefined(base.network),
+ ...pickDefined(overrides.network),
+ },
+ server: {
+ ...pickDefined(base.server),
+ ...pickDefined(overrides.server),
+ },
+ } as FullConfig;
+}
diff --git a/src/connection.ts b/src/connection.ts
new file mode 100644
index 0000000..1c931f8
--- /dev/null
+++ b/src/connection.ts
@@ -0,0 +1,98 @@
+/**
+ * Copyright (c) Microsoft Corporation.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Server as McpServer } from '@modelcontextprotocol/sdk/server/index.js';
+import { CallToolRequestSchema, ListToolsRequestSchema, Tool as McpTool } from '@modelcontextprotocol/sdk/types.js';
+import { zodToJsonSchema } from 'zod-to-json-schema';
+
+import { Context } from './context.js';
+import { snapshotTools, visionTools } from './tools.js';
+import { packageJSON } from './package.js';
+
+import { FullConfig } from './config.js';
+
+import type { BrowserContextFactory } from './browserContextFactory.js';
+
+export function createConnection(config: FullConfig, browserContextFactory: BrowserContextFactory): Connection {
+ const allTools = config.vision ? visionTools : snapshotTools;
+ const tools = allTools.filter(tool => !config.capabilities || tool.capability === 'core' || config.capabilities.includes(tool.capability));
+
+ const context = new Context(tools, config, browserContextFactory);
+ const server = new McpServer({ name: 'Playwright', version: packageJSON.version }, {
+ capabilities: {
+ tools: {},
+ }
+ });
+
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
+ return {
+ tools: tools.map(tool => ({
+ name: tool.schema.name,
+ description: tool.schema.description,
+ inputSchema: zodToJsonSchema(tool.schema.inputSchema),
+ annotations: {
+ title: tool.schema.title,
+ readOnlyHint: tool.schema.type === 'readOnly',
+ destructiveHint: tool.schema.type === 'destructive',
+ openWorldHint: true,
+ },
+ })) as McpTool[],
+ };
+ });
+
+ server.setRequestHandler(CallToolRequestSchema, async request => {
+ const errorResult = (...messages: string[]) => ({
+ content: [{ type: 'text', text: messages.join('\n') }],
+ isError: true,
+ });
+ const tool = tools.find(tool => tool.schema.name === request.params.name);
+ if (!tool)
+ return errorResult(`Tool "${request.params.name}" not found`);
+
+
+ const modalStates = context.modalStates().map(state => state.type);
+ if (tool.clearsModalState && !modalStates.includes(tool.clearsModalState))
+ return errorResult(`The tool "${request.params.name}" can only be used when there is related modal state present.`, ...context.modalStatesMarkdown());
+ if (!tool.clearsModalState && modalStates.length)
+ return errorResult(`Tool "${request.params.name}" does not handle the modal state.`, ...context.modalStatesMarkdown());
+
+ try {
+ return await context.run(tool, request.params.arguments);
+ } catch (error) {
+ return errorResult(String(error));
+ }
+ });
+
+ return new Connection(server, context);
+}
+
+export class Connection {
+ readonly server: McpServer;
+ readonly context: Context;
+
+ constructor(server: McpServer, context: Context) {
+ this.server = server;
+ this.context = context;
+ this.server.oninitialized = () => {
+ this.context.clientVersion = this.server.getClientVersion();
+ };
+ }
+
+ async close() {
+ await this.server.close();
+ await this.context.close();
+ }
+}
diff --git a/src/context.ts b/src/context.ts
index 943e290..ecf66b9 100644
--- a/src/context.ts
+++ b/src/context.ts
@@ -14,151 +14,338 @@
* limitations under the License.
*/
+import debug from 'debug';
import * as playwright from 'playwright';
-export type ContextOptions = {
- userDataDir: string;
- launchOptions?: playwright.LaunchOptions;
- cdpEndpoint?: string;
- remoteEndpoint?: string;
+import { callOnPageNoTrace, waitForCompletion } from './tools/utils.js';
+import { ManualPromise } from './manualPromise.js';
+import { Tab } from './tab.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 { FullConfig } from './config.js';
+import type { BrowserContextFactory } from './browserContextFactory.js';
+
+type PendingAction = {
+ dialogShown: ManualPromise;
};
+const testDebug = debug('pw:mcp:test');
+
export class Context {
- private _options: ContextOptions;
- private _browser: playwright.Browser | undefined;
- private _page: playwright.Page | undefined;
- private _console: playwright.ConsoleMessage[] = [];
- private _createPagePromise: Promise | undefined;
- private _fileChooser: playwright.FileChooser | undefined;
- private _lastSnapshotFrames: playwright.FrameLocator[] = [];
-
- constructor(options: ContextOptions) {
- this._options = options;
- }
-
- async createPage(): Promise {
- if (this._createPagePromise)
- return this._createPagePromise;
- this._createPagePromise = (async () => {
- const { browser, page } = await this._createPage();
- page.on('console', event => this._console.push(event));
- page.on('framenavigated', frame => {
- if (!frame.parentFrame())
- this._console.length = 0;
- });
- page.on('close', () => this._onPageClose());
- page.on('filechooser', chooser => this._fileChooser = chooser);
- page.setDefaultNavigationTimeout(60000);
- page.setDefaultTimeout(5000);
- this._page = page;
- this._browser = browser;
- return page;
- })();
- return this._createPagePromise;
+ readonly tools: Tool[];
+ readonly config: FullConfig;
+ private _browserContextPromise: Promise<{ browserContext: playwright.BrowserContext, close: () => Promise }> | undefined;
+ private _browserContextFactory: BrowserContextFactory;
+ private _tabs: Tab[] = [];
+ private _currentTab: Tab | undefined;
+ private _modalStates: (ModalState & { tab: Tab })[] = [];
+ private _pendingAction: PendingAction | undefined;
+ private _downloads: { download: playwright.Download, finished: boolean, outputFile: string }[] = [];
+ clientVersion: { name: string; version: string; } | undefined;
+
+ constructor(tools: Tool[], config: FullConfig, browserContextFactory: BrowserContextFactory) {
+ this.tools = tools;
+ this.config = config;
+ this._browserContextFactory = browserContextFactory;
+ testDebug('create context');
}
- private _onPageClose() {
- const browser = this._browser;
- const page = this._page;
- void page?.context()?.close().then(() => browser?.close()).catch(() => {});
+ clientSupportsImages(): boolean {
+ if (this.config.imageResponses === 'allow')
+ return true;
+ if (this.config.imageResponses === 'omit')
+ return false;
+ return !this.clientVersion?.name.includes('cursor');
+ }
- this._createPagePromise = undefined;
- this._browser = undefined;
- this._page = undefined;
- this._fileChooser = undefined;
- this._console.length = 0;
+ modalStates(): ModalState[] {
+ return this._modalStates;
}
- existingPage(): playwright.Page {
- if (!this._page)
- throw new Error('Navigate to a location to create a page');
- return this._page;
+ setModalState(modalState: ModalState, inTab: Tab) {
+ this._modalStates.push({ ...modalState, tab: inTab });
}
- async console(): Promise {
- return this._console;
+ clearModalState(modalState: ModalState) {
+ this._modalStates = this._modalStates.filter(state => state !== modalState);
}
- async close() {
- if (!this._page)
+ modalStatesMarkdown(): string[] {
+ const result: string[] = ['### Modal state'];
+ if (this._modalStates.length === 0)
+ result.push('- There is no modal state present');
+ for (const state of this._modalStates) {
+ const tool = this.tools.find(tool => tool.clearsModalState === state.type);
+ result.push(`- [${state.description}]: can be handled by the "${tool?.schema.name}" tool`);
+ }
+ return result;
+ }
+
+ tabs(): Tab[] {
+ return this._tabs;
+ }
+
+ currentTabOrDie(): Tab {
+ if (!this._currentTab)
+ throw new Error('No current snapshot available. Capture a snapshot or navigate to a new location first.');
+ return this._currentTab;
+ }
+
+ async newTab(): Promise {
+ const { browserContext } = await this._ensureBrowserContext();
+ const page = await browserContext.newPage();
+ this._currentTab = this._tabs.find(t => t.page === page)!;
+ return this._currentTab;
+ }
+
+ async selectTab(index: number) {
+ this._currentTab = this._tabs[index - 1];
+ await this._currentTab.page.bringToFront();
+ }
+
+ async ensureTab(): Promise {
+ const { browserContext } = await this._ensureBrowserContext();
+ if (!this._currentTab)
+ await browserContext.newPage();
+ return this._currentTab!;
+ }
+
+ async listTabsMarkdown(): Promise {
+ if (!this._tabs.length)
+ return '### No tabs open';
+ const lines: string[] = ['### Open tabs'];
+ for (let i = 0; i < this._tabs.length; i++) {
+ const tab = this._tabs[i];
+ const title = await tab.title();
+ const url = tab.page.url();
+ const current = tab === this._currentTab ? ' (current)' : '';
+ lines.push(`- ${i + 1}:${current} [${title}] (${url})`);
+ }
+ return lines.join('\n');
+ }
+
+ async closeTab(index: number | undefined) {
+ const tab = index === undefined ? this._currentTab : this._tabs[index - 1];
+ await tab?.page.close();
+ return await this.listTabsMarkdown();
+ }
+
+ async run(tool: Tool, params: Record | undefined) {
+ // Tab management is done outside of the action() call.
+ const toolResult = await tool.handle(this, tool.schema.inputSchema.parse(params || {}));
+ const { code, action, waitForNetwork, captureSnapshot, resultOverride } = toolResult;
+ const racingAction = action ? () => this._raceAgainstModalDialogs(action) : undefined;
+
+ if (resultOverride)
+ return resultOverride;
+
+ if (!this._currentTab) {
+ return {
+ content: [{
+ type: 'text',
+ text: 'No open pages available. Use the "browser_navigate" tool to navigate to a page first.',
+ }],
+ };
+ }
+
+ const tab = this.currentTabOrDie();
+ // TODO: race against modal dialogs to resolve clicks.
+ let actionResult: { content?: (ImageContent | TextContent)[] } | undefined;
+ try {
+ if (waitForNetwork)
+ actionResult = await waitForCompletion(this, tab, async () => racingAction?.()) ?? undefined;
+ else
+ actionResult = await racingAction?.() ?? undefined;
+ } finally {
+ if (captureSnapshot && !this._javaScriptBlocked())
+ await tab.captureSnapshot();
+ }
+
+ const result: string[] = [];
+ result.push(`- Ran Playwright code:
+\`\`\`js
+${code.join('\n')}
+\`\`\`
+`);
+
+ if (this.modalStates().length) {
+ result.push(...this.modalStatesMarkdown());
+ return {
+ content: [{
+ type: 'text',
+ text: result.join('\n'),
+ }],
+ };
+ }
+
+ if (this._downloads.length) {
+ result.push('', '### Downloads');
+ for (const entry of this._downloads) {
+ if (entry.finished)
+ result.push(`- Downloaded file ${entry.download.suggestedFilename()} to ${entry.outputFile}`);
+ else
+ result.push(`- Downloading file ${entry.download.suggestedFilename()} ...`);
+ }
+ result.push('');
+ }
+
+ if (this.tabs().length > 1)
+ result.push(await this.listTabsMarkdown(), '');
+
+ if (this.tabs().length > 1)
+ result.push('### Current tab');
+
+ result.push(
+ `- Page URL: ${tab.page.url()}`,
+ `- Page Title: ${await tab.title()}`
+ );
+
+ if (captureSnapshot && tab.hasSnapshot())
+ result.push(tab.snapshotOrDie().text());
+
+ const content = actionResult?.content ?? [];
+
+ return {
+ content: [
+ ...content,
+ {
+ type: 'text',
+ text: result.join('\n'),
+ }
+ ],
+ };
+ }
+
+ async waitForTimeout(time: number) {
+ if (!this._currentTab || this._javaScriptBlocked()) {
+ await new Promise(f => setTimeout(f, time));
return;
- await this._page.close();
+ }
+
+ await callOnPageNoTrace(this._currentTab.page, page => {
+ return page.evaluate(() => new Promise(f => setTimeout(f, 1000)));
+ });
+ }
+
+ private async _raceAgainstModalDialogs(action: () => Promise): Promise {
+ this._pendingAction = {
+ dialogShown: new ManualPromise(),
+ };
+
+ let result: ToolActionResult | undefined;
+ try {
+ await Promise.race([
+ action().then(r => result = r),
+ this._pendingAction.dialogShown,
+ ]);
+ } finally {
+ this._pendingAction = undefined;
+ }
+ return result;
+ }
+
+ private _javaScriptBlocked(): boolean {
+ return this._modalStates.some(state => state.type === 'dialog');
}
- async submitFileChooser(paths: string[]) {
- if (!this._fileChooser)
- throw new Error('No file chooser visible');
- await this._fileChooser.setFiles(paths);
- this._fileChooser = undefined;
+ dialogShown(tab: Tab, dialog: playwright.Dialog) {
+ this.setModalState({
+ type: 'dialog',
+ description: `"${dialog.type()}" dialog with message "${dialog.message()}"`,
+ dialog,
+ }, tab);
+ this._pendingAction?.dialogShown.resolve();
}
- hasFileChooser() {
- return !!this._fileChooser;
+ async downloadStarted(tab: Tab, download: playwright.Download) {
+ const entry = {
+ download,
+ finished: false,
+ outputFile: await outputFile(this.config, download.suggestedFilename())
+ };
+ this._downloads.push(entry);
+ await download.saveAs(entry.outputFile);
+ entry.finished = true;
}
- clearFileChooser() {
- this._fileChooser = undefined;
+ private _onPageCreated(page: playwright.Page) {
+ const tab = new Tab(this, page, tab => this._onPageClosed(tab));
+ this._tabs.push(tab);
+ if (!this._currentTab)
+ this._currentTab = tab;
}
- private async _createPage(): Promise<{ browser?: playwright.Browser, page: playwright.Page }> {
- if (this._options.remoteEndpoint) {
- const url = new URL(this._options.remoteEndpoint);
- if (this._options.launchOptions)
- url.searchParams.set('launch-options', JSON.stringify(this._options.launchOptions));
- const browser = await playwright.chromium.connect(String(url));
- const page = await browser.newPage();
- return { browser, page };
+ private _onPageClosed(tab: Tab) {
+ this._modalStates = this._modalStates.filter(state => state.tab !== tab);
+ const index = this._tabs.indexOf(tab);
+ if (index === -1)
+ return;
+ this._tabs.splice(index, 1);
+
+ if (this._currentTab === tab)
+ this._currentTab = this._tabs[Math.min(index, this._tabs.length - 1)];
+ if (!this._tabs.length)
+ void this.close();
+ }
+
+ async close() {
+ if (!this._browserContextPromise)
+ return;
+
+ testDebug('close context');
+
+ const promise = this._browserContextPromise;
+ this._browserContextPromise = undefined;
+
+ await promise.then(async ({ browserContext, close }) => {
+ if (this.config.saveTrace)
+ await browserContext.tracing.stop();
+ await close();
+ });
+ }
+
+ private async _setupRequestInterception(context: playwright.BrowserContext) {
+ if (this.config.network?.allowedOrigins?.length) {
+ await context.route('**', route => route.abort('blockedbyclient'));
+
+ for (const origin of this.config.network.allowedOrigins)
+ await context.route(`*://${origin}/**`, route => route.continue());
}
- if (this._options.cdpEndpoint) {
- const browser = await playwright.chromium.connectOverCDP(this._options.cdpEndpoint);
- const browserContext = browser.contexts()[0];
- let [page] = browserContext.pages();
- if (!page)
- page = await browserContext.newPage();
- return { browser, page };
+ if (this.config.network?.blockedOrigins?.length) {
+ for (const origin of this.config.network.blockedOrigins)
+ await context.route(`*://${origin}/**`, route => route.abort('blockedbyclient'));
}
+ }
- const context = await playwright.chromium.launchPersistentContext(this._options.userDataDir, this._options.launchOptions);
- const [page] = context.pages();
- return { page };
- }
-
- async allFramesSnapshot() {
- const page = this.existingPage();
- const visibleFrames = await page.locator('iframe').filter({ visible: true }).all();
- this._lastSnapshotFrames = visibleFrames.map(frame => frame.contentFrame());
-
- const snapshots = await Promise.all([
- page.locator('html').ariaSnapshot({ ref: true }),
- ...this._lastSnapshotFrames.map(async (frame, index) => {
- const snapshot = await frame.locator('html').ariaSnapshot({ ref: true });
- const args = [];
- const src = await frame.owner().getAttribute('src');
- if (src)
- args.push(`src=${src}`);
- const name = await frame.owner().getAttribute('name');
- if (name)
- args.push(`name=${name}`);
- return `\n# iframe ${args.join(' ')}\n` + snapshot.replaceAll('[ref=', `[ref=f${index}`);
- })
- ]);
-
- return snapshots.join('\n');
- }
-
- refLocator(ref: string): playwright.Locator {
- const page = this.existingPage();
- let frame: playwright.Frame | playwright.FrameLocator = page.mainFrame();
- const match = ref.match(/^f(\d+)(.*)/);
- if (match) {
- const frameIndex = parseInt(match[1], 10);
- if (!this._lastSnapshotFrames[frameIndex])
- throw new Error(`Frame does not exist. Provide ref from the most current snapshot.`);
- frame = this._lastSnapshotFrames[frameIndex];
- ref = match[2];
+ private _ensureBrowserContext() {
+ if (!this._browserContextPromise) {
+ this._browserContextPromise = this._setupBrowserContext();
+ this._browserContextPromise.catch(() => {
+ this._browserContextPromise = undefined;
+ });
}
+ return this._browserContextPromise;
+ }
- return frame.locator(`aria-ref=${ref}`);
+ private async _setupBrowserContext(): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise }> {
+ // TODO: move to the browser context factory to make it based on isolation mode.
+ const result = await this._browserContextFactory.createContext();
+ const { browserContext } = result;
+ await this._setupRequestInterception(browserContext);
+ for (const page of browserContext.pages())
+ this._onPageCreated(page);
+ browserContext.on('page', page => this._onPageCreated(page));
+ if (this.config.saveTrace) {
+ await browserContext.tracing.start({
+ name: 'trace',
+ screenshots: false,
+ snapshots: true,
+ sources: false,
+ });
+ }
+ return result;
}
}
diff --git a/src/fileUtils.ts b/src/fileUtils.ts
new file mode 100644
index 0000000..4155b74
--- /dev/null
+++ b/src/fileUtils.ts
@@ -0,0 +1,37 @@
+/**
+ * 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 os from 'node:os';
+import path from 'node:path';
+
+import type { FullConfig } from './config.js';
+
+export function cacheDir() {
+ let cacheDirectory: string;
+ if (process.platform === 'linux')
+ cacheDirectory = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
+ else if (process.platform === 'darwin')
+ cacheDirectory = path.join(os.homedir(), 'Library', 'Caches');
+ else if (process.platform === 'win32')
+ cacheDirectory = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
+ else
+ throw new Error('Unsupported platform: ' + process.platform);
+ return path.join(cacheDirectory, 'ms-playwright');
+}
+
+export async function userDataDir(browserConfig: FullConfig['browser']) {
+ return path.join(cacheDir(), 'ms-playwright', `mcp-${browserConfig.launchOptions?.channel ?? browserConfig?.browserName}-profile`);
+}
diff --git a/src/httpServer.ts b/src/httpServer.ts
new file mode 100644
index 0000000..9e67bef
--- /dev/null
+++ b/src/httpServer.ts
@@ -0,0 +1,232 @@
+/**
+ * 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 http from 'http';
+import net from 'net';
+
+import mime from 'mime';
+
+import { ManualPromise } from './manualPromise.js';
+
+
+export type ServerRouteHandler = (request: http.IncomingMessage, response: http.ServerResponse) => void;
+
+export type Transport = {
+ sendEvent?: (method: string, params: any) => void;
+ close?: () => void;
+ onconnect: () => void;
+ dispatch: (method: string, params: any) => Promise;
+ onclose: () => void;
+};
+
+export class HttpServer {
+ private _server: http.Server;
+ private _urlPrefixPrecise: string = '';
+ private _urlPrefixHumanReadable: string = '';
+ private _port: number = 0;
+ private _routes: { prefix?: string, exact?: string, handler: ServerRouteHandler }[] = [];
+
+ constructor() {
+ this._server = http.createServer(this._onRequest.bind(this));
+ decorateServer(this._server);
+ }
+
+ server() {
+ return this._server;
+ }
+
+ routePrefix(prefix: string, handler: ServerRouteHandler) {
+ this._routes.push({ prefix, handler });
+ }
+
+ routePath(path: string, handler: ServerRouteHandler) {
+ this._routes.push({ exact: path, handler });
+ }
+
+ port(): number {
+ return this._port;
+ }
+
+ private async _tryStart(port: number | undefined, host: string) {
+ const errorPromise = new ManualPromise();
+ const errorListener = (error: Error) => errorPromise.reject(error);
+ this._server.on('error', errorListener);
+
+ try {
+ this._server.listen(port, host);
+ await Promise.race([
+ new Promise(cb => this._server!.once('listening', cb)),
+ errorPromise,
+ ]);
+ } finally {
+ this._server.removeListener('error', errorListener);
+ }
+ }
+
+ async start(options: { port?: number, preferredPort?: number, host?: string } = {}): Promise {
+ const host = options.host || 'localhost';
+ if (options.preferredPort) {
+ try {
+ await this._tryStart(options.preferredPort, host);
+ } catch (e: any) {
+ if (!e || !e.message || !e.message.includes('EADDRINUSE'))
+ throw e;
+ await this._tryStart(undefined, host);
+ }
+ } else {
+ await this._tryStart(options.port, host);
+ }
+
+ const address = this._server.address();
+ if (typeof address === 'string') {
+ this._urlPrefixPrecise = address;
+ this._urlPrefixHumanReadable = address;
+ } else {
+ this._port = address!.port;
+ const resolvedHost = address!.family === 'IPv4' ? address!.address : `[${address!.address}]`;
+ this._urlPrefixPrecise = `http://${resolvedHost}:${address!.port}`;
+ this._urlPrefixHumanReadable = `http://${host}:${address!.port}`;
+ }
+ }
+
+ async stop() {
+ await new Promise(cb => this._server!.close(cb));
+ }
+
+ urlPrefix(purpose: 'human-readable' | 'precise'): string {
+ return purpose === 'human-readable' ? this._urlPrefixHumanReadable : this._urlPrefixPrecise;
+ }
+
+ serveFile(request: http.IncomingMessage, response: http.ServerResponse, absoluteFilePath: string, headers?: { [name: string]: string }): boolean {
+ try {
+ for (const [name, value] of Object.entries(headers || {}))
+ response.setHeader(name, value);
+ if (request.headers.range)
+ this._serveRangeFile(request, response, absoluteFilePath);
+ else
+ this._serveFile(response, absoluteFilePath);
+ return true;
+ } catch (e) {
+ return false;
+ }
+ }
+
+ _serveFile(response: http.ServerResponse, absoluteFilePath: string) {
+ const content = fs.readFileSync(absoluteFilePath);
+ response.statusCode = 200;
+ const contentType = mime.getType(path.extname(absoluteFilePath)) || 'application/octet-stream';
+ response.setHeader('Content-Type', contentType);
+ response.setHeader('Content-Length', content.byteLength);
+ response.end(content);
+ }
+
+ _serveRangeFile(request: http.IncomingMessage, response: http.ServerResponse, absoluteFilePath: string) {
+ const range = request.headers.range;
+ if (!range || !range.startsWith('bytes=') || range.includes(', ') || [...range].filter(char => char === '-').length !== 1) {
+ response.statusCode = 400;
+ return response.end('Bad request');
+ }
+
+ // Parse the range header: https://datatracker.ietf.org/doc/html/rfc7233#section-2.1
+ const [startStr, endStr] = range.replace(/bytes=/, '').split('-');
+
+ // Both start and end (when passing to fs.createReadStream) and the range header are inclusive and start counting at 0.
+ let start: number;
+ let end: number;
+ const size = fs.statSync(absoluteFilePath).size;
+ if (startStr !== '' && endStr === '') {
+ // No end specified: use the whole file
+ start = +startStr;
+ end = size - 1;
+ } else if (startStr === '' && endStr !== '') {
+ // No start specified: calculate start manually
+ start = size - +endStr;
+ end = size - 1;
+ } else {
+ start = +startStr;
+ end = +endStr;
+ }
+
+ // Handle unavailable range request
+ if (Number.isNaN(start) || Number.isNaN(end) || start >= size || end >= size || start > end) {
+ // Return the 416 Range Not Satisfiable: https://datatracker.ietf.org/doc/html/rfc7233#section-4.4
+ response.writeHead(416, {
+ 'Content-Range': `bytes */${size}`
+ });
+ return response.end();
+ }
+
+ // Sending Partial Content: https://datatracker.ietf.org/doc/html/rfc7233#section-4.1
+ response.writeHead(206, {
+ 'Content-Range': `bytes ${start}-${end}/${size}`,
+ 'Accept-Ranges': 'bytes',
+ 'Content-Length': end - start + 1,
+ 'Content-Type': mime.getType(path.extname(absoluteFilePath))!,
+ });
+
+ const readable = fs.createReadStream(absoluteFilePath, { start, end });
+ readable.pipe(response);
+ }
+
+ private _onRequest(request: http.IncomingMessage, response: http.ServerResponse) {
+ if (request.method === 'OPTIONS') {
+ response.writeHead(200);
+ response.end();
+ return;
+ }
+
+ request.on('error', () => response.end());
+ try {
+ if (!request.url) {
+ response.end();
+ return;
+ }
+ const url = new URL('http://localhost' + request.url);
+ for (const route of this._routes) {
+ if (route.exact && url.pathname === route.exact) {
+ route.handler(request, response);
+ return;
+ }
+ if (route.prefix && url.pathname.startsWith(route.prefix)) {
+ route.handler(request, response);
+ return;
+ }
+ }
+ response.statusCode = 404;
+ response.end();
+ } catch (e) {
+ response.end();
+ }
+ }
+}
+
+function decorateServer(server: net.Server) {
+ const sockets = new Set();
+ server.on('connection', socket => {
+ sockets.add(socket);
+ socket.once('close', () => sockets.delete(socket));
+ });
+
+ const close = server.close;
+ server.close = (callback?: (err?: Error) => void) => {
+ for (const socket of sockets)
+ socket.destroy();
+ sockets.clear();
+ return close.call(server, callback);
+ };
+}
diff --git a/src/index.ts b/src/index.ts
index d43a359..3c865be 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -14,73 +14,32 @@
* limitations under the License.
*/
-import { createServerWithTools } from './server';
-import * as snapshot from './tools/snapshot';
-import * as common from './tools/common';
-import * as screenshot from './tools/screenshot';
-import { console } from './resources/console';
-
-import type { Tool } from './tools/tool';
-import type { Resource } from './resources/resource';
-import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
-import type { LaunchOptions } from 'playwright';
-
-const commonTools: Tool[] = [
- common.pressKey,
- common.wait,
- common.pdf,
- common.close,
-];
-
-const snapshotTools: Tool[] = [
- common.navigate(true),
- common.goBack(true),
- common.goForward(true),
- common.chooseFile(true),
- snapshot.snapshot,
- snapshot.click,
- snapshot.hover,
- snapshot.type,
- snapshot.selectOption,
- snapshot.screenshot,
- ...commonTools,
-];
-
-const screenshotTools: Tool[] = [
- common.navigate(false),
- common.goBack(false),
- common.goForward(false),
- common.chooseFile(false),
- screenshot.screenshot,
- screenshot.moveMouse,
- screenshot.click,
- screenshot.drag,
- screenshot.type,
- ...commonTools,
-];
-
-const resources: Resource[] = [
- console,
-];
+import { Connection, createConnection as createConnectionImpl } from './connection.js';
+import { resolveConfig } from './config.js';
+import { contextFactory } from './browserContextFactory.js';
+
+import type { Config } from '../config.js';
+import type { BrowserContext } from 'playwright';
+import type { BrowserContextFactory } from './browserContextFactory.js';
+
+export async function createConnection(userConfig: Config = {}, contextGetter?: () => Promise): Promise {
+ const config = await resolveConfig(userConfig);
+ const factory = contextGetter ? new SimpleBrowserContextFactory(contextGetter) : contextFactory(config.browser);
+ return createConnectionImpl(config, factory);
+}
-type Options = {
- userDataDir?: string;
- launchOptions?: LaunchOptions;
- cdpEndpoint?: string;
- vision?: boolean;
-};
+class SimpleBrowserContextFactory implements BrowserContextFactory {
+ private readonly _contextGetter: () => Promise;
-const packageJSON = require('../package.json');
+ constructor(contextGetter: () => Promise) {
+ this._contextGetter = contextGetter;
+ }
-export function createServer(options?: Options): Server {
- const tools = options?.vision ? screenshotTools : snapshotTools;
- return createServerWithTools({
- name: 'Playwright',
- version: packageJSON.version,
- tools,
- resources,
- userDataDir: options?.userDataDir ?? '',
- launchOptions: options?.launchOptions,
- cdpEndpoint: options?.cdpEndpoint,
- });
+ async createContext(): Promise<{ browserContext: BrowserContext, close: () => Promise }> {
+ const browserContext = await this._contextGetter();
+ return {
+ browserContext,
+ close: () => browserContext.close()
+ };
+ }
}
diff --git a/src/javascript.ts b/src/javascript.ts
new file mode 100644
index 0000000..a1fabbd
--- /dev/null
+++ b/src/javascript.ts
@@ -0,0 +1,53 @@
+/**
+ * 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.
+ */
+
+// adapted from:
+// - https://github.com/microsoft/playwright/blob/76ee48dc9d4034536e3ec5b2c7ce8be3b79418a8/packages/playwright-core/src/utils/isomorphic/stringUtils.ts
+// - https://github.com/microsoft/playwright/blob/76ee48dc9d4034536e3ec5b2c7ce8be3b79418a8/packages/playwright-core/src/server/codegen/javascript.ts
+
+// NOTE: this function should not be used to escape any selectors.
+export function escapeWithQuotes(text: string, char: string = '\'') {
+ const stringified = JSON.stringify(text);
+ const escapedText = stringified.substring(1, stringified.length - 1).replace(/\\"/g, '"');
+ if (char === '\'')
+ return char + escapedText.replace(/[']/g, '\\\'') + char;
+ if (char === '"')
+ return char + escapedText.replace(/["]/g, '\\"') + char;
+ if (char === '`')
+ return char + escapedText.replace(/[`]/g, '`') + char;
+ throw new Error('Invalid escape char');
+}
+
+export function quote(text: string) {
+ return escapeWithQuotes(text, '\'');
+}
+
+export function formatObject(value: any, indent = ' '): string {
+ if (typeof value === 'string')
+ return quote(value);
+ if (Array.isArray(value))
+ return `[${value.map(o => formatObject(o)).join(', ')}]`;
+ if (typeof value === 'object') {
+ const keys = Object.keys(value).filter(key => value[key] !== undefined).sort();
+ if (!keys.length)
+ return '{}';
+ const tokens: string[] = [];
+ for (const key of keys)
+ tokens.push(`${key}: ${formatObject(value[key])}`);
+ return `{\n${indent}${tokens.join(`,\n${indent}`)}\n}`;
+ }
+ return String(value);
+}
diff --git a/src/manualPromise.ts b/src/manualPromise.ts
new file mode 100644
index 0000000..a5034e0
--- /dev/null
+++ b/src/manualPromise.ts
@@ -0,0 +1,127 @@
+/**
+ * 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 class ManualPromise extends Promise {
+ private _resolve!: (t: T) => void;
+ private _reject!: (e: Error) => void;
+ private _isDone: boolean;
+
+ constructor() {
+ let resolve: (t: T) => void;
+ let reject: (e: Error) => void;
+ super((f, r) => {
+ resolve = f;
+ reject = r;
+ });
+ this._isDone = false;
+ this._resolve = resolve!;
+ this._reject = reject!;
+ }
+
+ isDone() {
+ return this._isDone;
+ }
+
+ resolve(t: T) {
+ this._isDone = true;
+ this._resolve(t);
+ }
+
+ reject(e: Error) {
+ this._isDone = true;
+ this._reject(e);
+ }
+
+ static override get [Symbol.species]() {
+ return Promise;
+ }
+
+ override get [Symbol.toStringTag]() {
+ return 'ManualPromise';
+ }
+}
+
+export class LongStandingScope {
+ private _terminateError: Error | undefined;
+ private _closeError: Error | undefined;
+ private _terminatePromises = new Map, string[]>();
+ private _isClosed = false;
+
+ reject(error: Error) {
+ this._isClosed = true;
+ this._terminateError = error;
+ for (const p of this._terminatePromises.keys())
+ p.resolve(error);
+ }
+
+ close(error: Error) {
+ this._isClosed = true;
+ this._closeError = error;
+ for (const [p, frames] of this._terminatePromises)
+ p.resolve(cloneError(error, frames));
+ }
+
+ isClosed() {
+ return this._isClosed;
+ }
+
+ static async raceMultiple(scopes: LongStandingScope[], promise: Promise): Promise {
+ return Promise.race(scopes.map(s => s.race(promise)));
+ }
+
+ async race(promise: Promise | Promise[]): Promise {
+ return this._race(Array.isArray(promise) ? promise : [promise], false) as Promise;
+ }
+
+ async safeRace(promise: Promise, defaultValue?: T): Promise {
+ return this._race([promise], true, defaultValue);
+ }
+
+ private async _race(promises: Promise[], safe: boolean, defaultValue?: any): Promise {
+ const terminatePromise = new ManualPromise();
+ const frames = captureRawStack();
+ if (this._terminateError)
+ terminatePromise.resolve(this._terminateError);
+ if (this._closeError)
+ terminatePromise.resolve(cloneError(this._closeError, frames));
+ this._terminatePromises.set(terminatePromise, frames);
+ try {
+ return await Promise.race([
+ terminatePromise.then(e => safe ? defaultValue : Promise.reject(e)),
+ ...promises
+ ]);
+ } finally {
+ this._terminatePromises.delete(terminatePromise);
+ }
+ }
+}
+
+function cloneError(error: Error, frames: string[]) {
+ const clone = new Error();
+ clone.name = error.name;
+ clone.message = error.message;
+ clone.stack = [error.name + ':' + error.message, ...frames].join('\n');
+ return clone;
+}
+
+function captureRawStack(): string[] {
+ const stackTraceLimit = Error.stackTraceLimit;
+ Error.stackTraceLimit = 50;
+ const error = new Error();
+ const stack = error.stack || '';
+ Error.stackTraceLimit = stackTraceLimit;
+ return stack.split('\n');
+}
diff --git a/src/resources/console.ts b/src/package.ts
similarity index 57%
rename from src/resources/console.ts
rename to src/package.ts
index ca9bea8..a6c7019 100644
--- a/src/resources/console.ts
+++ b/src/package.ts
@@ -14,22 +14,9 @@
* limitations under the License.
*/
-import type { Resource } from './resource';
+import fs from 'node:fs';
+import url from 'node:url';
+import path from 'node:path';
-export const console: Resource = {
- schema: {
- uri: 'browser://console',
- name: 'Page console',
- mimeType: 'text/plain',
- },
-
- read: async (context, uri) => {
- const messages = await context.console();
- const log = messages.map(message => `[${message.type().toUpperCase()}] ${message.text()}`).join('\n');
- return [{
- uri,
- mimeType: 'text/plain',
- text: log
- }];
- },
-};
+const __filename = url.fileURLToPath(import.meta.url);
+export const packageJSON = JSON.parse(fs.readFileSync(path.join(path.dirname(__filename), '..', 'package.json'), 'utf8'));
diff --git a/src/pageSnapshot.ts b/src/pageSnapshot.ts
new file mode 100644
index 0000000..5f2f07d
--- /dev/null
+++ b/src/pageSnapshot.ts
@@ -0,0 +1,55 @@
+/**
+ * Copyright (c) Microsoft Corporation.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import * as playwright from 'playwright';
+import { callOnPageNoTrace } from './tools/utils.js';
+
+type PageEx = playwright.Page & {
+ _snapshotForAI: () => Promise;
+};
+
+export class PageSnapshot {
+ private _page: playwright.Page;
+ private _text!: string;
+
+ constructor(page: playwright.Page) {
+ this._page = page;
+ }
+
+ static async create(page: playwright.Page): Promise {
+ const snapshot = new PageSnapshot(page);
+ await snapshot._build();
+ return snapshot;
+ }
+
+ text(): string {
+ return this._text;
+ }
+
+ private async _build() {
+ const snapshot = await callOnPageNoTrace(this._page, page => (page as PageEx)._snapshotForAI());
+ this._text = [
+ `- Page Snapshot`,
+ '```yaml',
+ snapshot,
+ '```',
+ ].join('\n');
+ }
+
+ refLocator(params: { element: string, ref: string }): playwright.Locator {
+ return this._page.locator(`aria-ref=${params.ref}`).describe(params.element);
+ }
+}
diff --git a/src/program.ts b/src/program.ts
index c196106..537a244 100644
--- a/src/program.ts
+++ b/src/program.ts
@@ -14,136 +14,65 @@
* limitations under the License.
*/
-import http from 'http';
-import fs from 'fs';
-import os from 'os';
-import path from 'path';
-
import { program } from 'commander';
-import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
-import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
-
-
-import { createServer } from './index';
-import { ServerList } from './server';
-
-import type { LaunchOptions } from 'playwright';
-import assert from 'assert';
+// @ts-ignore
+import { startTraceViewerServer } from 'playwright-core/lib/server';
-const packageJSON = require('../package.json');
+import { startHttpTransport, startStdioTransport } from './transport.js';
+import { resolveCLIConfig } from './config.js';
+import { Server } from './server.js';
+import { packageJSON } from './package.js';
program
.version('Version ' + packageJSON.version)
.name(packageJSON.name)
- .option('--headless', 'Run browser in headless mode, headed by default')
- .option('--user-data-dir ', 'Path to the user data directory')
- .option('--vision', 'Run server that uses screenshots (Aria snapshots are used by default)')
- .option('--port ', 'Port to listen on for SSE transport.')
+ .option('--allowed-origins ', 'semicolon-separated list of origins to allow the browser to request. Default is to allow all.', semicolonSeparatedList)
+ .option('--blocked-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('--browser ', 'browser or chrome channel to use, possible values: chrome, firefox, webkit, msedge.')
+ .option('--browser-agent ', 'Use browser agent (experimental).')
+ .option('--caps ', 'comma-separated list of capabilities to enable, possible values: tabs, pdf, history, wait, files, install. Default is all.')
.option('--cdp-endpoint ', 'CDP endpoint to connect to.')
+ .option('--config ', 'path to the configuration file.')
+ .option('--device ', 'device to emulate, for example: "iPhone 15"')
+ .option('--executable-path ', 'path to the browser executable.')
+ .option('--headless', 'run browser in headless mode, headed by default')
+ .option('--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('--isolated', 'keep the browser profile in memory, do not save it to disk.')
+ .option('--image-responses ', '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('--output-dir ', 'path to the directory for output files.')
+ .option('--port ', 'port to listen on for SSE transport.')
+ .option('--proxy-bypass ', 'comma-separated domains to bypass proxy, for example ".com,chromium.org,.domain.com"')
+ .option('--proxy-server ', '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('--storage-state ', 'path to the storage state file for isolated sessions.')
+ .option('--user-agent ', 'specify user agent string')
+ .option('--user-data-dir ', 'path to the user data directory. If not specified, a temporary directory will be created.')
+ .option('--viewport-size ', 'specify browser viewport size in pixels, for example "1280, 720"')
+ .option('--vision', 'Run server that uses screenshots (Aria snapshots are used by default)')
.action(async options => {
- const launchOptions: LaunchOptions = {
- headless: !!options.headless,
- channel: 'chrome',
- };
- const userDataDir = options.userDataDir ?? await createUserDataDir();
- const serverList = new ServerList(() => createServer({
- userDataDir,
- launchOptions,
- vision: !!options.vision,
- cdpEndpoint: options.cdpEndpoint,
- }));
- setupExitWatchdog(serverList);
+ const config = await resolveCLIConfig(options);
+ const server = new Server(config);
+ server.setupExitWatchdog();
- if (options.port) {
- startSSEServer(+options.port, serverList);
- } else {
- const server = await serverList.create();
- await server.connect(new StdioServerTransport());
+ if (config.server.port !== undefined)
+ startHttpTransport(server);
+ else
+ await startStdioTransport(server);
+
+ if (config.saveTrace) {
+ const server = await startTraceViewerServer();
+ const urlPrefix = server.urlPrefix('human-readable');
+ const url = urlPrefix + '/trace/index.html?trace=' + config.browser.launchOptions.tracesDir + '/trace.json';
+ // eslint-disable-next-line no-console
+ console.error('\nTrace viewer listening on ' + url);
}
});
-function setupExitWatchdog(serverList: ServerList) {
- process.stdin.on('close', async () => {
- setTimeout(() => process.exit(0), 15000);
- await serverList.closeAll();
- process.exit(0);
- });
-}
-
-program.parse(process.argv);
-
-async function createUserDataDir() {
- let cacheDirectory: string;
- if (process.platform === 'linux')
- cacheDirectory = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
- else if (process.platform === 'darwin')
- cacheDirectory = path.join(os.homedir(), 'Library', 'Caches');
- else if (process.platform === 'win32')
- cacheDirectory = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
- else
- throw new Error('Unsupported platform: ' + process.platform);
- const result = path.join(cacheDirectory, 'ms-playwright', 'mcp-chrome-profile');
- await fs.promises.mkdir(result, { recursive: true });
- return result;
+function semicolonSeparatedList(value: string): string[] {
+ return value.split(';').map(v => v.trim());
}
-async function startSSEServer(port: number, serverList: ServerList) {
- const sessions = new Map();
- const httpServer = http.createServer(async (req, res) => {
- if (req.method === 'POST') {
- const searchParams = new URL(`http://localhost${req.url}`).searchParams;
- const sessionId = searchParams.get('sessionId');
- if (!sessionId) {
- res.statusCode = 400;
- res.end('Missing sessionId');
- return;
- }
- const transport = sessions.get(sessionId);
- if (!transport) {
- res.statusCode = 404;
- res.end('Session not found');
- return;
- }
-
- await transport.handlePostMessage(req, res);
- return;
- } else if (req.method === 'GET') {
- const transport = new SSEServerTransport('/sse', res);
- sessions.set(transport.sessionId, transport);
- const server = await serverList.create();
- res.on('close', () => {
- sessions.delete(transport.sessionId);
- serverList.close(server).catch(e => console.error(e));
- });
- await server.connect(transport);
- return;
- } else {
- res.statusCode = 405;
- res.end('Method not allowed');
- }
- });
-
- httpServer.listen(port, () => {
- const address = httpServer.address();
- assert(address, 'Could not bind server socket');
- let url: string;
- if (typeof address === 'string') {
- url = address;
- } else {
- const resolvedPort = address.port;
- let resolvedHost = address.family === 'IPv4' ? address.address : `[${address.address}]`;
- if (resolvedHost === '0.0.0.0' || resolvedHost === '[::]')
- resolvedHost = 'localhost';
- url = `http://${resolvedHost}:${resolvedPort}`;
- }
- console.log(`Listening on ${url}`);
- console.log('Put this in your client config:');
- console.log(JSON.stringify({
- 'mcpServers': {
- 'playwright': {
- 'url': `${url}/sse`
- }
- }
- }, undefined, 2));
- });
-}
+void program.parseAsync(process.argv);
diff --git a/src/resources/resource.ts b/src/resources/resource.ts
index d9b19e4..abe0e5b 100644
--- a/src/resources/resource.ts
+++ b/src/resources/resource.ts
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-import type { Context } from '../context';
+import type { Context } from '../context.js';
export type ResourceSchema = {
uri: string;
diff --git a/src/server.ts b/src/server.ts
index 2b20f98..8c143e1 100644
--- a/src/server.ts
+++ b/src/server.ts
@@ -14,101 +14,46 @@
* limitations under the License.
*/
-import { Server } from '@modelcontextprotocol/sdk/server/index.js';
-import { CallToolRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema } from '@modelcontextprotocol/sdk/types.js';
-
-import { Context } from './context';
-
-import type { Tool } from './tools/tool';
-import type { Resource } from './resources/resource';
-import type { ContextOptions } from './context';
-
-type Options = ContextOptions & {
- name: string;
- version: string;
- tools: Tool[];
- resources: Resource[],
-};
-
-export function createServerWithTools(options: Options): Server {
- const { name, version, tools, resources } = options;
- const context = new Context(options);
- const server = new Server({ name, version }, {
- capabilities: {
- tools: {},
- resources: {},
- }
- });
-
- server.setRequestHandler(ListToolsRequestSchema, async () => {
- return { tools: tools.map(tool => tool.schema) };
- });
-
- server.setRequestHandler(ListResourcesRequestSchema, async () => {
- return { resources: resources.map(resource => resource.schema) };
- });
-
- server.setRequestHandler(CallToolRequestSchema, async request => {
- const tool = tools.find(tool => tool.schema.name === request.params.name);
- if (!tool) {
- return {
- content: [{ type: 'text', text: `Tool "${request.params.name}" not found` }],
- isError: true,
- };
- }
-
- try {
- const result = await tool.handle(context, request.params.arguments);
- return result;
- } catch (error) {
- return {
- content: [{ type: 'text', text: String(error) }],
- isError: true,
- };
- }
- });
-
- server.setRequestHandler(ReadResourceRequestSchema, async request => {
- const resource = resources.find(resource => resource.schema.uri === request.params.uri);
- if (!resource)
- return { contents: [] };
-
- const contents = await resource.read(context, request.params.uri);
- return { contents };
- });
-
- const oldClose = server.close.bind(server);
-
- server.close = async () => {
- await oldClose();
- await context.close();
- };
-
- return server;
-}
-
-export class ServerList {
- private _servers: Server[] = [];
- private _serverFactory: () => Server;
-
- constructor(serverFactory: () => Server) {
- this._serverFactory = serverFactory;
- }
-
- async create() {
- const server = this._serverFactory();
- this._servers.push(server);
- return server;
+import { createConnection } from './connection.js';
+import { contextFactory } from './browserContextFactory.js';
+
+import type { FullConfig } from './config.js';
+import type { Connection } from './connection.js';
+import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
+import type { BrowserContextFactory } from './browserContextFactory.js';
+
+export class Server {
+ readonly config: FullConfig;
+ private _connectionList: Connection[] = [];
+ private _browserConfig: FullConfig['browser'];
+ private _contextFactory: BrowserContextFactory;
+
+ constructor(config: FullConfig) {
+ this.config = config;
+ this._browserConfig = config.browser;
+ this._contextFactory = contextFactory(this._browserConfig);
}
- async close(server: Server) {
- const index = this._servers.indexOf(server);
- if (index !== -1)
- this._servers.splice(index, 1);
- await server.close();
+ async createConnection(transport: Transport): Promise {
+ const connection = createConnection(this.config, this._contextFactory);
+ this._connectionList.push(connection);
+ await connection.server.connect(transport);
+ return connection;
}
- async closeAll() {
- await Promise.all(this._servers.map(server => server.close()));
+ setupExitWatchdog() {
+ let isExiting = false;
+ const handleExit = async () => {
+ if (isExiting)
+ return;
+ isExiting = true;
+ setTimeout(() => process.exit(0), 15000);
+ await Promise.all(this._connectionList.map(connection => connection.close()));
+ process.exit(0);
+ };
+
+ process.stdin.on('close', handleExit);
+ process.on('SIGINT', handleExit);
+ process.on('SIGTERM', handleExit);
}
}
diff --git a/src/tab.ts b/src/tab.ts
new file mode 100644
index 0000000..5d4e93a
--- /dev/null
+++ b/src/tab.ts
@@ -0,0 +1,121 @@
+/**
+ * Copyright (c) Microsoft Corporation.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import * as playwright from 'playwright';
+
+import { PageSnapshot } from './pageSnapshot.js';
+
+import type { Context } from './context.js';
+import { callOnPageNoTrace } from './tools/utils.js';
+
+export class Tab {
+ readonly context: Context;
+ readonly page: playwright.Page;
+ private _consoleMessages: playwright.ConsoleMessage[] = [];
+ private _requests: Map = new Map();
+ private _snapshot: PageSnapshot | undefined;
+ private _onPageClose: (tab: Tab) => void;
+
+ constructor(context: Context, page: playwright.Page, onPageClose: (tab: Tab) => void) {
+ this.context = context;
+ this.page = page;
+ this._onPageClose = onPageClose;
+ page.on('console', event => this._consoleMessages.push(event));
+ page.on('request', request => this._requests.set(request, null));
+ page.on('response', response => this._requests.set(response.request(), response));
+ page.on('close', () => this._onClose());
+ page.on('filechooser', chooser => {
+ this.context.setModalState({
+ type: 'fileChooser',
+ description: 'File chooser',
+ fileChooser: chooser,
+ }, this);
+ });
+ page.on('dialog', dialog => this.context.dialogShown(this, dialog));
+ page.on('download', download => {
+ void this.context.downloadStarted(this, download);
+ });
+ page.setDefaultNavigationTimeout(60000);
+ page.setDefaultTimeout(5000);
+ }
+
+ private _clearCollectedArtifacts() {
+ this._consoleMessages.length = 0;
+ this._requests.clear();
+ }
+
+ private _onClose() {
+ this._clearCollectedArtifacts();
+ this._onPageClose(this);
+ }
+
+ async title(): Promise {
+ return await callOnPageNoTrace(this.page, page => page.title());
+ }
+
+ async waitForLoadState(state: 'load', options?: { timeout?: number }): Promise {
+ await callOnPageNoTrace(this.page, page => page.waitForLoadState(state, options).catch(() => {}));
+ }
+
+ async navigate(url: string) {
+ this._clearCollectedArtifacts();
+
+ const downloadEvent = callOnPageNoTrace(this.page, page => page.waitForEvent('download').catch(() => {}));
+ try {
+ await this.page.goto(url, { waitUntil: 'domcontentloaded' });
+ } catch (_e: unknown) {
+ const e = _e as Error;
+ const mightBeDownload =
+ e.message.includes('net::ERR_ABORTED') // chromium
+ || e.message.includes('Download is starting'); // firefox + webkit
+ if (!mightBeDownload)
+ throw e;
+
+ // on chromium, the download event is fired *after* page.goto rejects, so we wait a lil bit
+ const download = await Promise.race([
+ downloadEvent,
+ new Promise(resolve => setTimeout(resolve, 500)),
+ ]);
+ if (!download)
+ throw e;
+ }
+
+ // Cap load event to 5 seconds, the page is operational at this point.
+ await this.waitForLoadState('load', { timeout: 5000 });
+ }
+
+ hasSnapshot(): boolean {
+ return !!this._snapshot;
+ }
+
+ snapshotOrDie(): PageSnapshot {
+ if (!this._snapshot)
+ throw new Error('No snapshot available');
+ return this._snapshot;
+ }
+
+ consoleMessages(): playwright.ConsoleMessage[] {
+ return this._consoleMessages;
+ }
+
+ requests(): Map {
+ return this._requests;
+ }
+
+ async captureSnapshot() {
+ this._snapshot = await PageSnapshot.create(this.page);
+ }
+}
diff --git a/src/tools.ts b/src/tools.ts
new file mode 100644
index 0000000..bd6db0f
--- /dev/null
+++ b/src/tools.ts
@@ -0,0 +1,66 @@
+/**
+ * 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 common from './tools/common.js';
+import console from './tools/console.js';
+import dialogs from './tools/dialogs.js';
+import files from './tools/files.js';
+import install from './tools/install.js';
+import keyboard from './tools/keyboard.js';
+import navigate from './tools/navigate.js';
+import network from './tools/network.js';
+import pdf from './tools/pdf.js';
+import snapshot from './tools/snapshot.js';
+import tabs from './tools/tabs.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 type { Tool } from './tools/tool.js';
+
+export const snapshotTools: Tool[] = [
+ ...common(true),
+ ...console,
+ ...dialogs(true),
+ ...files(true),
+ ...install,
+ ...keyboard(true),
+ ...navigate(true),
+ ...network,
+ ...pdf,
+ ...screenshot,
+ ...snapshot,
+ ...tabs(true),
+ ...testing,
+ ...wait(true),
+];
+
+export const visionTools: Tool[] = [
+ ...common(false),
+ ...console,
+ ...dialogs(false),
+ ...files(false),
+ ...install,
+ ...keyboard(false),
+ ...navigate(false),
+ ...network,
+ ...pdf,
+ ...tabs(false),
+ ...testing,
+ ...vision,
+ ...wait(false),
+];
diff --git a/src/tools/common.ts b/src/tools/common.ts
index a04ddaf..8a16c35 100644
--- a/src/tools/common.ts
+++ b/src/tools/common.ts
@@ -14,163 +14,65 @@
* limitations under the License.
*/
-import os from 'os';
-import path from 'path';
-
import { z } from 'zod';
-import { zodToJsonSchema } from 'zod-to-json-schema';
-
-import { captureAriaSnapshot, runAndWait } from './utils';
-
-import type { ToolFactory, Tool } from './tool';
-
-const navigateSchema = z.object({
- url: z.string().describe('The URL to navigate to'),
-});
-
-export const navigate: ToolFactory = snapshot => ({
- schema: {
- name: 'browser_navigate',
- description: 'Navigate to a URL',
- inputSchema: zodToJsonSchema(navigateSchema),
- },
- handle: async (context, params) => {
- const validatedParams = navigateSchema.parse(params);
- const page = await context.createPage();
- await page.goto(validatedParams.url, { waitUntil: 'domcontentloaded' });
- // Cap load event to 5 seconds, the page is operational at this point.
- await page.waitForLoadState('load', { timeout: 5000 }).catch(() => {});
- if (snapshot)
- return captureAriaSnapshot(context);
- return {
- content: [{
- type: 'text',
- text: `Navigated to ${validatedParams.url}`,
- }],
- };
- },
-});
+import { defineTool, type ToolFactory } from './tool.js';
-const goBackSchema = z.object({});
+const close = defineTool({
+ capability: 'core',
-export const goBack: ToolFactory = snapshot => ({
schema: {
- name: 'browser_go_back',
- description: 'Go back to the previous page',
- inputSchema: zodToJsonSchema(goBackSchema),
- },
- handle: async context => {
- return await runAndWait(context, 'Navigated back', async page => page.goBack(), snapshot);
+ name: 'browser_close',
+ title: 'Close browser',
+ description: 'Close the page',
+ inputSchema: z.object({}),
+ type: 'readOnly',
},
-});
-
-const goForwardSchema = z.object({});
-export const goForward: ToolFactory = snapshot => ({
- schema: {
- name: 'browser_go_forward',
- description: 'Go forward to the next page',
- inputSchema: zodToJsonSchema(goForwardSchema),
- },
handle: async context => {
- return await runAndWait(context, 'Navigated forward', async page => page.goForward(), snapshot);
- },
-});
-
-const waitSchema = z.object({
- time: z.number().describe('The time to wait in seconds'),
-});
-
-export const wait: Tool = {
- schema: {
- name: 'browser_wait',
- description: 'Wait for a specified time in seconds',
- inputSchema: zodToJsonSchema(waitSchema),
- },
- handle: async (context, params) => {
- const validatedParams = waitSchema.parse(params);
- await new Promise(f => setTimeout(f, Math.min(10000, validatedParams.time * 1000)));
+ await context.close();
return {
- content: [{
- type: 'text',
- text: `Waited for ${validatedParams.time} seconds`,
- }],
+ code: [`await page.close()`],
+ captureSnapshot: false,
+ waitForNetwork: false,
};
},
-};
-
-const pressKeySchema = z.object({
- key: z.string().describe('Name of the key to press or a character to generate, such as `ArrowLeft` or `a`'),
});
-export const pressKey: Tool = {
+const resize: ToolFactory = captureSnapshot => defineTool({
+ capability: 'core',
schema: {
- name: 'browser_press_key',
- description: 'Press a key on the keyboard',
- inputSchema: zodToJsonSchema(pressKeySchema),
+ name: 'browser_resize',
+ title: 'Resize browser window',
+ description: 'Resize the browser window',
+ inputSchema: z.object({
+ width: z.number().describe('Width of the browser window'),
+ height: z.number().describe('Height of the browser window'),
+ }),
+ type: 'readOnly',
},
+
handle: async (context, params) => {
- const validatedParams = pressKeySchema.parse(params);
- return await runAndWait(context, `Pressed key ${validatedParams.key}`, async page => {
- await page.keyboard.press(validatedParams.key);
- });
- },
-};
+ const tab = context.currentTabOrDie();
-const pdfSchema = z.object({});
+ const code = [
+ `// Resize browser window to ${params.width}x${params.height}`,
+ `await page.setViewportSize({ width: ${params.width}, height: ${params.height} });`
+ ];
-export const pdf: Tool = {
- schema: {
- name: 'browser_save_as_pdf',
- description: 'Save page as PDF',
- inputSchema: zodToJsonSchema(pdfSchema),
- },
- handle: async context => {
- const page = context.existingPage();
- const fileName = path.join(os.tmpdir(), `/page-${new Date().toISOString()}.pdf`);
- await page.pdf({ path: fileName });
- return {
- content: [{
- type: 'text',
- text: `Saved as ${fileName}`,
- }],
+ const action = async () => {
+ await tab.page.setViewportSize({ width: params.width, height: params.height });
};
- },
-};
-
-const closeSchema = z.object({});
-export const close: Tool = {
- schema: {
- name: 'browser_close',
- description: 'Close the page',
- inputSchema: zodToJsonSchema(closeSchema),
- },
- handle: async context => {
- await context.close();
return {
- content: [{
- type: 'text',
- text: `Page closed`,
- }],
+ code,
+ action,
+ captureSnapshot,
+ waitForNetwork: true
};
},
-};
-
-const chooseFileSchema = z.object({
- paths: z.array(z.string()).describe('The absolute paths to the files to upload. Can be a single file or multiple files.'),
});
-export const chooseFile: ToolFactory = snapshot => ({
- schema: {
- name: 'browser_choose_file',
- description: 'Choose one or multiple files to upload',
- inputSchema: zodToJsonSchema(chooseFileSchema),
- },
- handle: async (context, params) => {
- const validatedParams = chooseFileSchema.parse(params);
- return await runAndWait(context, `Chose files ${validatedParams.paths.join(', ')}`, async () => {
- await context.submitFileChooser(validatedParams.paths);
- }, snapshot);
- },
-});
+export default (captureSnapshot: boolean) => [
+ close,
+ resize(captureSnapshot)
+];
diff --git a/src/tools/console.ts b/src/tools/console.ts
new file mode 100644
index 0000000..45bf3d7
--- /dev/null
+++ b/src/tools/console.ts
@@ -0,0 +1,47 @@
+/**
+ * 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 console = defineTool({
+ capability: 'core',
+ schema: {
+ name: 'browser_console_messages',
+ title: 'Get console messages',
+ description: 'Returns all console messages',
+ inputSchema: z.object({}),
+ type: 'readOnly',
+ },
+ handle: async context => {
+ const messages = context.currentTabOrDie().consoleMessages();
+ const log = messages.map(message => `[${message.type().toUpperCase()}] ${message.text()}`).join('\n');
+ return {
+ code: [`// `],
+ action: async () => {
+ return {
+ content: [{ type: 'text', text: log }]
+ };
+ },
+ captureSnapshot: false,
+ waitForNetwork: false,
+ };
+ },
+});
+
+export default [
+ console,
+];
diff --git a/src/tools/dialogs.ts b/src/tools/dialogs.ts
new file mode 100644
index 0000000..348e461
--- /dev/null
+++ b/src/tools/dialogs.ts
@@ -0,0 +1,62 @@
+/**
+ * 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, type ToolFactory } from './tool.js';
+
+const handleDialog: ToolFactory = captureSnapshot => defineTool({
+ capability: 'core',
+
+ schema: {
+ name: 'browser_handle_dialog',
+ title: 'Handle a dialog',
+ description: 'Handle a dialog',
+ inputSchema: z.object({
+ accept: z.boolean().describe('Whether to accept the dialog.'),
+ promptText: z.string().optional().describe('The text of the prompt in case of a prompt dialog.'),
+ }),
+ type: 'destructive',
+ },
+
+ handle: async (context, params) => {
+ const dialogState = context.modalStates().find(state => state.type === 'dialog');
+ if (!dialogState)
+ throw new Error('No dialog visible');
+
+ if (params.accept)
+ await dialogState.dialog.accept(params.promptText);
+ else
+ await dialogState.dialog.dismiss();
+
+ context.clearModalState(dialogState);
+
+ const code = [
+ `// `,
+ ];
+
+ return {
+ code,
+ captureSnapshot,
+ waitForNetwork: false,
+ };
+ },
+
+ clearsModalState: 'dialog',
+});
+
+export default (captureSnapshot: boolean) => [
+ handleDialog(captureSnapshot),
+];
diff --git a/src/tools/files.ts b/src/tools/files.ts
new file mode 100644
index 0000000..2dc7837
--- /dev/null
+++ b/src/tools/files.ts
@@ -0,0 +1,59 @@
+/**
+ * Copyright (c) Microsoft Corporation.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { z } from 'zod';
+import { defineTool, type ToolFactory } from './tool.js';
+
+const uploadFile: ToolFactory = captureSnapshot => defineTool({
+ capability: 'files',
+
+ schema: {
+ name: 'browser_file_upload',
+ title: 'Upload files',
+ description: 'Upload one or multiple files',
+ inputSchema: z.object({
+ paths: z.array(z.string()).describe('The absolute paths to the files to upload. Can be a single file or multiple files.'),
+ }),
+ type: 'destructive',
+ },
+
+ handle: async (context, params) => {
+ const modalState = context.modalStates().find(state => state.type === 'fileChooser');
+ if (!modalState)
+ throw new Error('No file chooser visible');
+
+ const code = [
+ `// {
+ await modalState.fileChooser.setFiles(params.paths);
+ context.clearModalState(modalState);
+ };
+
+ return {
+ code,
+ action,
+ captureSnapshot,
+ waitForNetwork: true,
+ };
+ },
+ clearsModalState: 'fileChooser',
+});
+
+export default (captureSnapshot: boolean) => [
+ uploadFile(captureSnapshot),
+];
diff --git a/src/tools/install.ts b/src/tools/install.ts
new file mode 100644
index 0000000..d0d5145
--- /dev/null
+++ b/src/tools/install.ts
@@ -0,0 +1,63 @@
+/**
+ * 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 { fork } from 'child_process';
+import path from 'path';
+
+import { z } from 'zod';
+import { defineTool } from './tool.js';
+
+import { fileURLToPath } from 'node:url';
+
+const install = defineTool({
+ capability: 'install',
+ schema: {
+ name: 'browser_install',
+ title: 'Install the browser specified in the config',
+ description: 'Install the browser specified in the config. Call this if you get an error about the browser not being installed.',
+ inputSchema: z.object({}),
+ type: 'destructive',
+ },
+
+ handle: async context => {
+ const channel = context.config.browser?.launchOptions?.channel ?? context.config.browser?.browserName ?? 'chrome';
+ const cliUrl = import.meta.resolve('playwright/package.json');
+ const cliPath = path.join(fileURLToPath(cliUrl), '..', 'cli.js');
+ const child = fork(cliPath, ['install', channel], {
+ stdio: 'pipe',
+ });
+ const output: string[] = [];
+ child.stdout?.on('data', data => output.push(data.toString()));
+ child.stderr?.on('data', data => output.push(data.toString()));
+ await new Promise((resolve, reject) => {
+ child.on('close', code => {
+ if (code === 0)
+ resolve();
+ else
+ reject(new Error(`Failed to install browser: ${output.join('')}`));
+ });
+ });
+ return {
+ code: [`// Browser ${channel} installed`],
+ captureSnapshot: false,
+ waitForNetwork: false,
+ };
+ },
+});
+
+export default [
+ install,
+];
diff --git a/src/tools/keyboard.ts b/src/tools/keyboard.ts
new file mode 100644
index 0000000..521aab2
--- /dev/null
+++ b/src/tools/keyboard.ts
@@ -0,0 +1,54 @@
+/**
+ * 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, type ToolFactory } from './tool.js';
+
+const pressKey: ToolFactory = captureSnapshot => defineTool({
+ capability: 'core',
+
+ schema: {
+ name: 'browser_press_key',
+ title: 'Press a key',
+ description: 'Press a key on the keyboard',
+ inputSchema: z.object({
+ key: z.string().describe('Name of the key to press or a character to generate, such as `ArrowLeft` or `a`'),
+ }),
+ type: 'destructive',
+ },
+
+ handle: async (context, params) => {
+ const tab = context.currentTabOrDie();
+
+ const code = [
+ `// Press ${params.key}`,
+ `await page.keyboard.press('${params.key}');`,
+ ];
+
+ const action = () => tab.page.keyboard.press(params.key);
+
+ return {
+ code,
+ action,
+ captureSnapshot,
+ waitForNetwork: true
+ };
+ },
+});
+
+export default (captureSnapshot: boolean) => [
+ pressKey(captureSnapshot),
+];
diff --git a/src/tools/navigate.ts b/src/tools/navigate.ts
new file mode 100644
index 0000000..501576e
--- /dev/null
+++ b/src/tools/navigate.ts
@@ -0,0 +1,104 @@
+/**
+ * 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, type ToolFactory } from './tool.js';
+
+const navigate: ToolFactory = captureSnapshot => defineTool({
+ capability: 'core',
+
+ schema: {
+ name: 'browser_navigate',
+ title: 'Navigate to a URL',
+ description: 'Navigate to a URL',
+ inputSchema: z.object({
+ url: z.string().describe('The URL to navigate to'),
+ }),
+ type: 'destructive',
+ },
+
+ handle: async (context, params) => {
+ const tab = await context.ensureTab();
+ await tab.navigate(params.url);
+
+ const code = [
+ `// Navigate to ${params.url}`,
+ `await page.goto('${params.url}');`,
+ ];
+
+ return {
+ code,
+ captureSnapshot,
+ waitForNetwork: false,
+ };
+ },
+});
+
+const goBack: ToolFactory = captureSnapshot => defineTool({
+ capability: 'history',
+ schema: {
+ name: 'browser_navigate_back',
+ title: 'Go back',
+ description: 'Go back to the previous page',
+ inputSchema: z.object({}),
+ type: 'readOnly',
+ },
+
+ handle: async context => {
+ const tab = await context.ensureTab();
+ await tab.page.goBack();
+ const code = [
+ `// Navigate back`,
+ `await page.goBack();`,
+ ];
+
+ return {
+ code,
+ captureSnapshot,
+ waitForNetwork: false,
+ };
+ },
+});
+
+const goForward: ToolFactory = captureSnapshot => defineTool({
+ capability: 'history',
+ schema: {
+ name: 'browser_navigate_forward',
+ title: 'Go forward',
+ description: 'Go forward to the next page',
+ inputSchema: z.object({}),
+ type: 'readOnly',
+ },
+ handle: async context => {
+ const tab = context.currentTabOrDie();
+ await tab.page.goForward();
+ const code = [
+ `// Navigate forward`,
+ `await page.goForward();`,
+ ];
+ return {
+ code,
+ captureSnapshot,
+ waitForNetwork: false,
+ };
+ },
+});
+
+export default (captureSnapshot: boolean) => [
+ navigate(captureSnapshot),
+ goBack(captureSnapshot),
+ goForward(captureSnapshot),
+];
diff --git a/src/tools/network.ts b/src/tools/network.ts
new file mode 100644
index 0000000..9e1946c
--- /dev/null
+++ b/src/tools/network.ts
@@ -0,0 +1,59 @@
+/**
+ * Copyright (c) Microsoft Corporation.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { z } from 'zod';
+import { defineTool } from './tool.js';
+
+import type * as playwright from 'playwright';
+
+const requests = defineTool({
+ capability: 'core',
+
+ schema: {
+ name: 'browser_network_requests',
+ title: 'List network requests',
+ description: 'Returns all network requests since loading the page',
+ inputSchema: z.object({}),
+ type: 'readOnly',
+ },
+
+ handle: async context => {
+ const requests = context.currentTabOrDie().requests();
+ const log = [...requests.entries()].map(([request, response]) => renderRequest(request, response)).join('\n');
+ return {
+ code: [`// `],
+ action: async () => {
+ return {
+ content: [{ type: 'text', text: log }]
+ };
+ },
+ captureSnapshot: false,
+ waitForNetwork: false,
+ };
+ },
+});
+
+function renderRequest(request: playwright.Request, response: playwright.Response | null) {
+ const result: string[] = [];
+ result.push(`[${request.method().toUpperCase()}] ${request.url()}`);
+ if (response)
+ result.push(`=> [${response.status()}] ${response.statusText()}`);
+ return result.join(' ');
+}
+
+export default [
+ requests,
+];
diff --git a/src/tools/pdf.ts b/src/tools/pdf.ts
new file mode 100644
index 0000000..c020f03
--- /dev/null
+++ b/src/tools/pdf.ts
@@ -0,0 +1,58 @@
+/**
+ * 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 { outputFile } from '../config.js';
+
+const pdfSchema = z.object({
+ filename: z.string().optional().describe('File name to save the pdf to. Defaults to `page-{timestamp}.pdf` if not specified.'),
+});
+
+const pdf = defineTool({
+ capability: 'pdf',
+
+ schema: {
+ name: 'browser_pdf_save',
+ title: 'Save as PDF',
+ description: 'Save page as PDF',
+ inputSchema: pdfSchema,
+ type: 'readOnly',
+ },
+
+ handle: async (context, params) => {
+ const tab = context.currentTabOrDie();
+ const fileName = await outputFile(context.config, params.filename ?? `page-${new Date().toISOString()}.pdf`);
+
+ const code = [
+ `// Save page as ${fileName}`,
+ `await page.pdf(${javascript.formatObject({ path: fileName })});`,
+ ];
+
+ return {
+ code,
+ action: async () => tab.page.pdf({ path: fileName }).then(() => {}),
+ captureSnapshot: false,
+ waitForNetwork: false,
+ };
+ },
+});
+
+export default [
+ pdf,
+];
diff --git a/src/tools/screenshot.ts b/src/tools/screenshot.ts
index cef2ba1..439d79a 100644
--- a/src/tools/screenshot.ts
+++ b/src/tools/screenshot.ts
@@ -15,119 +15,76 @@
*/
import { z } from 'zod';
-import { zodToJsonSchema } from 'zod-to-json-schema';
-import { runAndWait } from './utils';
-
-import type { Tool } from './tool';
-
-export const screenshot: Tool = {
- schema: {
- name: 'browser_screenshot',
- description: 'Take a screenshot of the current page',
- inputSchema: zodToJsonSchema(z.object({})),
- },
-
- handle: async context => {
- const page = context.existingPage();
- const screenshot = await page.screenshot({ type: 'jpeg', quality: 50, scale: 'css' });
- return {
- content: [{ type: 'image', data: screenshot.toString('base64'), mimeType: 'image/jpeg' }],
- };
- },
-};
-
-const elementSchema = z.object({
- element: z.string().describe('Human-readable element description used to obtain permission to interact with the element'),
+import { defineTool } from './tool.js';
+import * as javascript from '../javascript.js';
+import { outputFile } from '../config.js';
+import { generateLocator } from './utils.js';
+
+import type * as playwright from 'playwright';
+
+const screenshotSchema = z.object({
+ raw: z.boolean().optional().describe('Whether to return without compression (in PNG format). Default is false, which returns a JPEG image.'),
+ filename: z.string().optional().describe('File name to save the screenshot to. Defaults to `page-{timestamp}.{png|jpeg}` if not specified.'),
+ element: z.string().optional().describe('Human-readable element description used to obtain permission to screenshot the element. If not provided, the screenshot will be taken of viewport. If element is provided, ref must be provided too.'),
+ ref: z.string().optional().describe('Exact target element reference from the page snapshot. If not provided, the screenshot will be taken of viewport. If ref is provided, element must be provided too.'),
+}).refine(data => {
+ return !!data.element === !!data.ref;
+}, {
+ message: 'Both element and ref must be provided or neither.',
+ path: ['ref', 'element']
});
-const moveMouseSchema = elementSchema.extend({
- x: z.number().describe('X coordinate'),
- y: z.number().describe('Y coordinate'),
-});
-
-export const moveMouse: Tool = {
+const screenshot = defineTool({
+ capability: 'core',
schema: {
- name: 'browser_move_mouse',
- description: 'Move mouse to a given position',
- inputSchema: zodToJsonSchema(moveMouseSchema),
+ name: 'browser_take_screenshot',
+ title: 'Take a screenshot',
+ description: `Take a screenshot of the current page. You can't perform actions based on the screenshot, use browser_snapshot for actions.`,
+ inputSchema: screenshotSchema,
+ type: 'readOnly',
},
handle: async (context, params) => {
- const validatedParams = moveMouseSchema.parse(params);
- const page = context.existingPage();
- await page.mouse.move(validatedParams.x, validatedParams.y);
- return {
- content: [{ type: 'text', text: `Moved mouse to (${validatedParams.x}, ${validatedParams.y})` }],
+ const tab = context.currentTabOrDie();
+ const snapshot = tab.snapshotOrDie();
+ const fileType = params.raw ? 'png' : 'jpeg';
+ const fileName = await outputFile(context.config, params.filename ?? `page-${new Date().toISOString()}.${fileType}`);
+ const options: playwright.PageScreenshotOptions = { type: fileType, quality: fileType === 'png' ? undefined : 50, scale: 'css', path: fileName };
+ const isElementScreenshot = params.element && params.ref;
+
+ const code = [
+ `// Screenshot ${isElementScreenshot ? params.element : 'viewport'} and save it as ${fileName}`,
+ ];
+
+ const locator = params.ref ? snapshot.refLocator({ element: params.element || '', ref: params.ref }) : null;
+
+ if (locator)
+ code.push(`await page.${await generateLocator(locator)}.screenshot(${javascript.formatObject(options)});`);
+ else
+ code.push(`await page.screenshot(${javascript.formatObject(options)});`);
+
+ const includeBase64 = context.clientSupportsImages();
+ const action = async () => {
+ const screenshot = locator ? await locator.screenshot(options) : await tab.page.screenshot(options);
+ return {
+ content: includeBase64 ? [{
+ type: 'image' as 'image',
+ data: screenshot.toString('base64'),
+ mimeType: fileType === 'png' ? 'image/png' : 'image/jpeg',
+ }] : []
+ };
};
- },
-};
-
-const clickSchema = elementSchema.extend({
- x: z.number().describe('X coordinate'),
- y: z.number().describe('Y coordinate'),
-});
-
-export const click: Tool = {
- schema: {
- name: 'browser_click',
- description: 'Click left mouse button',
- inputSchema: zodToJsonSchema(clickSchema),
- },
- handle: async (context, params) => {
- return await runAndWait(context, 'Clicked mouse', async page => {
- const validatedParams = clickSchema.parse(params);
- await page.mouse.move(validatedParams.x, validatedParams.y);
- await page.mouse.down();
- await page.mouse.up();
- });
- },
-};
-
-const dragSchema = elementSchema.extend({
- startX: z.number().describe('Start X coordinate'),
- startY: z.number().describe('Start Y coordinate'),
- endX: z.number().describe('End X coordinate'),
- endY: z.number().describe('End Y coordinate'),
-});
-
-export const drag: Tool = {
- schema: {
- name: 'browser_drag',
- description: 'Drag left mouse button',
- inputSchema: zodToJsonSchema(dragSchema),
- },
-
- handle: async (context, params) => {
- const validatedParams = dragSchema.parse(params);
- return await runAndWait(context, `Dragged mouse from (${validatedParams.startX}, ${validatedParams.startY}) to (${validatedParams.endX}, ${validatedParams.endY})`, async page => {
- await page.mouse.move(validatedParams.startX, validatedParams.startY);
- await page.mouse.down();
- await page.mouse.move(validatedParams.endX, validatedParams.endY);
- await page.mouse.up();
- });
- },
-};
-
-const typeSchema = z.object({
- text: z.string().describe('Text to type into the element'),
- submit: z.boolean().describe('Whether to submit entered text (press Enter after)'),
+ return {
+ code,
+ action,
+ captureSnapshot: true,
+ waitForNetwork: false,
+ };
+ }
});
-export const type: Tool = {
- schema: {
- name: 'browser_type',
- description: 'Type text',
- inputSchema: zodToJsonSchema(typeSchema),
- },
-
- handle: async (context, params) => {
- const validatedParams = typeSchema.parse(params);
- return await runAndWait(context, `Typed text "${validatedParams.text}"`, async page => {
- await page.keyboard.type(validatedParams.text);
- if (validatedParams.submit)
- await page.keyboard.press('Enter');
- });
- },
-};
+export default [
+ screenshot,
+];
diff --git a/src/tools/snapshot.ts b/src/tools/snapshot.ts
index 4e75805..49aaf52 100644
--- a/src/tools/snapshot.ts
+++ b/src/tools/snapshot.ts
@@ -15,141 +15,212 @@
*/
import { z } from 'zod';
-import zodToJsonSchema from 'zod-to-json-schema';
-import { captureAriaSnapshot, runAndWait } from './utils';
+import { defineTool } from './tool.js';
+import * as javascript from '../javascript.js';
+import { generateLocator } from './utils.js';
-import type * as playwright from 'playwright';
-import type { Tool } from './tool';
-
-export const snapshot: Tool = {
+const snapshot = defineTool({
+ capability: 'core',
schema: {
name: 'browser_snapshot',
+ title: 'Page snapshot',
description: 'Capture accessibility snapshot of the current page, this is better than screenshot',
- inputSchema: zodToJsonSchema(z.object({})),
+ inputSchema: z.object({}),
+ type: 'readOnly',
},
handle: async context => {
- return await captureAriaSnapshot(context);
+ await context.ensureTab();
+
+ return {
+ code: [`// `],
+ captureSnapshot: true,
+ waitForNetwork: false,
+ };
},
-};
+});
const elementSchema = z.object({
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'),
});
-export const click: Tool = {
+const click = defineTool({
+ capability: 'core',
schema: {
name: 'browser_click',
+ title: 'Click',
description: 'Perform click on a web page',
- inputSchema: zodToJsonSchema(elementSchema),
+ inputSchema: elementSchema,
+ type: 'destructive',
},
handle: async (context, params) => {
- const validatedParams = elementSchema.parse(params);
- return runAndWait(context, `"${validatedParams.element}" clicked`, () => context.refLocator(validatedParams.ref).click(), true);
- },
-};
+ const tab = context.currentTabOrDie();
+ const locator = tab.snapshotOrDie().refLocator(params);
-const dragSchema = z.object({
- startElement: z.string().describe('Human-readable source element description used to obtain the permission to interact with the element'),
- startRef: z.string().describe('Exact source element reference from the page snapshot'),
- endElement: z.string().describe('Human-readable target element description used to obtain the permission to interact with the element'),
- endRef: z.string().describe('Exact target element reference from the page snapshot'),
+ const code = [
+ `// Click ${params.element}`,
+ `await page.${await generateLocator(locator)}.click();`
+ ];
+
+ return {
+ code,
+ action: () => locator.click(),
+ captureSnapshot: true,
+ waitForNetwork: true,
+ };
+ },
});
-export const drag: Tool = {
+const drag = defineTool({
+ capability: 'core',
schema: {
name: 'browser_drag',
+ title: 'Drag mouse',
description: 'Perform drag and drop between two elements',
- inputSchema: zodToJsonSchema(dragSchema),
+ inputSchema: z.object({
+ startElement: z.string().describe('Human-readable source element description used to obtain the permission to interact with the element'),
+ startRef: z.string().describe('Exact source element reference from the page snapshot'),
+ endElement: z.string().describe('Human-readable target element description used to obtain the permission to interact with the element'),
+ endRef: z.string().describe('Exact target element reference from the page snapshot'),
+ }),
+ type: 'destructive',
},
handle: async (context, params) => {
- const validatedParams = dragSchema.parse(params);
- return runAndWait(context, `Dragged "${validatedParams.startElement}" to "${validatedParams.endElement}"`, async () => {
- const startLocator = context.refLocator(validatedParams.startRef);
- const endLocator = context.refLocator(validatedParams.endRef);
- await startLocator.dragTo(endLocator);
- }, true);
+ const snapshot = context.currentTabOrDie().snapshotOrDie();
+ const startLocator = snapshot.refLocator({ ref: params.startRef, element: params.startElement });
+ const endLocator = snapshot.refLocator({ ref: params.endRef, element: params.endElement });
+
+ const code = [
+ `// Drag ${params.startElement} to ${params.endElement}`,
+ `await page.${await generateLocator(startLocator)}.dragTo(page.${await generateLocator(endLocator)});`
+ ];
+
+ return {
+ code,
+ action: () => startLocator.dragTo(endLocator),
+ captureSnapshot: true,
+ waitForNetwork: true,
+ };
},
-};
+});
-export const hover: Tool = {
+const hover = defineTool({
+ capability: 'core',
schema: {
name: 'browser_hover',
+ title: 'Hover mouse',
description: 'Hover over element on page',
- inputSchema: zodToJsonSchema(elementSchema),
+ inputSchema: elementSchema,
+ type: 'readOnly',
},
handle: async (context, params) => {
- const validatedParams = elementSchema.parse(params);
- return runAndWait(context, `Hovered over "${validatedParams.element}"`, () => context.refLocator(validatedParams.ref).hover(), true);
+ const snapshot = context.currentTabOrDie().snapshotOrDie();
+ const locator = snapshot.refLocator(params);
+
+ const code = [
+ `// Hover over ${params.element}`,
+ `await page.${await generateLocator(locator)}.hover();`
+ ];
+
+ return {
+ code,
+ action: () => locator.hover(),
+ captureSnapshot: true,
+ waitForNetwork: true,
+ };
},
-};
+});
const typeSchema = elementSchema.extend({
text: z.string().describe('Text to type into the element'),
- submit: z.boolean().describe('Whether to submit entered text (press Enter after)'),
+ 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.'),
});
-export const type: Tool = {
+const type = defineTool({
+ capability: 'core',
schema: {
name: 'browser_type',
+ title: 'Type text',
description: 'Type text into editable element',
- inputSchema: zodToJsonSchema(typeSchema),
+ inputSchema: typeSchema,
+ type: 'destructive',
},
handle: async (context, params) => {
- const validatedParams = typeSchema.parse(params);
- return await runAndWait(context, `Typed "${validatedParams.text}" into "${validatedParams.element}"`, async () => {
- const locator = context.refLocator(validatedParams.ref);
- await locator.fill(validatedParams.text);
- if (validatedParams.submit)
- await locator.press('Enter');
- }, true);
+ const snapshot = context.currentTabOrDie().snapshotOrDie();
+ const locator = snapshot.refLocator(params);
+
+ const code: string[] = [];
+ const steps: (() => Promise)[] = [];
+
+ 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({
values: z.array(z.string()).describe('Array of values to select in the dropdown. This can be a single value or multiple values.'),
});
-export const selectOption: Tool = {
+const selectOption = defineTool({
+ capability: 'core',
schema: {
name: 'browser_select_option',
+ title: 'Select option',
description: 'Select an option in a dropdown',
- inputSchema: zodToJsonSchema(selectOptionSchema),
+ inputSchema: selectOptionSchema,
+ type: 'destructive',
},
handle: async (context, params) => {
- const validatedParams = selectOptionSchema.parse(params);
- return await runAndWait(context, `Selected option in "${validatedParams.element}"`, async () => {
- const locator = context.refLocator(validatedParams.ref);
- await locator.selectOption(validatedParams.values);
- }, true);
- },
-};
-
-const screenshotSchema = z.object({
- raw: z.boolean().optional().describe('Whether to return without compression (in PNG format). Default is false, which returns a JPEG image.'),
-});
+ const snapshot = context.currentTabOrDie().snapshotOrDie();
+ const locator = snapshot.refLocator(params);
-export const screenshot: Tool = {
- schema: {
- name: 'browser_take_screenshot',
- description: `Take a screenshot of the current page. You can't perform actions based on the screenshot, use browser_snapshot for actions.`,
- inputSchema: zodToJsonSchema(screenshotSchema),
- },
+ const code = [
+ `// Select options [${params.values.join(', ')}] in ${params.element}`,
+ `await page.${await generateLocator(locator)}.selectOption(${javascript.formatObject(params.values)});`
+ ];
- handle: async (context, params) => {
- const validatedParams = screenshotSchema.parse(params);
- const page = context.existingPage();
- const options: playwright.PageScreenshotOptions = validatedParams.raw ? { type: 'png', scale: 'css' } : { type: 'jpeg', quality: 50, scale: 'css' };
- const screenshot = await page.screenshot(options);
return {
- content: [{ type: 'image', data: screenshot.toString('base64'), mimeType: validatedParams.raw ? 'image/png' : 'image/jpeg' }],
+ code,
+ action: () => locator.selectOption(params.values).then(() => {}),
+ captureSnapshot: true,
+ waitForNetwork: true,
};
},
-};
+});
+
+export default [
+ snapshot,
+ click,
+ drag,
+ hover,
+ type,
+ selectOption,
+];
diff --git a/src/tools/tabs.ts b/src/tools/tabs.ts
new file mode 100644
index 0000000..4133bf1
--- /dev/null
+++ b/src/tools/tabs.ts
@@ -0,0 +1,134 @@
+/**
+ * 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, type ToolFactory } from './tool.js';
+
+const listTabs = defineTool({
+ capability: 'tabs',
+
+ schema: {
+ name: 'browser_tab_list',
+ title: 'List tabs',
+ description: 'List browser tabs',
+ inputSchema: z.object({}),
+ type: 'readOnly',
+ },
+
+ handle: async context => {
+ await context.ensureTab();
+ return {
+ code: [`// `],
+ captureSnapshot: false,
+ waitForNetwork: false,
+ resultOverride: {
+ content: [{
+ type: 'text',
+ text: await context.listTabsMarkdown(),
+ }],
+ },
+ };
+ },
+});
+
+const selectTab: ToolFactory = captureSnapshot => defineTool({
+ capability: 'tabs',
+
+ schema: {
+ name: 'browser_tab_select',
+ title: 'Select a tab',
+ description: 'Select a tab by index',
+ inputSchema: z.object({
+ index: z.number().describe('The index of the tab to select'),
+ }),
+ type: 'readOnly',
+ },
+
+ handle: async (context, params) => {
+ await context.selectTab(params.index);
+ const code = [
+ `// `,
+ ];
+
+ return {
+ code,
+ captureSnapshot,
+ waitForNetwork: false
+ };
+ },
+});
+
+const newTab: ToolFactory = captureSnapshot => defineTool({
+ capability: 'tabs',
+
+ schema: {
+ name: 'browser_tab_new',
+ title: 'Open a new tab',
+ description: 'Open a new tab',
+ inputSchema: z.object({
+ url: z.string().optional().describe('The URL to navigate to in the new tab. If not provided, the new tab will be blank.'),
+ }),
+ type: 'readOnly',
+ },
+
+ handle: async (context, params) => {
+ await context.newTab();
+ if (params.url)
+ await context.currentTabOrDie().navigate(params.url);
+
+ const code = [
+ `// `,
+ ];
+ return {
+ code,
+ captureSnapshot,
+ waitForNetwork: false
+ };
+ },
+});
+
+const closeTab: ToolFactory = captureSnapshot => defineTool({
+ capability: 'tabs',
+
+ schema: {
+ name: 'browser_tab_close',
+ title: 'Close a tab',
+ description: 'Close a tab',
+ inputSchema: z.object({
+ index: z.number().optional().describe('The index of the tab to close. Closes current tab if not provided.'),
+ }),
+ type: 'destructive',
+ },
+
+ handle: async (context, params) => {
+ await context.closeTab(params.index);
+ const code = [
+ `// `,
+ ];
+ return {
+ code,
+ captureSnapshot,
+ waitForNetwork: false
+ };
+ },
+});
+
+export default (captureSnapshot: boolean) => [
+ listTabs,
+ newTab(captureSnapshot),
+ selectTab(captureSnapshot),
+ closeTab(captureSnapshot),
+];
diff --git a/src/tools/testing.ts b/src/tools/testing.ts
new file mode 100644
index 0000000..9518d19
--- /dev/null
+++ b/src/tools/testing.ts
@@ -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,
+];
diff --git a/src/tools/tool.ts b/src/tools/tool.ts
index 19f1b60..4b88c89 100644
--- a/src/tools/tool.ts
+++ b/src/tools/tool.ts
@@ -14,24 +14,55 @@
* limitations under the License.
*/
-import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types';
-import type { JsonSchema7Type } from 'zod-to-json-schema';
-import type { Context } from '../context';
+import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types.js';
+import type { z } from 'zod';
+import type { Context } from '../context.js';
+import type * as playwright from 'playwright';
+import type { ToolCapability } from '../../config.js';
-export type ToolSchema = {
+export type ToolSchema = {
name: string;
+ title: string;
description: string;
- inputSchema: JsonSchema7Type;
+ inputSchema: Input;
+ type: 'readOnly' | 'destructive';
};
+type InputType = z.Schema;
+
+export type FileUploadModalState = {
+ type: 'fileChooser';
+ description: string;
+ fileChooser: playwright.FileChooser;
+};
+
+export type DialogModalState = {
+ type: 'dialog';
+ description: string;
+ dialog: playwright.Dialog;
+};
+
+export type ModalState = FileUploadModalState | DialogModalState;
+
+export type ToolActionResult = { content?: (ImageContent | TextContent)[] } | undefined | void;
+
export type ToolResult = {
- content: (ImageContent | TextContent)[];
- isError?: boolean;
+ code: string[];
+ action?: () => Promise;
+ captureSnapshot: boolean;
+ waitForNetwork: boolean;
+ resultOverride?: ToolActionResult;
};
-export type Tool = {
- schema: ToolSchema;
- handle: (context: Context, params?: Record) => Promise;
+export type Tool = {
+ capability: ToolCapability;
+ schema: ToolSchema;
+ clearsModalState?: ModalState['type'];
+ handle: (context: Context, params: z.output) => Promise;
};
-export type ToolFactory = (snapshot: boolean) => Tool;
+export type ToolFactory = (snapshot: boolean) => Tool;
+
+export function defineTool(tool: Tool): Tool {
+ return tool;
+}
diff --git a/src/tools/utils.ts b/src/tools/utils.ts
index 29b6dc9..497b038 100644
--- a/src/tools/utils.ts
+++ b/src/tools/utils.ts
@@ -15,10 +15,10 @@
*/
import type * as playwright from 'playwright';
-import type { ToolResult } from './tool';
-import type { Context } from '../context';
+import type { Context } from '../context.js';
+import type { Tab } from '../tab.js';
-async function waitForCompletion(page: playwright.Page, callback: () => Promise): Promise {
+export async function waitForCompletion(context: Context, tab: Tab, callback: () => Promise): Promise {
const requests = new Set();
let frameNavigated = false;
let waitCallback: () => void = () => {};
@@ -37,9 +37,7 @@ async function waitForCompletion(page: playwright.Page, callback: () => Promi
frameNavigated = true;
dispose();
clearTimeout(timeout);
- void frame.waitForLoadState('load').then(() => {
- waitCallback();
- });
+ void tab.waitForLoadState('load').then(waitCallback);
};
const onTimeout = () => {
@@ -47,15 +45,15 @@ async function waitForCompletion(page: playwright.Page, callback: () => Promi
waitCallback();
};
- page.on('request', requestListener);
- page.on('requestfinished', requestFinishedListener);
- page.on('framenavigated', frameNavigateListener);
+ tab.page.on('request', requestListener);
+ tab.page.on('requestfinished', requestFinishedListener);
+ tab.page.on('framenavigated', frameNavigateListener);
const timeout = setTimeout(onTimeout, 10000);
const dispose = () => {
- page.off('request', requestListener);
- page.off('requestfinished', requestFinishedListener);
- page.off('framenavigated', frameNavigateListener);
+ tab.page.off('request', requestListener);
+ tab.page.off('requestfinished', requestFinishedListener);
+ tab.page.off('framenavigated', frameNavigateListener);
clearTimeout(timeout);
};
@@ -64,45 +62,25 @@ async function waitForCompletion(page: playwright.Page, callback: () => Promi
if (!requests.size && !frameNavigated)
waitCallback();
await waitBarrier;
- await page.evaluate(() => new Promise(f => setTimeout(f, 1000)));
+ await context.waitForTimeout(1000);
return result;
} finally {
dispose();
}
}
-export async function runAndWait(context: Context, status: string, callback: (page: playwright.Page) => Promise, snapshot: boolean = false): Promise {
- const page = context.existingPage();
- const dismissFileChooser = context.hasFileChooser();
- await waitForCompletion(page, () => callback(page));
- if (dismissFileChooser)
- context.clearFileChooser();
- const result: ToolResult = snapshot ? await captureAriaSnapshot(context, status) : {
- content: [{ type: 'text', text: status }],
- };
- return result;
+export function sanitizeForFilePath(s: string) {
+ const sanitize = (s: string) => s.replace(/[\x00-\x2C\x2E-\x2F\x3A-\x40\x5B-\x60\x7B-\x7F]+/g, '-');
+ const separator = s.lastIndexOf('.');
+ if (separator === -1)
+ return sanitize(s);
+ return sanitize(s.substring(0, separator)) + '.' + sanitize(s.substring(separator + 1));
}
-export async function captureAriaSnapshot(context: Context, status: string = ''): Promise {
- const page = context.existingPage();
- const lines = [];
- if (status)
- lines.push(`${status}`);
- lines.push(
- '',
- `- Page URL: ${page.url()}`,
- `- Page Title: ${await page.title()}`
- );
- if (context.hasFileChooser())
- lines.push(`- There is a file chooser visible that requires browser_choose_file to be called`);
- lines.push(
- `- Page Snapshot`,
- '```yaml',
- await context.allFramesSnapshot(),
- '```',
- ''
- );
- return {
- content: [{ type: 'text', text: lines.join('\n') }],
- };
+export async function generateLocator(locator: playwright.Locator): Promise {
+ return (locator as any)._generateLocatorString();
+}
+
+export async function callOnPageNoTrace(page: playwright.Page, callback: (page: playwright.Page) => Promise): Promise {
+ return await (page as any)._wrapApiCall(() => callback(page), { internal: true });
}
diff --git a/src/tools/vision.ts b/src/tools/vision.ts
new file mode 100644
index 0000000..a380311
--- /dev/null
+++ b/src/tools/vision.ts
@@ -0,0 +1,213 @@
+/**
+ * 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';
+
+const elementSchema = z.object({
+ element: z.string().describe('Human-readable element description used to obtain permission to interact with the element'),
+});
+
+const screenshot = defineTool({
+ capability: 'core',
+ schema: {
+ 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',
+ description: 'Move mouse to a given position',
+ inputSchema: elementSchema.extend({
+ x: z.number().describe('X coordinate'),
+ y: z.number().describe('Y coordinate'),
+ }),
+ type: 'readOnly',
+ },
+
+ handle: async (context, params) => {
+ const tab = context.currentTabOrDie();
+ const code = [
+ `// Move mouse to (${params.x}, ${params.y})`,
+ `await page.mouse.move(${params.x}, ${params.y});`,
+ ];
+ const action = () => tab.page.mouse.move(params.x, params.y);
+ return {
+ code,
+ action,
+ captureSnapshot: false,
+ waitForNetwork: false
+ };
+ },
+});
+
+const click = defineTool({
+ capability: 'core',
+ schema: {
+ name: 'browser_screen_click',
+ title: 'Click',
+ description: 'Click left mouse button',
+ inputSchema: elementSchema.extend({
+ x: z.number().describe('X coordinate'),
+ y: z.number().describe('Y coordinate'),
+ }),
+ type: 'destructive',
+ },
+
+ handle: async (context, params) => {
+ const tab = context.currentTabOrDie();
+ const code = [
+ `// Click mouse at coordinates (${params.x}, ${params.y})`,
+ `await page.mouse.move(${params.x}, ${params.y});`,
+ `await page.mouse.down();`,
+ `await page.mouse.up();`,
+ ];
+ const action = async () => {
+ await tab.page.mouse.move(params.x, params.y);
+ await tab.page.mouse.down();
+ await tab.page.mouse.up();
+ };
+ return {
+ code,
+ action,
+ captureSnapshot: false,
+ waitForNetwork: true,
+ };
+ },
+});
+
+const drag = defineTool({
+ capability: 'core',
+ schema: {
+ name: 'browser_screen_drag',
+ title: 'Drag mouse',
+ description: 'Drag left mouse button',
+ inputSchema: elementSchema.extend({
+ startX: z.number().describe('Start X coordinate'),
+ startY: z.number().describe('Start Y coordinate'),
+ endX: z.number().describe('End X coordinate'),
+ endY: z.number().describe('End Y coordinate'),
+ }),
+ type: 'destructive',
+ },
+
+ handle: async (context, params) => {
+ const tab = context.currentTabOrDie();
+
+ const code = [
+ `// Drag mouse from (${params.startX}, ${params.startY}) to (${params.endX}, ${params.endY})`,
+ `await page.mouse.move(${params.startX}, ${params.startY});`,
+ `await page.mouse.down();`,
+ `await page.mouse.move(${params.endX}, ${params.endY});`,
+ `await page.mouse.up();`,
+ ];
+
+ const action = async () => {
+ await tab.page.mouse.move(params.startX, params.startY);
+ await tab.page.mouse.down();
+ await tab.page.mouse.move(params.endX, params.endY);
+ await tab.page.mouse.up();
+ };
+
+ return {
+ code,
+ action,
+ captureSnapshot: false,
+ waitForNetwork: true,
+ };
+ },
+});
+
+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 [
+ screenshot,
+ moveMouse,
+ click,
+ drag,
+ type,
+];
diff --git a/src/tools/wait.ts b/src/tools/wait.ts
new file mode 100644
index 0000000..fc8be82
--- /dev/null
+++ b/src/tools/wait.ts
@@ -0,0 +1,70 @@
+/**
+ * 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, type ToolFactory } from './tool.js';
+
+const wait: ToolFactory = captureSnapshot => defineTool({
+ capability: 'wait',
+
+ schema: {
+ name: 'browser_wait_for',
+ title: 'Wait for',
+ description: 'Wait for text to appear or disappear or a specified time to pass',
+ inputSchema: z.object({
+ time: z.number().optional().describe('The time to wait in seconds'),
+ text: z.string().optional().describe('The text to wait for'),
+ textGone: z.string().optional().describe('The text to wait for to disappear'),
+ }),
+ type: 'readOnly',
+ },
+
+ handle: async (context, params) => {
+ if (!params.text && !params.textGone && !params.time)
+ throw new Error('Either time, text or textGone must be provided');
+
+ const code: string[] = [];
+
+ if (params.time) {
+ code.push(`await new Promise(f => setTimeout(f, ${params.time!} * 1000));`);
+ await new Promise(f => setTimeout(f, Math.min(10000, params.time! * 1000)));
+ }
+
+ const tab = context.currentTabOrDie();
+ const locator = params.text ? tab.page.getByText(params.text).first() : undefined;
+ const goneLocator = params.textGone ? tab.page.getByText(params.textGone).first() : undefined;
+
+ if (goneLocator) {
+ code.push(`await page.getByText(${JSON.stringify(params.textGone)}).first().waitFor({ state: 'hidden' });`);
+ await goneLocator.waitFor({ state: 'hidden' });
+ }
+
+ if (locator) {
+ code.push(`await page.getByText(${JSON.stringify(params.text)}).first().waitFor({ state: 'visible' });`);
+ await locator.waitFor({ state: 'visible' });
+ }
+
+ return {
+ code,
+ captureSnapshot,
+ waitForNetwork: false,
+ };
+ },
+});
+
+export default (captureSnapshot: boolean) => [
+ wait(captureSnapshot),
+];
diff --git a/src/transport.ts b/src/transport.ts
new file mode 100644
index 0000000..14f6a8d
--- /dev/null
+++ b/src/transport.ts
@@ -0,0 +1,138 @@
+/**
+ * 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 http from 'node:http';
+import assert from 'node:assert';
+import crypto from 'node:crypto';
+
+import debug from 'debug';
+import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
+import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
+import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
+
+import type { Server } from './server.js';
+
+export async function startStdioTransport(server: Server) {
+ await server.createConnection(new StdioServerTransport());
+}
+
+const testDebug = debug('pw:mcp:test');
+
+async function handleSSE(server: Server, req: http.IncomingMessage, res: http.ServerResponse, url: URL, sessions: Map) {
+ if (req.method === 'POST') {
+ const sessionId = url.searchParams.get('sessionId');
+ if (!sessionId) {
+ res.statusCode = 400;
+ return res.end('Missing sessionId');
+ }
+
+ const transport = sessions.get(sessionId);
+ if (!transport) {
+ res.statusCode = 404;
+ return res.end('Session not found');
+ }
+
+ return await transport.handlePostMessage(req, res);
+ } else if (req.method === 'GET') {
+ const transport = new SSEServerTransport('/sse', res);
+ sessions.set(transport.sessionId, transport);
+ testDebug(`create SSE session: ${transport.sessionId}`);
+ const connection = await server.createConnection(transport);
+ res.on('close', () => {
+ testDebug(`delete SSE session: ${transport.sessionId}`);
+ sessions.delete(transport.sessionId);
+ // eslint-disable-next-line no-console
+ void connection.close().catch(e => console.error(e));
+ });
+ return;
+ }
+
+ res.statusCode = 405;
+ res.end('Method not allowed');
+}
+
+async function handleStreamable(server: Server, req: http.IncomingMessage, res: http.ServerResponse, sessions: Map) {
+ const sessionId = req.headers['mcp-session-id'] as string | undefined;
+ if (sessionId) {
+ const transport = sessions.get(sessionId);
+ if (!transport) {
+ res.statusCode = 404;
+ res.end('Session not found');
+ return;
+ }
+ return await transport.handleRequest(req, res);
+ }
+
+ if (req.method === 'POST') {
+ const transport = new StreamableHTTPServerTransport({
+ sessionIdGenerator: () => crypto.randomUUID(),
+ onsessioninitialized: sessionId => {
+ sessions.set(sessionId, transport);
+ }
+ });
+ transport.onclose = () => {
+ if (transport.sessionId)
+ sessions.delete(transport.sessionId);
+ };
+ await server.createConnection(transport);
+ await transport.handleRequest(req, res);
+ return;
+ }
+
+ res.statusCode = 400;
+ res.end('Invalid request');
+}
+
+export function startHttpTransport(server: Server) {
+ const sseSessions = new Map();
+ const streamableSessions = new Map();
+ const httpServer = http.createServer(async (req, res) => {
+ const url = new URL(`http://localhost${req.url}`);
+ if (url.pathname.startsWith('/mcp'))
+ await handleStreamable(server, req, res, streamableSessions);
+ else
+ await handleSSE(server, req, res, url, sseSessions);
+ });
+ const { host, port } = server.config.server;
+ httpServer.listen(port, host, () => {
+ const address = httpServer.address();
+ assert(address, 'Could not bind server socket');
+ let url: string;
+ if (typeof address === 'string') {
+ url = address;
+ } else {
+ const resolvedPort = address.port;
+ let resolvedHost = address.family === 'IPv4' ? address.address : `[${address.address}]`;
+ if (resolvedHost === '0.0.0.0' || resolvedHost === '[::]')
+ resolvedHost = 'localhost';
+ url = `http://${resolvedHost}:${resolvedPort}`;
+ }
+ const message = [
+ `Listening on ${url}`,
+ 'Put this in your client config:',
+ JSON.stringify({
+ 'mcpServers': {
+ 'playwright': {
+ 'url': `${url}/sse`
+ }
+ }
+ }, undefined, 2),
+ 'If your client supports streamable HTTP, you can use the /mcp endpoint instead.',
+ ].join('\n');
+ // eslint-disable-next-line no-console
+ console.error(message);
+ });
+}
diff --git a/tests/agent.spec.ts b/tests/agent.spec.ts
new file mode 100644
index 0000000..ce2a8ee
--- /dev/null
+++ b/tests/agent.spec.ts
@@ -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/browserAgent.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(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();
+});
diff --git a/tests/basic.spec.ts b/tests/basic.spec.ts
deleted file mode 100644
index ee9ecc7..0000000
--- a/tests/basic.spec.ts
+++ /dev/null
@@ -1,333 +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/promises';
-import { spawn } from 'node:child_process';
-import path from 'node:path';
-import { test, expect } from './fixtures';
-
-test('test tool list', async ({ client, visionClient }) => {
- const { tools } = await client.listTools();
- expect(tools.map(t => t.name)).toEqual([
- 'browser_navigate',
- 'browser_go_back',
- 'browser_go_forward',
- 'browser_choose_file',
- 'browser_snapshot',
- 'browser_click',
- 'browser_hover',
- 'browser_type',
- 'browser_select_option',
- 'browser_take_screenshot',
- 'browser_press_key',
- 'browser_wait',
- 'browser_save_as_pdf',
- 'browser_close',
- ]);
-
- const { tools: visionTools } = await visionClient.listTools();
- expect(visionTools.map(t => t.name)).toEqual([
- 'browser_navigate',
- 'browser_go_back',
- 'browser_go_forward',
- 'browser_choose_file',
- 'browser_screenshot',
- 'browser_move_mouse',
- 'browser_click',
- 'browser_drag',
- 'browser_type',
- 'browser_press_key',
- 'browser_wait',
- 'browser_save_as_pdf',
- 'browser_close',
- ]);
-});
-
-test('test resources list', async ({ client }) => {
- const { resources } = await client.listResources();
- expect(resources).toEqual([
- expect.objectContaining({
- uri: 'browser://console',
- mimeType: 'text/plain',
- }),
- ]);
-});
-
-test('test browser_navigate', async ({ client }) => {
- expect(await client.callTool({
- name: 'browser_navigate',
- arguments: {
- url: 'data:text/html,TitleHello, world!',
- },
- })).toHaveTextContent(`
-- Page URL: data:text/html,TitleHello, world!
-- Page Title: Title
-- Page Snapshot
-\`\`\`yaml
-- document [ref=s1e2]: Hello, world!
-\`\`\`
-`
- );
-});
-
-test('test browser_click', async ({ client }) => {
- await client.callTool({
- name: 'browser_navigate',
- arguments: {
- url: 'data:text/html,Title',
- },
- });
-
- expect(await client.callTool({
- name: 'browser_click',
- arguments: {
- element: 'Submit button',
- ref: 's1e4',
- },
- })).toHaveTextContent(`"Submit button" clicked
-
-- Page URL: data:text/html,Title
-- Page Title: Title
-- Page Snapshot
-\`\`\`yaml
-- document [ref=s2e2]:
- - button "Submit" [ref=s2e4]
-\`\`\`
-`);
-});
-
-test('test reopen browser', async ({ client }) => {
- await client.callTool({
- name: 'browser_navigate',
- arguments: {
- url: 'data:text/html,TitleHello, world!',
- },
- });
-
- expect(await client.callTool({
- name: 'browser_close',
- })).toHaveTextContent('Page closed');
-
- expect(await client.callTool({
- name: 'browser_navigate',
- arguments: {
- url: 'data:text/html,TitleHello, world!',
- },
- })).toHaveTextContent(`
-- Page URL: data:text/html,TitleHello, world!
-- Page Title: Title
-- Page Snapshot
-\`\`\`yaml
-- document [ref=s1e2]: Hello, world!
-\`\`\`
-`);
-});
-
-test('single option', async ({ client }) => {
- await client.callTool({
- name: 'browser_navigate',
- arguments: {
- url: 'data:text/html,Title',
- },
- });
-
- expect(await client.callTool({
- name: 'browser_select_option',
- arguments: {
- element: 'Select',
- ref: 's1e4',
- values: ['bar'],
- },
- })).toHaveTextContent(`Selected option in "Select"
-
-- Page URL: data:text/html,Title
-- Page Title: Title
-- Page Snapshot
-\`\`\`yaml
-- document [ref=s2e2]:
- - combobox [ref=s2e4]:
- - option "Foo" [ref=s2e5]
- - option "Bar" [selected] [ref=s2e6]
-\`\`\`
-`);
-});
-
-test('multiple option', async ({ client }) => {
- await client.callTool({
- name: 'browser_navigate',
- arguments: {
- url: 'data:text/html,Title',
- },
- });
-
- expect(await client.callTool({
- name: 'browser_select_option',
- arguments: {
- element: 'Select',
- ref: 's1e4',
- values: ['bar', 'baz'],
- },
- })).toHaveTextContent(`Selected option in "Select"
-
-- Page URL: data:text/html,Title
-- Page Title: Title
-- Page Snapshot
-\`\`\`yaml
-- document [ref=s2e2]:
- - listbox [ref=s2e4]:
- - option "Foo" [ref=s2e5]
- - option "Bar" [selected] [ref=s2e6]
- - option "Baz" [selected] [ref=s2e7]
-\`\`\`
-`);
-});
-
-test('browser://console', async ({ client }) => {
- await client.callTool({
- name: 'browser_navigate',
- arguments: {
- url: 'data:text/html,',
- },
- });
-
- const resource = await client.readResource({
- uri: 'browser://console',
- });
- expect(resource.contents).toEqual([{
- uri: 'browser://console',
- mimeType: 'text/plain',
- text: '[LOG] Hello, world!\n[ERROR] Error',
- }]);
-});
-
-test('stitched aria frames', async ({ client }) => {
- expect(await client.callTool({
- name: 'browser_navigate',
- arguments: {
- url: 'data:text/html,Hello
',
- },
- })).toHaveTextContent(`
-- Page URL: data:text/html,Hello
-- Page Title:
-- Page Snapshot
-\`\`\`yaml
-- document [ref=s1e2]:
- - heading "Hello" [level=1] [ref=s1e4]
-
-# iframe src=data:text/html,World
-- document [ref=f0s1e2]:
- - heading "World" [level=1] [ref=f0s1e4]
-\`\`\`
-`);
-});
-
-test('browser_choose_file', async ({ client }) => {
- expect(await client.callTool({
- name: 'browser_navigate',
- arguments: {
- url: 'data:text/html,Title',
- },
- })).toContainTextContent('- textbox [ref=s1e4]');
-
- expect(await client.callTool({
- name: 'browser_click',
- arguments: {
- element: 'Textbox',
- ref: 's1e4',
- },
- })).toContainTextContent('There is a file chooser visible that requires browser_choose_file to be called');
-
- const filePath = test.info().outputPath('test.txt');
- await fs.writeFile(filePath, 'Hello, world!');
-
- {
- const response = await client.callTool({
- name: 'browser_choose_file',
- arguments: {
- paths: [filePath],
- },
- });
-
- expect(response).not.toContainTextContent('There is a file chooser visible that requires browser_choose_file to be called');
- expect(response).toContainTextContent('textbox [ref=s3e4]: C:\\fakepath\\test.txt');
- }
-
- {
- const response = await client.callTool({
- name: 'browser_click',
- arguments: {
- element: 'Textbox',
- ref: 's3e4',
- },
- });
-
- expect(response).toContainTextContent('There is a file chooser visible that requires browser_choose_file to be called');
- expect(response).toContainTextContent('button "Button" [ref=s4e5]');
- }
-
- {
- const response = await client.callTool({
- name: 'browser_click',
- arguments: {
- element: 'Button',
- ref: 's4e5',
- },
- });
-
- expect(response, 'not submitting browser_choose_file dismisses file chooser').not.toContainTextContent('There is a file chooser visible that requires browser_choose_file to be called');
- }
-});
-
-test('sse transport', async () => {
- const cp = spawn('node', [path.join(__dirname, '../cli.js'), '--port', '0'], { stdio: 'pipe' });
- try {
- let stdout = '';
- const url = await new Promise(resolve => cp.stdout?.on('data', data => {
- stdout += data.toString();
- const match = stdout.match(/Listening on (http:\/\/.*)/);
- if (match)
- resolve(match[1]);
- }));
-
- // need dynamic import b/c of some ESM nonsense
- const { SSEClientTransport } = await import('@modelcontextprotocol/sdk/client/sse.js');
- const { Client } = await import('@modelcontextprotocol/sdk/client/index.js');
- const transport = new SSEClientTransport(new URL(url));
- const client = new Client({ name: 'test', version: '1.0.0' });
- await client.connect(transport);
- await client.ping();
- } finally {
- cp.kill();
- }
-});
-
-test('cdp server', async ({ cdpEndpoint, startClient }) => {
- const client = await startClient({ args: [`--cdp-endpoint=${cdpEndpoint}`] });
- expect(await client.callTool({
- name: 'browser_navigate',
- arguments: {
- url: 'data:text/html,TitleHello, world!',
- },
- })).toHaveTextContent(`
-- Page URL: data:text/html,TitleHello, world!
-- Page Title: Title
-- Page Snapshot
-\`\`\`yaml
-- document [ref=s1e2]: Hello, world!
-\`\`\`
-`
- );
-});
diff --git a/tests/capabilities.spec.ts b/tests/capabilities.spec.ts
new file mode 100644
index 0000000..83af532
--- /dev/null
+++ b/tests/capabilities.spec.ts
@@ -0,0 +1,92 @@
+/**
+ * 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('test snapshot tool list', async ({ client }) => {
+ const { tools } = await client.listTools();
+ expect(new Set(tools.map(t => t.name))).toEqual(new Set([
+ 'browser_click',
+ 'browser_console_messages',
+ 'browser_drag',
+ 'browser_file_upload',
+ 'browser_generate_playwright_test',
+ 'browser_handle_dialog',
+ 'browser_hover',
+ 'browser_select_option',
+ 'browser_type',
+ 'browser_close',
+ 'browser_install',
+ 'browser_navigate_back',
+ 'browser_navigate_forward',
+ 'browser_navigate',
+ 'browser_network_requests',
+ 'browser_pdf_save',
+ 'browser_press_key',
+ 'browser_resize',
+ 'browser_snapshot',
+ 'browser_tab_close',
+ 'browser_tab_list',
+ 'browser_tab_new',
+ 'browser_tab_select',
+ 'browser_take_screenshot',
+ 'browser_wait_for',
+ ]));
+});
+
+test('test vision tool list', async ({ visionClient }) => {
+ const { tools: visionTools } = await visionClient.listTools();
+ expect(new Set(visionTools.map(t => t.name))).toEqual(new Set([
+ 'browser_close',
+ 'browser_console_messages',
+ 'browser_file_upload',
+ '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', async ({ startClient }) => {
+ const { client } = await startClient({
+ args: ['--caps="core"'],
+ });
+ const { tools } = await client.listTools();
+ const toolNames = tools.map(t => t.name);
+ expect(toolNames).not.toContain('browser_file_upload');
+ expect(toolNames).not.toContain('browser_pdf_save');
+ expect(toolNames).not.toContain('browser_screen_capture');
+ expect(toolNames).not.toContain('browser_screen_click');
+ expect(toolNames).not.toContain('browser_screen_drag');
+ expect(toolNames).not.toContain('browser_screen_move_mouse');
+ expect(toolNames).not.toContain('browser_screen_type');
+});
diff --git a/tests/cdp.spec.ts b/tests/cdp.spec.ts
new file mode 100644
index 0000000..7a3492b
--- /dev/null
+++ b/tests/cdp.spec.ts
@@ -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 { test, expect } from './fixtures.js';
+
+test('cdp server', async ({ cdpServer, startClient, server }) => {
+ await cdpServer.start();
+ const { client } = await startClient({ args: [`--cdp-endpoint=${cdpServer.endpoint}`] });
+ expect(await client.callTool({
+ name: 'browser_navigate',
+ arguments: { url: server.HELLO_WORLD },
+ })).toContainTextContent(`- generic [ref=e1]: Hello, world!`);
+});
+
+test('cdp server reuse tab', async ({ cdpServer, startClient, server }) => {
+ const browserContext = await cdpServer.start();
+ const { client } = await startClient({ args: [`--cdp-endpoint=${cdpServer.endpoint}`] });
+
+ const [page] = browserContext.pages();
+ await page.goto(server.HELLO_WORLD);
+
+ expect(await client.callTool({
+ name: 'browser_click',
+ arguments: {
+ element: 'Hello, world!',
+ ref: 'f0',
+ },
+ })).toHaveTextContent(`Error: No current snapshot available. Capture a snapshot or navigate to a new location first.`);
+
+ expect(await client.callTool({
+ name: 'browser_snapshot',
+ })).toHaveTextContent(`
+- Ran Playwright code:
+\`\`\`js
+//
+\`\`\`
+
+- Page URL: ${server.HELLO_WORLD}
+- Page Title: Title
+- Page Snapshot
+\`\`\`yaml
+- generic [ref=e1]: Hello, world!
+\`\`\`
+`);
+});
+
+test('should throw connection error and allow re-connecting', async ({ cdpServer, startClient, server }) => {
+ const { client } = await startClient({ args: [`--cdp-endpoint=${cdpServer.endpoint}`] });
+
+ server.setContent('/', `
+ Title
+ Hello, world!
+ `, 'text/html');
+
+ expect(await client.callTool({
+ name: 'browser_navigate',
+ arguments: { url: server.PREFIX },
+ })).toContainTextContent(`Error: browserType.connectOverCDP: connect ECONNREFUSED`);
+ await cdpServer.start();
+ expect(await client.callTool({
+ name: 'browser_navigate',
+ arguments: { url: server.PREFIX },
+ })).toContainTextContent(`- generic [ref=e1]: Hello, world!`);
+});
diff --git a/tests/config.spec.ts b/tests/config.spec.ts
new file mode 100644
index 0000000..4478347
--- /dev/null
+++ b/tests/config.spec.ts
@@ -0,0 +1,63 @@
+/**
+ * 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 { Config } from '../config.js';
+import { test, expect } from './fixtures.js';
+
+test('config user data dir', async ({ startClient, server }, testInfo) => {
+ server.setContent('/', `
+ Title
+ Hello, world!
+ `, 'text/html');
+
+ const config: Config = {
+ browser: {
+ userDataDir: testInfo.outputPath('user-data-dir'),
+ },
+ };
+ const configPath = testInfo.outputPath('config.json');
+ await fs.promises.writeFile(configPath, JSON.stringify(config, null, 2));
+
+ const { client } = await startClient({ args: ['--config', configPath] });
+ expect(await client.callTool({
+ name: 'browser_navigate',
+ arguments: { url: server.PREFIX },
+ })).toContainTextContent(`Hello, world!`);
+
+ const files = await fs.promises.readdir(config.browser!.userDataDir!);
+ expect(files.length).toBeGreaterThan(0);
+});
+
+test.describe(() => {
+ test.use({ mcpBrowser: '' });
+ test('browserName', { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright-mcp/issues/458' } }, async ({ startClient }, testInfo) => {
+ const config: Config = {
+ browser: {
+ browserName: 'firefox',
+ },
+ };
+ const configPath = testInfo.outputPath('config.json');
+ await fs.promises.writeFile(configPath, JSON.stringify(config, null, 2));
+
+ const { client } = await startClient({ args: ['--config', configPath] });
+ expect(await client.callTool({
+ name: 'browser_navigate',
+ arguments: { url: 'data:text/html,' },
+ })).toContainTextContent(`Firefox`);
+ });
+});
diff --git a/tests/console.spec.ts b/tests/console.spec.ts
new file mode 100644
index 0000000..8a9f60a
--- /dev/null
+++ b/tests/console.spec.ts
@@ -0,0 +1,44 @@
+/**
+ * Copyright (c) Microsoft Corporation.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { test, expect } from './fixtures.js';
+
+test('browser_console_messages', async ({ client, server }) => {
+ server.setContent('/', `
+
+
+
+
+ `, 'text/html');
+
+ await client.callTool({
+ name: 'browser_navigate',
+ arguments: {
+ url: server.PREFIX,
+ },
+ });
+
+ const resource = await client.callTool({
+ name: 'browser_console_messages',
+ });
+ expect(resource).toHaveTextContent([
+ '[LOG] Hello, world!',
+ '[ERROR] Error',
+ ].join('\n'));
+});
diff --git a/tests/core.spec.ts b/tests/core.spec.ts
new file mode 100644
index 0000000..003ea98
--- /dev/null
+++ b/tests/core.spec.ts
@@ -0,0 +1,239 @@
+/**
+ * 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_navigate', async ({ client, server }) => {
+ expect(await client.callTool({
+ name: 'browser_navigate',
+ arguments: { url: server.HELLO_WORLD },
+ })).toHaveTextContent(`
+- Ran Playwright code:
+\`\`\`js
+// Navigate to ${server.HELLO_WORLD}
+await page.goto('${server.HELLO_WORLD}');
+\`\`\`
+
+- Page URL: ${server.HELLO_WORLD}
+- Page Title: Title
+- Page Snapshot
+\`\`\`yaml
+- generic [ref=e1]: Hello, world!
+\`\`\`
+`
+ );
+});
+
+test('browser_click', async ({ client, server }) => {
+ server.setContent('/', `
+ Title
+
+ `, '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 }) => {
+ server.setContent('/', `
+ Title
+
+ `, 'text/html');
+
+ await client.callTool({
+ name: 'browser_navigate',
+ arguments: { url: server.PREFIX },
+ });
+
+ expect(await client.callTool({
+ name: 'browser_select_option',
+ arguments: {
+ element: 'Select',
+ ref: 'e2',
+ values: ['bar'],
+ },
+ })).toHaveTextContent(`
+- Ran Playwright code:
+\`\`\`js
+// Select options [bar] in Select
+await page.getByRole('combobox').selectOption(['bar']);
+\`\`\`
+
+- Page URL: ${server.PREFIX}
+- Page Title: Title
+- Page Snapshot
+\`\`\`yaml
+- combobox [ref=e2]:
+ - option "Foo"
+ - option "Bar" [selected]
+\`\`\`
+`);
+});
+
+test('browser_select_option (multiple)', async ({ client, server }) => {
+ server.setContent('/', `
+ Title
+
+ `, 'text/html');
+
+ await client.callTool({
+ name: 'browser_navigate',
+ arguments: { url: server.PREFIX },
+ });
+
+ expect(await client.callTool({
+ name: 'browser_select_option',
+ arguments: {
+ element: 'Select',
+ ref: 'e2',
+ values: ['bar', 'baz'],
+ },
+ })).toHaveTextContent(`
+- Ran Playwright code:
+\`\`\`js
+// Select options [bar, baz] in Select
+await page.getByRole('listbox').selectOption(['bar', 'baz']);
+\`\`\`
+
+- Page URL: ${server.PREFIX}
+- Page Title: Title
+- Page Snapshot
+\`\`\`yaml
+- listbox [ref=e2]:
+ - option "Foo" [ref=e3]
+ - option "Bar" [selected] [ref=e4]
+ - option "Baz" [selected] [ref=e5]
+\`\`\`
+`);
+});
+
+test('browser_type', async ({ client, server }) => {
+ server.setContent('/', `
+
+
+
+
+ `, 'text/html');
+
+ await client.callTool({
+ name: 'browser_navigate',
+ arguments: {
+ url: server.PREFIX,
+ },
+ });
+ await client.callTool({
+ name: 'browser_type',
+ arguments: {
+ element: 'textbox',
+ ref: 'e2',
+ text: 'Hi!',
+ submit: true,
+ },
+ });
+ expect(await client.callTool({
+ name: 'browser_console_messages',
+ })).toHaveTextContent('[LOG] Key pressed: Enter , Text: Hi!');
+});
+
+test('browser_type (slowly)', async ({ client, server }) => {
+ server.setContent('/', `
+
+ `, 'text/html');
+
+ await client.callTool({
+ name: 'browser_navigate',
+ arguments: {
+ url: server.PREFIX,
+ },
+ });
+ await client.callTool({
+ name: 'browser_type',
+ arguments: {
+ element: 'textbox',
+ ref: 'e2',
+ text: 'Hi!',
+ submit: true,
+ slowly: true,
+ },
+ });
+ expect(await client.callTool({
+ name: 'browser_console_messages',
+ })).toHaveTextContent([
+ '[LOG] Key pressed: H Text: ',
+ '[LOG] Key pressed: i Text: H',
+ '[LOG] Key pressed: ! Text: Hi',
+ '[LOG] Key pressed: Enter Text: Hi!',
+ ].join('\n'));
+});
+
+test('browser_resize', async ({ client, server }) => {
+ server.setContent('/', `
+ Resize Test
+
+ Waiting for resize...
+
+
+ `, 'text/html');
+ await client.callTool({
+ name: 'browser_navigate',
+ arguments: { url: server.PREFIX },
+ });
+
+ const response = await client.callTool({
+ name: 'browser_resize',
+ arguments: {
+ width: 390,
+ height: 780,
+ },
+ });
+ expect(response).toContainTextContent(`- Ran Playwright code:
+\`\`\`js
+// Resize browser window to 390x780
+await page.setViewportSize({ width: 390, height: 780 });
+\`\`\``);
+ await expect.poll(() => client.callTool({ name: 'browser_snapshot' })).toContainTextContent('Window size: 390x780');
+});
diff --git a/tests/device.spec.ts b/tests/device.spec.ts
new file mode 100644
index 0000000..32ceecb
--- /dev/null
+++ b/tests/device.spec.ts
@@ -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 { test, expect } from './fixtures.js';
+
+test('--device should work', async ({ startClient, server }) => {
+ const { client } = await startClient({
+ args: ['--device', 'iPhone 15'],
+ });
+
+ server.route('/', (req, res) => {
+ res.writeHead(200, { 'Content-Type': 'text/html' });
+ res.end(`
+
+
+
+
+
+ `);
+ });
+
+ expect(await client.callTool({
+ name: 'browser_navigate',
+ arguments: {
+ url: server.PREFIX,
+ },
+ })).toContainTextContent(`393x659`);
+});
diff --git a/tests/dialogs.spec.ts b/tests/dialogs.spec.ts
new file mode 100644
index 0000000..b70fe2d
--- /dev/null
+++ b/tests/dialogs.spec.ts
@@ -0,0 +1,212 @@
+/**
+ * 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';
+
+// https://github.com/microsoft/playwright/issues/35663
+test.skip(({ mcpBrowser, mcpHeadless }) => mcpBrowser === 'webkit' && mcpHeadless);
+
+test('alert dialog', async ({ client, server }) => {
+ server.setContent('/', ``, '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).toHaveTextContent(`- Ran Playwright code:
+\`\`\`js
+//
+\`\`\`
+
+- Page URL: ${server.PREFIX}
+- Page Title:
+- Page Snapshot
+\`\`\`yaml
+- button "Button" [ref=e2]
+\`\`\`
+`);
+});
+
+test('two alert dialogs', async ({ client, server }) => {
+ test.fixme(true, 'Race between the dialog and ariaSnapshot');
+
+ server.setContent('/', `
+ Title
+
+
+
+ `, '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 1"]: 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');
+});
+
+test('confirm dialog (true)', async ({ client, server }) => {
+ server.setContent('/', `
+ Title
+
+
+
+ `, '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',
+ },
+ })).toContainTextContent(`### Modal state
+- ["confirm" dialog with message "Confirm"]: 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('// ');
+ expect(result).toContainTextContent(`- Page Snapshot
+\`\`\`yaml
+- generic [ref=e1]: "true"
+\`\`\``);
+});
+
+test('confirm dialog (false)', async ({ client, server }) => {
+ server.setContent('/', `
+ Title
+
+
+
+ `, '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',
+ },
+ })).toContainTextContent(`### Modal state
+- ["confirm" dialog with message "Confirm"]: can be handled by the "browser_handle_dialog" tool`);
+
+ const result = await client.callTool({
+ name: 'browser_handle_dialog',
+ arguments: {
+ accept: false,
+ },
+ });
+
+ expect(result).toContainTextContent(`- Page Snapshot
+\`\`\`yaml
+- generic [ref=e1]: "false"
+\`\`\``);
+});
+
+test('prompt dialog', async ({ client, server }) => {
+ server.setContent('/', `
+ Title
+
+
+
+ `, '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',
+ },
+ })).toContainTextContent(`### Modal state
+- ["prompt" dialog with message "Prompt"]: can be handled by the "browser_handle_dialog" tool`);
+
+ const result = await client.callTool({
+ name: 'browser_handle_dialog',
+ arguments: {
+ accept: true,
+ promptText: 'Answer',
+ },
+ });
+
+ expect(result).toContainTextContent(`- Page Snapshot
+\`\`\`yaml
+- generic [ref=e1]: Answer
+\`\`\``);
+});
diff --git a/tests/files.spec.ts b/tests/files.spec.ts
new file mode 100644
index 0000000..3653bca
--- /dev/null
+++ b/tests/files.spec.ts
@@ -0,0 +1,147 @@
+/**
+ * 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';
+import fs from 'fs/promises';
+
+test('browser_file_upload', async ({ client, server }, testInfo) => {
+ server.setContent('/', `
+
+
+ `, 'text/html');
+
+ expect(await client.callTool({
+ name: 'browser_navigate',
+ arguments: { url: server.PREFIX },
+ })).toContainTextContent(`
+\`\`\`yaml
+- generic [ref=e1]:
+ - button "Choose File" [ref=e2]
+ - button "Button" [ref=e3]
+\`\`\``);
+
+ {
+ expect(await client.callTool({
+ name: 'browser_file_upload',
+ arguments: { paths: [] },
+ })).toHaveTextContent(`
+The tool "browser_file_upload" can only be used when there is related modal state present.
+### Modal state
+- There is no modal state present
+ `.trim());
+ }
+
+ expect(await client.callTool({
+ name: 'browser_click',
+ arguments: {
+ element: 'Textbox',
+ ref: 'e2',
+ },
+ })).toContainTextContent(`### Modal state
+- [File chooser]: can be handled by the "browser_file_upload" tool`);
+
+ const filePath = testInfo.outputPath('test.txt');
+ await fs.writeFile(filePath, 'Hello, world!');
+
+ {
+ const response = await client.callTool({
+ name: 'browser_file_upload',
+ arguments: {
+ paths: [filePath],
+ },
+ });
+
+ expect(response).not.toContainTextContent('### Modal state');
+ expect(response).toContainTextContent(`
+\`\`\`yaml
+- generic [ref=e1]:
+ - button "Choose File" [ref=e2]
+ - button "Button" [ref=e3]
+\`\`\``);
+ }
+
+ {
+ const response = await client.callTool({
+ name: 'browser_click',
+ arguments: {
+ element: 'Textbox',
+ ref: 'e2',
+ },
+ });
+
+ expect(response).toContainTextContent('- [File chooser]: can be handled by the \"browser_file_upload\" tool');
+ }
+
+ {
+ const response = await client.callTool({
+ name: 'browser_click',
+ arguments: {
+ element: 'Button',
+ ref: 'e3',
+ },
+ });
+
+ expect(response).toContainTextContent(`Tool "browser_click" does not handle the modal state.
+### Modal state
+- [File chooser]: can be handled by the "browser_file_upload" tool`);
+ }
+});
+
+test('clicking on download link emits download', async ({ startClient, server }, testInfo) => {
+ const { client } = await startClient({
+ config: { outputDir: testInfo.outputPath('output') },
+ });
+
+ server.setContent('/', `Download`, 'text/html');
+ server.setContent('/download', 'Data', 'text/plain');
+
+ expect(await client.callTool({
+ name: 'browser_navigate',
+ arguments: { url: server.PREFIX },
+ })).toContainTextContent('- link "Download" [ref=e2]');
+ await client.callTool({
+ name: 'browser_click',
+ arguments: {
+ element: 'Download link',
+ ref: 'e2',
+ },
+ });
+ await expect.poll(() => client.callTool({ name: 'browser_snapshot' })).toContainTextContent(`
+### Downloads
+- Downloaded file test.txt to ${testInfo.outputPath('output', 'test.txt')}`);
+});
+
+test('navigating to download link emits download', async ({ startClient, server, mcpBrowser }, testInfo) => {
+ const { client } = await startClient({
+ config: { outputDir: testInfo.outputPath('output') },
+ });
+
+ test.skip(mcpBrowser === 'webkit' && process.platform === 'linux', 'https://github.com/microsoft/playwright/blob/8e08fdb52c27bb75de9bf87627bf740fadab2122/tests/library/download.spec.ts#L436');
+ server.route('/download', (req, res) => {
+ res.writeHead(200, {
+ 'Content-Type': 'text/plain',
+ 'Content-Disposition': 'attachment; filename=test.txt',
+ });
+ res.end('Hello world!');
+ });
+
+ expect(await client.callTool({
+ name: 'browser_navigate',
+ arguments: {
+ url: server.PREFIX + 'download',
+ },
+ })).toContainTextContent('### Downloads');
+});
diff --git a/tests/fixtures.ts b/tests/fixtures.ts
index e805f31..5c6c166 100644
--- a/tests/fixtures.ts
+++ b/tests/fixtures.ts
@@ -14,49 +14,88 @@
* limitations under the License.
*/
+import fs from 'fs';
+import url from 'url';
import path from 'path';
import { chromium } from 'playwright';
import { test as baseTest, expect as baseExpect } from '@playwright/test';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
+import { TestServer } from './testserver/index.ts';
-type Fixtures = {
+import type { Config } from '../config';
+import type { BrowserContext } from 'playwright';
+
+export type TestOptions = {
+ mcpBrowser: string | undefined;
+ mcpMode: 'docker' | undefined;
+};
+
+type CDPServer = {
+ endpoint: string;
+ start: () => Promise;
+};
+
+type TestFixtures = {
client: Client;
visionClient: Client;
- startClient: (options?: { args?: string[], vision?: boolean }) => Promise;
+ startClient: (options?: { clientName?: string, args?: string[], config?: Config }) => Promise<{ client: Client, stderr: () => string }>;
wsEndpoint: string;
- cdpEndpoint: string;
+ cdpServer: CDPServer;
+ server: TestServer;
+ httpsServer: TestServer;
+ mcpHeadless: boolean;
};
-export const test = baseTest.extend({
+type WorkerFixtures = {
+ _workerServers: { server: TestServer, httpsServer: TestServer };
+};
+
+export const test = baseTest.extend({
client: async ({ startClient }, use) => {
- await use(await startClient());
+ const { client } = await startClient();
+ await use(client);
},
visionClient: async ({ startClient }, use) => {
- await use(await startClient({ vision: true }));
+ const { client } = await startClient({ args: ['--vision'] });
+ await use(client);
},
- startClient: async ({ }, use, testInfo) => {
- const userDataDir = testInfo.outputPath('user-data-dir');
- let client: StdioClientTransport | undefined;
+ startClient: async ({ mcpHeadless, mcpBrowser, mcpMode }, use, testInfo) => {
+ const userDataDir = mcpMode !== 'docker' ? testInfo.outputPath('user-data-dir') : undefined;
+ const configDir = path.dirname(test.info().config.configFile!);
+ let client: Client | undefined;
- use(async options => {
- const args = ['--headless', '--user-data-dir', userDataDir];
- if (options?.vision)
- args.push('--vision');
+ await use(async options => {
+ const args: string[] = [];
+ if (userDataDir)
+ args.push('--user-data-dir', userDataDir);
+ if (process.env.CI && process.platform === 'linux')
+ args.push('--no-sandbox');
+ if (mcpHeadless)
+ args.push('--headless');
+ if (mcpBrowser)
+ args.push(`--browser=${mcpBrowser}`);
if (options?.args)
args.push(...options.args);
- const transport = new StdioClientTransport({
- command: 'node',
- args: [path.join(__dirname, '../cli.js'), ...args],
+ if (options?.config) {
+ const configFile = testInfo.outputPath('config.json');
+ await fs.promises.writeFile(configFile, JSON.stringify(options.config, null, 2));
+ args.push(`--config=${path.relative(configDir, configFile)}`);
+ }
+
+ client = new Client({ name: options?.clientName ?? 'test', version: '1.0.0' });
+ const transport = createTransport(args, mcpMode);
+ let stderr = '';
+ transport.stderr?.on('data', data => {
+ stderr += data.toString();
});
- const client = new Client({ name: 'test', version: '1.0.0' });
await client.connect(transport);
await client.ping();
- return client;
+ return { client, stderr: () => stderr };
});
await client?.close();
@@ -68,28 +107,103 @@ export const test = baseTest.extend({
await browserServer.close();
},
- cdpEndpoint: async ({ }, use, testInfo) => {
- const port = 3200 + (+process.env.TEST_PARALLEL_INDEX!);
- const browser = await chromium.launchPersistentContext(testInfo.outputPath('user-data-dir'), {
- args: [`--remote-debugging-port=${port}`],
+ cdpServer: async ({ mcpBrowser }, use, testInfo) => {
+ test.skip(!['chrome', 'msedge', 'chromium'].includes(mcpBrowser!), 'CDP is not supported for non-Chromium browsers');
+
+ let browserContext: BrowserContext | undefined;
+ const port = 3200 + test.info().parallelIndex;
+ await use({
+ endpoint: `http://localhost:${port}`,
+ start: async () => {
+ browserContext = await chromium.launchPersistentContext(testInfo.outputPath('cdp-user-data-dir'), {
+ channel: mcpBrowser,
+ headless: true,
+ args: [
+ `--remote-debugging-port=${port}`,
+ ],
+ });
+ return browserContext;
+ }
});
- await use(`http://localhost:${port}`);
- await browser.close();
+ await browserContext?.close();
+ },
+
+ mcpHeadless: async ({ headless }, use) => {
+ await use(headless);
+ },
+
+ mcpBrowser: ['chrome', { option: true }],
+
+ mcpMode: [undefined, { option: true }],
+
+ _workerServers: [async ({}, use, workerInfo) => {
+ const port = 8907 + workerInfo.workerIndex * 4;
+ const server = await TestServer.create(port);
+
+ const httpsPort = port + 1;
+ const httpsServer = await TestServer.createHTTPS(httpsPort);
+
+ await use({ server, httpsServer });
+
+ await Promise.all([
+ server.stop(),
+ httpsServer.stop(),
+ ]);
+ }, { scope: 'worker' }],
+
+ server: async ({ _workerServers }, use) => {
+ _workerServers.server.reset();
+ await use(_workerServers.server);
+ },
+
+ httpsServer: async ({ _workerServers }, use) => {
+ _workerServers.httpsServer.reset();
+ await use(_workerServers.httpsServer);
},
});
+function createTransport(args: string[], mcpMode: TestOptions['mcpMode']) {
+ // NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename.
+ const __filename = url.fileURLToPath(import.meta.url);
+ if (mcpMode === 'docker') {
+ const dockerArgs = ['run', '--rm', '-i', '--network=host', '-v', `${test.info().project.outputDir}:/app/test-results`];
+ return new StdioClientTransport({
+ command: 'docker',
+ args: [...dockerArgs, 'playwright-mcp-dev:latest', ...args],
+ });
+ }
+ return new StdioClientTransport({
+ command: 'node',
+ args: [path.join(path.dirname(__filename), '../cli.js'), ...args],
+ cwd: path.join(path.dirname(__filename), '..'),
+ stderr: 'pipe',
+ env: {
+ ...process.env,
+ DEBUG: 'pw:mcp:test',
+ DEBUG_COLORS: '0',
+ DEBUG_HIDE_DATE: '1',
+ },
+ });
+}
+
type Response = Awaited>;
export const expect = baseExpect.extend({
- toHaveTextContent(response: Response, content: string | string[]) {
+ toHaveTextContent(response: Response, content: string | RegExp) {
const isNot = this.isNot;
try {
- content = Array.isArray(content) ? content : [content];
- const texts = (response.content as any).map(c => c.text);
- if (isNot)
- baseExpect(texts).not.toEqual(content);
- else
- baseExpect(texts).toEqual(content);
+ const text = (response.content as any)[0].text;
+ if (typeof content === 'string') {
+ if (isNot)
+ baseExpect(text.trim()).not.toBe(content.trim());
+ else
+ baseExpect(text.trim()).toBe(content.trim());
+ } else {
+ if (isNot)
+ baseExpect(text).not.toMatch(content);
+ else
+ baseExpect(text).toMatch(content);
+ }
} catch (e) {
return {
pass: isNot,
@@ -125,3 +239,7 @@ export const expect = baseExpect.extend({
};
},
});
+
+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);
+}
diff --git a/tests/headed.spec.ts b/tests/headed.spec.ts
new file mode 100644
index 0000000..69202c4
--- /dev/null
+++ b/tests/headed.spec.ts
@@ -0,0 +1,50 @@
+/**
+ * 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';
+
+for (const mcpHeadless of [false, true]) {
+ test.describe(`mcpHeadless: ${mcpHeadless}`, () => {
+ test.use({ mcpHeadless });
+ test.skip(process.platform === 'linux', 'Auto-detection wont let this test run on linux');
+ test.skip(({ mcpMode, mcpHeadless }) => mcpMode === 'docker' && !mcpHeadless, 'Headed mode is not supported in docker');
+ test('browser', async ({ client, server, mcpBrowser }) => {
+ test.skip(!['chrome', 'msedge', 'chromium'].includes(mcpBrowser ?? ''), 'Only chrome is supported for this test');
+ server.route('/', (req, res) => {
+ res.writeHead(200, { 'Content-Type': 'text/html' });
+ res.end(`
+
+
+ `);
+ });
+
+ const response = await client.callTool({
+ name: 'browser_navigate',
+ arguments: {
+ url: server.PREFIX,
+ },
+ });
+
+ expect(response).toContainTextContent(`Mozilla/5.0`);
+ if (mcpHeadless)
+ expect(response).toContainTextContent(`HeadlessChrome`);
+ else
+ expect(response).not.toContainTextContent(`HeadlessChrome`);
+ });
+ });
+}
diff --git a/tests/iframes.spec.ts b/tests/iframes.spec.ts
new file mode 100644
index 0000000..f856270
--- /dev/null
+++ b/tests/iframes.spec.ts
@@ -0,0 +1,44 @@
+/**
+ * Copyright (c) Microsoft Corporation.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { test, expect } from './fixtures.js';
+
+test('stitched aria frames', async ({ client }) => {
+ expect(await client.callTool({
+ name: 'browser_navigate',
+ arguments: {
+ url: `data:text/html,Hello
`,
+ },
+ })).toContainTextContent(`
+\`\`\`yaml
+- generic [ref=e1]:
+ - heading "Hello" [level=1] [ref=e2]
+ - iframe [ref=e3]:
+ - generic [ref=f1e1]:
+ - button "World" [ref=f1e2]
+ - main [ref=f1e3]:
+ - iframe [ref=f1e4]:
+ - paragraph [ref=f2e2]: Nested
+\`\`\``);
+
+ expect(await client.callTool({
+ name: 'browser_click',
+ arguments: {
+ element: 'World',
+ ref: 'f1e2',
+ },
+ })).toContainTextContent(`// Click World`);
+});
diff --git a/tests/install.spec.ts b/tests/install.spec.ts
new file mode 100644
index 0000000..66a11d5
--- /dev/null
+++ b/tests/install.spec.ts
@@ -0,0 +1,24 @@
+/**
+ * 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_install', async ({ client, mcpBrowser }) => {
+ test.skip(mcpBrowser !== 'chromium', 'Test only chromium');
+ expect(await client.callTool({
+ name: 'browser_install',
+ })).toContainTextContent(`No open pages available.`);
+});
diff --git a/tests/launch.spec.ts b/tests/launch.spec.ts
new file mode 100644
index 0000000..f0ad4b2
--- /dev/null
+++ b/tests/launch.spec.ts
@@ -0,0 +1,157 @@
+/**
+ * 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 { test, expect, formatOutput } from './fixtures.js';
+
+test('test reopen browser', async ({ startClient, server }) => {
+ const { client, stderr } = await startClient();
+ await client.callTool({
+ name: 'browser_navigate',
+ arguments: { url: server.HELLO_WORLD },
+ });
+
+ expect(await client.callTool({
+ name: 'browser_close',
+ })).toContainTextContent('No open pages available');
+
+ expect(await client.callTool({
+ name: 'browser_navigate',
+ arguments: { url: server.HELLO_WORLD },
+ })).toContainTextContent(`- generic [ref=e1]: Hello, world!`);
+
+ await client.close();
+
+ if (process.platform === 'win32')
+ return;
+
+ await expect.poll(() => formatOutput(stderr()), { timeout: 0 }).toEqual([
+ 'create context',
+ 'create browser context (persistent)',
+ 'lock user data dir',
+ 'close context',
+ 'close browser context (persistent)',
+ 'release user data dir',
+ 'close browser context complete (persistent)',
+ 'create browser context (persistent)',
+ 'lock user data dir',
+ 'close context',
+ 'close browser context (persistent)',
+ 'release user data dir',
+ 'close browser context complete (persistent)',
+ ]);
+});
+
+test('executable path', async ({ startClient, server }) => {
+ const { client } = await startClient({ args: [`--executable-path=bogus`] });
+ const response = await client.callTool({
+ name: 'browser_navigate',
+ arguments: { url: server.HELLO_WORLD },
+ });
+ expect(response).toContainTextContent(`executable doesn't exist`);
+});
+
+test('persistent context', async ({ startClient, server }) => {
+ server.setContent('/', `
+
+
+
+ `, 'text/html');
+
+ const { client } = await startClient();
+ const response = await client.callTool({
+ name: 'browser_navigate',
+ arguments: { url: server.PREFIX },
+ });
+ expect(response).toContainTextContent(`Storage: NO`);
+
+ await new Promise(resolve => setTimeout(resolve, 3000));
+
+ await client.callTool({
+ name: 'browser_close',
+ });
+
+ const { client: client2 } = await startClient();
+ const response2 = await client2.callTool({
+ name: 'browser_navigate',
+ arguments: { url: server.PREFIX },
+ });
+
+ expect(response2).toContainTextContent(`Storage: YES`);
+});
+
+test('isolated context', async ({ startClient, server }) => {
+ server.setContent('/', `
+
+
+
+ `, 'text/html');
+
+ const { client: client1 } = await startClient({ args: [`--isolated`] });
+ const response = await client1.callTool({
+ name: 'browser_navigate',
+ arguments: { url: server.PREFIX },
+ });
+ expect(response).toContainTextContent(`Storage: NO`);
+
+ await client1.callTool({
+ name: 'browser_close',
+ });
+
+ const { client: client2 } = await startClient({ args: [`--isolated`] });
+ const response2 = await client2.callTool({
+ name: 'browser_navigate',
+ arguments: { url: server.PREFIX },
+ });
+ expect(response2).toContainTextContent(`Storage: NO`);
+});
+
+test('isolated context with storage state', async ({ startClient, server }, testInfo) => {
+ const storageStatePath = testInfo.outputPath('storage-state.json');
+ await fs.promises.writeFile(storageStatePath, JSON.stringify({
+ origins: [
+ {
+ origin: server.PREFIX,
+ localStorage: [{ name: 'test', value: 'session-value' }],
+ },
+ ],
+ }));
+
+ server.setContent('/', `
+
+
+
+ `, 'text/html');
+
+ const { client } = await startClient({ args: [
+ `--isolated`,
+ `--storage-state=${storageStatePath}`,
+ ] });
+ const response = await client.callTool({
+ name: 'browser_navigate',
+ arguments: { url: server.PREFIX },
+ });
+ expect(response).toContainTextContent(`Storage: session-value`);
+});
diff --git a/tests/library.spec.ts b/tests/library.spec.ts
new file mode 100644
index 0000000..954403f
--- /dev/null
+++ b/tests/library.spec.ts
@@ -0,0 +1,28 @@
+/**
+ * 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';
+import fs from 'node:fs/promises';
+import child_process from 'node:child_process';
+
+test('library can be used from CommonJS', { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright-mcp/issues/456' } }, async ({}, testInfo) => {
+ const file = testInfo.outputPath('main.cjs');
+ await fs.writeFile(file, `
+ import('@playwright/mcp')
+ .then(playwrightMCP => playwrightMCP.createConnection())
+ .then(() => console.log('OK'));
+ `);
+ expect(child_process.execSync(`node ${file}`, { encoding: 'utf-8' })).toContain('OK');
+});
diff --git a/tests/network.spec.ts b/tests/network.spec.ts
new file mode 100644
index 0000000..56e71c0
--- /dev/null
+++ b/tests/network.spec.ts
@@ -0,0 +1,45 @@
+/**
+ * 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_network_requests', async ({ client, server }) => {
+ server.setContent('/', `
+
+ `, 'text/html');
+
+ server.setContent('/json', JSON.stringify({ name: 'John Doe' }), 'application/json');
+
+ await client.callTool({
+ name: 'browser_navigate',
+ arguments: {
+ url: server.PREFIX,
+ },
+ });
+
+ await client.callTool({
+ name: 'browser_click',
+ arguments: {
+ element: 'Click me button',
+ ref: 'e2',
+ },
+ });
+
+ await expect.poll(() => client.callTool({
+ name: 'browser_network_requests',
+ })).toHaveTextContent(`[GET] ${`${server.PREFIX}`} => [200] OK
+[GET] ${`${server.PREFIX}json`} => [200] OK`);
+});
diff --git a/tests/pdf.spec.ts b/tests/pdf.spec.ts
new file mode 100644
index 0000000..8af4667
--- /dev/null
+++ b/tests/pdf.spec.ts
@@ -0,0 +1,83 @@
+/**
+ * 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 { test, expect } from './fixtures.js';
+
+test('save as pdf unavailable', async ({ startClient, server }) => {
+ const { client } = await startClient({ args: ['--caps="no-pdf"'] });
+ await client.callTool({
+ name: 'browser_navigate',
+ arguments: { url: server.HELLO_WORLD },
+ });
+
+ expect(await client.callTool({
+ name: 'browser_pdf_save',
+ })).toHaveTextContent(/Tool \"browser_pdf_save\" not found/);
+});
+
+test('save as pdf', async ({ startClient, mcpBrowser, server }, testInfo) => {
+ const { client } = await startClient({
+ config: { outputDir: testInfo.outputPath('output') },
+ });
+
+ test.skip(!!mcpBrowser && !['chromium', 'chrome', 'msedge'].includes(mcpBrowser), 'Save as PDF is only supported in Chromium.');
+
+ expect(await client.callTool({
+ name: 'browser_navigate',
+ arguments: { url: server.HELLO_WORLD },
+ })).toContainTextContent(`- generic [ref=e1]: Hello, world!`);
+
+ const response = await client.callTool({
+ name: 'browser_pdf_save',
+ });
+ expect(response).toHaveTextContent(/Save page as.*page-[^:]+.pdf/);
+});
+
+test('save as pdf (filename: output.pdf)', async ({ startClient, mcpBrowser, server }, testInfo) => {
+ const outputDir = testInfo.outputPath('output');
+ test.skip(!!mcpBrowser && !['chromium', 'chrome', 'msedge'].includes(mcpBrowser), 'Save as PDF is only supported in Chromium.');
+ const { client } = await startClient({
+ config: { outputDir },
+ });
+
+ expect(await client.callTool({
+ name: 'browser_navigate',
+ arguments: { url: server.HELLO_WORLD },
+ })).toContainTextContent(`- generic [ref=e1]: Hello, world!`);
+
+ expect(await client.callTool({
+ name: 'browser_pdf_save',
+ arguments: {
+ filename: 'output.pdf',
+ },
+ })).toEqual({
+ content: [
+ {
+ type: 'text',
+ text: expect.stringContaining(`output.pdf`),
+ },
+ ],
+ });
+
+ const files = [...fs.readdirSync(outputDir)];
+
+ expect(fs.existsSync(outputDir)).toBeTruthy();
+ const pdfFiles = files.filter(f => f.endsWith('.pdf'));
+ expect(pdfFiles).toHaveLength(1);
+ expect(pdfFiles[0]).toMatch(/^output.pdf$/);
+});
diff --git a/tests/request-blocking.spec.ts b/tests/request-blocking.spec.ts
new file mode 100644
index 0000000..21a7185
--- /dev/null
+++ b/tests/request-blocking.spec.ts
@@ -0,0 +1,82 @@
+/**
+ * Copyright (c) Microsoft Corporation.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Client } from '@modelcontextprotocol/sdk/client/index.js';
+import { test, expect } from './fixtures.ts';
+
+const BLOCK_MESSAGE = /Blocked by Web Inspector|NS_ERROR_FAILURE|net::ERR_BLOCKED_BY_CLIENT/g;
+
+const fetchPage = async (client: Client, url: string) => {
+ const result = await client.callTool({
+ name: 'browser_navigate',
+ arguments: {
+ url,
+ },
+ });
+
+ return JSON.stringify(result, null, 2);
+};
+
+test('default to allow all', async ({ server, client }) => {
+ server.setContent('/ppp', 'content:PPP', 'text/html');
+ const result = await fetchPage(client, server.PREFIX + 'ppp');
+ expect(result).toContain('content:PPP');
+});
+
+test('blocked works', async ({ startClient }) => {
+ const { client } = await startClient({
+ args: ['--blocked-origins', 'microsoft.com;example.com;playwright.dev']
+ });
+ const result = await fetchPage(client, 'https://example.com/');
+ expect(result).toMatch(BLOCK_MESSAGE);
+});
+
+test('allowed works', async ({ server, startClient }) => {
+ server.setContent('/ppp', 'content:PPP', 'text/html');
+ const { client } = await startClient({
+ args: ['--allowed-origins', `microsoft.com;${new URL(server.PREFIX).host};playwright.dev`]
+ });
+ const result = await fetchPage(client, server.PREFIX + 'ppp');
+ expect(result).toContain('content:PPP');
+});
+
+test('blocked takes precedence', async ({ startClient }) => {
+ const { client } = await startClient({
+ args: [
+ '--blocked-origins', 'example.com',
+ '--allowed-origins', 'example.com',
+ ],
+ });
+ const result = await fetchPage(client, 'https://example.com/');
+ expect(result).toMatch(BLOCK_MESSAGE);
+});
+
+test('allowed without blocked blocks all non-explicitly specified origins', async ({ startClient }) => {
+ const { client } = await startClient({
+ args: ['--allowed-origins', 'playwright.dev'],
+ });
+ const result = await fetchPage(client, 'https://example.com/');
+ expect(result).toMatch(BLOCK_MESSAGE);
+});
+
+test('blocked without allowed allows non-explicitly specified origins', async ({ server, startClient }) => {
+ server.setContent('/ppp', 'content:PPP', 'text/html');
+ const { client } = await startClient({
+ args: ['--blocked-origins', 'example.com'],
+ });
+ const result = await fetchPage(client, server.PREFIX + 'ppp');
+ expect(result).toContain('content:PPP');
+});
diff --git a/tests/screenshot.spec.ts b/tests/screenshot.spec.ts
new file mode 100644
index 0000000..b83e10a
--- /dev/null
+++ b/tests/screenshot.spec.ts
@@ -0,0 +1,232 @@
+/**
+ * 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 { test, expect } from './fixtures.js';
+
+test('browser_take_screenshot (viewport)', async ({ startClient, server }, testInfo) => {
+ const { client } = await startClient({
+ config: { outputDir: testInfo.outputPath('output') },
+ });
+ expect(await client.callTool({
+ name: 'browser_navigate',
+ arguments: { url: server.HELLO_WORLD },
+ })).toContainTextContent(`Navigate to http://localhost`);
+
+ expect(await client.callTool({
+ name: 'browser_take_screenshot',
+ })).toEqual({
+ content: [
+ {
+ data: expect.any(String),
+ mimeType: 'image/jpeg',
+ type: 'image',
+ },
+ {
+ text: expect.stringContaining(`Screenshot viewport and save it as`),
+ type: 'text',
+ },
+ ],
+ });
+});
+
+test('browser_take_screenshot (element)', 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]`);
+
+ expect(await client.callTool({
+ name: 'browser_take_screenshot',
+ arguments: {
+ element: 'hello button',
+ ref: 'e1',
+ },
+ })).toEqual({
+ content: [
+ {
+ data: expect.any(String),
+ mimeType: 'image/jpeg',
+ type: 'image',
+ },
+ {
+ text: expect.stringContaining(`page.getByText('Hello, world!').screenshot`),
+ type: 'text',
+ },
+ ],
+ });
+});
+
+test('--output-dir should work', async ({ startClient, server }, testInfo) => {
+ const outputDir = testInfo.outputPath('output');
+ const { client } = await startClient({
+ config: { outputDir },
+ });
+ expect(await client.callTool({
+ name: 'browser_navigate',
+ arguments: { url: server.HELLO_WORLD },
+ })).toContainTextContent(`Navigate to http://localhost`);
+
+ await client.callTool({
+ name: 'browser_take_screenshot',
+ });
+
+ expect(fs.existsSync(outputDir)).toBeTruthy();
+ const files = [...fs.readdirSync(outputDir)].filter(f => f.endsWith('.jpeg'));
+ expect(files).toHaveLength(1);
+ expect(files[0]).toMatch(/^page-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z\.jpeg$/);
+});
+
+for (const raw of [undefined, true]) {
+ test(`browser_take_screenshot (raw: ${raw})`, async ({ startClient, server }, testInfo) => {
+ const outputDir = testInfo.outputPath('output');
+ const ext = raw ? 'png' : 'jpeg';
+ const { client } = await startClient({
+ config: { outputDir },
+ });
+ expect(await client.callTool({
+ name: 'browser_navigate',
+ arguments: { url: server.PREFIX },
+ })).toContainTextContent(`Navigate to http://localhost`);
+
+ expect(await client.callTool({
+ name: 'browser_take_screenshot',
+ arguments: { raw },
+ })).toEqual({
+ content: [
+ {
+ data: expect.any(String),
+ mimeType: `image/${ext}`,
+ type: 'image',
+ },
+ {
+ text: expect.stringMatching(
+ new RegExp(`page-\\d{4}-\\d{2}-\\d{2}T\\d{2}-\\d{2}-\\d{2}\\-\\d{3}Z\\.${ext}`)
+ ),
+ type: 'text',
+ },
+ ],
+ });
+
+ const files = [...fs.readdirSync(outputDir)].filter(f => f.endsWith(`.${ext}`));
+
+ expect(fs.existsSync(outputDir)).toBeTruthy();
+ expect(files).toHaveLength(1);
+ expect(files[0]).toMatch(
+ new RegExp(`^page-\\d{4}-\\d{2}-\\d{2}T\\d{2}-\\d{2}-\\d{2}-\\d{3}Z\\.${ext}$`)
+ );
+ });
+
+}
+
+test('browser_take_screenshot (filename: "output.jpeg")', async ({ startClient, server }, testInfo) => {
+ const outputDir = testInfo.outputPath('output');
+ const { client } = await startClient({
+ config: { outputDir },
+ });
+ expect(await client.callTool({
+ name: 'browser_navigate',
+ arguments: { url: server.HELLO_WORLD },
+ })).toContainTextContent(`Navigate to http://localhost`);
+
+ expect(await client.callTool({
+ name: 'browser_take_screenshot',
+ arguments: {
+ filename: 'output.jpeg',
+ },
+ })).toEqual({
+ content: [
+ {
+ data: expect.any(String),
+ mimeType: 'image/jpeg',
+ type: 'image',
+ },
+ {
+ text: expect.stringContaining(`output.jpeg`),
+ type: 'text',
+ },
+ ],
+ });
+
+ const files = [...fs.readdirSync(outputDir)].filter(f => f.endsWith('.jpeg'));
+
+ expect(fs.existsSync(outputDir)).toBeTruthy();
+ expect(files).toHaveLength(1);
+ expect(files[0]).toMatch(/^output\.jpeg$/);
+});
+
+test('browser_take_screenshot (imageResponses=omit)', async ({ startClient, server }, testInfo) => {
+ const outputDir = testInfo.outputPath('output');
+ const { client } = await startClient({
+ config: {
+ outputDir,
+ imageResponses: 'omit',
+ },
+ });
+
+ expect(await client.callTool({
+ name: 'browser_navigate',
+ arguments: { url: server.HELLO_WORLD },
+ })).toContainTextContent(`Navigate to http://localhost`);
+
+ await client.callTool({
+ name: 'browser_take_screenshot',
+ });
+
+ expect(await client.callTool({
+ name: 'browser_take_screenshot',
+ })).toEqual({
+ content: [
+ {
+ text: expect.stringContaining(`Screenshot viewport and save it as`),
+ type: 'text',
+ },
+ ],
+ });
+});
+
+test('browser_take_screenshot (cursor)', async ({ startClient, server }, testInfo) => {
+ const outputDir = testInfo.outputPath('output');
+
+ const { client } = await startClient({
+ clientName: 'cursor:vscode',
+ config: { outputDir },
+ });
+
+ expect(await client.callTool({
+ name: 'browser_navigate',
+ arguments: { url: server.HELLO_WORLD },
+ })).toContainTextContent(`Navigate to http://localhost`);
+
+ await client.callTool({
+ name: 'browser_take_screenshot',
+ });
+
+ expect(await client.callTool({
+ name: 'browser_take_screenshot',
+ })).toEqual({
+ content: [
+ {
+ text: expect.stringContaining(`Screenshot viewport and save it as`),
+ type: 'text',
+ },
+ ],
+ });
+});
diff --git a/tests/sse.spec.ts b/tests/sse.spec.ts
new file mode 100644
index 0000000..9e888a8
--- /dev/null
+++ b/tests/sse.spec.ts
@@ -0,0 +1,246 @@
+/**
+ * Copyright (c) Microsoft Corporation.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import fs from 'node:fs';
+import url from 'node:url';
+
+import { ChildProcess, spawn } from 'node:child_process';
+import path from 'node:path';
+import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
+import { 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(resolve => cp!.stderr?.on('data', data => {
+ stderr += data.toString();
+ const match = stderr.match(/Listening on (http:\/\/.*)/);
+ if (match)
+ resolve(match[1]);
+ }));
+
+ return { url: new URL(url), stderr: () => stderr };
+ });
+ cp?.kill('SIGTERM');
+ },
+});
+
+test('sse transport', async ({ serverEndpoint }) => {
+ const { url } = await serverEndpoint();
+ const transport = new SSEClientTransport(url);
+ const client = new Client({ name: 'test', version: '1.0.0' });
+ await client.connect(transport);
+ await client.ping();
+});
+
+test('sse transport (config)', async ({ serverEndpoint }) => {
+ const config: Config = {
+ server: {
+ port: 0,
+ }
+ };
+ const configFile = test.info().outputPath('config.json');
+ await fs.promises.writeFile(configFile, JSON.stringify(config, null, 2));
+
+ const { url } = await serverEndpoint({ noPort: true, args: ['--config=' + configFile] });
+ const transport = new SSEClientTransport(url);
+ const client = new Client({ name: 'test', version: '1.0.0' });
+ await client.connect(transport);
+ await client.ping();
+});
+
+test('sse transport browser lifecycle (isolated)', async ({ serverEndpoint, server }) => {
+ const { url, stderr } = await serverEndpoint({ args: ['--isolated'] });
+
+ const transport1 = new SSEClientTransport(url);
+ const client1 = new Client({ name: 'test', version: '1.0.0' });
+ await client1.connect(transport1);
+ await client1.callTool({
+ name: 'browser_navigate',
+ arguments: { url: server.HELLO_WORLD },
+ });
+ await client1.close();
+
+ const transport2 = new SSEClientTransport(url);
+ const client2 = new Client({ name: 'test', version: '1.0.0' });
+ await client2.connect(transport2);
+ await client2.callTool({
+ name: 'browser_navigate',
+ arguments: { url: server.HELLO_WORLD },
+ });
+ await client2.close();
+
+ await expect(async () => {
+ const lines = stderr().split('\n');
+ expect(lines.filter(line => line.match(/create SSE session/)).length).toBe(2);
+ expect(lines.filter(line => line.match(/delete SSE session/)).length).toBe(2);
+
+ expect(lines.filter(line => line.match(/create context/)).length).toBe(2);
+ expect(lines.filter(line => line.match(/close context/)).length).toBe(2);
+
+ expect(lines.filter(line => line.match(/create browser context \(isolated\)/)).length).toBe(2);
+ expect(lines.filter(line => line.match(/close browser context \(isolated\)/)).length).toBe(2);
+
+ expect(lines.filter(line => line.match(/obtain browser \(isolated\)/)).length).toBe(2);
+ expect(lines.filter(line => line.match(/close browser \(isolated\)/)).length).toBe(2);
+ }).toPass();
+});
+
+test('sse transport browser lifecycle (isolated, multiclient)', async ({ serverEndpoint, server }) => {
+ const { url, stderr } = await serverEndpoint({ args: ['--isolated'] });
+
+ const transport1 = new SSEClientTransport(url);
+ const client1 = new Client({ name: 'test', version: '1.0.0' });
+ await client1.connect(transport1);
+ await client1.callTool({
+ name: 'browser_navigate',
+ arguments: { url: server.HELLO_WORLD },
+ });
+
+ const transport2 = new SSEClientTransport(url);
+ const client2 = new Client({ name: 'test', version: '1.0.0' });
+ await client2.connect(transport2);
+ await client2.callTool({
+ name: 'browser_navigate',
+ arguments: { url: server.HELLO_WORLD },
+ });
+ await client1.close();
+
+ const transport3 = new SSEClientTransport(url);
+ const client3 = new Client({ name: 'test', version: '1.0.0' });
+ await client3.connect(transport3);
+ await client3.callTool({
+ name: 'browser_navigate',
+ arguments: { url: server.HELLO_WORLD },
+ });
+
+ await client2.close();
+ await client3.close();
+
+ await expect(async () => {
+ const lines = stderr().split('\n');
+ expect(lines.filter(line => line.match(/create SSE session/)).length).toBe(3);
+ expect(lines.filter(line => line.match(/delete SSE session/)).length).toBe(3);
+
+ expect(lines.filter(line => line.match(/create context/)).length).toBe(3);
+ expect(lines.filter(line => line.match(/close context/)).length).toBe(3);
+
+ expect(lines.filter(line => line.match(/create browser context \(isolated\)/)).length).toBe(3);
+ expect(lines.filter(line => line.match(/close browser context \(isolated\)/)).length).toBe(3);
+
+ expect(lines.filter(line => line.match(/obtain browser \(isolated\)/)).length).toBe(1);
+ expect(lines.filter(line => line.match(/close browser \(isolated\)/)).length).toBe(1);
+ }).toPass();
+});
+
+test('sse transport browser lifecycle (persistent)', async ({ serverEndpoint, server }) => {
+ const { url, stderr } = await serverEndpoint();
+
+ const transport1 = new SSEClientTransport(url);
+ const client1 = new Client({ name: 'test', version: '1.0.0' });
+ await client1.connect(transport1);
+ await client1.callTool({
+ name: 'browser_navigate',
+ arguments: { url: server.HELLO_WORLD },
+ });
+ await client1.close();
+
+ const transport2 = new SSEClientTransport(url);
+ const client2 = new Client({ name: 'test', version: '1.0.0' });
+ await client2.connect(transport2);
+ await client2.callTool({
+ name: 'browser_navigate',
+ arguments: { url: server.HELLO_WORLD },
+ });
+ await client2.close();
+
+ await expect(async () => {
+ const lines = stderr().split('\n');
+ expect(lines.filter(line => line.match(/create SSE session/)).length).toBe(2);
+ expect(lines.filter(line => line.match(/delete SSE session/)).length).toBe(2);
+
+ expect(lines.filter(line => line.match(/create context/)).length).toBe(2);
+ expect(lines.filter(line => line.match(/close context/)).length).toBe(2);
+
+ expect(lines.filter(line => line.match(/create browser context \(persistent\)/)).length).toBe(2);
+ expect(lines.filter(line => line.match(/close browser context \(persistent\)/)).length).toBe(2);
+
+ expect(lines.filter(line => line.match(/lock user data dir/)).length).toBe(2);
+ expect(lines.filter(line => line.match(/release user data dir/)).length).toBe(2);
+ }).toPass();
+});
+
+test('sse transport browser lifecycle (persistent, multiclient)', async ({ serverEndpoint, server }) => {
+ const { url } = await serverEndpoint();
+
+ const transport1 = new SSEClientTransport(url);
+ const client1 = new Client({ name: 'test', version: '1.0.0' });
+ await client1.connect(transport1);
+ await client1.callTool({
+ name: 'browser_navigate',
+ arguments: { url: server.HELLO_WORLD },
+ });
+
+ const transport2 = new SSEClientTransport(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('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();
+});
diff --git a/tests/tabs.spec.ts b/tests/tabs.spec.ts
new file mode 100644
index 0000000..08afd63
--- /dev/null
+++ b/tests/tabs.spec.ts
@@ -0,0 +1,152 @@
+/**
+ * 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';
+
+import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
+
+async function createTab(client: Client, title: string, body: string) {
+ return await client.callTool({
+ name: 'browser_tab_new',
+ arguments: {
+ url: `data:text/html,${title}${body}`,
+ },
+ });
+}
+
+test('list initial tabs', async ({ client }) => {
+ expect(await client.callTool({
+ name: 'browser_tab_list',
+ })).toHaveTextContent(`### Open tabs
+- 1: (current) [] (about:blank)`);
+});
+
+test('list first tab', async ({ client }) => {
+ await createTab(client, 'Tab one', 'Body one');
+ expect(await client.callTool({
+ name: 'browser_tab_list',
+ })).toHaveTextContent(`### Open tabs
+- 1: [] (about:blank)
+- 2: (current) [Tab one] (data:text/html,Tab oneBody one)`);
+});
+
+test('create new tab', async ({ client }) => {
+ expect(await createTab(client, 'Tab one', 'Body one')).toHaveTextContent(`
+- Ran Playwright code:
+\`\`\`js
+//
+\`\`\`
+
+### Open tabs
+- 1: [] (about:blank)
+- 2: (current) [Tab one] (data:text/html,Tab oneBody one)
+
+### Current tab
+- Page URL: data:text/html,Tab oneBody one
+- Page Title: Tab one
+- Page Snapshot
+\`\`\`yaml
+- generic [ref=e1]: Body one
+\`\`\``);
+
+ expect(await createTab(client, 'Tab two', 'Body two')).toHaveTextContent(`
+- Ran Playwright code:
+\`\`\`js
+//
+\`\`\`
+
+### Open tabs
+- 1: [] (about:blank)
+- 2: [Tab one] (data:text/html,Tab oneBody one)
+- 3: (current) [Tab two] (data:text/html,Tab twoBody two)
+
+### Current tab
+- Page URL: data:text/html,Tab twoBody two
+- Page Title: Tab two
+- Page Snapshot
+\`\`\`yaml
+- generic [ref=e1]: Body two
+\`\`\``);
+});
+
+test('select tab', async ({ client }) => {
+ await createTab(client, 'Tab one', 'Body one');
+ await createTab(client, 'Tab two', 'Body two');
+ expect(await client.callTool({
+ name: 'browser_tab_select',
+ arguments: {
+ index: 2,
+ },
+ })).toHaveTextContent(`
+- Ran Playwright code:
+\`\`\`js
+//
+\`\`\`
+
+### Open tabs
+- 1: [] (about:blank)
+- 2: (current) [Tab one] (data:text/html,Tab oneBody one)
+- 3: [Tab two] (data:text/html,Tab twoBody two)
+
+### Current tab
+- Page URL: data:text/html,Tab oneBody one
+- Page Title: Tab one
+- Page Snapshot
+\`\`\`yaml
+- generic [ref=e1]: Body one
+\`\`\``);
+});
+
+test('close tab', async ({ client }) => {
+ await createTab(client, 'Tab one', 'Body one');
+ await createTab(client, 'Tab two', 'Body two');
+ expect(await client.callTool({
+ name: 'browser_tab_close',
+ arguments: {
+ index: 3,
+ },
+ })).toHaveTextContent(`
+- Ran Playwright code:
+\`\`\`js
+//
+\`\`\`
+
+### Open tabs
+- 1: [] (about:blank)
+- 2: (current) [Tab one] (data:text/html,Tab oneBody one)
+
+### Current tab
+- Page URL: data:text/html,Tab oneBody one
+- Page Title: Tab one
+- Page Snapshot
+\`\`\`yaml
+- generic [ref=e1]: Body one
+\`\`\``);
+});
+
+test('reuse first tab when navigating', async ({ startClient, cdpServer, server }) => {
+ const browserContext = await cdpServer.start();
+ const pages = browserContext.pages();
+
+ const { client } = await startClient({ args: [`--cdp-endpoint=${cdpServer.endpoint}`] });
+ await client.callTool({
+ name: 'browser_navigate',
+ arguments: { url: server.HELLO_WORLD },
+ });
+
+ expect(pages.length).toBe(1);
+ expect(await pages[0].title()).toBe('Title');
+});
diff --git a/tests/testserver/cert.pem b/tests/testserver/cert.pem
new file mode 100644
index 0000000..3388ed5
--- /dev/null
+++ b/tests/testserver/cert.pem
@@ -0,0 +1,29 @@
+-----BEGIN CERTIFICATE-----
+MIIFCjCCAvKgAwIBAgIULU/gkDm8IqC7PG8u3RID0AYyP6gwDQYJKoZIhvcNAQEL
+BQAwGjEYMBYGA1UEAwwPcGxheXdyaWdodC10ZXN0MB4XDTIzMDgxMDIyNTc1MFoX
+DTMzMDgwNzIyNTc1MFowGjEYMBYGA1UEAwwPcGxheXdyaWdodC10ZXN0MIICIjAN
+BgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEArbS99qjKcnHr5G0Zc2xhDaOZnjQv
+Fbiqxf/nbXt/7WaqryzpVKu7AT1ainBvuPEo7If9DhVnfF//2pGl0gbU31OU4/mr
+ymQmczGEyZvOBDsZhtCif54o5OoO0BjhODNT8OWec9RT87n6RkH58MHlOi8xsPxQ
+9n5U1CN/h2DyQF3aRKunEFCgtwPKWSjG+J/TAI9i0aSENXPiR8wjTrjg79s8Ehuj
+NN8Wk6rKLU3sepG3GIMID5vLsVa2t9xqn562sP95Ee+Xp2YX3z7oYK99QCJdzacw
+alhMHob1GCEKjDyxsD2IFRi7Dysiutfyzy3pMo6NALxFrwKVhWX0L4zVFIsI6JlV
+dK8dHmDk0MRSqgB9sWXvEfSTXADEe8rncFSFpFz4Z8RNLmn5YSzQJzokNn41DUCP
+dZTlTkcGTqvn5NqoY4sOV8rkFbgmTcqyijV/sebPjxCbJNcNmaSWa9FJ5IjRTpzM
+38wLmxn+eKGK68n2JB3P7JP6LtsBShQEpXAF3rFfyNsP1bjquvGZVSjV8w/UwPE4
+kV5eq3j3D4913Zfxvzjp6PEmhStG0EQtIXvx/TRoYpaNWypIgZdbkZQp1HUIQL15
+D2Web4nazP3so1FC3ZgbrJZ2ozoadjLMp49NcSFdh+WRyVKuo0DIqR0zaiAzzf2D
+G1q7TLKimM3XBMUCAwEAAaNIMEYwCQYDVR0TBAIwADALBgNVHQ8EBAMCBeAwLAYD
+VR0RBCUwI4IJbG9jYWxob3N0hwR/AAABhxAAAAAAAAAAAAAAAAAAAAABMA0GCSqG
+SIb3DQEBCwUAA4ICAQAvC5M1JFc21WVSLPvE2iVbt4HmirO3EENdDqs+rTYG5VJG
+iE5ZuI6h/LjS5ptTfKovXQKaMr3pwp1pLMd/9q+6ZR1Hs9Z2wF6OZan4sb0uT32Y
+1KGlj86QMiiSLdrJ/1Z9JHskHYNCep1ZTsUhGk0qqiNv+G3K2y7ZpvrT/xlnYMth
+KLTuSVUwM8BBEPrCRLoXuaEy0LnvMvMVepIfP8tnMIL6zqmj3hXMPe4r4OFV/C5o
+XX25bC7GyuPWIRYn2OWP92J1CODZD1rGRoDtmvqrQpHdeX9RYcKH0ZLZoIf5L3Hf
+pPUtVkw3QGtjvKeG3b9usxaV9Od2Z08vKKk1PRkXFe8gqaeyicK7YVIOMTSuspAf
+JeJEHns6Hg61Exbo7GwdX76xlmQ/Z43E9BPHKgLyZ9WuJ0cysqN4aCyvS9yws9to
+ki7iMZqJUsmE2o09n9VaEsX6uQANZtLjI9wf+IgJuueDTNrkzQkhU7pbaPMsSG40
+AgGY/y4BR0H8sbhNnhqtZH7RcXV9VCJoPBAe+YiuXRiXyZHWxwBRyBE3e7g4MKHg
+hrWtaWUAs7gbavHwjqgU63iVItDSk7t4fCiEyObjK09AaNf2DjjaSGf8YGza4bNy
+BjYinYJ6/eX//gp+abqfocFbBP7D9zRDgMIbVmX/Ey6TghKiLkZOdbzcpO4Wgg==
+-----END CERTIFICATE-----
diff --git a/tests/testserver/index.ts b/tests/testserver/index.ts
new file mode 100644
index 0000000..b40f7f4
--- /dev/null
+++ b/tests/testserver/index.ts
@@ -0,0 +1,172 @@
+/**
+ * Copyright 2017 Google Inc. All rights reserved.
+ * Modifications copyright (c) Microsoft Corporation.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import fs from 'fs';
+import url from 'node:url';
+import http from 'http';
+import https from 'https';
+import path from 'path';
+import debug from 'debug';
+
+const fulfillSymbol = Symbol('fulfil callback');
+const rejectSymbol = Symbol('reject callback');
+
+// NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename.
+const __filename = url.fileURLToPath(import.meta.url);
+
+export class TestServer {
+ private _server: http.Server;
+ readonly debugServer: any;
+ private _routes = new Map any>();
+ private _csp = new Map();
+ private _extraHeaders = new Map();
+ private _requestSubscribers = new Map>();
+ readonly PORT: number;
+ readonly PREFIX: string;
+ readonly CROSS_PROCESS_PREFIX: string;
+ readonly HELLO_WORLD: string;
+
+ static async create(port: number): Promise {
+ const server = new TestServer(port);
+ await new Promise(x => server._server.once('listening', x));
+ return server;
+ }
+
+ static async createHTTPS(port: number): Promise {
+ const server = new TestServer(port, {
+ key: await fs.promises.readFile(path.join(path.dirname(__filename), 'key.pem')),
+ cert: await fs.promises.readFile(path.join(path.dirname(__filename), 'cert.pem')),
+ passphrase: 'aaaa',
+ });
+ await new Promise(x => server._server.once('listening', x));
+ return server;
+ }
+
+ constructor(port: number, sslOptions?: object) {
+ if (sslOptions)
+ this._server = https.createServer(sslOptions, this._onRequest.bind(this));
+ else
+ this._server = http.createServer(this._onRequest.bind(this));
+ this._server.listen(port);
+ this.debugServer = debug('pw:testserver');
+
+ const cross_origin = '127.0.0.1';
+ const same_origin = 'localhost';
+ const protocol = sslOptions ? 'https' : 'http';
+ this.PORT = port;
+ this.PREFIX = `${protocol}://${same_origin}:${port}/`;
+ this.CROSS_PROCESS_PREFIX = `${protocol}://${cross_origin}:${port}/`;
+ this.HELLO_WORLD = `${this.PREFIX}hello-world`;
+ }
+
+ setCSP(path: string, csp: string) {
+ this._csp.set(path, csp);
+ }
+
+ setExtraHeaders(path: string, object: Record) {
+ this._extraHeaders.set(path, object);
+ }
+
+ async stop() {
+ this.reset();
+ await new Promise(x => this._server.close(x));
+ }
+
+ route(path: string, handler: (request: http.IncomingMessage, response: http.ServerResponse) => any) {
+ this._routes.set(path, handler);
+ }
+
+ setContent(path: string, content: string, mimeType: string) {
+ this.route(path, (req, res) => {
+ res.writeHead(200, { 'Content-Type': mimeType });
+ res.end(mimeType === 'text/html' ? `${content}` : content);
+ });
+ }
+
+ redirect(from: string, to: string) {
+ this.route(from, (req, res) => {
+ const headers = this._extraHeaders.get(req.url!) || {};
+ res.writeHead(302, { ...headers, location: to });
+ res.end();
+ });
+ }
+
+ waitForRequest(path: string): Promise {
+ let promise = this._requestSubscribers.get(path);
+ if (promise)
+ return promise;
+ let fulfill, reject;
+ promise = new Promise((f, r) => {
+ fulfill = f;
+ reject = r;
+ });
+ promise[fulfillSymbol] = fulfill;
+ promise[rejectSymbol] = reject;
+ this._requestSubscribers.set(path, promise);
+ return promise;
+ }
+
+ reset() {
+ this._routes.clear();
+ this._csp.clear();
+ this._extraHeaders.clear();
+ this._server.closeAllConnections();
+ const error = new Error('Static Server has been reset');
+ for (const subscriber of this._requestSubscribers.values())
+ subscriber[rejectSymbol].call(null, error);
+ this._requestSubscribers.clear();
+
+ this.setContent('/favicon.ico', '', 'image/x-icon');
+
+ this.setContent('/', ``, 'text/html');
+
+ this.setContent('/hello-world', `
+ Title
+ Hello, world!
+ `, 'text/html');
+ }
+
+ _onRequest(request: http.IncomingMessage, response: http.ServerResponse) {
+ request.on('error', error => {
+ if ((error as any).code === 'ECONNRESET')
+ response.end();
+ else
+ throw error;
+ });
+ (request as any).postBody = new Promise(resolve => {
+ const chunks: Buffer[] = [];
+ request.on('data', chunk => {
+ chunks.push(chunk);
+ });
+ request.on('end', () => resolve(Buffer.concat(chunks)));
+ });
+ const path = request.url || '/';
+ this.debugServer(`request ${request.method} ${path}`);
+ // Notify request subscriber.
+ if (this._requestSubscribers.has(path)) {
+ this._requestSubscribers.get(path)![fulfillSymbol].call(null, request);
+ this._requestSubscribers.delete(path);
+ }
+ const handler = this._routes.get(path);
+ if (handler) {
+ handler.call(null, request, response);
+ } else {
+ response.writeHead(404);
+ response.end();
+ }
+ }
+}
diff --git a/tests/testserver/key.pem b/tests/testserver/key.pem
new file mode 100644
index 0000000..28edf51
--- /dev/null
+++ b/tests/testserver/key.pem
@@ -0,0 +1,52 @@
+-----BEGIN PRIVATE KEY-----
+MIIJQgIBADANBgkqhkiG9w0BAQEFAASCCSwwggkoAgEAAoICAQCttL32qMpycevk
+bRlzbGENo5meNC8VuKrF/+dte3/tZqqvLOlUq7sBPVqKcG+48Sjsh/0OFWd8X//a
+kaXSBtTfU5Tj+avKZCZzMYTJm84EOxmG0KJ/nijk6g7QGOE4M1Pw5Z5z1FPzufpG
+QfnwweU6LzGw/FD2flTUI3+HYPJAXdpEq6cQUKC3A8pZKMb4n9MAj2LRpIQ1c+JH
+zCNOuODv2zwSG6M03xaTqsotTex6kbcYgwgPm8uxVra33Gqfnraw/3kR75enZhff
+Puhgr31AIl3NpzBqWEwehvUYIQqMPLGwPYgVGLsPKyK61/LPLekyjo0AvEWvApWF
+ZfQvjNUUiwjomVV0rx0eYOTQxFKqAH2xZe8R9JNcAMR7yudwVIWkXPhnxE0uaflh
+LNAnOiQ2fjUNQI91lOVORwZOq+fk2qhjiw5XyuQVuCZNyrKKNX+x5s+PEJsk1w2Z
+pJZr0UnkiNFOnMzfzAubGf54oYrryfYkHc/sk/ou2wFKFASlcAXesV/I2w/VuOq6
+8ZlVKNXzD9TA8TiRXl6rePcPj3Xdl/G/OOno8SaFK0bQRC0he/H9NGhilo1bKkiB
+l1uRlCnUdQhAvXkPZZ5vidrM/eyjUULdmBuslnajOhp2Msynj01xIV2H5ZHJUq6j
+QMipHTNqIDPN/YMbWrtMsqKYzdcExQIDAQABAoICAGqXttpdyZ1g+vg5WpzRrNzJ
+v8KtExepMmI+Hq24U1BC6AqG7MfgeejQ1XaOeIBsvEgpSsgRqmdQIZjmN3Mibg59
+I6ih1SFlQ5L8mBd/XHSML6Xi8VSOoVmXp29bVRk/pgr1XL6HVN0DCumCIvXyhc+m
+lj+dFbGs5DEpd2CDxSRqcz4gd2wzjevAj7MWqsJ2kOyPEHzFD7wdWIXmZuQv3xhQ
+2BPkkcon+5qx+07BupOcR1brUU8Cs4QnSgiZYXSB2GnU215+P/mhVJTR7ZcnGRz5
++cXxCmy3sj4pYs1juS1FMWSM3azUeDVeqvks+vrXmXpEr5H79mbmlwo8/hMPwNDO
+07HRZwa8T01aT9EYVm0lIOYjMF/2f6j6cu2apJtjXICOksR2HefRBVXQirOxRHma
+9XAYfNkZ/2164ZbgFmJv9khFnegPEuth9tLVdFIeGSmsG0aX9tH63zGT2NROyyLc
+QXPqsDl2CxCYPRs2oiGkM9dnfP1wAOp96sq42GIuN7ykfqfRnwAIvvnLKvyCq1vR
+pIno3CIX6vnzt+1/Hrmv13b0L6pJPitpXwKWHv9zJKBTpN8HEzP3Qmth2Ef60/7/
+CBo1PVTd1A6zcU7816flg7SCY+Vk+OxVHV3dGBIIqN9SfrQ8BPcOl6FNV5Anbrnv
+CpSw+LzH9n5xympDnk0BAoIBAQDjenvDfCnrNVeqx8+sYaYey4/WPVLXOQhREvRY
+oOtX9eqlNSi20+Wl+iuXmyj8wdHrDET7rfjCbpDQ7u105yzLw4gy4qIRDKZ1nE45
+YX+tm8mZgBqRnTp0DoGOArqmp3IKXJtUYmpbTz9tOfY7Usb1o1epb4winEB+Pl+8
+mgXOEo8xvWBzKeRA7tE73V64Mwbvbo9Ff2EguhXweQP29yBkEjT4iViayuHUmyPt
+hOVSMj2oFQuQGPdhAk7nUXojSGK/Zas/AGpH9CHH9De0h4m08vd3oM4vj0HwzgjU
+Co9aRa9SAH7EiaocOTcjDRPxWdZPHhxmrVRIYlF0MNmOAkXJAoIBAQDDfEqu4sNi
+pq74VXVatQqhzCILZo+o48bdgEjF7mF99mqPj8rwIDrEoEriDK861kenLc3vWKRY
+5wh1iX3S896re9kUMoxx6p4heYTcsOJ9BbkcpT8bJPZx9gBJb4jJENeVf1exf6sG
+RhFnulpzReRRaUjX2yAkyUPfc8YcUt+Nalrg+2W0fzeLCUpABCAcj2B1Vv7qRZHj
+oEtlCV5Nz+iMhrwIa16g9c8wGt5DZb4PI+VIJ6EYkdsjhgqIF0T/wDq9/habGBPo
+mHN+/DX3hCJWN2QgoVGJskHGt0zDMgiEgXfLZ2Grl02vQtq+mW2O2vGVeUd9Y5Ew
+RUiY4bSRTrUdAoIBAHxL1wiP9c/By+9TUtScXssA681ioLtdPIAgXUd4VmAvzVEM
+ZPzRd/BjbCJg89p4hZ1rjN4Ax6ZmB9dCVpnEH6QPaYJ0d53dTa+CAvQzpDJWp6eq
+adobEW+M5ZmVQCwD3rpus6k+RWMzQDMMstDjgDeEU0gP3YCj5FGW/3TsrDNXzMqe
+8e67ey9Hzyho43K+3xFBViPhYE8jnw1Q8quliRtlH3CWi8W5CgDD7LPCJBPvw+Tt
+6u2H1tQ5EKgwyw4wZVSz1wiLz4cVjMfXWADa9pHbGQFS6pbuLlfIHObQBliLLysd
+ficiGcNmOAx8/uKn9gQxLc+k8iLDJkLY1mdUMpECggEAJLl87k37ltTpmg2z9k58
+qNjIrIugAYKJIaOwCD84YYmhi0bgQSxM3hOe/ciUQuFupKGeRpDIj0sX87zYvoDC
+HEUwCvNUHzKMco15wFwasJIarJ7+tALFqbMlaqZhdCSN27AIsXfikVMogewoge9n
+bUPyQ1sPNtn4vknptfh7tv18BTg1aytbK+ua31vnDHaDEIg/a5OWTMUYZOrVpJii
+f4PwX0SMioCjY84oY1EB26ZKtLt9MDh2ir3rzJVSiRl776WEaa6kTtYVHI4VNWLF
+cJ0HWnnz74JliQd2jFUh9IK+FqBdYPcTyREuNxBr3KKVMBeQrqW96OubL913JrU6
+oQKCAQEA0yzORUouT0yleWs7RmzBlT9OLD/3cBYJMf/r1F8z8OQjB8fU1jKbO1Cs
+q4l+o9FmI+eHkgc3xbEG0hahOFWm/hTTli9vzksxurgdawZELThRkK33uTU9pKla
+Okqx3Ru/iMOW2+DQUx9UB+jK+hSAgq4gGqLeJVyaBerIdLQLlvqxrwSxjvvj+wJC
+Y66mgRzdCi6VDF1vV0knCrQHK6tRwcPozu/k4zjJzvdbMJnKEy2S7Vh6vO8lEPJm
+MQtaHPpmz+F4z14b9unNIiSbHO60Q4O+BwIBCzxApQQbFg63vBLYYwEMRd7hh92s
+ZkZVSOEp+sYBf/tmptlKr49nO+dTjQ==
+-----END PRIVATE KEY-----
diff --git a/tests/testserver/san.cnf b/tests/testserver/san.cnf
new file mode 100644
index 0000000..2f4864b
--- /dev/null
+++ b/tests/testserver/san.cnf
@@ -0,0 +1,19 @@
+# openssl req -new -x509 -days 3650 -key key.pem -out cert.pem -config san.cnf -extensions v3_req
+
+[req]
+distinguished_name = req_distinguished_name
+req_extensions = v3_req
+prompt = no
+
+[req_distinguished_name]
+CN = playwright-test
+
+[v3_req]
+basicConstraints = CA:FALSE
+keyUsage = nonRepudiation, digitalSignature, keyEncipherment
+subjectAltName = @alt_names
+
+[alt_names]
+DNS.1 = localhost
+IP.1 = 127.0.0.1
+IP.2 = ::1
diff --git a/tests/trace.spec.ts b/tests/trace.spec.ts
new file mode 100644
index 0000000..13e9d4f
--- /dev/null
+++ b/tests/trace.spec.ts
@@ -0,0 +1,35 @@
+/**
+ * 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 trace is saved', async ({ startClient, server }, testInfo) => {
+ const outputDir = testInfo.outputPath('output');
+
+ const { client } = await startClient({
+ args: ['--save-trace', `--output-dir=${outputDir}`],
+ });
+
+ expect(await client.callTool({
+ name: 'browser_navigate',
+ arguments: { url: server.HELLO_WORLD },
+ })).toContainTextContent(`Navigate to http://localhost`);
+
+ expect(fs.existsSync(path.join(outputDir, 'traces', 'trace.trace'))).toBeTruthy();
+});
diff --git a/tests/wait.spec.ts b/tests/wait.spec.ts
new file mode 100644
index 0000000..d29a09a
--- /dev/null
+++ b/tests/wait.spec.ts
@@ -0,0 +1,85 @@
+/**
+ * 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_wait_for(text)', async ({ client, server }) => {
+ server.setContent('/', `
+
+
+
+ Text to disappear
+
+ `, 'text/html');
+
+ await client.callTool({
+ name: 'browser_navigate',
+ arguments: { url: server.PREFIX },
+ });
+
+ await client.callTool({
+ name: 'browser_click',
+ arguments: {
+ element: 'Click me',
+ ref: 'e2',
+ },
+ });
+
+ expect(await client.callTool({
+ name: 'browser_wait_for',
+ arguments: { text: 'Text to appear' },
+ })).toContainTextContent(`- generic [ref=e3]: Text to appear`);
+});
+
+test('browser_wait_for(textGone)', async ({ client, server }) => {
+ server.setContent('/', `
+
+
+
+ Text to disappear
+
+ `, 'text/html');
+
+ await client.callTool({
+ name: 'browser_navigate',
+ arguments: { url: server.PREFIX },
+ });
+
+ await client.callTool({
+ name: 'browser_click',
+ arguments: {
+ element: 'Click me',
+ ref: 'e2',
+ },
+ });
+
+ expect(await client.callTool({
+ name: 'browser_wait_for',
+ arguments: { textGone: 'Text to disappear' },
+ })).toContainTextContent(`- generic [ref=e3]: Text to appear`);
+});
diff --git a/tests/webdriver.spec.ts b/tests/webdriver.spec.ts
new file mode 100644
index 0000000..faf5fdc
--- /dev/null
+++ b/tests/webdriver.spec.ts
@@ -0,0 +1,38 @@
+/**
+ * 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('do not falsely advertise user agent as a test driver', async ({ client, server, mcpBrowser }) => {
+ test.skip(mcpBrowser === 'firefox');
+ test.skip(mcpBrowser === 'webkit');
+ server.route('/', (req, res) => {
+ res.writeHead(200, { 'Content-Type': 'text/html' });
+ res.end(`
+
+
+ `);
+ });
+
+ expect(await client.callTool({
+ name: 'browser_navigate',
+ arguments: {
+ url: server.PREFIX,
+ },
+ })).toContainTextContent('webdriver: false');
+});
diff --git a/tsconfig.all.json b/tsconfig.all.json
new file mode 100644
index 0000000..2e60f02
--- /dev/null
+++ b/tsconfig.all.json
@@ -0,0 +1,4 @@
+{
+ "extends": "./tsconfig.json",
+ "include": ["**/*.ts", "**/*.js"],
+}
diff --git a/tsconfig.json b/tsconfig.json
index 7bc09d3..114ce8b 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,12 +1,13 @@
{
"compilerOptions": {
"target": "ESNext",
- "skipLibCheck": true,
"esModuleInterop": true,
- "moduleResolution": "node",
+ "moduleResolution": "nodenext",
"strict": true,
- "module": "CommonJS",
- "outDir": "./lib"
+ "module": "NodeNext",
+ "rootDir": "src",
+ "outDir": "./lib",
+ "resolveJsonModule": true
},
"include": [
"src",
diff --git a/utils/generate-links.js b/utils/generate-links.js
new file mode 100644
index 0000000..a54fe9c
--- /dev/null
+++ b/utils/generate-links.js
@@ -0,0 +1,6 @@
+const config = JSON.stringify({ name: 'playwright', command: 'npx', args: ["@playwright/mcp@latest"] });
+const urlForWebsites = `vscode:mcp/install?${encodeURIComponent(config)}`;
+// Github markdown does not allow linking to `vscode:` directly, so you can use our redirect:
+const urlForGithub = `https://insiders.vscode.dev/redirect?url=${encodeURIComponent(urlForWebsites)}`;
+
+console.log(urlForGithub);
\ No newline at end of file
diff --git a/utils/update-readme.js b/utils/update-readme.js
new file mode 100644
index 0000000..90d8c4b
--- /dev/null
+++ b/utils/update-readme.js
@@ -0,0 +1,194 @@
+#!/usr/bin/env node
+/**
+ * Copyright (c) Microsoft Corporation.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+// @ts-check
+
+import fs from 'node:fs'
+import path from 'node:path'
+import url from 'node:url'
+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';
+
+const categories = {
+ 'Interactions': [
+ ...snapshotTools,
+ ...keyboardTools(true),
+ ...waitTools(true),
+ ...filesTools(true),
+ ...dialogsTools(true),
+ ],
+ '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),
+ ],
+};
+
+// NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename.
+const __filename = url.fileURLToPath(import.meta.url);
+
+/**
+ * @param {import('../src/tools/tool.js').ToolSchema} tool
+ * @returns {string[]}
+ */
+function formatToolForReadme(tool) {
+ const lines = /** @type {string[]} */ ([]);
+ lines.push(``);
+ lines.push(``);
+ lines.push(`- **${tool.name}**`);
+ lines.push(` - Title: ${tool.title}`);
+ lines.push(` - Description: ${tool.description}`);
+
+ const inputSchema = /** @type {any} */ (zodToJsonSchema(tool.inputSchema || {}));
+ const requiredParams = inputSchema.required || [];
+ if (inputSchema.properties && Object.keys(inputSchema.properties).length) {
+ lines.push(` - Parameters:`);
+ Object.entries(inputSchema.properties).forEach(([name, param]) => {
+ const optional = !requiredParams.includes(name);
+ const meta = /** @type {string[]} */ ([]);
+ if (param.type)
+ meta.push(param.type);
+ if (optional)
+ meta.push('optional');
+ lines.push(` - \`${name}\` ${meta.length ? `(${meta.join(', ')})` : ''}: ${param.description}`);
+ });
+ } else {
+ lines.push(` - Parameters: None`);
+ }
+ lines.push(` - Read-only: **${tool.type === 'readOnly'}**`);
+ lines.push('');
+ return lines;
+}
+
+/**
+ * @param {string} content
+ * @param {string} startMarker
+ * @param {string} endMarker
+ * @param {string[]} generatedLines
+ * @returns {Promise}
+ */
+async function updateSection(content, startMarker, endMarker, generatedLines) {
+ const startMarkerIndex = content.indexOf(startMarker);
+ const endMarkerIndex = content.indexOf(endMarker);
+ if (startMarkerIndex === -1 || endMarkerIndex === -1)
+ throw new Error('Markers for generated section not found in README');
+
+ return [
+ content.slice(0, startMarkerIndex + startMarker.length),
+ '',
+ generatedLines.join('\n'),
+ '',
+ content.slice(endMarkerIndex),
+ ].join('\n');
+}
+
+/**
+ * @param {string} content
+ * @returns {Promise}
+ */
+async function updateTools(content) {
+ console.log('Loading tool information from compiled modules...');
+
+ const totalTools = Object.values(categories).flat().length;
+ console.log(`Found ${totalTools} tools`);
+
+ const generatedLines = /** @type {string[]} */ ([]);
+ for (const [category, categoryTools] of Object.entries(categories)) {
+ generatedLines.push(`\n${category}
`);
+ generatedLines.push('');
+ for (const tool of categoryTools)
+ generatedLines.push(...formatToolForReadme(tool.schema));
+ generatedLines.push(` `);
+ generatedLines.push('');
+ }
+
+ const startMarker = ``;
+ const endMarker = ``;
+ return updateSection(content, startMarker, endMarker, generatedLines);
+}
+
+/**
+ * @param {string} content
+ * @returns {Promise}
+ */
+async function updateOptions(content) {
+ console.log('Listing options...');
+ const output = execSync('node cli.js --help');
+ const lines = output.toString().split('\n');
+ const firstLine = lines.findIndex(line => line.includes('--version'));
+ lines.splice(0, firstLine + 1);
+ const lastLine = lines.findIndex(line => line.includes('--help'));
+ lines.splice(lastLine);
+ const startMarker = ``;
+ const endMarker = ``;
+ return updateSection(content, startMarker, endMarker, [
+ '```',
+ '> npx @playwright/mcp@latest --help',
+ ...lines,
+ '```',
+ ]);
+}
+
+async function updateReadme() {
+ const readmePath = path.join(path.dirname(__filename), '..', 'README.md');
+ const readmeContent = await fs.promises.readFile(readmePath, 'utf-8');
+ const withTools = await updateTools(readmeContent);
+ const withOptions = await updateOptions(withTools);
+ await fs.promises.writeFile(readmePath, withOptions, 'utf-8');
+ console.log('README updated successfully');
+}
+
+updateReadme().catch(err => {
+ console.error('Error updating README:', err);
+ process.exit(1);
+});