24 Commits

Author SHA1 Message Date
Pavel Feldman
9efaea6a1c chore: mark v0.0.16 (#298) 2025-04-29 19:51:57 -07:00
Pavel Feldman
3f72fe53ec chore: add support for device (#300)
Fixes https://github.com/microsoft/playwright-mcp/issues/294
2025-04-29 19:51:00 -07:00
Pavel Feldman
40d125f0bb docs: document configuration file (#299) 2025-04-29 15:29:56 -07:00
Pavel Feldman
21d2f80fef chore: store channel profiles separately (#297) 2025-04-29 13:34:56 -07:00
Simon Knott
6efdc90078 fix: show custom error for modal state (#240)
Calling a tool that resolves modal state, when there's no such modal
state visible, currently shows this misleading message:

```md
Tool "browser_file_upload" does not handle the modal state.
### Modal state
```

Instead, we should show the error message from the tool implementation.
2025-04-29 18:48:52 +02:00
zwmmm
ad4147da54 docs: Fix the default path to User data directory (#290)
Fix the default path to User data directory
2025-04-29 08:53:30 -07:00
Pavel Feldman
69703cc882 chore: follow up to exposing playwright config options (#289) 2025-04-29 08:53:03 -07:00
Max Schmitt
4147e21a3a chore: fix update-readme TS linting (#296) 2025-04-29 16:12:17 +02:00
Pavel Feldman
80c9b93b72 chore: allow configuring raw Playwright options (#287)
Fixes: https://github.com/microsoft/playwright-mcp/issues/272
2025-04-28 20:17:16 -07:00
Pavel Feldman
12e72a96c4 chore: allow configuring screenshot tool (#286)
Fixes: https://github.com/microsoft/playwright-mcp/issues/277
2025-04-28 17:21:23 -07:00
Pavel Feldman
697a69a8c2 chore: allow specifying output dir (#285)
Ref: https://github.com/microsoft/playwright-mcp/issues/279
2025-04-28 16:35:33 -07:00
Pavel Feldman
6e76d5e550 chore: split context.ts into files (#284) 2025-04-28 16:14:16 -07:00
Pavel Feldman
26779ceb20 chore: allow passing config file (#281) 2025-04-28 15:04:59 -07:00
Pavel Feldman
23704ace1f chore: update docs on lint (#283) 2025-04-28 14:56:00 -07:00
Pavel Feldman
b02370df2f chore: roll playwright to latest (#269) 2025-04-28 13:44:24 -07:00
Simon Knott
bf7dbabca4 feat: support streamable http transport (#243)
Adds support for the new StreamableHttp transport. I'm not aware of any
clients that implement it, but somebody's gotta make the start! Once
some clients support it, we can also advertise it in the README.
2025-04-28 11:11:31 +02:00
Zheng Xi Zhou
7256ee3701 docs(readme): Fix syntax error and improve formatting (#263)
The commit fixes a syntax error in the `npx` command by removing
an extra backtick. It also improves the formatting by adding line
breaks before code blocks to enhance readability.
2025-04-24 10:30:35 +02:00
Zheng Xi Zhou
0ed0bcd914 feat(server): add host option to SSE server configuration (#261) 2025-04-23 23:04:00 -07:00
Zheng Xi Zhou
4d95761f66 chore(gitignore): Add .idea and .DS_Store to .gitignore (#262) 2025-04-23 22:05:06 -07:00
Max Schmitt
b9dc323734 chore: enable @typescript-eslint/no-floating-promises rule (#260) 2025-04-23 16:03:30 +02:00
Pavel Feldman
586492a3f0 chore: mark v0.0.15 (#250) 2025-04-22 16:17:36 -07:00
Pavel Feldman
f7e9bae571 chore: roll playwright to 1745357020000 (#249) 2025-04-22 16:04:50 -07:00
Pavel Feldman
1bc3c761de feat(network): implement listing network requests (#247)
Fixes: https://github.com/microsoft/playwright-mcp/issues/242
2025-04-22 16:04:25 -07:00
Simon Knott
c80f7cf222 chore: infer tool params (#241)
Moves the `schema.parse` call to the calling side of the handler, so we
don't have to duplicate it everywhere.
2025-04-22 13:24:38 +02:00
50 changed files with 1692 additions and 842 deletions

View File

@@ -21,7 +21,6 @@ jobs:
- run: npm run build
- name: Run ESLint
run: npm run lint
- run: npm run update-readme
- name: Ensure no changes
run: git diff --exit-code
@@ -58,4 +57,4 @@ jobs:
run: npx playwright install --with-deps
- name: Run tests
run: npm test
run: npm test -- --forbid-only

3
.gitignore vendored
View File

@@ -2,3 +2,6 @@ lib/
node_modules/
test-results/
.vscode/mcp.json
.idea
.DS_Store

View File

@@ -4,3 +4,4 @@ LICENSE
!lib/**/*.js
!cli.js
!index.*
!config.d.ts

189
README.md
View File

@@ -15,9 +15,14 @@ A Model Context Protocol (MCP) server that provides browser automation capabilit
- Automated testing driven by LLMs
- General-purpose browser interaction for agents
### Example config
<!--
// Generate using:
node utils/generate_links.js
-->
#### NPX
[<img src="https://img.shields.io/badge/VS_Code-VS_Code?style=flat-square&label=Install%20Server&color=0098FF" alt="Install in VS Code">](https://insiders.vscode.dev/redirect?url=vscode%3Amcp%2Finstall%3F%257B%2522name%2522%253A%2522playwright%2522%252C%2522command%2522%253A%2522npx%2522%252C%2522args%2522%253A%255B%2522%2540playwright%252Fmcp%2540latest%2522%255D%257D) [<img alt="Install in VS Code Insiders" src="https://img.shields.io/badge/VS_Code_Insiders-VS_Code_Insiders?style=flat-square&label=Install%20Server&color=24bfa5">](https://insiders.vscode.dev/redirect?url=vscode-insiders%3Amcp%2Finstall%3F%257B%2522name%2522%253A%2522playwright%2522%252C%2522command%2522%253A%2522npx%2522%252C%2522args%2522%253A%255B%2522%2540playwright%252Fmcp%2540latest%2522%255D%257D)
### Example config
```js
{
@@ -32,35 +37,29 @@ A Model Context Protocol (MCP) server that provides browser automation capabilit
}
```
#### Installation in VS Code
### Table of Contents
Install the Playwright MCP server in VS Code using one of these buttons:
- [Installation in VS Code](#installation-in-vs-code)
- [Command line](#command-line)
- [User profile](#user-profile)
- [Configuration file](#configuration-file)
- [Running on Linux](#running-on-linux)
- [Docker](#docker)
- [Programmatic usage](#programmatic-usage)
- [Tool modes](#tool-modes)
<!--
// Generate using?:
const config = JSON.stringify({ name: 'playwright', command: 'npx', args: ["-y", "@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)}`;
-->
### Installation in VS Code
[<img src="https://img.shields.io/badge/VS_Code-VS_Code?style=flat-square&label=Install%20Server&color=0098FF" alt="Install in VS Code">](https://insiders.vscode.dev/redirect?url=vscode%3Amcp%2Finstall%3F%257B%2522name%2522%253A%2522playwright%2522%252C%2522command%2522%253A%2522npx%2522%252C%2522args%2522%253A%255B%2522-y%2522%252C%2522%2540playwright%252Fmcp%2540latest%2522%255D%257D) [<img alt="Install in VS Code Insiders" src="https://img.shields.io/badge/VS_Code_Insiders-VS_Code_Insiders?style=flat-square&label=Install%20Server&color=24bfa5">](https://insiders.vscode.dev/redirect?url=vscode-insiders%3Amcp%2Finstall%3F%257B%2522name%2522%253A%2522playwright%2522%252C%2522command%2522%253A%2522npx%2522%252C%2522args%2522%253A%255B%2522-y%2522%252C%2522%2540playwright%252Fmcp%2540latest%2522%255D%257D)
Alternatively, you can install the Playwright MCP server using the VS Code CLI:
You can 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.
### CLI Options
### Command line
The Playwright MCP server supports the following command-line options:
@@ -73,42 +72,102 @@ The Playwright MCP server supports the following command-line options:
- `--cdp-endpoint <endpoint>`: CDP endpoint to connect to
- `--executable-path <path>`: Path to the browser executable
- `--headless`: Run browser in headless mode (headed by default)
- `--port <port>`: Port to listen on for SSE transport
- `--device`: Emulate mobile device
- `--user-data-dir <path>`: Path to the user data directory
- `--port <port>`: Port to listen on for SSE transport
- `--host <host>`: Host to bind server to. Default is localhost. Use 0.0.0.0 to bind to all interfaces.
- `--vision`: Run server that uses screenshots (Aria snapshots are used by default)
- `--config <path>`: Path to the configuration file
### User data directory
### User profile
Playwright MCP will launch the browser with the new profile, located at
```
- `%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
- `%USERPROFILE%\AppData\Local\ms-playwright\mcp-{channel}-profile` on Windows
- `~/Library/Caches/ms-playwright/mcp-{channel}-profile` on macOS
- `~/.cache/ms-playwright/mcp-{channel}-profile` on Linux
```
All the logged in information will be stored in that profile, you can delete it between sessions if you'd like to clear the offline state.
### Configuration file
### Running headless browser (Browser without GUI).
The Playwright MCP server can be configured using a JSON configuration file. Here's the complete configuration format:
This mode is useful for background or batch operations.
```js
```typescript
{
"mcpServers": {
"playwright": {
"command": "npx",
"args": [
"@playwright/mcp@latest",
"--headless"
]
// Browser configuration
browser?: {
// Browser type to use (chromium, firefox, or webkit)
browserName?: 'chromium' | 'firefox' | 'webkit';
// 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
>;
// Enable vision mode (screenshots instead of accessibility snapshots)
vision?: boolean;
// Directory for output files
outputDir?: string;
// Tool-specific configurations
tools?: {
browser_take_screenshot?: {
// Disable base64-encoded image responses
omitBase64?: boolean;
}
}
}
```
### Running headed browser on Linux w/o DISPLAY
You can specify the configuration file using the `--config` command line option:
```bash
npx @playwright/mcp@latest --config path/to/config.json
```
### Running on Linux
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.
@@ -132,6 +191,7 @@ And then in MCP client config, set the `url` to the SSE endpoint:
### Docker
**NOTE:** The Docker implementation only supports headless chromium at the moment.
```js
{
"mcpServers": {
@@ -143,7 +203,33 @@ And then in MCP client config, set the `url` to the SSE endpoint:
}
```
### Tool Modes
You can build the Docker image yourself.
```
docker build -t mcp/playwright .
```
### Programmatic usage
```js
import http from 'http';
import { createServer } 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 mcpServer = await createServer({ headless: true });
const transport = new SSEServerTransport('/messages', res);
await mcpServer.connect(transport);
// ...
});
```
### Tool modes
The tools are available in two modes:
@@ -169,33 +255,6 @@ 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.
### Build with Docker
You can build the Docker image yourself.
```
docker build -t mcp/playwright .
```
### Programmatic usage with custom transports
```js
import http from 'http';
import { createServer } 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 mcpServer = await createServer({ headless: true });
const transport = new SSEServerTransport('/messages', res);
await mcpServer.connect(transport);
// ...
});
```
<!--- Generated by update-readme.js -->

113
config.d.ts vendored Normal file
View File

@@ -0,0 +1,113 @@
/**
* 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';
export type Config = {
/**
* The browser to use.
*/
browser?: {
/**
* The type of browser to use.
*/
browserName?: 'chromium' | 'firefox' | 'webkit';
/**
* 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.BrowserLaunchOptions;
/**
* 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;
/**
* The directory to save output files.
*/
outputDir?: string;
/**
* Configuration for specific tools.
*/
tools?: {
/**
* Configuration for the browser_take_screenshot tool.
*/
browser_take_screenshot?: {
/**
* Whether to disable base64-encoded image responses to the clients that
* don't support binary data or prefer to save on tokens.
*/
omitBase64?: boolean;
}
}
};

View File

@@ -33,6 +33,7 @@ const plugins = {
};
export const baseRules = {
"@typescript-eslint/no-floating-promises": "error",
"@typescript-eslint/no-unused-vars": [
2,
{ args: "none", caughtErrors: "none" },
@@ -184,6 +185,9 @@ const languageOptions = {
parser: tsParser,
ecmaVersion: 9,
sourceType: "module",
parserOptions: {
project: path.join(fileURLToPath(import.meta.url), "..", "tsconfig.all.json"),
}
};
export default [

41
index.d.ts vendored
View File

@@ -17,44 +17,7 @@
import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
type ToolCapability = 'core' | 'tabs' | 'pdf' | 'history' | 'wait' | 'files' | 'install';
import type { Config } from './config';
type Options = {
/**
* The browser to use (e.g., 'chrome', 'chromium', 'firefox', 'webkit', 'msedge').
*/
browser?: string;
/**
* Path to a user data directory for browser profile persistence.
*/
userDataDir?: string;
/**
* Whether to run the browser in headless mode (default: true).
*/
headless?: boolean;
/**
* Path to a custom browser executable.
*/
executablePath?: string;
/**
* Chrome DevTools Protocol endpoint to connect to an existing browser instance.
*/
cdpEndpoint?: string;
/**
* Enable vision capabilities (e.g., visual automation or OCR).
*/
vision?: boolean;
/**
* 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[];
};
export declare function createServer(options?: Options): Promise<Server>;
export declare function createServer(config?: Config): Promise<Server>;
export {};

53
package-lock.json generated
View File

@@ -1,17 +1,17 @@
{
"name": "@playwright/mcp",
"version": "0.0.14",
"version": "0.0.16",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@playwright/mcp",
"version": "0.0.14",
"version": "0.0.16",
"license": "Apache-2.0",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.6.1",
"@modelcontextprotocol/sdk": "^1.10.1",
"commander": "^13.1.0",
"playwright": "^1.52.0-alpha-1743163434000",
"playwright": "1.53.0-alpha-2025-04-25",
"yaml": "^2.7.1",
"zod-to-json-schema": "^3.24.4"
},
@@ -21,7 +21,7 @@
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.19.0",
"@playwright/test": "^1.52.0-alpha-1743163434000",
"@playwright/test": "1.53.0-alpha-2025-04-25",
"@stylistic/eslint-plugin": "^3.0.1",
"@types/node": "^22.13.10",
"@typescript-eslint/eslint-plugin": "^8.26.1",
@@ -228,17 +228,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.10.1",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.10.1.tgz",
"integrity": "sha512-xNYdFdkJqEfIaTVP1gPKoEvluACHZsHZegIoICX8DM1o6Qf3G5u2BQJHmgd0n4YgRPqqK/u1ujQvrgAxxSJT9w==",
"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"
@@ -286,13 +287,13 @@
}
},
"node_modules/@playwright/test": {
"version": "1.52.0-alpha-1743163434000",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.52.0-alpha-1743163434000.tgz",
"integrity": "sha512-4uBgNlJ6hgPtB8DrwQsgoKuVoe7j+nPqudna7CLXWCmmT3LYPMD5aOjGoBkszr+R9NejtKashq/bOi/ny9hsIA==",
"version": "1.53.0-alpha-2025-04-25",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.53.0-alpha-2025-04-25.tgz",
"integrity": "sha512-3y4C2ZjAc2oUpwavC2yG2JzH53TOKgcMZvWb5GmpxnOa6fhuSVXK0kIsiIaImKmdffIVM1agsqNHp8yldeBTHQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.52.0-alpha-1743163434000"
"playwright": "1.53.0-alpha-2025-04-25"
},
"bin": {
"playwright": "cli.js"
@@ -1091,7 +1092,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",
@@ -2786,7 +2786,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": {
@@ -3256,7 +3255,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"
@@ -3292,21 +3290,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-1743163434000",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.52.0-alpha-1743163434000.tgz",
"integrity": "sha512-4uYv49ekPjolydfFfTfFQ2z4URF9UZMVUXLy7aXam/tPxEQ5O7+jQC+yzrDMGmhcj5QkMnxjlyk7N2V9a0QLdQ==",
"version": "1.53.0-alpha-2025-04-25",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.53.0-alpha-2025-04-25.tgz",
"integrity": "sha512-b5VT4lWgyhhy99zHeCoUBt/FQckPxeQVA5ksvxBv0HeqcEvzZzhuyqrrcZewJyflE+5U+bmvqI+yoU0ks8mE3Q==",
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.52.0-alpha-1743163434000"
"playwright-core": "1.53.0-alpha-2025-04-25"
},
"bin": {
"playwright": "cli.js"
@@ -3319,9 +3317,9 @@
}
},
"node_modules/playwright-core": {
"version": "1.52.0-alpha-1743163434000",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.52.0-alpha-1743163434000.tgz",
"integrity": "sha512-Tn4u3Ywwjkh847/bYWlXIrNxv5DRJRDgtb+VYMXHvNCKkrxL6yfZ1ApIAYD7IAkkKH/KLTXszGWl3a/Z/KDfQA==",
"version": "1.53.0-alpha-2025-04-25",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.53.0-alpha-2025-04-25.tgz",
"integrity": "sha512-gjV01l6A4q/zg+/pwEX50k9lhYWaE9NcDVypSDD331jB3EYrdk0LeDQxqz5XFDOzq/tC/8QTouDs9a/s/p95hA==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
@@ -3796,7 +3794,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"
@@ -3809,7 +3806,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"
@@ -4238,7 +4234,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"

View File

@@ -1,6 +1,6 @@
{
"name": "@playwright/mcp",
"version": "0.0.14",
"version": "0.0.16",
"description": "Playwright Tools for MCP",
"repository": {
"type": "git",
@@ -16,7 +16,7 @@
"license": "Apache-2.0",
"scripts": {
"build": "tsc",
"lint": "eslint .",
"lint": "npm run update-readme && eslint .",
"update-readme": "node utils/update-readme.js",
"watch": "tsc --watch",
"test": "playwright test",
@@ -34,16 +34,16 @@
}
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.6.1",
"@modelcontextprotocol/sdk": "^1.10.1",
"commander": "^13.1.0",
"playwright": "^1.52.0-alpha-1743163434000",
"playwright": "1.53.0-alpha-2025-04-25",
"yaml": "^2.7.1",
"zod-to-json-schema": "^3.24.4"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.19.0",
"@playwright/test": "^1.52.0-alpha-1743163434000",
"@playwright/test": "1.53.0-alpha-2025-04-25",
"@stylistic/eslint-plugin": "^3.0.1",
"@types/node": "^22.13.10",
"@typescript-eslint/eslint-plugin": "^8.26.1",

181
src/config.ts Normal file
View File

@@ -0,0 +1,181 @@
/**
* 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 net from 'net';
import os from 'os';
import path from 'path';
import { devices } from 'playwright';
import { sanitizeForFilePath } from './tools/utils';
import type { Config, ToolCapability } from '../config';
import type { BrowserContextOptions, LaunchOptions } from 'playwright';
export type CLIOptions = {
browser?: string;
caps?: string;
cdpEndpoint?: string;
executablePath?: string;
headless?: boolean;
device?: string;
userDataDir?: string;
port?: number;
host?: string;
vision?: boolean;
config?: string;
};
const defaultConfig: Config = {
browser: {
browserName: 'chromium',
userDataDir: os.tmpdir(),
launchOptions: {
channel: 'chrome',
headless: os.platform() === 'linux' && !process.env.DISPLAY,
},
contextOptions: {
viewport: null,
},
},
};
export async function resolveConfig(cliOptions: CLIOptions): Promise<Config> {
const config = await loadConfig(cliOptions.config);
const cliOverrides = await configFromCLIOptions(cliOptions);
return mergeConfig(defaultConfig, mergeConfig(config, cliOverrides));
}
export async function configFromCLIOptions(cliOptions: CLIOptions): Promise<Config> {
let browserName: 'chromium' | 'firefox' | 'webkit';
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;
default:
browserName = 'chromium';
channel = 'chrome';
}
const launchOptions: LaunchOptions = {
channel,
executablePath: cliOptions.executablePath,
headless: cliOptions.headless,
};
if (browserName === 'chromium')
(launchOptions as any).webSocketPort = await findFreePort();
const contextOptions: BrowserContextOptions | undefined = cliOptions.device ? devices[cliOptions.device] : undefined;
return {
browser: {
browserName,
userDataDir: cliOptions.userDataDir ?? await createUserDataDir({ browserName, channel }),
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,
};
}
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);
});
}
async function loadConfig(configFile: string | undefined): Promise<Config> {
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}`);
}
}
async function createUserDataDir(options: { browserName: 'chromium' | 'firefox' | 'webkit', channel: string | undefined }) {
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-${options.channel ?? options.browserName}-profile`);
await fs.promises.mkdir(result, { recursive: true });
return result;
}
export async function outputFile(config: Config, name: string): Promise<string> {
const result = config.outputDir ?? os.tmpdir();
await fs.promises.mkdir(result, { recursive: true });
const fileName = sanitizeForFilePath(name);
return path.join(result, fileName);
}
function mergeConfig(base: Config, overrides: Config): Config {
const browser: Config['browser'] = {
...base.browser,
...overrides.browser,
launchOptions: {
...base.browser?.launchOptions,
...overrides.browser?.launchOptions,
...{ assistantMode: true },
},
contextOptions: {
...base.browser?.contextOptions,
...overrides.browser?.contextOptions,
},
};
return {
...base,
...overrides,
browser,
};
}

View File

@@ -15,23 +15,14 @@
*/
import * as playwright from 'playwright';
import yaml from 'yaml';
import { waitForCompletion } from './tools/utils';
import { ManualPromise } from './manualPromise';
import { Tab } from './tab';
import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types';
import type { ModalState, Tool, ToolActionResult } from './tools/tool';
export type ContextOptions = {
browserName?: 'chromium' | 'firefox' | 'webkit';
userDataDir: string;
launchOptions?: playwright.LaunchOptions;
cdpEndpoint?: string;
remoteEndpoint?: string;
};
type PageOrFrameLocator = playwright.Page | playwright.FrameLocator;
import type { Config } from '../config';
type PendingAction = {
dialogShown: ManualPromise<void>;
@@ -39,17 +30,18 @@ type PendingAction = {
export class Context {
readonly tools: Tool[];
readonly options: ContextOptions;
readonly config: Config;
private _browser: playwright.Browser | undefined;
private _browserContext: playwright.BrowserContext | undefined;
private _createBrowserContextPromise: Promise<{ browser?: playwright.Browser, browserContext: playwright.BrowserContext }> | undefined;
private _tabs: Tab[] = [];
private _currentTab: Tab | undefined;
private _modalStates: (ModalState & { tab: Tab })[] = [];
private _pendingAction: PendingAction | undefined;
constructor(tools: Tool[], options: ContextOptions) {
constructor(tools: Tool[], config: Config) {
this.tools = tools;
this.options = options;
this.config = config;
}
modalStates(): ModalState[] {
@@ -66,6 +58,8 @@ export class Context {
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`);
@@ -124,7 +118,7 @@ export class Context {
async run(tool: Tool, params: Record<string, unknown> | undefined) {
// Tab management is done outside of the action() call.
const toolResult = await tool.handle(this, params);
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;
@@ -259,6 +253,7 @@ ${code.join('\n')}
return;
const browserContext = this._browserContext;
const browser = this._browser;
this._createBrowserContextPromise = undefined;
this._browserContext = undefined;
this._browser = undefined;
@@ -280,176 +275,42 @@ ${code.join('\n')}
}
private async _createBrowserContext(): Promise<{ browser?: playwright.Browser, browserContext: playwright.BrowserContext }> {
if (this.options.remoteEndpoint) {
const url = new URL(this.options.remoteEndpoint);
if (this.options.browserName)
url.searchParams.set('browser', this.options.browserName);
if (this.options.launchOptions)
url.searchParams.set('launch-options', JSON.stringify(this.options.launchOptions));
const browser = await playwright[this.options.browserName ?? 'chromium'].connect(String(url));
if (!this._createBrowserContextPromise)
this._createBrowserContextPromise = this._innerCreateBrowserContext();
return this._createBrowserContextPromise;
}
private async _innerCreateBrowserContext(): Promise<{ browser?: playwright.Browser, browserContext: playwright.BrowserContext }> {
if (this.config.browser?.remoteEndpoint) {
const url = new URL(this.config.browser?.remoteEndpoint);
if (this.config.browser.browserName)
url.searchParams.set('browser', this.config.browser.browserName);
if (this.config.browser.launchOptions)
url.searchParams.set('launch-options', JSON.stringify(this.config.browser.launchOptions));
const browser = await playwright[this.config.browser?.browserName ?? 'chromium'].connect(String(url));
const browserContext = await browser.newContext();
return { browser, browserContext };
}
if (this.options.cdpEndpoint) {
const browser = await playwright.chromium.connectOverCDP(this.options.cdpEndpoint);
if (this.config.browser?.cdpEndpoint) {
const browser = await playwright.chromium.connectOverCDP(this.config.browser.cdpEndpoint);
const browserContext = browser.contexts()[0];
return { browser, browserContext };
}
const browserContext = await this._launchPersistentContext();
const browserContext = await launchPersistentContext(this.config.browser);
return { browserContext };
}
private async _launchPersistentContext(): Promise<playwright.BrowserContext> {
try {
const browserType = this.options.browserName ? playwright[this.options.browserName] : playwright.chromium;
return await browserType.launchPersistentContext(this.options.userDataDir, this.options.launchOptions);
} 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.`);
throw error;
}
}
}
export class Tab {
readonly context: Context;
readonly page: playwright.Page;
private _console: playwright.ConsoleMessage[] = [];
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._console.push(event));
page.on('framenavigated', frame => {
if (!frame.parentFrame())
this._console.length = 0;
});
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.setDefaultNavigationTimeout(60000);
page.setDefaultTimeout(5000);
}
private _onClose() {
this._console.length = 0;
this._onPageClose(this);
}
async navigate(url: string) {
await this.page.goto(url, { waitUntil: 'domcontentloaded' });
// Cap load event to 5 seconds, the page is operational at this point.
await this.page.waitForLoadState('load', { timeout: 5000 }).catch(() => {});
}
hasSnapshot(): boolean {
return !!this._snapshot;
}
snapshotOrDie(): PageSnapshot {
if (!this._snapshot)
throw new Error('No snapshot available');
return this._snapshot;
}
async console(): Promise<playwright.ConsoleMessage[]> {
return this._console;
}
async captureSnapshot() {
this._snapshot = await PageSnapshot.create(this.page);
}
}
class PageSnapshot {
private _frameLocators: PageOrFrameLocator[] = [];
private _text!: string;
constructor() {
}
static async create(page: playwright.Page): Promise<PageSnapshot> {
const snapshot = new PageSnapshot();
await snapshot._build(page);
return snapshot;
}
text(): string {
return this._text;
}
private async _build(page: playwright.Page) {
const yamlDocument = await this._snapshotFrame(page);
this._text = [
`- Page Snapshot`,
'```yaml',
yamlDocument.toString({ indentSeq: false }).trim(),
'```',
].join('\n');
}
private async _snapshotFrame(frame: playwright.Page | playwright.FrameLocator) {
const frameIndex = this._frameLocators.push(frame) - 1;
const snapshotString = await frame.locator('body').ariaSnapshot({ ref: true });
const snapshot = yaml.parseDocument(snapshotString);
const visit = async (node: any): Promise<unknown> => {
if (yaml.isPair(node)) {
await Promise.all([
visit(node.key).then(k => node.key = k),
visit(node.value).then(v => node.value = v)
]);
} else if (yaml.isSeq(node) || yaml.isMap(node)) {
node.items = await Promise.all(node.items.map(visit));
} else if (yaml.isScalar(node)) {
if (typeof node.value === 'string') {
const value = node.value;
if (frameIndex > 0)
node.value = value.replace('[ref=', `[ref=f${frameIndex}`);
if (value.startsWith('iframe ')) {
const ref = value.match(/\[ref=(.*)\]/)?.[1];
if (ref) {
try {
const childSnapshot = await this._snapshotFrame(frame.frameLocator(`aria-ref=${ref}`));
return snapshot.createPair(node.value, childSnapshot);
} catch (error) {
return snapshot.createPair(node.value, '<could not take iframe snapshot>');
}
}
}
}
}
return node;
};
await visit(snapshot.contents);
return snapshot;
}
refLocator(ref: string): playwright.Locator {
let frame = this._frameLocators[0];
const match = ref.match(/^f(\d+)(.*)/);
if (match) {
const frameIndex = parseInt(match[1], 10);
frame = this._frameLocators[frameIndex];
ref = match[2];
}
if (!frame)
throw new Error(`Frame does not exist. Provide ref from the most current snapshot.`);
return frame.locator(`aria-ref=${ref}`);
async function launchPersistentContext(browserConfig: Config['browser']): Promise<playwright.BrowserContext> {
try {
const browserType = browserConfig?.browserName ? playwright[browserConfig.browserName] : playwright.chromium;
return await browserType.launchPersistentContext(browserConfig?.userDataDir || '', { ...browserConfig?.launchOptions, ...browserConfig?.contextOptions });
} 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.`);
throw error;
}
}

View File

@@ -14,10 +14,6 @@
* limitations under the License.
*/
import path from 'path';
import os from 'os';
import fs from 'fs';
import { createServerWithTools } from './server';
import common from './tools/common';
import console from './tools/console';
@@ -26,16 +22,17 @@ import files from './tools/files';
import install from './tools/install';
import keyboard from './tools/keyboard';
import navigate from './tools/navigate';
import network from './tools/network';
import pdf from './tools/pdf';
import snapshot from './tools/snapshot';
import tabs from './tools/tabs';
import screen from './tools/screen';
import type { Tool, ToolCapability } from './tools/tool';
import type { Tool } from './tools/tool';
import type { Config } from '../config';
import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
import type { LaunchOptions } from 'playwright';
const snapshotTools: Tool[] = [
const snapshotTools: Tool<any>[] = [
...common(true),
...console,
...dialogs(true),
@@ -43,12 +40,13 @@ const snapshotTools: Tool[] = [
...install,
...keyboard(true),
...navigate(true),
...network,
...pdf,
...snapshot,
...tabs(true),
];
const screenshotTools: Tool[] = [
const screenshotTools: Tool<any>[] = [
...common(false),
...console,
...dialogs(false),
@@ -56,84 +54,20 @@ const screenshotTools: Tool[] = [
...install,
...keyboard(false),
...navigate(false),
...network,
...pdf,
...screen,
...tabs(false),
];
type Options = {
browser?: string;
userDataDir?: string;
headless?: boolean;
executablePath?: string;
cdpEndpoint?: string;
vision?: boolean;
capabilities?: ToolCapability[];
};
const packageJSON = require('../package.json');
export async function createServer(options?: Options): Promise<Server> {
let browserName: 'chromium' | 'firefox' | 'webkit';
let channel: string | undefined;
switch (options?.browser) {
case 'chrome':
case 'chrome-beta':
case 'chrome-canary':
case 'chrome-dev':
case 'msedge':
case 'msedge-beta':
case 'msedge-canary':
case 'msedge-dev':
browserName = 'chromium';
channel = options.browser;
break;
case 'chromium':
browserName = 'chromium';
break;
case 'firefox':
browserName = 'firefox';
break;
case 'webkit':
browserName = 'webkit';
break;
default:
browserName = 'chromium';
channel = 'chrome';
}
const userDataDir = options?.userDataDir ?? await createUserDataDir(browserName);
const launchOptions: LaunchOptions = {
headless: !!(options?.headless ?? (os.platform() === 'linux' && !process.env.DISPLAY)),
channel,
executablePath: options?.executablePath,
};
const allTools = options?.vision ? screenshotTools : snapshotTools;
const tools = allTools.filter(tool => !options?.capabilities || tool.capability === 'core' || options.capabilities.includes(tool.capability));
export async function createServer(config: Config = {}): Promise<Server> {
const allTools = config.vision ? screenshotTools : snapshotTools;
const tools = allTools.filter(tool => !config.capabilities || tool.capability === 'core' || config.capabilities.includes(tool.capability));
return createServerWithTools({
name: 'Playwright',
version: packageJSON.version,
tools,
resources: [],
browserName,
userDataDir,
launchOptions,
cdpEndpoint: options?.cdpEndpoint,
});
}
async function createUserDataDir(browserName: 'chromium' | 'firefox' | 'webkit') {
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-${browserName}-profile`);
await fs.promises.mkdir(result, { recursive: true });
return result;
}, config);
}

101
src/pageSnapshot.ts Normal file
View File

@@ -0,0 +1,101 @@
/**
* 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 yaml from 'yaml';
type PageOrFrameLocator = playwright.Page | playwright.FrameLocator;
export class PageSnapshot {
private _frameLocators: PageOrFrameLocator[] = [];
private _text!: string;
constructor() {
}
static async create(page: playwright.Page): Promise<PageSnapshot> {
const snapshot = new PageSnapshot();
await snapshot._build(page);
return snapshot;
}
text(): string {
return this._text;
}
private async _build(page: playwright.Page) {
const yamlDocument = await this._snapshotFrame(page);
this._text = [
`- Page Snapshot`,
'```yaml',
yamlDocument.toString({ indentSeq: false }).trim(),
'```',
].join('\n');
}
private async _snapshotFrame(frame: playwright.Page | playwright.FrameLocator) {
const frameIndex = this._frameLocators.push(frame) - 1;
const snapshotString = await frame.locator('body').ariaSnapshot({ ref: true, emitGeneric: true });
const snapshot = yaml.parseDocument(snapshotString);
const visit = async (node: any): Promise<unknown> => {
if (yaml.isPair(node)) {
await Promise.all([
visit(node.key).then(k => node.key = k),
visit(node.value).then(v => node.value = v)
]);
} else if (yaml.isSeq(node) || yaml.isMap(node)) {
node.items = await Promise.all(node.items.map(visit));
} else if (yaml.isScalar(node)) {
if (typeof node.value === 'string') {
const value = node.value;
if (frameIndex > 0)
node.value = value.replace('[ref=', `[ref=f${frameIndex}`);
if (value.startsWith('iframe ')) {
const ref = value.match(/\[ref=(.*)\]/)?.[1];
if (ref) {
try {
const childSnapshot = await this._snapshotFrame(frame.frameLocator(`aria-ref=${ref}`));
return snapshot.createPair(node.value, childSnapshot);
} catch (error) {
return snapshot.createPair(node.value, '<could not take iframe snapshot>');
}
}
}
}
}
return node;
};
await visit(snapshot.contents);
return snapshot;
}
refLocator(ref: string): playwright.Locator {
let frame = this._frameLocators[0];
const match = ref.match(/^f(\d+)(.*)/);
if (match) {
const frameIndex = parseInt(match[1], 10);
frame = this._frameLocators[frameIndex];
ref = match[2];
}
if (!frame)
throw new Error(`Frame does not exist. Provide ref from the most current snapshot.`);
return frame.locator(`aria-ref=${ref}`);
}
}

View File

@@ -14,18 +14,14 @@
* limitations under the License.
*/
import http from 'http';
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 assert from 'assert';
import { ToolCapability } from './tools/tool';
import { startHttpTransport, startStdioTransport } from './transport';
import { resolveConfig } from './config';
const packageJSON = require('../package.json');
@@ -37,27 +33,21 @@ program
.option('--cdp-endpoint <endpoint>', 'CDP endpoint to connect to.')
.option('--executable-path <path>', 'Path to the browser executable.')
.option('--headless', 'Run browser in headless mode, headed by default')
.option('--port <port>', 'Port to listen on for SSE transport.')
.option('--device <device>', 'Device to emulate, for example: "iPhone 15"')
.option('--user-data-dir <path>', 'Path to the user data directory')
.option('--port <port>', 'Port to listen on for SSE transport.')
.option('--host <host>', 'Host to bind server to. Default is localhost. Use 0.0.0.0 to bind to all interfaces.')
.option('--vision', 'Run server that uses screenshots (Aria snapshots are used by default)')
.option('--config <path>', 'Path to the configuration file.')
.action(async options => {
const serverList = new ServerList(() => createServer({
browser: options.browser,
userDataDir: options.userDataDir,
headless: options.headless,
executablePath: options.executablePath,
vision: !!options.vision,
cdpEndpoint: options.cdpEndpoint,
capabilities: options.caps?.split(',').map((c: string) => c.trim() as ToolCapability),
}));
const config = await resolveConfig(options);
const serverList = new ServerList(() => createServer(config));
setupExitWatchdog(serverList);
if (options.port) {
startSSEServer(+options.port, serverList);
} else {
const server = await serverList.create();
await server.connect(new StdioServerTransport());
}
if (options.port)
startHttpTransport(+options.port, options.host, serverList);
else
await startStdioTransport(serverList);
});
function setupExitWatchdog(serverList: ServerList) {
@@ -73,64 +63,3 @@ function setupExitWatchdog(serverList: ServerList) {
}
program.parse(process.argv);
async function startSSEServer(port: number, serverList: ServerList) {
const sessions = new Map<string, SSEServerTransport>();
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));
});
}

View File

@@ -15,80 +15,62 @@
*/
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { CallToolRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema } from '@modelcontextprotocol/sdk/types.js';
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
import { zodToJsonSchema } from 'zod-to-json-schema';
import { Context } from './context';
import type { Tool } from './tools/tool';
import type { Resource } from './resources/resource';
import type { ContextOptions } from './context';
import type { Config } from '../config';
type Options = ContextOptions & {
type MCPServerOptions = {
name: string;
version: string;
tools: Tool[];
resources: Resource[],
};
export function createServerWithTools(options: Options): Server {
const { name, version, tools, resources } = options;
const context = new Context(tools, options);
export function createServerWithTools(serverOptions: MCPServerOptions, config: Config): Server {
const { name, version, tools } = serverOptions;
const context = new Context(tools, config);
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) };
return {
tools: tools.map(tool => ({
name: tool.schema.name,
description: tool.schema.description,
inputSchema: zodToJsonSchema(tool.schema.inputSchema)
})),
};
});
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 {
content: [{ type: 'text', text: `Tool "${request.params.name}" not found` }],
isError: true,
};
}
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)) ||
(!tool.clearsModalState && modalStates.length)) {
const text = [
`Tool "${request.params.name}" does not handle the modal state.`,
...context.modalStatesMarkdown(),
].join('\n');
return {
content: [{ type: 'text', text }],
isError: true,
};
}
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 {
content: [{ type: 'text', text: String(error) }],
isError: true,
};
return errorResult(String(error));
}
});
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 () => {

92
src/tab.ts Normal file
View File

@@ -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 * as playwright from 'playwright';
import { PageSnapshot } from './pageSnapshot';
import type { Context } from './context';
export class Tab {
readonly context: Context;
readonly page: playwright.Page;
private _console: playwright.ConsoleMessage[] = [];
private _requests: Map<playwright.Request, playwright.Response | null> = 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._console.push(event));
page.on('request', request => this._requests.set(request, null));
page.on('response', response => this._requests.set(response.request(), response));
page.on('framenavigated', frame => {
if (!frame.parentFrame())
this._clearCollectedArtifacts();
});
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.setDefaultNavigationTimeout(60000);
page.setDefaultTimeout(5000);
}
private _clearCollectedArtifacts() {
this._console.length = 0;
this._requests.clear();
}
private _onClose() {
this._clearCollectedArtifacts();
this._onPageClose(this);
}
async navigate(url: string) {
await this.page.goto(url, { waitUntil: 'domcontentloaded' });
// Cap load event to 5 seconds, the page is operational at this point.
await this.page.waitForLoadState('load', { timeout: 5000 }).catch(() => {});
}
hasSnapshot(): boolean {
return !!this._snapshot;
}
snapshotOrDie(): PageSnapshot {
if (!this._snapshot)
throw new Error('No snapshot available');
return this._snapshot;
}
console(): playwright.ConsoleMessage[] {
return this._console;
}
requests(): Map<playwright.Request, playwright.Response | null> {
return this._requests;
}
async captureSnapshot() {
this._snapshot = await PageSnapshot.create(this.page);
}
}

View File

@@ -15,43 +15,36 @@
*/
import { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
import { defineTool, type ToolFactory } from './tool';
import type { Tool, ToolFactory } from './tool';
const waitSchema = z.object({
time: z.number().describe('The time to wait in seconds'),
});
const wait: ToolFactory = captureSnapshot => ({
const wait: ToolFactory = captureSnapshot => defineTool({
capability: 'wait',
schema: {
name: 'browser_wait',
description: 'Wait for a specified time in seconds',
inputSchema: zodToJsonSchema(waitSchema),
inputSchema: z.object({
time: z.number().describe('The time to wait in seconds'),
}),
},
handle: async (context, params) => {
const validatedParams = waitSchema.parse(params);
await new Promise(f => setTimeout(f, Math.min(10000, validatedParams.time * 1000)));
await new Promise(f => setTimeout(f, Math.min(10000, params.time * 1000)));
return {
code: [`// Waited for ${validatedParams.time} seconds`],
code: [`// Waited for ${params.time} seconds`],
captureSnapshot,
waitForNetwork: false,
};
},
});
const closeSchema = z.object({});
const close: Tool = {
const close = defineTool({
capability: 'core',
schema: {
name: 'browser_close',
description: 'Close the page',
inputSchema: zodToJsonSchema(closeSchema),
inputSchema: z.object({}),
},
handle: async context => {
@@ -62,33 +55,29 @@ const close: Tool = {
waitForNetwork: false,
};
},
};
const resizeSchema = z.object({
width: z.number().describe('Width of the browser window'),
height: z.number().describe('Height of the browser window'),
});
const resize: ToolFactory = captureSnapshot => ({
const resize: ToolFactory = captureSnapshot => defineTool({
capability: 'core',
schema: {
name: 'browser_resize',
description: 'Resize the browser window',
inputSchema: zodToJsonSchema(resizeSchema),
inputSchema: z.object({
width: z.number().describe('Width of the browser window'),
height: z.number().describe('Height of the browser window'),
}),
},
handle: async (context, params) => {
const validatedParams = resizeSchema.parse(params);
const tab = context.currentTabOrDie();
const code = [
`// Resize browser window to ${validatedParams.width}x${validatedParams.height}`,
`await page.setViewportSize({ width: ${validatedParams.width}, height: ${validatedParams.height} });`
`// Resize browser window to ${params.width}x${params.height}`,
`await page.setViewportSize({ width: ${params.width}, height: ${params.height} });`
];
const action = async () => {
await tab.page.setViewportSize({ width: validatedParams.width, height: validatedParams.height });
await tab.page.setViewportSize({ width: params.width, height: params.height });
};
return {

View File

@@ -15,21 +15,17 @@
*/
import { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
import { defineTool } from './tool';
import type { Tool } from './tool';
const consoleSchema = z.object({});
const console: Tool = {
const console = defineTool({
capability: 'core',
schema: {
name: 'browser_console_messages',
description: 'Returns all console messages',
inputSchema: zodToJsonSchema(consoleSchema),
inputSchema: z.object({}),
},
handle: async context => {
const messages = await context.currentTabOrDie().console();
const messages = context.currentTabOrDie().console();
const log = messages.map(message => `[${message.type().toUpperCase()}] ${message.text()}`).join('\n');
return {
code: [`// <internal code to get console messages>`],
@@ -42,7 +38,7 @@ const console: Tool = {
waitForNetwork: false,
};
},
};
});
export default [
console,

View File

@@ -15,32 +15,27 @@
*/
import { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
import { defineTool, type ToolFactory } from './tool';
import type { ToolFactory } from './tool';
const handleDialogSchema = 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.'),
});
const handleDialog: ToolFactory = captureSnapshot => ({
const handleDialog: ToolFactory = captureSnapshot => defineTool({
capability: 'core',
schema: {
name: 'browser_handle_dialog',
description: 'Handle a dialog',
inputSchema: zodToJsonSchema(handleDialogSchema),
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.'),
}),
},
handle: async (context, params) => {
const validatedParams = handleDialogSchema.parse(params);
const dialogState = context.modalStates().find(state => state.type === 'dialog');
if (!dialogState)
throw new Error('No dialog visible');
if (validatedParams.accept)
await dialogState.dialog.accept(validatedParams.promptText);
if (params.accept)
await dialogState.dialog.accept(params.promptText);
else
await dialogState.dialog.dismiss();

View File

@@ -15,35 +15,30 @@
*/
import { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
import { defineTool, type ToolFactory } from './tool';
import type { ToolFactory } from './tool';
const uploadFileSchema = z.object({
paths: z.array(z.string()).describe('The absolute paths to the files to upload. Can be a single file or multiple files.'),
});
const uploadFile: ToolFactory = captureSnapshot => ({
const uploadFile: ToolFactory = captureSnapshot => defineTool({
capability: 'files',
schema: {
name: 'browser_file_upload',
description: 'Upload one or multiple files',
inputSchema: zodToJsonSchema(uploadFileSchema),
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.'),
}),
},
handle: async (context, params) => {
const validatedParams = uploadFileSchema.parse(params);
const modalState = context.modalStates().find(state => state.type === 'fileChooser');
if (!modalState)
throw new Error('No file chooser visible');
const code = [
`// <internal code to chose files ${validatedParams.paths.join(', ')}`,
`// <internal code to chose files ${params.paths.join(', ')}`,
];
const action = async () => {
await modalState.fileChooser.setFiles(validatedParams.paths);
await modalState.fileChooser.setFiles(params.paths);
context.clearModalState(modalState);
};

View File

@@ -18,20 +18,18 @@ import { fork } from 'child_process';
import path from 'path';
import { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
import { defineTool } from './tool';
import type { Tool } from './tool';
const install: Tool = {
const install = defineTool({
capability: 'install',
schema: {
name: 'browser_install',
description: 'Install the browser specified in the config. Call this if you get an error about the browser not being installed.',
inputSchema: zodToJsonSchema(z.object({})),
inputSchema: z.object({}),
},
handle: async context => {
const channel = context.options.launchOptions?.channel ?? context.options.browserName ?? 'chrome';
const channel = context.config.browser?.launchOptions?.channel ?? context.config.browser?.launchOptions.browserName ?? 'chrome';
const cli = path.join(require.resolve('playwright/package.json'), '..', 'cli.js');
const child = fork(cli, ['install', channel], {
stdio: 'pipe',
@@ -53,7 +51,7 @@ const install: Tool = {
waitForNetwork: false,
};
},
};
});
export default [
install,

View File

@@ -15,33 +15,28 @@
*/
import { z } from 'zod';
import zodToJsonSchema from 'zod-to-json-schema';
import { defineTool, type ToolFactory } from './tool';
import type { ToolFactory } from './tool';
const pressKeySchema = z.object({
key: z.string().describe('Name of the key to press or a character to generate, such as `ArrowLeft` or `a`'),
});
const pressKey: ToolFactory = captureSnapshot => ({
const pressKey: ToolFactory = captureSnapshot => defineTool({
capability: 'core',
schema: {
name: 'browser_press_key',
description: 'Press a key on the keyboard',
inputSchema: zodToJsonSchema(pressKeySchema),
inputSchema: z.object({
key: z.string().describe('Name of the key to press or a character to generate, such as `ArrowLeft` or `a`'),
}),
},
handle: async (context, params) => {
const validatedParams = pressKeySchema.parse(params);
const tab = context.currentTabOrDie();
const code = [
`// Press ${validatedParams.key}`,
`await page.keyboard.press('${validatedParams.key}');`,
`// Press ${params.key}`,
`await page.keyboard.press('${params.key}');`,
];
const action = () => tab.page.keyboard.press(validatedParams.key);
const action = () => tab.page.keyboard.press(params.key);
return {
code,

View File

@@ -15,31 +15,26 @@
*/
import { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
import { defineTool, type ToolFactory } from './tool';
import type { ToolFactory } from './tool';
const navigateSchema = z.object({
url: z.string().describe('The URL to navigate to'),
});
const navigate: ToolFactory = captureSnapshot => ({
const navigate: ToolFactory = captureSnapshot => defineTool({
capability: 'core',
schema: {
name: 'browser_navigate',
description: 'Navigate to a URL',
inputSchema: zodToJsonSchema(navigateSchema),
inputSchema: z.object({
url: z.string().describe('The URL to navigate to'),
}),
},
handle: async (context, params) => {
const validatedParams = navigateSchema.parse(params);
const tab = await context.ensureTab();
await tab.navigate(validatedParams.url);
await tab.navigate(params.url);
const code = [
`// Navigate to ${validatedParams.url}`,
`await page.goto('${validatedParams.url}');`,
`// Navigate to ${params.url}`,
`await page.goto('${params.url}');`,
];
return {
@@ -50,14 +45,12 @@ const navigate: ToolFactory = captureSnapshot => ({
},
});
const goBackSchema = z.object({});
const goBack: ToolFactory = captureSnapshot => ({
const goBack: ToolFactory = captureSnapshot => defineTool({
capability: 'history',
schema: {
name: 'browser_navigate_back',
description: 'Go back to the previous page',
inputSchema: zodToJsonSchema(goBackSchema),
inputSchema: z.object({}),
},
handle: async context => {
@@ -76,14 +69,12 @@ const goBack: ToolFactory = captureSnapshot => ({
},
});
const goForwardSchema = z.object({});
const goForward: ToolFactory = captureSnapshot => ({
const goForward: ToolFactory = captureSnapshot => defineTool({
capability: 'history',
schema: {
name: 'browser_navigate_forward',
description: 'Go forward to the next page',
inputSchema: zodToJsonSchema(goForwardSchema),
inputSchema: z.object({}),
},
handle: async context => {
const tab = context.currentTabOrDie();

57
src/tools/network.ts Normal file
View File

@@ -0,0 +1,57 @@
/**
* 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';
import type * as playwright from 'playwright';
const requests = defineTool({
capability: 'core',
schema: {
name: 'browser_network_requests',
description: 'Returns all network requests since loading the page',
inputSchema: z.object({}),
},
handle: async context => {
const requests = context.currentTabOrDie().requests();
const log = [...requests.entries()].map(([request, response]) => renderRequest(request, response)).join('\n');
return {
code: [`// <internal code to list network requests>`],
action: async () => {
return {
content: [{ type: 'text', text: log }]
};
},
captureSnapshot: false,
waitForNetwork: false,
};
},
});
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,
];

View File

@@ -14,31 +14,24 @@
* limitations under the License.
*/
import os from 'os';
import path from 'path';
import { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
import { defineTool } from './tool';
import { sanitizeForFilePath } from './utils';
import * as javascript from '../javascript';
import { outputFile } from '../config';
import type { Tool } from './tool';
const pdfSchema = z.object({});
const pdf: Tool = {
const pdf = defineTool({
capability: 'pdf',
schema: {
name: 'browser_pdf_save',
description: 'Save page as PDF',
inputSchema: zodToJsonSchema(pdfSchema),
inputSchema: z.object({}),
},
handle: async context => {
const tab = context.currentTabOrDie();
const fileName = path.join(os.tmpdir(), sanitizeForFilePath(`page-${new Date().toISOString()}`)) + '.pdf';
const fileName = await outputFile(context.config, `page-${new Date().toISOString()}'.pdf'`);
const code = [
`// Save page as ${fileName}`,
@@ -52,7 +45,7 @@ const pdf: Tool = {
waitForNetwork: false,
};
},
};
});
export default [
pdf,

View File

@@ -15,18 +15,20 @@
*/
import { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
import { defineTool } from './tool';
import * as javascript from '../javascript';
import type { Tool } from './tool';
const elementSchema = z.object({
element: z.string().describe('Human-readable element description used to obtain permission to interact with the element'),
});
const screenshot: Tool = {
const screenshot = defineTool({
capability: 'core',
schema: {
name: 'browser_screen_capture',
description: 'Take a screenshot of the current page',
inputSchema: zodToJsonSchema(z.object({})),
inputSchema: z.object({}),
},
handle: async context => {
@@ -51,33 +53,26 @@ const screenshot: Tool = {
waitForNetwork: false
};
},
};
const elementSchema = z.object({
element: z.string().describe('Human-readable element description used to obtain permission to interact with the element'),
});
const moveMouseSchema = elementSchema.extend({
x: z.number().describe('X coordinate'),
y: z.number().describe('Y coordinate'),
});
const moveMouse: Tool = {
const moveMouse = defineTool({
capability: 'core',
schema: {
name: 'browser_screen_move_mouse',
description: 'Move mouse to a given position',
inputSchema: zodToJsonSchema(moveMouseSchema),
inputSchema: elementSchema.extend({
x: z.number().describe('X coordinate'),
y: z.number().describe('Y coordinate'),
}),
},
handle: async (context, params) => {
const validatedParams = moveMouseSchema.parse(params);
const tab = context.currentTabOrDie();
const code = [
`// Move mouse to (${validatedParams.x}, ${validatedParams.y})`,
`await page.mouse.move(${validatedParams.x}, ${validatedParams.y});`,
`// Move mouse to (${params.x}, ${params.y})`,
`await page.mouse.move(${params.x}, ${params.y});`,
];
const action = () => tab.page.mouse.move(validatedParams.x, validatedParams.y);
const action = () => tab.page.mouse.move(params.x, params.y);
return {
code,
action,
@@ -85,32 +80,29 @@ const moveMouse: Tool = {
waitForNetwork: false
};
},
};
const clickSchema = elementSchema.extend({
x: z.number().describe('X coordinate'),
y: z.number().describe('Y coordinate'),
});
const click: Tool = {
const click = defineTool({
capability: 'core',
schema: {
name: 'browser_screen_click',
description: 'Click left mouse button',
inputSchema: zodToJsonSchema(clickSchema),
inputSchema: elementSchema.extend({
x: z.number().describe('X coordinate'),
y: z.number().describe('Y coordinate'),
}),
},
handle: async (context, params) => {
const validatedParams = clickSchema.parse(params);
const tab = context.currentTabOrDie();
const code = [
`// Click mouse at coordinates (${validatedParams.x}, ${validatedParams.y})`,
`await page.mouse.move(${validatedParams.x}, ${validatedParams.y});`,
`// 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(validatedParams.x, validatedParams.y);
await tab.page.mouse.move(params.x, params.y);
await tab.page.mouse.down();
await tab.page.mouse.up();
};
@@ -121,40 +113,37 @@ const click: Tool = {
waitForNetwork: true,
};
},
};
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'),
});
const drag: Tool = {
const drag = defineTool({
capability: 'core',
schema: {
name: 'browser_screen_drag',
description: 'Drag left mouse button',
inputSchema: zodToJsonSchema(dragSchema),
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'),
}),
},
handle: async (context, params) => {
const validatedParams = dragSchema.parse(params);
const tab = context.currentTabOrDie();
const code = [
`// Drag mouse from (${validatedParams.startX}, ${validatedParams.startY}) to (${validatedParams.endX}, ${validatedParams.endY})`,
`await page.mouse.move(${validatedParams.startX}, ${validatedParams.startY});`,
`// 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(${validatedParams.endX}, ${validatedParams.endY});`,
`await page.mouse.move(${params.endX}, ${params.endY});`,
`await page.mouse.up();`,
];
const action = async () => {
await tab.page.mouse.move(validatedParams.startX, validatedParams.startY);
await tab.page.mouse.move(params.startX, params.startY);
await tab.page.mouse.down();
await tab.page.mouse.move(validatedParams.endX, validatedParams.endY);
await tab.page.mouse.move(params.endX, params.endY);
await tab.page.mouse.up();
};
@@ -165,38 +154,35 @@ const drag: Tool = {
waitForNetwork: true,
};
},
};
const typeSchema = 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)'),
});
const type: Tool = {
const type = defineTool({
capability: 'core',
schema: {
name: 'browser_screen_type',
description: 'Type text',
inputSchema: zodToJsonSchema(typeSchema),
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)'),
}),
},
handle: async (context, params) => {
const validatedParams = typeSchema.parse(params);
const tab = context.currentTabOrDie();
const code = [
`// Type ${validatedParams.text}`,
`await page.keyboard.type('${validatedParams.text}');`,
`// Type ${params.text}`,
`await page.keyboard.type('${params.text}');`,
];
const action = async () => {
await tab.page.keyboard.type(validatedParams.text);
if (validatedParams.submit)
await tab.page.keyboard.type(params.text);
if (params.submit)
await tab.page.keyboard.press('Enter');
};
if (validatedParams.submit) {
if (params.submit) {
code.push(`// Submit text`);
code.push(`await page.keyboard.press('Enter');`);
}
@@ -208,7 +194,7 @@ const type: Tool = {
waitForNetwork: true,
};
},
};
});
export default [
screenshot,

View File

@@ -14,25 +14,20 @@
* limitations under the License.
*/
import path from 'path';
import os from 'os';
import { z } from 'zod';
import zodToJsonSchema from 'zod-to-json-schema';
import { sanitizeForFilePath } from './utils';
import { generateLocator } from '../context';
import { defineTool } from './tool';
import * as javascript from '../javascript';
import { outputFile } from '../config';
import type * as playwright from 'playwright';
import type { Tool } from './tool';
const snapshot: Tool = {
const snapshot = defineTool({
capability: 'core',
schema: {
name: 'browser_snapshot',
description: 'Capture accessibility snapshot of the current page, this is better than screenshot',
inputSchema: zodToJsonSchema(z.object({})),
inputSchema: z.object({}),
},
handle: async context => {
@@ -44,28 +39,27 @@ const snapshot: Tool = {
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'),
});
const click: Tool = {
const click = defineTool({
capability: 'core',
schema: {
name: 'browser_click',
description: 'Perform click on a web page',
inputSchema: zodToJsonSchema(elementSchema),
inputSchema: elementSchema,
},
handle: async (context, params) => {
const validatedParams = elementSchema.parse(params);
const tab = context.currentTabOrDie();
const locator = tab.snapshotOrDie().refLocator(validatedParams.ref);
const locator = tab.snapshotOrDie().refLocator(params.ref);
const code = [
`// Click ${validatedParams.element}`,
`// Click ${params.element}`,
`await page.${await generateLocator(locator)}.click();`
];
@@ -76,31 +70,28 @@ const click: Tool = {
waitForNetwork: true,
};
},
};
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 drag: Tool = {
const drag = defineTool({
capability: 'core',
schema: {
name: 'browser_drag',
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'),
}),
},
handle: async (context, params) => {
const validatedParams = dragSchema.parse(params);
const snapshot = context.currentTabOrDie().snapshotOrDie();
const startLocator = snapshot.refLocator(validatedParams.startRef);
const endLocator = snapshot.refLocator(validatedParams.endRef);
const startLocator = snapshot.refLocator(params.startRef);
const endLocator = snapshot.refLocator(params.endRef);
const code = [
`// Drag ${validatedParams.startElement} to ${validatedParams.endElement}`,
`// Drag ${params.startElement} to ${params.endElement}`,
`await page.${await generateLocator(startLocator)}.dragTo(page.${await generateLocator(endLocator)});`
];
@@ -111,23 +102,22 @@ const drag: Tool = {
waitForNetwork: true,
};
},
};
});
const hover: Tool = {
const hover = defineTool({
capability: 'core',
schema: {
name: 'browser_hover',
description: 'Hover over element on page',
inputSchema: zodToJsonSchema(elementSchema),
inputSchema: elementSchema,
},
handle: async (context, params) => {
const validatedParams = elementSchema.parse(params);
const snapshot = context.currentTabOrDie().snapshotOrDie();
const locator = snapshot.refLocator(validatedParams.ref);
const locator = snapshot.refLocator(params.ref);
const code = [
`// Hover over ${validatedParams.element}`,
`// Hover over ${params.element}`,
`await page.${await generateLocator(locator)}.hover();`
];
@@ -138,7 +128,7 @@ const hover: Tool = {
waitForNetwork: true,
};
},
};
});
const typeSchema = elementSchema.extend({
text: z.string().describe('Text to type into the element'),
@@ -146,33 +136,32 @@ const typeSchema = elementSchema.extend({
slowly: z.boolean().optional().describe('Whether to type one character at a time. Useful for triggering key handlers in the page. By default entire text is filled in at once.'),
});
const type: Tool = {
const type = defineTool({
capability: 'core',
schema: {
name: 'browser_type',
description: 'Type text into editable element',
inputSchema: zodToJsonSchema(typeSchema),
inputSchema: typeSchema,
},
handle: async (context, params) => {
const validatedParams = typeSchema.parse(params);
const snapshot = context.currentTabOrDie().snapshotOrDie();
const locator = snapshot.refLocator(validatedParams.ref);
const locator = snapshot.refLocator(params.ref);
const code: string[] = [];
const steps: (() => Promise<void>)[] = [];
if (validatedParams.slowly) {
code.push(`// Press "${validatedParams.text}" sequentially into "${validatedParams.element}"`);
code.push(`await page.${await generateLocator(locator)}.pressSequentially(${javascript.quote(validatedParams.text)});`);
steps.push(() => locator.pressSequentially(validatedParams.text));
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 "${validatedParams.text}" into "${validatedParams.element}"`);
code.push(`await page.${await generateLocator(locator)}.fill(${javascript.quote(validatedParams.text)});`);
steps.push(() => locator.fill(validatedParams.text));
code.push(`// Fill "${params.text}" into "${params.element}"`);
code.push(`await page.${await generateLocator(locator)}.fill(${javascript.quote(params.text)});`);
steps.push(() => locator.fill(params.text));
}
if (validatedParams.submit) {
if (params.submit) {
code.push(`// Submit text`);
code.push(`await page.${await generateLocator(locator)}.press('Enter');`);
steps.push(() => locator.press('Enter'));
@@ -185,38 +174,37 @@ const type: Tool = {
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.'),
});
const selectOption: Tool = {
const selectOption = defineTool({
capability: 'core',
schema: {
name: 'browser_select_option',
description: 'Select an option in a dropdown',
inputSchema: zodToJsonSchema(selectOptionSchema),
inputSchema: selectOptionSchema,
},
handle: async (context, params) => {
const validatedParams = selectOptionSchema.parse(params);
const snapshot = context.currentTabOrDie().snapshotOrDie();
const locator = snapshot.refLocator(validatedParams.ref);
const locator = snapshot.refLocator(params.ref);
const code = [
`// Select options [${validatedParams.values.join(', ')}] in ${validatedParams.element}`,
`await page.${await generateLocator(locator)}.selectOption(${javascript.formatObject(validatedParams.values)});`
`// Select options [${params.values.join(', ')}] in ${params.element}`,
`await page.${await generateLocator(locator)}.selectOption(${javascript.formatObject(params.values)});`
];
return {
code,
action: () => locator.selectOption(validatedParams.values).then(() => {}),
action: () => locator.selectOption(params.values).then(() => {}),
captureSnapshot: true,
waitForNetwork: 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.'),
@@ -229,42 +217,42 @@ const screenshotSchema = z.object({
path: ['ref', 'element']
});
const screenshot: Tool = {
const screenshot = defineTool({
capability: 'core',
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),
inputSchema: screenshotSchema,
},
handle: async (context, params) => {
const validatedParams = screenshotSchema.parse(params);
const tab = context.currentTabOrDie();
const snapshot = tab.snapshotOrDie();
const fileType = validatedParams.raw ? 'png' : 'jpeg';
const fileName = path.join(os.tmpdir(), sanitizeForFilePath(`page-${new Date().toISOString()}`)) + `.${fileType}`;
const fileType = params.raw ? 'png' : 'jpeg';
const fileName = await outputFile(context.config, `page-${new Date().toISOString()}.${fileType}`);
const options: playwright.PageScreenshotOptions = { type: fileType, quality: fileType === 'png' ? undefined : 50, scale: 'css', path: fileName };
const isElementScreenshot = validatedParams.element && validatedParams.ref;
const isElementScreenshot = params.element && params.ref;
const code = [
`// Screenshot ${isElementScreenshot ? validatedParams.element : 'viewport'} and save it as ${fileName}`,
`// Screenshot ${isElementScreenshot ? params.element : 'viewport'} and save it as ${fileName}`,
];
const locator = validatedParams.ref ? snapshot.refLocator(validatedParams.ref) : null;
const locator = params.ref ? snapshot.refLocator(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.config.tools?.browser_take_screenshot?.omitBase64;
const action = async () => {
const screenshot = locator ? await locator.screenshot(options) : await tab.page.screenshot(options);
return {
content: [{
content: includeBase64 ? [{
type: 'image' as 'image',
data: screenshot.toString('base64'),
mimeType: fileType === 'png' ? 'image/png' : 'image/jpeg',
}]
}] : []
};
};
@@ -275,8 +263,11 @@ const screenshot: Tool = {
waitForNetwork: false,
};
}
};
});
export async function generateLocator(locator: playwright.Locator): Promise<string> {
return (locator as any)._generateLocatorString();
}
export default [
snapshot,

View File

@@ -15,17 +15,15 @@
*/
import { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
import { defineTool, type ToolFactory } from './tool';
import type { ToolFactory, Tool } from './tool';
const listTabs: Tool = {
const listTabs = defineTool({
capability: 'tabs',
schema: {
name: 'browser_tab_list',
description: 'List browser tabs',
inputSchema: zodToJsonSchema(z.object({})),
inputSchema: z.object({}),
},
handle: async context => {
@@ -42,26 +40,23 @@ const listTabs: Tool = {
},
};
},
};
const selectTabSchema = z.object({
index: z.number().describe('The index of the tab to select'),
});
const selectTab: ToolFactory = captureSnapshot => ({
const selectTab: ToolFactory = captureSnapshot => defineTool({
capability: 'tabs',
schema: {
name: 'browser_tab_select',
description: 'Select a tab by index',
inputSchema: zodToJsonSchema(selectTabSchema),
inputSchema: z.object({
index: z.number().describe('The index of the tab to select'),
}),
},
handle: async (context, params) => {
const validatedParams = selectTabSchema.parse(params);
await context.selectTab(validatedParams.index);
await context.selectTab(params.index);
const code = [
`// <internal code to select tab ${validatedParams.index}>`,
`// <internal code to select tab ${params.index}>`,
];
return {
@@ -72,24 +67,21 @@ const selectTab: ToolFactory = captureSnapshot => ({
},
});
const newTabSchema = 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.'),
});
const newTab: ToolFactory = captureSnapshot => ({
const newTab: ToolFactory = captureSnapshot => defineTool({
capability: 'tabs',
schema: {
name: 'browser_tab_new',
description: 'Open a new tab',
inputSchema: zodToJsonSchema(newTabSchema),
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.'),
}),
},
handle: async (context, params) => {
const validatedParams = newTabSchema.parse(params);
await context.newTab();
if (validatedParams.url)
await context.currentTabOrDie().navigate(validatedParams.url);
if (params.url)
await context.currentTabOrDie().navigate(params.url);
const code = [
`// <internal code to open a new tab>`,
@@ -102,24 +94,21 @@ const newTab: ToolFactory = captureSnapshot => ({
},
});
const closeTabSchema = z.object({
index: z.number().optional().describe('The index of the tab to close. Closes current tab if not provided.'),
});
const closeTab: ToolFactory = captureSnapshot => ({
const closeTab: ToolFactory = captureSnapshot => defineTool({
capability: 'tabs',
schema: {
name: 'browser_tab_close',
description: 'Close a tab',
inputSchema: zodToJsonSchema(closeTabSchema),
inputSchema: z.object({
index: z.number().optional().describe('The index of the tab to close. Closes current tab if not provided.'),
}),
},
handle: async (context, params) => {
const validatedParams = closeTabSchema.parse(params);
await context.closeTab(validatedParams.index);
await context.closeTab(params.index);
const code = [
`// <internal code to close tab ${validatedParams.index}>`,
`// <internal code to close tab ${params.index}>`,
];
return {
code,

View File

@@ -15,17 +15,19 @@
*/
import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types';
import type { JsonSchema7Type } from 'zod-to-json-schema';
import type { z } from 'zod';
import type { Context } from '../context';
import type * as playwright from 'playwright';
export type ToolCapability = 'core' | 'tabs' | 'pdf' | 'history' | 'wait' | 'files' | 'install';
import type { ToolCapability } from '../../config';
export type ToolSchema = {
export type ToolSchema<Input extends InputType> = {
name: string;
description: string;
inputSchema: JsonSchema7Type;
inputSchema: Input;
};
type InputType = z.Schema;
export type FileUploadModalState = {
type: 'fileChooser';
description: string;
@@ -50,11 +52,15 @@ export type ToolResult = {
resultOverride?: ToolActionResult;
};
export type Tool = {
export type Tool<Input extends InputType = InputType> = {
capability: ToolCapability;
schema: ToolSchema;
schema: ToolSchema<Input>;
clearsModalState?: ModalState['type'];
handle: (context: Context, params?: Record<string, any>) => Promise<ToolResult>;
handle: (context: Context, params: z.output<Input>) => Promise<ToolResult>;
};
export type ToolFactory = (snapshot: boolean) => Tool;
export type ToolFactory = (snapshot: boolean) => Tool<any>;
export function defineTool<Input extends InputType>(tool: Tool<Input>): Tool<Input> {
return tool;
}

127
src/transport.ts Normal file
View File

@@ -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.
*/
import http from 'node:http';
import assert from 'node:assert';
import crypto from 'node:crypto';
import { ServerList } from './server';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
export async function startStdioTransport(serverList: ServerList) {
const server = await serverList.create();
await server.connect(new StdioServerTransport());
}
async function handleSSE(req: http.IncomingMessage, res: http.ServerResponse, url: URL, serverList: ServerList, sessions: Map<string, SSEServerTransport>) {
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);
const server = await serverList.create();
res.on('close', () => {
sessions.delete(transport.sessionId);
serverList.close(server).catch(e => console.error(e));
});
return await server.connect(transport);
}
res.statusCode = 405;
res.end('Method not allowed');
}
async function handleStreamable(req: http.IncomingMessage, res: http.ServerResponse, serverList: ServerList, sessions: Map<string, StreamableHTTPServerTransport>) {
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);
};
const server = await serverList.create();
await server.connect(transport);
return await transport.handleRequest(req, res);
}
res.statusCode = 400;
res.end('Invalid request');
}
export function startHttpTransport(port: number, hostname: string | undefined, serverList: ServerList) {
const sseSessions = new Map<string, SSEServerTransport>();
const streamableSessions = new Map<string, StreamableHTTPServerTransport>();
const httpServer = http.createServer(async (req, res) => {
const url = new URL(`http://localhost${req.url}`);
if (url.pathname.startsWith('/mcp'))
await handleStreamable(req, res, serverList, streamableSessions);
else
await handleSSE(req, res, url, serverList, sseSessions);
});
httpServer.listen(port, hostname, () => {
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));
console.log('If your client supports streamable HTTP, you can use the /mcp endpoint instead.');
});
}

View File

@@ -32,6 +32,7 @@ test('test snapshot tool list', async ({ client }) => {
'browser_navigate_back',
'browser_navigate_forward',
'browser_navigate',
'browser_network_requests',
'browser_pdf_save',
'browser_press_key',
'browser_resize',
@@ -56,6 +57,7 @@ test('test vision tool list', async ({ visionClient }) => {
'browser_navigate_back',
'browser_navigate_forward',
'browser_navigate',
'browser_network_requests',
'browser_pdf_save',
'browser_press_key',
'browser_resize',
@@ -72,11 +74,6 @@ test('test vision tool list', async ({ visionClient }) => {
]));
});
test('test resources list', async ({ client }) => {
const { resources } = await client.listResources();
expect(resources).toEqual([]);
});
test('test capabilities', async ({ startClient }) => {
const client = await startClient({
args: ['--caps="core"'],

View File

@@ -96,8 +96,8 @@ await page.getByRole('combobox').selectOption(['bar']);
- Page Snapshot
\`\`\`yaml
- combobox [ref=s2e3]:
- option "Foo" [ref=s2e4]
- option "Bar" [selected] [ref=s2e5]
- option "Foo"
- option "Bar" [selected]
\`\`\`
`);
});
@@ -206,5 +206,5 @@ test('browser_resize', async ({ client }) => {
// Resize browser window to 390x780
await page.setViewportSize({ width: 390, height: 780 });
\`\`\``);
await expect.poll(() => client.callTool({ name: 'browser_snapshot' })).toContainTextContent('Window size: 390x780');
await expect.poll(() => client.callTool({ name: 'browser_snapshot', arguments: {} })).toContainTextContent('Window size: 390x780');
});

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

@@ -0,0 +1,43 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { test, expect } from './fixtures';
test('browser_take_screenshot (viewport)', async ({ startClient, server }) => {
const client = await startClient({
args: ['--device', 'iPhone 15'],
});
server.route('/', (req, res) => {
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(`
<head>
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body></body>
<script>
document.body.textContent = window.innerWidth + "x" + window.innerHeight;
</script>
`);
});
expect(await client.callTool({
name: 'browser_navigate',
arguments: {
url: server.PREFIX,
},
})).toContainTextContent(`393x659`);
});

View File

@@ -23,7 +23,23 @@ test('browser_file_upload', async ({ client }) => {
arguments: {
url: 'data:text/html,<html><title>Title</title><input type="file" /><button>Button</button></html>',
},
})).toContainTextContent('- textbox [ref=s1e3]');
})).toContainTextContent(`
\`\`\`yaml
- generic [ref=s1e2]:
- button "Choose File" [ref=s1e3]
- button "Button" [ref=s1e4]
\`\`\``);
{
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',
@@ -46,7 +62,12 @@ test('browser_file_upload', async ({ client }) => {
});
expect(response).not.toContainTextContent('### Modal state');
expect(response).toContainTextContent('textbox [ref=s3e3]: C:\\fakepath\\test.txt');
expect(response).toContainTextContent(`
\`\`\`yaml
- generic [ref=s3e2]:
- button "Choose File" [ref=s3e3]
- button "Button" [ref=s3e4]
\`\`\``);
}
{

View File

@@ -14,6 +14,7 @@
* limitations under the License.
*/
import fs from 'fs';
import path from 'path';
import { chromium } from 'playwright';
@@ -21,18 +22,24 @@ 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 { spawn } from 'child_process';
import { TestServer } from './testserver';
import type { Config } from '../config';
type TestFixtures = {
client: Client;
visionClient: Client;
startClient: (options?: { args?: string[] }) => Promise<Client>;
startClient: (options?: { args?: string[], config?: Config }) => Promise<Client>;
wsEndpoint: string;
cdpEndpoint: string;
server: TestServer;
httpsServer: TestServer;
};
type WorkerFixtures = {
mcpHeadless: boolean;
mcpBrowser: string | undefined;
_workerServers: { server: TestServer, httpsServer: TestServer };
};
export const test = baseTest.extend<TestFixtures, WorkerFixtures>({
@@ -49,7 +56,7 @@ export const test = baseTest.extend<TestFixtures, WorkerFixtures>({
const userDataDir = testInfo.outputPath('user-data-dir');
let client: StdioClientTransport | undefined;
use(async options => {
await use(async options => {
const args = ['--user-data-dir', userDataDir];
if (mcpHeadless)
args.push('--headless');
@@ -57,6 +64,11 @@ export const test = baseTest.extend<TestFixtures, WorkerFixtures>({
args.push(`--browser=${mcpBrowser}`);
if (options?.args)
args.push(...options.args);
if (options?.config) {
const configFile = testInfo.outputPath('config.json');
await fs.promises.writeFile(configFile, JSON.stringify(options.config, null, 2));
args.push(`--config=${configFile}`);
}
const transport = new StdioClientTransport({
command: 'node',
args: [path.join(__dirname, '../cli.js'), ...args],
@@ -85,6 +97,7 @@ export const test = baseTest.extend<TestFixtures, WorkerFixtures>({
`--no-first-run`,
`--no-sandbox`,
`--headless`,
'--use-mock-keychain',
`data:text/html,hello world`,
], {
stdio: 'pipe',
@@ -103,7 +116,32 @@ export const test = baseTest.extend<TestFixtures, WorkerFixtures>({
await use(headless);
}, { scope: 'worker' }],
mcpBrowser: ['chromium', { option: true, scope: 'worker' }],
mcpBrowser: ['chrome', { option: true, scope: 'worker' }],
_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);
},
});
type Response = Awaited<ReturnType<Client['callTool']>>;

View File

@@ -24,12 +24,14 @@ test('stitched aria frames', async ({ client }) => {
},
})).toContainTextContent(`
\`\`\`yaml
- heading "Hello" [level=1] [ref=s1e3]
- iframe [ref=s1e4]:
- button "World" [ref=f1s1e3]
- main [ref=f1s1e4]:
- iframe [ref=f1s1e5]:
- paragraph [ref=f2s1e3]: Nested
- generic [ref=s1e2]:
- heading "Hello" [level=1] [ref=s1e3]
- iframe [ref=s1e4]:
- generic [ref=f1s1e2]:
- button "World" [ref=f1s1e3]
- main [ref=f1s1e4]:
- iframe [ref=f1s1e5]:
- paragraph [ref=f2s1e3]: Nested
\`\`\``);
expect(await client.callTool({

View File

@@ -26,6 +26,7 @@ test('test reopen browser', async ({ client }) => {
expect(await client.callTool({
name: 'browser_close',
arguments: {},
})).toContainTextContent('No open pages available');
expect(await client.callTool({

49
tests/network.spec.ts Normal file
View File

@@ -0,0 +1,49 @@
/**
* 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';
test('browser_network_requests', async ({ client, server }) => {
server.route('/', (req, res) => {
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(`<button onclick="fetch('/json')">Click me</button>`);
});
server.route('/json', (req, res) => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ name: 'John Doe' }));
});
await client.callTool({
name: 'browser_navigate',
arguments: {
url: server.PREFIX,
},
});
await client.callTool({
name: 'browser_click',
arguments: {
element: 'Click me button',
ref: 's1e3',
},
});
await expect.poll(() => client.callTool({
name: 'browser_network_requests',
arguments: {},
})).toHaveTextContent(`[GET] http://localhost:${server.PORT}/json => [200] OK`);
});

View File

@@ -41,6 +41,7 @@ test('save as pdf', async ({ client, mcpBrowser }) => {
const response = await client.callTool({
name: 'browser_pdf_save',
arguments: {},
});
expect(response).toHaveTextContent(/Save page as.*page-[^:]+.pdf/);
});

View File

@@ -14,6 +14,8 @@
* limitations under the License.
*/
import fs from 'fs';
import { test, expect } from './fixtures';
test('browser_take_screenshot (viewport)', async ({ client }) => {
@@ -70,3 +72,60 @@ test('browser_take_screenshot (element)', async ({ client }) => {
],
});
});
test('browser_take_screenshot (outputDir)', async ({ startClient }, testInfo) => {
const outputDir = testInfo.outputPath('output');
const client = await startClient({
config: { outputDir },
});
expect(await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
},
})).toContainTextContent(`Navigate to data:text/html`);
await client.callTool({
name: 'browser_take_screenshot',
arguments: {},
});
expect(fs.existsSync(outputDir)).toBeTruthy();
expect([...fs.readdirSync(outputDir)]).toHaveLength(1);
});
test('browser_take_screenshot (omitBase64)', async ({ startClient }) => {
const client = await startClient({
config: {
tools: {
browser_take_screenshot: {
omitBase64: true,
},
},
},
});
expect(await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
},
})).toContainTextContent(`Navigate to data:text/html`);
await client.callTool({
name: 'browser_take_screenshot',
arguments: {},
});
expect(await client.callTool({
name: 'browser_take_screenshot',
arguments: {},
})).toEqual({
content: [
{
text: expect.stringContaining(`Screenshot viewport and save it as`),
type: 'text',
},
],
});
});

View File

@@ -16,27 +16,45 @@
import { spawn } from 'node:child_process';
import path from 'node:path';
import { test } from './fixtures';
import { test as baseTest } from './fixtures';
import { expect } from 'playwright/test';
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<string>(resolve => cp.stdout?.on('data', data => {
stdout += data.toString();
const match = stdout.match(/Listening on (http:\/\/.*)/);
if (match)
resolve(match[1]);
}));
const test = baseTest.extend<{ serverEndpoint: string }>({
serverEndpoint: async ({}, use) => {
const cp = spawn('node', [path.join(__dirname, '../cli.js'), '--port', '0'], { stdio: 'pipe' });
try {
let stdout = '';
const url = await new Promise<string>(resolve => cp.stdout?.on('data', data => {
stdout += data.toString();
const match = stdout.match(/Listening on (http:\/\/.*)/);
if (match)
resolve(match[1]);
}));
// 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();
}
await use(url);
} finally {
cp.kill();
}
},
});
test('sse transport', async ({ serverEndpoint }) => {
// 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(serverEndpoint));
const client = new Client({ name: 'test', version: '1.0.0' });
await client.connect(transport);
await client.ping();
});
test('streamable http transport', async ({ serverEndpoint }) => {
// need dynamic import b/c of some ESM nonsense
const { StreamableHTTPClientTransport } = await import('@modelcontextprotocol/sdk/client/streamableHttp.js');
const { Client } = await import('@modelcontextprotocol/sdk/client/index.js');
const transport = new StreamableHTTPClientTransport(new URL('/mcp', serverEndpoint));
const client = new Client({ name: 'test', version: '1.0.0' });
await client.connect(transport);
await client.ping();
expect(transport.sessionId, 'has session support').toBeDefined();
});

View File

@@ -32,6 +32,7 @@ async function createTab(client: Client, title: string, body: string) {
test('list initial tabs', async ({ client }) => {
expect(await client.callTool({
name: 'browser_tab_list',
arguments: {},
})).toHaveTextContent(`### Open tabs
- 1: (current) [] (about:blank)`);
});
@@ -40,6 +41,7 @@ test('list first tab', async ({ client }) => {
await createTab(client, 'Tab one', 'Body one');
expect(await client.callTool({
name: 'browser_tab_list',
arguments: {},
})).toHaveTextContent(`### Open tabs
- 1: [] (about:blank)
- 2: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)`);

29
tests/testserver/cert.pem Normal file
View File

@@ -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-----

145
tests/testserver/index.ts Normal file
View File

@@ -0,0 +1,145 @@
/**
* 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 http from 'http';
import https from 'https';
import path from 'path';
const fulfillSymbol = Symbol('fulfil callback');
const rejectSymbol = Symbol('reject callback');
export class TestServer {
private _server: http.Server;
readonly debugServer: any;
private _routes = new Map<string, (request: http.IncomingMessage, response: http.ServerResponse) => any>();
private _csp = new Map<string, string>();
private _extraHeaders = new Map<string, object>();
private _requestSubscribers = new Map<string, Promise<any>>();
readonly PORT: number;
readonly PREFIX: string;
readonly CROSS_PROCESS_PREFIX: string;
static async create(port: number): Promise<TestServer> {
const server = new TestServer(port);
await new Promise(x => server._server.once('listening', x));
return server;
}
static async createHTTPS(port: number): Promise<TestServer> {
const server = new TestServer(port, {
key: await fs.promises.readFile(path.join(__dirname, 'key.pem')),
cert: await fs.promises.readFile(path.join(__dirname, '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 = require('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}`;
}
setCSP(path: string, csp: string) {
this._csp.set(path, csp);
}
setExtraHeaders(path: string, object: Record<string, string>) {
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);
}
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<http.IncomingMessage> {
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();
}
_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);
}
}

52
tests/testserver/key.pem Normal file
View File

@@ -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-----

19
tests/testserver/san.cnf Normal file
View File

@@ -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

38
tests/webdriver.spec.ts Normal file
View File

@@ -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';
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(`
<body></body>
<script>
document.body.textContent = 'webdriver: ' + navigator.webdriver;
</script>
`);
});
expect(await client.callTool({
name: 'browser_navigate',
arguments: {
url: server.PREFIX,
},
})).toContainTextContent('webdriver: false');
});

4
tsconfig.all.json Normal file
View File

@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"include": ["**/*.ts", "**/*.js"],
}

6
utils/generate_links.js Normal file
View File

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

View File

@@ -18,6 +18,7 @@
const fs = require('node:fs');
const path = require('node:path');
const zodToJsonSchema = require('zod-to-json-schema').default;
const commonTools = require('../lib/tools/common').default;
const consoleTools = require('../lib/tools/console').default;
@@ -107,11 +108,11 @@ function formatToolForReadme(tool) {
*/
/**
* @param {import('../src/tools/tool').ToolSchema} schema
* @param {import('../src/tools/tool').ToolSchema<any>} schema
* @returns {ParsedToolSchema}
*/
function processToolSchema(schema) {
const inputSchema = /** @type {import('zod-to-json-schema').JsonSchema7ObjectType} */ (schema.inputSchema || {});
const inputSchema = /** @type {import('zod-to-json-schema').JsonSchema7ObjectType} */ (zodToJsonSchema(schema.inputSchema || {}));
if (inputSchema.type !== 'object')
throw new Error(`Tool ${schema.name} input schema is not an object`);