38 Commits

Author SHA1 Message Date
Pavel Feldman
d8a59e0d0d chore: mark v0.0.20 (#336) 2025-05-02 21:31:06 -07:00
Pavel Feldman
21533d9000 chore: installation test added (#335) 2025-05-02 21:30:55 -07:00
Ryosuke Iwanaga
49979641fa fix: require is not defined (#334)
Since it's moved to ESM, `require` isn't defined.
This hotfix is just recreating `require` to workaround this issue.
2025-05-02 21:19:54 -07:00
Pavel Feldman
43aa4001b5 chore: mark v0.0.19 (#332) 2025-05-02 18:38:20 -07:00
Pavel Feldman
7e087af6a6 chore: slightly adjust gen test prompt (#333) 2025-05-02 18:38:06 -07:00
Pavel Feldman
927a1280f1 chore: allow generating tests for script (#331) 2025-05-02 17:41:58 -07:00
Pavel Feldman
292e75d464 chore: roll Playwright to remove empty generic nodes (#330) 2025-05-02 16:10:48 -07:00
Simon Knott
2c9376e50f chore: don't sanitize file extension away (#327) 2025-05-02 10:58:48 -07:00
Max Schmitt
062cdd0704 fix: sticky launch errors (#324)
This fixes an issue that there were sticky launch errors. When the
[following code
path](a15f0f301b/src/context.ts (L307-L339))
was throwing, the Error was stored in the Promise and not cleared
afterwards, this meant:

- If a browser was not there and the user tried to install it via
`browser_install` it was never working since the error was sticky.
- If other errors like CDP is not available yet etc. error appear a
re-connect would not work - the MCP server would require a restart.

Test plan: Since we don't have any `browser_install` tests I added a CDP
test for now to cover this bug.
2025-05-02 15:32:37 +02:00
Max Schmitt
a713300c5b test: use TestOptions type in config (#326) 2025-05-02 13:50:03 +02:00
Simon Knott
a15f0f301b chore: save downloads to outputDir (#310) 2025-05-02 10:57:31 +02:00
Pavel Feldman
23ce973377 lint: ban console output (#317) 2025-04-30 14:15:32 -07:00
Max Schmitt
685dea9e19 chore: migrate to ESM (#303)
- [Why do I need `.js`
extension?](https://stackoverflow.com/a/77150985/6512681)
- [Why setting `rootDir` in the
`tsconfig.json`?](https://stackoverflow.com/a/58941798/6512681)
- [How to ensure that we add the `.js` extension via
ESLint](https://github.com/import-js/eslint-plugin-import/blob/main/docs/rules/extensions.md#importextensions)

Fixes https://github.com/microsoft/playwright-mcp/issues/302
2025-04-30 23:06:56 +02:00
Pavel Feldman
878be97668 chore: mark v0.0.18 (#315) 2025-04-30 13:07:55 -07:00
Pavel Feldman
6d6b1a384b chore: fix merge config (#311) 2025-04-30 08:41:19 -07:00
Pavel Feldman
fd22def4c5 chore: fix test harness, close the client (#312) 2025-04-30 08:07:54 -07:00
Simon Knott
1b60870f50 chore: bump to 0.0.17 (#306) 2025-04-30 12:30:03 +02:00
Simon Knott
1c760b3826 fix: default to headful (#305)
See https://github.com/microsoft/playwright-mcp/issues/304

Regressed in
69703cc882.
2025-04-30 12:23:30 +02:00
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
61 changed files with 1507 additions and 760 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

4
.gitignore vendored
View File

@@ -1,4 +1,8 @@
lib/
node_modules/
test-results/
playwright-report/
.vscode/mcp.json
.idea
.DS_Store

View File

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

201
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,103 @@ 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
'testing' // Testing
>;
// 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 +192,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 +204,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 +256,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 -->
@@ -426,4 +486,15 @@ http.createServer(async (req, res) => {
- `accept` (boolean): Whether to accept the dialog.
- `promptText` (string, optional): The text of the prompt in case of a prompt dialog.
### Testing
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_generate_playwright_test**
- Description: Generate a Playwright test for given scenario
- Parameters:
- `name` (string): The name of the test
- `description` (string): The description of the test
- `steps` (array): The steps of the test
<!--- End of generated section -->

2
cli.js
View File

@@ -15,4 +15,4 @@
* limitations under the License.
*/
require('./lib/program');
import './lib/program.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' | 'testing';
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,8 @@ const plugins = {
};
export const baseRules = {
"import/extensions": ["error", "ignorePackages", {ts: "always"}],
"@typescript-eslint/no-floating-promises": "error",
"@typescript-eslint/no-unused-vars": [
2,
{ args: "none", caughtErrors: "none" },
@@ -178,12 +180,16 @@ export const baseRules = {
// react
"react/react-in-jsx-scope": 0,
"no-console": 2,
};
const languageOptions = {
parser: tsParser,
ecmaVersion: 9,
sourceType: "module",
parserOptions: {
project: path.join(fileURLToPath(import.meta.url), "..", "tsconfig.all.json"),
}
};
export default [

View File

@@ -1,47 +0,0 @@
# Examples
Below are some examples of prompts to use with Playwright MCP. Draw inspiration from them to create your own. If you come up with a new prompt that works well, feel free to let us know in an issue or pull request.
## Write a regression test for a bug report
We have received a user report about a bug on our website https://debs-obrien.github.io/playwright-movies-app. I'd like you to verify if this bug exists by trying to follow the steps. If you see different behaviour, explain how it's different. If you see the same behaviour, please write me a Playwright regression test that encapsulates the buggy user flow. While writing the test, draw inspiration from the Playwright code you ran. Here's the user report:
Howdy. I love your movies site, but there's something fishy going on. I'm looking at The Garfield Movie - a spectacular film - but somehow it's showing the wrong length. The movie is 90mins long, I measured myself yesterday! But somehow your site is claiming 101mins. Am I looking at it wrong, or is there a bug?
**Output:**
```ts
import { test, expect } from '@playwright/test';
/**
* Regression test for bug: The Garfield Movie showing incorrect runtime
* User reported actual runtime is 90 minutes, but site shows 101 minutes
*/
test('verify The Garfield Movie runtime bug', async ({ page }) => {
// Navigate to the movies app home page
await page.goto('https://debs-obrien.github.io/playwright-movies-app');
// Wait for page to load completely
await expect(page.getByRole('heading', { name: 'Popular', level: 1 })).toBeVisible();
// Look for The Garfield Movie and click on it
const garfieldMovieLink = page.getByRole('link', { name: /poster of The Garfield Movie/i });
await expect(garfieldMovieLink).toBeVisible();
await garfieldMovieLink.click();
// Verify we're on the correct movie page
await expect(page.getByRole('heading', { name: 'The Garfield Movie', level: 1 })).toBeVisible();
// Check the runtime information
// The bug: Expected runtime should be 90 min, but site shows 101 min
const movieInfoText = await page.locator('text=English / 101 min. / 2024').textContent();
// This test will fail because of the bug (which is what we want to demonstrate)
// Once fixed, this assertion should be updated to the correct runtime (90 min)
expect(movieInfoText).toContain('90 min');
// Alternative assertion that verifies the incorrect runtime is still present
// Uncomment this and comment the above assertion to verify the bug exists
// expect(movieInfoText).toContain('101 min');
});
```

10
examples/generate-test.md Normal file
View File

@@ -0,0 +1,10 @@
Generate test for scenario:
## GitHub PR Checks Navigation Checklist
1. Open the [Microsoft Playwright GitHub repository](https://github.com/microsoft/playwright).
2. Click on the **Pull requests** tab.
3. Find and open the pull request titled **"chore: make noWaitAfter a default"**.
4. Switch to the **Checks** tab for that pull request.
5. Expand the **infra** check suite to view its jobs.
6. Click on the **docs & lint** job to view its details.

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 {};

View File

@@ -15,5 +15,5 @@
* limitations under the License.
*/
const { createServer } = require('./lib/index');
module.exports = { createServer };
import { createServer } from './lib/index';
export default { createServer };

53
package-lock.json generated
View File

@@ -1,17 +1,17 @@
{
"name": "@playwright/mcp",
"version": "0.0.15",
"version": "0.0.20",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@playwright/mcp",
"version": "0.0.15",
"version": "0.0.20",
"license": "Apache-2.0",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.6.1",
"@modelcontextprotocol/sdk": "^1.10.1",
"commander": "^13.1.0",
"playwright": "1.53.0-alpha-1745357020000",
"playwright": "1.53.0-alpha-1746218818000",
"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.53.0-alpha-1745357020000",
"@playwright/test": "1.53.0-alpha-1746218818000",
"@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.53.0-alpha-1745357020000",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.53.0-alpha-1745357020000.tgz",
"integrity": "sha512-7xQRHhsS//elVJVt2WybJPXAy++WiE8yJzMtVFcnzdQNg9VNSbpqo4b61io5IIG1nEfB22N4BhjQ/8jPrUyu9A==",
"version": "1.53.0-alpha-1746218818000",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.53.0-alpha-1746218818000.tgz",
"integrity": "sha512-J05FD0oOCVbjbp4IjQi5tOPKywchi5EENS9jRjgkA5N9jd/+BaZ3jT8HlLMIgALdk/eLsprQa7vh9h45Q1FOPA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.53.0-alpha-1745357020000"
"playwright": "1.53.0-alpha-1746218818000"
},
"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.53.0-alpha-1745357020000",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.53.0-alpha-1745357020000.tgz",
"integrity": "sha512-evnZJIB1CRSA1HfwCkLhyqyGZybSWdNwfyyUWhBoez9ISbYMuYrTtidx75oiGVXtbKr5s8iC0+opuvagu4L1vA==",
"version": "1.53.0-alpha-1746218818000",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.53.0-alpha-1746218818000.tgz",
"integrity": "sha512-mVIjtdqIawIqWVyvCaLmV6XTALCT4oWWrbMjoHyyWRln3jQjnm3RUO9LkaINz+Yh88O3FkuY6RfjGXPXeFeJ4Q==",
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.53.0-alpha-1745357020000"
"playwright-core": "1.53.0-alpha-1746218818000"
},
"bin": {
"playwright": "cli.js"
@@ -3319,9 +3317,9 @@
}
},
"node_modules/playwright-core": {
"version": "1.53.0-alpha-1745357020000",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.53.0-alpha-1745357020000.tgz",
"integrity": "sha512-3oPzOUwJ/yhNWUs3fh5UbmI1Mf18sHUDo3gxzuPwqxN3QCSFKx9Ncg7cSB+FyJCkgz7ZD8fUlzJ75YsDE+PMfA==",
"version": "1.53.0-alpha-1746218818000",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.53.0-alpha-1746218818000.tgz",
"integrity": "sha512-iaIZmhO/psGssWpxIprJkFrn2h4xFjgL0jZsKGtReAMZ/XhlqMUJxtSitwWM4BV+wxJIptsZD0s5Ml2KU62Z3w==",
"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,7 +1,8 @@
{
"name": "@playwright/mcp",
"version": "0.0.15",
"version": "0.0.20",
"description": "Playwright Tools for MCP",
"type": "module",
"repository": {
"type": "git",
"url": "git+https://github.com/microsoft/playwright-mcp.git"
@@ -16,7 +17,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 +35,16 @@
}
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.6.1",
"@modelcontextprotocol/sdk": "^1.10.1",
"commander": "^13.1.0",
"playwright": "1.53.0-alpha-1745357020000",
"playwright": "1.53.0-alpha-1746218818000",
"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.53.0-alpha-1745357020000",
"@playwright/test": "1.53.0-alpha-1746218818000",
"@stylistic/eslint-plugin": "^3.0.1",
"@types/node": "^22.13.10",
"@typescript-eslint/eslint-plugin": "^8.26.1",

View File

@@ -16,9 +16,9 @@
import { defineConfig } from '@playwright/test';
import type { Project } from '@playwright/test';
import type { TestOptions } from './tests/fixtures.js';
export default defineConfig({
export default defineConfig<TestOptions>({
testDir: './tests',
fullyParallel: true,
forbidOnly: !!process.env.CI,
@@ -31,5 +31,5 @@ export default defineConfig({
{ name: 'chromium', use: { mcpBrowser: 'chromium' } },
{ name: 'firefox', use: { mcpBrowser: 'firefox' } },
{ name: 'webkit', use: { mcpBrowser: 'webkit' } },
].filter(Boolean) as Project[],
],
});

189
src/config.ts Normal file
View File

@@ -0,0 +1,189 @@
/**
* 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 type { Config, ToolCapability } from '../config.js';
import type { BrowserContextOptions, LaunchOptions } from 'playwright';
import { sanitizeForFilePath } from './tools/utils.js';
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 pickDefined<T extends object>(obj: T | undefined): Partial<T> {
return Object.fromEntries(
Object.entries(obj ?? {}).filter(([_, v]) => v !== undefined)
) as Partial<T>;
}
function mergeConfig(base: Config, overrides: Config): Config {
const browser: Config['browser'] = {
...pickDefined(base.browser),
...pickDefined(overrides.browser),
launchOptions: {
...pickDefined(base.browser?.launchOptions),
...pickDefined(overrides.browser?.launchOptions),
...{ assistantMode: true },
},
contextOptions: {
...pickDefined(base.browser?.contextOptions),
...pickDefined(overrides.browser?.contextOptions),
},
};
if (browser.browserName !== 'chromium')
delete browser.launchOptions.channel;
return {
...pickDefined(base),
...pickDefined(overrides),
browser,
};
}

View File

@@ -15,23 +15,15 @@
*/
import * as playwright from 'playwright';
import yaml from 'yaml';
import { waitForCompletion } from './tools/utils';
import { ManualPromise } from './manualPromise';
import { waitForCompletion } from './tools/utils.js';
import { ManualPromise } from './manualPromise.js';
import { Tab } from './tab.js';
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 { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types.js';
import type { ModalState, Tool, ToolActionResult } from './tools/tool.js';
import type { Config } from '../config.js';
import { outputFile } from './config.js';
type PendingAction = {
dialogShown: ManualPromise<void>;
@@ -39,17 +31,19 @@ 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;
private _downloads: { download: playwright.Download, finished: boolean, outputFile: string }[] = [];
constructor(tools: Tool[], options: ContextOptions) {
constructor(tools: Tool[], config: Config) {
this.tools = tools;
this.options = options;
this.config = config;
}
modalStates(): ModalState[] {
@@ -66,6 +60,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`);
@@ -170,6 +166,17 @@ ${code.join('\n')}
};
}
if (this._downloads.length) {
result.push('', '### Downloads');
for (const entry of this._downloads) {
if (entry.finished)
result.push(`- Downloaded file ${entry.download.suggestedFilename()} to ${entry.outputFile}`);
else
result.push(`- Downloading file ${entry.download.suggestedFilename()} ...`);
}
result.push('');
}
if (this.tabs().length > 1)
result.push(await this.listTabsMarkdown(), '');
@@ -234,6 +241,17 @@ ${code.join('\n')}
this._pendingAction?.dialogShown.resolve();
}
async downloadStarted(tab: Tab, download: playwright.Download) {
const entry = {
download,
finished: false,
outputFile: await outputFile(this.config, download.suggestedFilename())
};
this._downloads.push(entry);
await download.saveAs(entry.outputFile);
entry.finished = true;
}
private _onPageCreated(page: playwright.Page) {
const tab = new Tab(this, page, tab => this._onPageClosed(tab));
this._tabs.push(tab);
@@ -259,6 +277,7 @@ ${code.join('\n')}
return;
const browserContext = this._browserContext;
const browser = this._browser;
this._createBrowserContextPromise = undefined;
this._browserContext = undefined;
this._browser = undefined;
@@ -280,188 +299,46 @@ ${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();
void this._createBrowserContextPromise.catch(() => {
this._createBrowserContextPromise = undefined;
});
}
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 _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);
}
}
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}`);
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,27 +14,23 @@
* 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';
import dialogs from './tools/dialogs';
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 { createServerWithTools } from './server.js';
import common from './tools/common.js';
import console from './tools/console.js';
import dialogs from './tools/dialogs.js';
import files from './tools/files.js';
import install from './tools/install.js';
import keyboard from './tools/keyboard.js';
import navigate from './tools/navigate.js';
import network from './tools/network.js';
import pdf from './tools/pdf.js';
import snapshot from './tools/snapshot.js';
import tabs from './tools/tabs.js';
import screen from './tools/screen.js';
import testing from './tools/testing.js';
import type { Tool } from './tools/tool.js';
import type { Config } from '../config.js';
import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
import type { LaunchOptions } from 'playwright';
const snapshotTools: Tool<any>[] = [
...common(true),
@@ -48,6 +44,7 @@ const snapshotTools: Tool<any>[] = [
...pdf,
...snapshot,
...tabs(true),
...testing,
];
const screenshotTools: Tool<any>[] = [
@@ -62,81 +59,17 @@ const screenshotTools: Tool<any>[] = [
...pdf,
...screen,
...tabs(false),
...testing,
];
type Options = {
browser?: string;
userDataDir?: string;
headless?: boolean;
executablePath?: string;
cdpEndpoint?: string;
vision?: boolean;
capabilities?: ToolCapability[];
};
import packageJSON from '../package.json' with { type: 'json' };
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,20 +14,16 @@
* 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.js';
import { ServerList } from './server.js';
import { createServer } from './index';
import { ServerList } from './server';
import { startHttpTransport, startStdioTransport } from './transport.js';
import assert from 'assert';
import { ToolCapability } from './tools/tool';
import { resolveConfig } from './config.js';
const packageJSON = require('../package.json');
import packageJSON from '../package.json' with { type: 'json' };
program
.version('Version ' + packageJSON.version)
@@ -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

@@ -14,7 +14,7 @@
* limitations under the License.
*/
import type { Context } from '../context';
import type { Context } from '../context.js';
export type ResourceSchema = {
uri: string;

View File

@@ -15,29 +15,26 @@
*/
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 { Context } from './context.js';
import type { Tool } from './tools/tool';
import type { Resource } from './resources/resource';
import type { ContextOptions } from './context';
import type { Tool } from './tools/tool.js';
import type { Config } from '../config.js';
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: {},
}
});
@@ -51,51 +48,29 @@ export function createServerWithTools(options: Options): Server {
};
});
server.setRequestHandler(ListResourcesRequestSchema, async () => {
return { resources: resources.map(resource => resource.schema) };
});
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 () => {

95
src/tab.ts Normal file
View File

@@ -0,0 +1,95 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as playwright from 'playwright';
import { PageSnapshot } from './pageSnapshot.js';
import type { Context } from './context.js';
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.on('download', download => {
void this.context.downloadStarted(this, download);
});
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,7 +15,7 @@
*/
import { z } from 'zod';
import { defineTool, type ToolFactory } from './tool';
import { defineTool, type ToolFactory } from './tool.js';
const wait: ToolFactory = captureSnapshot => defineTool({
capability: 'wait',

View File

@@ -15,7 +15,7 @@
*/
import { z } from 'zod';
import { defineTool } from './tool';
import { defineTool } from './tool.js';
const console = defineTool({
capability: 'core',

View File

@@ -15,7 +15,7 @@
*/
import { z } from 'zod';
import { defineTool, type ToolFactory } from './tool';
import { defineTool, type ToolFactory } from './tool.js';
const handleDialog: ToolFactory = captureSnapshot => defineTool({
capability: 'core',

View File

@@ -15,7 +15,7 @@
*/
import { z } from 'zod';
import { defineTool, type ToolFactory } from './tool';
import { defineTool, type ToolFactory } from './tool.js';
const uploadFile: ToolFactory = captureSnapshot => defineTool({
capability: 'files',

View File

@@ -18,7 +18,10 @@ import { fork } from 'child_process';
import path from 'path';
import { z } from 'zod';
import { defineTool } from './tool';
import { defineTool } from './tool.js';
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const install = defineTool({
capability: 'install',
@@ -29,7 +32,7 @@ const install = defineTool({
},
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',

View File

@@ -15,7 +15,7 @@
*/
import { z } from 'zod';
import { defineTool, type ToolFactory } from './tool';
import { defineTool, type ToolFactory } from './tool.js';
const pressKey: ToolFactory = captureSnapshot => defineTool({
capability: 'core',

View File

@@ -15,7 +15,7 @@
*/
import { z } from 'zod';
import { defineTool, type ToolFactory } from './tool';
import { defineTool, type ToolFactory } from './tool.js';
const navigate: ToolFactory = captureSnapshot => defineTool({
capability: 'core',

View File

@@ -15,7 +15,7 @@
*/
import { z } from 'zod';
import { defineTool } from './tool';
import { defineTool } from './tool.js';
import type * as playwright from 'playwright';

View File

@@ -14,14 +14,11 @@
* limitations under the License.
*/
import os from 'os';
import path from 'path';
import { z } from 'zod';
import { defineTool } from './tool';
import { defineTool } from './tool.js';
import { sanitizeForFilePath } from './utils';
import * as javascript from '../javascript';
import * as javascript from '../javascript.js';
import { outputFile } from '../config.js';
const pdf = defineTool({
capability: 'pdf',
@@ -34,7 +31,7 @@ const pdf = defineTool({
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}`,

View File

@@ -15,9 +15,9 @@
*/
import { z } from 'zod';
import { defineTool } from './tool';
import { defineTool } from './tool.js';
import * as javascript from '../javascript';
import * as javascript from '../javascript.js';
const elementSchema = z.object({
element: z.string().describe('Human-readable element description used to obtain permission to interact with the element'),

View File

@@ -14,17 +14,13 @@
* limitations under the License.
*/
import path from 'path';
import os from 'os';
import { z } from 'zod';
import { sanitizeForFilePath } from './utils';
import { generateLocator } from '../context';
import * as javascript from '../javascript';
import { defineTool } from './tool.js';
import * as javascript from '../javascript.js';
import { outputFile } from '../config.js';
import type * as playwright from 'playwright';
import { defineTool } from './tool';
const snapshot = defineTool({
capability: 'core',
@@ -233,7 +229,7 @@ const screenshot = defineTool({
const tab = context.currentTabOrDie();
const snapshot = tab.snapshotOrDie();
const fileType = params.raw ? 'png' : 'jpeg';
const fileName = path.join(os.tmpdir(), sanitizeForFilePath(`page-${new Date().toISOString()}`)) + `.${fileType}`;
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 = params.element && params.ref;
@@ -248,14 +244,15 @@ const screenshot = defineTool({
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',
}]
}] : []
};
};
@@ -268,6 +265,9 @@ const screenshot = defineTool({
}
});
export async function generateLocator(locator: playwright.Locator): Promise<string> {
return (locator as any)._generateLocatorString();
}
export default [
snapshot,

View File

@@ -15,7 +15,7 @@
*/
import { z } from 'zod';
import { defineTool, type ToolFactory } from './tool';
import { defineTool, type ToolFactory } from './tool.js';
const listTabs = defineTool({
capability: 'tabs',

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

@@ -0,0 +1,65 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { z } from 'zod';
import { defineTool } from './tool.js';
const generateTestSchema = z.object({
name: z.string().describe('The name of the test'),
description: z.string().describe('The description of the test'),
steps: z.array(z.string()).describe('The steps of the test'),
});
const generateTest = defineTool({
capability: 'testing',
schema: {
name: 'browser_generate_playwright_test',
description: 'Generate a Playwright test for given scenario',
inputSchema: generateTestSchema,
},
handle: async (context, params) => {
return {
resultOverride: {
content: [{
type: 'text',
text: instructions(params),
}],
},
code: [],
captureSnapshot: false,
waitForNetwork: false,
};
},
});
const instructions = (params: { name: string, description: string, steps: string[] }) => [
`## Instructions`,
`- You are a playwright test generator.`,
`- You are given a scenario and you need to generate a playwright test for it.`,
'- DO NOT generate test code based on the scenario alone. DO run steps one by one using the tools provided instead.',
'- Only after all steps are completed, emit a Playwright TypeScript test that uses @playwright/test based on message history',
'- Save generated test file in the tests directory',
`Test name: ${params.name}`,
`Description: ${params.description}`,
`Steps:`,
...params.steps.map((step, index) => `- ${index + 1}. ${step}`),
].join('\n');
export default [
generateTest,
];

View File

@@ -14,11 +14,11 @@
* limitations under the License.
*/
import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types';
import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types.js';
import type { z } from 'zod';
import type { Context } from '../context';
import type { Context } from '../context.js';
import type * as playwright from 'playwright';
export type ToolCapability = 'core' | 'tabs' | 'pdf' | 'history' | 'wait' | 'files' | 'install';
import type { ToolCapability } from '../../config.js';
export type ToolSchema<Input extends InputType> = {
name: string;

View File

@@ -15,7 +15,7 @@
*/
import type * as playwright from 'playwright';
import type { Context } from '../context';
import type { Context } from '../context.js';
export async function waitForCompletion<R>(context: Context, page: playwright.Page, callback: () => Promise<R>): Promise<R> {
const requests = new Set<playwright.Request>();
@@ -71,5 +71,9 @@ export async function waitForCompletion<R>(context: Context, page: playwright.Pa
}
export function sanitizeForFilePath(s: string) {
return s.replace(/[\x00-\x2C\x2E-\x2F\x3A-\x40\x5B-\x60\x7B-\x7F]+/g, '-');
const sanitize = (s: string) => s.replace(/[\x00-\x2C\x2E-\x2F\x3A-\x40\x5B-\x60\x7B-\x7F]+/g, '-');
const separator = s.lastIndexOf('.');
if (separator === -1)
return sanitize(s);
return sanitize(s.substring(0, separator)) + '.' + sanitize(s.substring(separator + 1));
}

134
src/transport.ts Normal file
View File

@@ -0,0 +1,134 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import http from 'node:http';
import assert from 'node:assert';
import crypto from 'node:crypto';
import { ServerList } from './server.js';
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 => {
// eslint-disable-next-line no-console
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}`;
}
const message = [
`Listening on ${url}`,
'Put this in your client config:',
JSON.stringify({
'mcpServers': {
'playwright': {
'url': `${url}/sse`
}
}
}, undefined, 2),
'If your client supports streamable HTTP, you can use the /mcp endpoint instead.',
].join('\n');
// eslint-disable-next-line no-console
console.log(message);
});
}

View File

@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { test, expect } from './fixtures';
import { test, expect } from './fixtures.js';
test('test snapshot tool list', async ({ client }) => {
const { tools } = await client.listTools();
@@ -23,6 +23,7 @@ test('test snapshot tool list', async ({ client }) => {
'browser_console_messages',
'browser_drag',
'browser_file_upload',
'browser_generate_playwright_test',
'browser_handle_dialog',
'browser_hover',
'browser_select_option',
@@ -52,6 +53,7 @@ test('test vision tool list', async ({ visionClient }) => {
'browser_close',
'browser_console_messages',
'browser_file_upload',
'browser_generate_playwright_test',
'browser_handle_dialog',
'browser_install',
'browser_navigate_back',
@@ -74,11 +76,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

@@ -14,20 +14,20 @@
* limitations under the License.
*/
import { test, expect } from './fixtures';
import { test, expect } from './fixtures.js';
test('cdp server', async ({ cdpEndpoint, startClient }) => {
const client = await startClient({ args: [`--cdp-endpoint=${cdpEndpoint}`] });
const client = await startClient({ args: [`--cdp-endpoint=${await cdpEndpoint()}`] });
expect(await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
},
})).toContainTextContent(`- text: Hello, world!`);
})).toContainTextContent(`- generic [ref=s1e2]: Hello, world!`);
});
test('cdp server reuse tab', async ({ cdpEndpoint, startClient }) => {
const client = await startClient({ args: [`--cdp-endpoint=${cdpEndpoint}`] });
const client = await startClient({ args: [`--cdp-endpoint=${await cdpEndpoint()}`] });
expect(await client.callTool({
name: 'browser_click',
@@ -50,7 +50,25 @@ test('cdp server reuse tab', async ({ cdpEndpoint, startClient }) => {
- Page Title:
- Page Snapshot
\`\`\`yaml
- text: hello world
- generic [ref=s1e2]: hello world
\`\`\`
`);
});
test('should throw connection error and allow re-connecting', async ({ cdpEndpoint, startClient }) => {
const port = 3200 + test.info().parallelIndex;
const client = await startClient({ args: [`--cdp-endpoint=http://localhost:${port}`] });
expect(await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
},
})).toContainTextContent(`Error: browserType.connectOverCDP: connect ECONNREFUSED`);
await cdpEndpoint(port);
expect(await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
},
})).toContainTextContent(`- generic [ref=s1e2]: Hello, world!`);
});

View File

@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { test, expect } from './fixtures';
import { test, expect } from './fixtures.js';
test('browser_console_messages', async ({ client }) => {
await client.callTool({

View File

@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { test, expect } from './fixtures';
import { test, expect } from './fixtures.js';
test('browser_navigate', async ({ client }) => {
expect(await client.callTool({
@@ -33,7 +33,7 @@ await page.goto('data:text/html,<html><title>Title</title><body>Hello, world!</b
- Page Title: Title
- Page Snapshot
\`\`\`yaml
- text: Hello, world!
- generic [ref=s1e2]: Hello, world!
\`\`\`
`
);

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.js';
test('--device should work', async ({ startClient, server }) => {
const client = await startClient({
args: ['--device', 'iPhone 15'],
});
server.route('/', (req, res) => {
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(`
<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

@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { test, expect } from './fixtures';
import { test, expect } from './fixtures.js';
// https://github.com/microsoft/playwright/issues/35663
test.skip(({ mcpBrowser, mcpHeadless }) => mcpBrowser === 'webkit' && mcpHeadless);
@@ -126,7 +126,7 @@ test('confirm dialog (true)', async ({ client }) => {
expect(result).toContainTextContent('// <internal code to handle "confirm" dialog>');
expect(result).toContainTextContent(`- Page Snapshot
\`\`\`yaml
- text: "true"
- generic [ref=s2e2]: "true"
\`\`\``);
});
@@ -156,7 +156,7 @@ test('confirm dialog (false)', async ({ client }) => {
expect(result).toContainTextContent(`- Page Snapshot
\`\`\`yaml
- text: "false"
- generic [ref=s2e2]: "false"
\`\`\``);
});
@@ -187,6 +187,6 @@ test('prompt dialog', async ({ client }) => {
expect(result).toContainTextContent(`- Page Snapshot
\`\`\`yaml
- text: Answer
- generic [ref=s2e2]: Answer
\`\`\``);
});

View File

@@ -14,8 +14,9 @@
* limitations under the License.
*/
import { test, expect } from './fixtures';
import { test, expect } from './fixtures.js';
import fs from 'fs/promises';
import path from 'path';
test('browser_file_upload', async ({ client }) => {
expect(await client.callTool({
@@ -25,11 +26,21 @@ test('browser_file_upload', async ({ client }) => {
},
})).toContainTextContent(`
\`\`\`yaml
- generic [ref=s1e2]:
- button "Choose File" [ref=s1e3]
- button "Button" [ref=s1e4]
- 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',
arguments: {
@@ -53,9 +64,8 @@ test('browser_file_upload', async ({ client }) => {
expect(response).not.toContainTextContent('### Modal state');
expect(response).toContainTextContent(`
\`\`\`yaml
- generic [ref=s3e2]:
- button "Choose File" [ref=s3e3]
- button "Button" [ref=s3e4]
- button "Choose File" [ref=s3e3]
- button "Button" [ref=s3e4]
\`\`\``);
}
@@ -85,3 +95,27 @@ test('browser_file_upload', async ({ client }) => {
- [File chooser]: can be handled by the "browser_file_upload" tool`);
}
});
test('clicking on download link emits download', 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,<a href="data:text/plain,Hello world!" download="test.txt">Download</a>',
},
})).toContainTextContent('- link "Download" [ref=s1e3]');
await client.callTool({
name: 'browser_click',
arguments: {
element: 'Download link',
ref: 's1e3',
},
});
await expect.poll(() => client.callTool({ name: 'browser_snapshot', arguments: {} })).toContainTextContent(`
### Downloads
- Downloaded file test.txt to ${path.join(outputDir, 'test.txt')}`);
});

View File

@@ -14,32 +14,39 @@
* limitations under the License.
*/
import fs from 'fs';
import url from 'url';
import path from 'path';
import { chromium } from 'playwright';
import { test as baseTest, expect as baseExpect } from '@playwright/test';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { spawn } from 'child_process';
import { TestServer } from './testserver';
import { ChildProcessWithoutNullStreams, spawn } from 'child_process';
import { TestServer } from './testserver/index.ts';
import type { Config } from '../config';
export type TestOptions = {
mcpBrowser: string | undefined;
};
type TestFixtures = {
client: Client;
visionClient: Client;
startClient: (options?: { args?: string[] }) => Promise<Client>;
startClient: (options?: { args?: string[], config?: Config }) => Promise<Client>;
wsEndpoint: string;
cdpEndpoint: string;
cdpEndpoint: (port?: number) => Promise<string>;
server: TestServer;
httpsServer: TestServer;
mcpHeadless: boolean;
};
type WorkerFixtures = {
mcpHeadless: boolean;
mcpBrowser: string | undefined;
_workerServers: { server: TestServer, httpsServer: TestServer };
};
export const test = baseTest.extend<TestFixtures, WorkerFixtures>({
export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>({
client: async ({ startClient }, use) => {
await use(await startClient());
@@ -51,9 +58,9 @@ export const test = baseTest.extend<TestFixtures, WorkerFixtures>({
startClient: async ({ mcpHeadless, mcpBrowser }, use, testInfo) => {
const userDataDir = testInfo.outputPath('user-data-dir');
let client: StdioClientTransport | undefined;
let client: Client | undefined;
use(async options => {
await use(async options => {
const args = ['--user-data-dir', userDataDir];
if (mcpHeadless)
args.push('--headless');
@@ -61,11 +68,18 @@ 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}`);
}
// NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename.
const __filename = url.fileURLToPath(import.meta.url);
const transport = new StdioClientTransport({
command: 'node',
args: [path.join(__dirname, '../cli.js'), ...args],
args: [path.join(path.dirname(__filename), '../cli.js'), ...args],
});
const client = new Client({ name: 'test', version: '1.0.0' });
client = new Client({ name: 'test', version: '1.0.0' });
await client.connect(transport);
await client.ping();
return client;
@@ -81,33 +95,40 @@ export const test = baseTest.extend<TestFixtures, WorkerFixtures>({
},
cdpEndpoint: async ({ }, use, testInfo) => {
const port = 3200 + (+process.env.TEST_PARALLEL_INDEX!);
const executablePath = chromium.executablePath();
const browserProcess = spawn(executablePath, [
`--user-data-dir=${testInfo.outputPath('user-data-dir')}`,
`--remote-debugging-port=${port}`,
`--no-first-run`,
`--no-sandbox`,
`--headless`,
`data:text/html,hello world`,
], {
stdio: 'pipe',
});
await new Promise<void>(resolve => {
browserProcess.stderr.on('data', data => {
if (data.toString().includes('DevTools listening on '))
resolve();
let browserProcess: ChildProcessWithoutNullStreams | undefined;
await use(async port => {
if (!port)
port = 3200 + test.info().parallelIndex;
if (browserProcess)
return `http://localhost:${port}`;
browserProcess = spawn(chromium.executablePath(), [
`--user-data-dir=${testInfo.outputPath('user-data-dir')}`,
`--remote-debugging-port=${port}`,
`--no-first-run`,
`--no-sandbox`,
`--headless`,
'--use-mock-keychain',
`data:text/html,hello world`,
], {
stdio: 'pipe',
});
await new Promise<void>(resolve => {
browserProcess!.stderr.on('data', data => {
if (data.toString().includes('DevTools listening on '))
resolve();
});
});
return `http://localhost:${port}`;
});
await use(`http://localhost:${port}`);
browserProcess.kill();
browserProcess?.kill();
},
mcpHeadless: [async ({ headless }, use) => {
mcpHeadless: async ({ headless }, use) => {
await use(headless);
}, { scope: 'worker' }],
},
mcpBrowser: ['chrome', { option: true, scope: 'worker' }],
mcpBrowser: ['chrome', { option: true }],
_workerServers: [async ({}, use, workerInfo) => {
const port = 8907 + workerInfo.workerIndex * 4;

49
tests/headed.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.js';
for (const mcpHeadless of [false, true]) {
test.describe(`mcpHeadless: ${mcpHeadless}`, () => {
test.use({ mcpHeadless });
test.skip(process.platform === 'linux', 'Auto-detection wont let this test run on linux');
test('browser', async ({ client, server, mcpBrowser }) => {
test.skip(!['chrome', 'msedge', 'chromium'].includes(mcpBrowser ?? ''), 'Only chrome is supported for this test');
server.route('/', (req, res) => {
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(`
<body></body>
<script>
document.body.textContent = navigator.userAgent;
</script>
`);
});
const response = await client.callTool({
name: 'browser_navigate',
arguments: {
url: server.PREFIX,
},
});
expect(response).toContainTextContent(`Mozilla/5.0`);
if (mcpHeadless)
expect(response).toContainTextContent(`HeadlessChrome`);
else
expect(response).not.toContainTextContent(`HeadlessChrome`);
});
});
}

View File

@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { test, expect } from './fixtures';
import { test, expect } from './fixtures.js';
test('stitched aria frames', async ({ client }) => {
expect(await client.callTool({
@@ -24,14 +24,12 @@ test('stitched aria frames', async ({ client }) => {
},
})).toContainTextContent(`
\`\`\`yaml
- 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
- heading "Hello" [level=1] [ref=s1e3]
- iframe [ref=s1e4]:
- button "World" [ref=f1s1e3]
- main [ref=f1s1e4]:
- iframe [ref=f1s1e5]:
- paragraph [ref=f2s1e3]: Nested
\`\`\``);
expect(await client.callTool({

25
tests/install.spec.ts Normal file
View File

@@ -0,0 +1,25 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { test, expect } from './fixtures.js';
test('browser_install', async ({ client, mcpBrowser }) => {
test.skip(mcpBrowser !== 'chromium', 'Test only chromium');
expect(await client.callTool({
name: 'browser_install',
arguments: {},
})).toContainTextContent(`No open pages available.`);
});

View File

@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { test, expect } from './fixtures';
import { test, expect } from './fixtures.js';
test('test reopen browser', async ({ client }) => {
await client.callTool({
@@ -34,7 +34,7 @@ test('test reopen browser', async ({ client }) => {
arguments: {
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
},
})).toContainTextContent(`- text: Hello, world!`);
})).toContainTextContent(`- generic [ref=s1e2]: Hello, world!`);
});
test('executable path', async ({ startClient }) => {

View File

@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { test, expect } from './fixtures';
import { test, expect } from './fixtures.js';
test('browser_network_requests', async ({ client, server }) => {
server.route('/', (req, res) => {
@@ -42,8 +42,8 @@ test('browser_network_requests', async ({ client, server }) => {
},
});
expect.poll(() => client.callTool({
await expect.poll(() => client.callTool({
name: 'browser_network_requests',
arguments: {},
})).toHaveTextContent(`[GET] http://localhost:8907/json => [200] OK`);
})).toHaveTextContent(`[GET] http://localhost:${server.PORT}/json => [200] OK`);
});

View File

@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { test, expect } from './fixtures';
import { test, expect } from './fixtures.js';
test('save as pdf unavailable', async ({ startClient }) => {
const client = await startClient({ args: ['--caps="no-pdf"'] });
@@ -37,7 +37,7 @@ test('save as pdf', async ({ client, mcpBrowser }) => {
arguments: {
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
},
})).toContainTextContent(`- text: Hello, world!`);
})).toContainTextContent(`- generic [ref=s1e2]: Hello, world!`);
const response = await client.callTool({
name: 'browser_pdf_save',

View File

@@ -14,7 +14,9 @@
* limitations under the License.
*/
import { test, expect } from './fixtures';
import fs from 'fs';
import { test, expect } from './fixtures.js';
test('browser_take_screenshot (viewport)', async ({ client }) => {
expect(await client.callTool({
@@ -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

@@ -14,29 +14,51 @@
* limitations under the License.
*/
import url from 'node:url';
import { spawn } from 'node:child_process';
import path from 'node:path';
import { test } from './fixtures';
import { test as baseTest } from './fixtures.js';
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]);
}));
// NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename.
const __filename = url.fileURLToPath(import.meta.url);
// 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();
}
const test = baseTest.extend<{ serverEndpoint: string }>({
serverEndpoint: async ({}, use) => {
const cp = spawn('node', [path.join(path.dirname(__filename), '../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]);
}));
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

@@ -16,7 +16,7 @@
import { chromium } from 'playwright';
import { test, expect } from './fixtures';
import { test, expect } from './fixtures.js';
import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
@@ -63,7 +63,7 @@ test('create new tab', async ({ client }) => {
- Page Title: Tab one
- Page Snapshot
\`\`\`yaml
- text: Body one
- generic [ref=s1e2]: Body one
\`\`\``);
expect(await createTab(client, 'Tab two', 'Body two')).toHaveTextContent(`
@@ -82,7 +82,7 @@ test('create new tab', async ({ client }) => {
- Page Title: Tab two
- Page Snapshot
\`\`\`yaml
- text: Body two
- generic [ref=s1e2]: Body two
\`\`\``);
});
@@ -110,7 +110,7 @@ test('select tab', async ({ client }) => {
- Page Title: Tab one
- Page Snapshot
\`\`\`yaml
- text: Body one
- generic [ref=s2e2]: Body one
\`\`\``);
});
@@ -137,16 +137,16 @@ test('close tab', async ({ client }) => {
- Page Title: Tab one
- Page Snapshot
\`\`\`yaml
- text: Body one
- generic [ref=s2e2]: Body one
\`\`\``);
});
test('reuse first tab when navigating', async ({ startClient, cdpEndpoint }) => {
const browser = await chromium.connectOverCDP(cdpEndpoint);
const browser = await chromium.connectOverCDP(await cdpEndpoint());
const [context] = browser.contexts();
const pages = context.pages();
const client = await startClient({ args: [`--cdp-endpoint=${cdpEndpoint}`] });
const client = await startClient({ args: [`--cdp-endpoint=${await cdpEndpoint()}`] });
await client.callTool({
name: 'browser_navigate',
arguments: {

View File

@@ -16,13 +16,18 @@
*/
import fs from 'fs';
import url from 'node:url';
import http from 'http';
import https from 'https';
import path from 'path';
import debug from 'debug';
const fulfillSymbol = Symbol('fulfil callback');
const rejectSymbol = Symbol('reject callback');
// NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename.
const __filename = url.fileURLToPath(import.meta.url);
export class TestServer {
private _server: http.Server;
readonly debugServer: any;
@@ -42,8 +47,8 @@ export class TestServer {
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')),
key: await fs.promises.readFile(path.join(path.dirname(__filename), 'key.pem')),
cert: await fs.promises.readFile(path.join(path.dirname(__filename), 'cert.pem')),
passphrase: 'aaaa',
});
await new Promise(x => server._server.once('listening', x));
@@ -56,7 +61,7 @@ export class TestServer {
else
this._server = http.createServer(this._onRequest.bind(this));
this._server.listen(port);
this.debugServer = require('debug')('pw:testserver');
this.debugServer = debug('pw:testserver');
const cross_origin = '127.0.0.1';
const same_origin = 'localhost';

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.js';
test('do not falsely advertise user agent as a test driver', async ({ client, server, mcpBrowser }) => {
test.skip(mcpBrowser === 'firefox');
test.skip(mcpBrowser === 'webkit');
server.route('/', (req, res) => {
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(`
<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"],
}

View File

@@ -3,10 +3,12 @@
"target": "ESNext",
"skipLibCheck": true,
"esModuleInterop": true,
"moduleResolution": "node",
"moduleResolution": "nodenext",
"strict": true,
"module": "CommonJS",
"outDir": "./lib"
"module": "NodeNext",
"rootDir": "src",
"outDir": "./lib",
"resolveJsonModule": true
},
"include": [
"src",

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

@@ -16,21 +16,23 @@
*/
// @ts-check
const fs = require('node:fs');
const path = require('node:path');
const zodToJsonSchema = require('zod-to-json-schema').default;
import fs from 'node:fs'
import path from 'node:path'
import url from 'node:url'
import zodToJsonSchema from 'zod-to-json-schema'
const commonTools = require('../lib/tools/common').default;
const consoleTools = require('../lib/tools/console').default;
const dialogsTools = require('../lib/tools/dialogs').default;
const filesTools = require('../lib/tools/files').default;
const installTools = require('../lib/tools/install').default;
const keyboardTools = require('../lib/tools/keyboard').default;
const navigateTools = require('../lib/tools/navigate').default;
const pdfTools = require('../lib/tools/pdf').default;
const snapshotTools = require('../lib/tools/snapshot').default;
const tabsTools = require('../lib/tools/tabs').default;
const screenTools = require('../lib/tools/screen').default;
import commonTools from '../lib/tools/common.js';
import consoleTools from '../lib/tools/console.js';
import dialogsTools from '../lib/tools/dialogs.js';
import filesTools from '../lib/tools/files.js';
import installTools from '../lib/tools/install.js';
import keyboardTools from '../lib/tools/keyboard.js';
import navigateTools from '../lib/tools/navigate.js';
import pdfTools from '../lib/tools/pdf.js';
import snapshotTools from '../lib/tools/snapshot.js';
import tabsTools from '../lib/tools/tabs.js';
import screenTools from '../lib/tools/screen.js';
import testTools from '../lib/tools/testing.js';
// Category definitions for tools
const categories = {
@@ -61,8 +63,14 @@ const categories = {
...installTools,
...dialogsTools(true),
],
'Testing': [
...testTools,
],
};
// NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename.
const __filename = url.fileURLToPath(import.meta.url);
const kStartMarker = `<!--- Generated by ${path.basename(__filename)} -->`;
const kEndMarker = `<!--- End of generated section -->`;
@@ -112,7 +120,7 @@ function formatToolForReadme(tool) {
* @returns {ParsedToolSchema}
*/
function processToolSchema(schema) {
const inputSchema = /** @type {import('zod-to-json-schema').JsonSchema7ObjectType} */ zodToJsonSchema(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`);
@@ -152,7 +160,7 @@ async function updateReadme() {
}
}
const readmePath = path.join(__dirname, '..', 'README.md');
const readmePath = path.join(path.dirname(__filename), '..', 'README.md');
const readmeContent = await fs.promises.readFile(readmePath, 'utf-8');
const startMarker = readmeContent.indexOf(kStartMarker);
const endMarker = readmeContent.indexOf(kEndMarker);