This is page 1 of 3. Use http://codebase.md/cloudflare/playwright-mcp?page={x} to view the full context. # Directory Structure ``` ├── .github │ └── workflows │ ├── cf_ci.yml │ ├── cf_publish_release_npm.yml │ ├── ci.yml │ └── publish.yml ├── .gitignore ├── .npmignore ├── cli.js ├── cloudflare │ ├── .npmignore │ ├── example │ │ ├── .gitignore │ │ ├── package-lock.json │ │ ├── package.json │ │ ├── README.md │ │ ├── src │ │ │ └── index.ts │ │ ├── tsconfig.json │ │ ├── worker-configuration.d.ts │ │ └── wrangler.toml │ ├── index.d.ts │ ├── package-lock.json │ ├── package.json │ ├── src │ │ ├── index.ts │ │ └── package.ts │ ├── tsconfig.json │ └── vite.config.ts ├── config.d.ts ├── Dockerfile ├── docs │ └── imgs │ ├── claudemcp.gif │ ├── playground-ai-screenshot.png │ ├── todomvc-screenshot-1.png │ ├── todomvc-screenshot-2.png │ └── todomvc-screenshot-3.png ├── eslint.config.mjs ├── examples │ └── generate-test.md ├── index.d.ts ├── index.js ├── LICENSE ├── package-lock.json ├── package.json ├── playwright.config.ts ├── README.md ├── SECURITY.md ├── src │ ├── browserContextFactory.ts │ ├── browserServer.ts │ ├── config.ts │ ├── connection.ts │ ├── context.ts │ ├── fileUtils.ts │ ├── httpServer.ts │ ├── index.ts │ ├── javascript.ts │ ├── manualPromise.ts │ ├── package.ts │ ├── pageSnapshot.ts │ ├── program.ts │ ├── server.ts │ ├── tab.ts │ ├── tools │ │ ├── common.ts │ │ ├── console.ts │ │ ├── dialogs.ts │ │ ├── files.ts │ │ ├── install.ts │ │ ├── keyboard.ts │ │ ├── navigate.ts │ │ ├── network.ts │ │ ├── pdf.ts │ │ ├── screenshot.ts │ │ ├── snapshot.ts │ │ ├── tabs.ts │ │ ├── testing.ts │ │ ├── tool.ts │ │ ├── utils.ts │ │ ├── vision.ts │ │ └── wait.ts │ ├── tools.ts │ └── transport.ts ├── tests │ ├── browser-server.spec.ts │ ├── capabilities.spec.ts │ ├── cdp.spec.ts │ ├── config.spec.ts │ ├── console.spec.ts │ ├── core.spec.ts │ ├── device.spec.ts │ ├── dialogs.spec.ts │ ├── files.spec.ts │ ├── fixtures.ts │ ├── headed.spec.ts │ ├── iframes.spec.ts │ ├── install.spec.ts │ ├── launch.spec.ts │ ├── library.spec.ts │ ├── network.spec.ts │ ├── pdf.spec.ts │ ├── request-blocking.spec.ts │ ├── screenshot.spec.ts │ ├── sse.spec.ts │ ├── tabs.spec.ts │ ├── testserver │ │ ├── cert.pem │ │ ├── index.ts │ │ ├── key.pem │ │ └── san.cnf │ ├── trace.spec.ts │ ├── wait.spec.ts │ └── webdriver.spec.ts ├── tsconfig.all.json ├── tsconfig.json └── utils ├── copyright.js ├── generate-links.js └── update-readme.js ``` # Files -------------------------------------------------------------------------------- /cloudflare/example/.gitignore: -------------------------------------------------------------------------------- ``` .wrangler node_modules ``` -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- ``` **/* README.md LICENSE !lib/**/*.js !cli.js !index.* !config.d.ts ``` -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- ``` lib/ node_modules/ test-results/ playwright-report/ .vscode/mcp.json .idea .DS_Store ``` -------------------------------------------------------------------------------- /cloudflare/.npmignore: -------------------------------------------------------------------------------- ``` # This ignores everything by default, except for package.json and LICENSE and README.md. # See https://docs.npmjs.com/misc/developers **/* # Include sources from lib, but not map files. !lib/**/*.js # Include playwright core and test entry points !index.d.ts ``` -------------------------------------------------------------------------------- /cloudflare/example/README.md: -------------------------------------------------------------------------------- ```markdown ## Cloudflare Playwright MCP Example [](https://deploy.workers.cloudflare.com/?url=https://github.com/cloudflare/playwright-mcp/tree/main/cloudflare/example) ### Overview This project demonstrates how to use [Playwright with Cloudflare Workers](https://github.com/cloudflare/playwright) as a Model Control Protocol (MCP) server using [Cloudflare Playwright MCP](https://github.com/cloudflare/playwright-mcp). It enables AI assistants to control a browser through a set of tools, allowing them to perform web automation tasks like navigation, typing, clicking, and taking screenshots. The server can be used with various AI platforms including Cloudflare AI Playground, Claude Desktop, and GitHub Copilot in VS Code. ### Deploy Follow these steps to set up and deploy the project: 1. Install dependencies: ```bash npm ci ``` 2. Deploy to Cloudflare Workers: ```bash npx wrangler deploy ``` ### Use with Cloudflare AI Playground  [Cloudflare playground AI](https://playground.ai.cloudflare.com/) is a great way to test MCP servers using LLM models available in Workers AI. - Navigate to https://playground.ai.cloudflare.com/ - Ensure model is set to `llama-3.3-70b-instruct-fp8-fast` - In **MCP Servers**, set **URL** to `https://[my-mcp-url].workers.dev/sse` - Click **Connect** - Status should update to **Connected** and it should list 14 available tools You can now start to interact with the model, and it will run necessary tools to accomplish what was requested. > [!TIP] > For best results, give simple instructions consisting of one single action, e. g., "Create a new todo entry", "Go to cloudflare site", "Take a screenshot" Example of a conversation: | Role | Message | |------|---------| | **User** | Go to demo.playwright.dev/todomvc | | **Assistant** | *runs browser_navigate tool*<br> The page has been navigated to https://demo.playwright.dev/todomvc/. The page title is "React • TodoMVC". The page snapshot shows the content of the page, including a heading, a textbox, and several links. | | **User** | Create some todo entry | | **Assistant** | *runs browser_type tool*<br> A new todo entry "Buy groceries" has been created. The page snapshot shows the updated content of the page, including the new todo entry. | | **User** | Nice. Now create a todo in parrot style | | **Assistant** | *runs browser_type tool*<br> A new todo entry "Polly wants a cracker" has been created in parrot style. The page snapshot shows the updated content of the page, including the new todo entry. | | **User** | And create another todo in yoda style | | **Assistant** | *runs browser_type tool*<br> A new todo entry "A walk, I must take" has been created in Yoda style. The page snapshot shows the updated content of the page, including the new todo entry. | | **User** | Take a screenshot | | **Assistant** | *runs browser_take_screenshot tool*<br>  A screenshot of the current page has been taken. The screenshot is in PNG format and is 56KB in size. | | **User** | Now click the checkbox of the parrot todo entry | | **Assistant** | *runs browser_clickt tool*<br> The checkbox of the parrot todo entry "Polly wants a cracker" has been clicked. The page snapshot shows the updated content of the page, with the checkbox now checked. | | **User** | Take a screenshot | | **Assistant** | *runs browser_take_screenshot tool*<br>  A screenshot of the current page has been taken. The screenshot is in PNG format and is 58KB in size. | | **User** | Remove completed todo entries | | **Assistant** | *runs browser_clickt tool*<br> The "Clear completed" button has been clicked. The page snapshot shows the updated content of the page, with the completed todo entry "Polly wants a cracker" removed. | | **User** | Take a final screenshot | | **Assistant** | *runs browser_take_screenshot tool*<br>  A final screenshot of the current page has been taken. The screenshot is in PNG format and is 50KB in size. | ### Use with Claude Desktop As of now, [Claude Desktop](https://claude.ai/download) only supports local MCP servers. To use `playground-mcp` with Claude Desktop we make use of [mcp-remote](https://github.com/geelen/mcp-remote), a tool that proxies remote MCP servers and exposes them locally. Use the following configuration: 1. Open the configuration file for Claude Desktop. 2. Add the following JSON snippet under the `mcpServers` section: ```json { "mcpServers": { "cloudflare-playwright-mcp": { "command": "npx", "args": [ "mcp-remote", "https://[my-mcp-url].workers.dev/sse" ] } } } ``` 3. Save the configuration file and **restart** Claude Desktop to apply the changes. This setup ensures that Claude Desktop can communicate with the Cloudflare Playwright MCP server. Here's an example of a session opening the TODO demo app, adding "buy lemons" and doing a screenshot, taking advantage of playwright-mcp tools and Browser Rendering:  ### Configure in VSCode You can install the Playwright MCP server using the [VS Code CLI](https://code.visualstudio.com/docs/configure/command-line): ```bash # For VS Code code --add-mcp '{"name":"cloudflare-playwright","type":"sse","url":"https://[my-mcp-url].workers.dev/sse"}' ``` ```bash # For VS Code Insiders code-insiders --add-mcp '{"name":"cloudflare-playwright","type":"sse","url":"https://[my-mcp-url].workers.dev/sse"}' ``` After installation, the Playwright MCP server will be available for use with your GitHub Copilot agent in VS Code. ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown ## Cloudflare Playwright MCP [](https://deploy.workers.cloudflare.com/?url=https://github.com/cloudflare/playwright-mcp/tree/main/cloudflare/example) ### Overview This project leverages Playwright for automated browser testing and integrates with Cloudflare Workers, [Browser Rendering](https://developers.cloudflare.com/browser-rendering/) and [`@cloudflare/playwright`](https://github.com/cloudflare/playwright) for deployment. ### Build and Deploy Follow these steps to set up and deploy the project: 1. Install dependencies: ```bash npm ci ``` 2. Build: ```bash cd cloudflare npm run build ``` 3. Deploy to Cloudflare Workers: ```bash cd cloudflare/example npm ci npx wrangler deploy ``` ### Use with Cloudflare AI Playground  [Cloudflare playground AI](https://playground.ai.cloudflare.com/) is a great way to test MCP servers using LLM models available in Workers AI. - Navigate to https://playground.ai.cloudflare.com/ - Ensure model is set to `llama-3.3-70b-instruct-fp8-fast` - In **MCP Servers**, set **URL** to `https://[my-mcp-url].workers.dev/sse` - Click **Connect** - Status should update to **Connected** and it should list 14 available tools You can now start to interact with the model, and it will run necessary tools to accomplish what was requested. > [!TIP] > For best results, give simple instructions consisting of one single action, e. g., "Create a new todo entry", "Go to cloudflare site", "Take a screenshot" Example of a conversation: | Role | Message | |------|---------| | **User** | Go to demo.playwright.dev/todomvc | | **Assistant** | *runs browser_navigate tool*<br> The page has been navigated to https://demo.playwright.dev/todomvc/. The page title is "React • TodoMVC". The page snapshot shows the content of the page, including a heading, a textbox, and several links. | | **User** | Create some todo entry | | **Assistant** | *runs browser_type tool*<br> A new todo entry "Buy groceries" has been created. The page snapshot shows the updated content of the page, including the new todo entry. | | **User** | Nice. Now create a todo in parrot style | | **Assistant** | *runs browser_type tool*<br> A new todo entry "Polly wants a cracker" has been created in parrot style. The page snapshot shows the updated content of the page, including the new todo entry. | | **User** | And create another todo in yoda style | | **Assistant** | *runs browser_type tool*<br> A new todo entry "A walk, I must take" has been created in Yoda style. The page snapshot shows the updated content of the page, including the new todo entry. | | **User** | Take a screenshot | | **Assistant** | *runs browser_take_screenshot tool*<br>  A screenshot of the current page has been taken. The screenshot is in PNG format and is 56KB in size. | | **User** | Now click the checkbox of the parrot todo entry | | **Assistant** | *runs browser_clickt tool*<br> The checkbox of the parrot todo entry "Polly wants a cracker" has been clicked. The page snapshot shows the updated content of the page, with the checkbox now checked. | | **User** | Take a screenshot | | **Assistant** | *runs browser_take_screenshot tool*<br>  A screenshot of the current page has been taken. The screenshot is in PNG format and is 58KB in size. | | **User** | Remove completed todo entries | | **Assistant** | *runs browser_clickt tool*<br> The "Clear completed" button has been clicked. The page snapshot shows the updated content of the page, with the completed todo entry "Polly wants a cracker" removed. | | **User** | Take a final screenshot | | **Assistant** | *runs browser_take_screenshot tool*<br>  A final screenshot of the current page has been taken. The screenshot is in PNG format and is 50KB in size. | ### Use with Claude Desktop As of now, [Claude Desktop](https://claude.ai/download) only supports local MCP servers. To use `playground-mcp` with Claude Desktop we make use of [mcp-remote](https://github.com/geelen/mcp-remote), a tool that proxies remote MCP servers and exposes them locally. Use the following configuration: 1. Open the configuration file for Claude Desktop. 2. Add the following JSON snippet under the `mcpServers` section: ```json { "mcpServers": { "cloudflare-playwright-mcp": { "command": "npx", "args": [ "mcp-remote", "https://[my-mcp-url].workers.dev/sse" ] } } } ``` 3. Save the configuration file and **restart** Claude Desktop to apply the changes. This setup ensures that Claude Desktop can communicate with the Cloudflare Playwright MCP server. Here's an example of a session opening the TODO demo app, adding "buy lemons" and doing a screenshot, taking advantage of playwright-mcp tools and Browser Rendering:  ### Configure in VSCode You can install the Playwright MCP server using the [VS Code CLI](https://code.visualstudio.com/docs/configure/command-line): ```bash # For VS Code code --add-mcp '{"name":"cloudflare-playwright","type":"sse","url":"https://[my-mcp-url].workers.dev/sse"}' ``` ```bash # For VS Code Insiders code-insiders --add-mcp '{"name":"cloudflare-playwright","type":"sse","url":"https://[my-mcp-url].workers.dev/sse"}' ``` After installation, the Playwright MCP server will be available for use with your GitHub Copilot agent in VS Code. </details> ### Tool Modes The tools are available in two modes: 1. **Snapshot Mode** (default): Uses accessibility snapshots for better performance and reliability 2. **Vision Mode**: Uses screenshots for visual-based interactions Vision Mode works best with the computer use models that are able to interact with elements using X Y coordinate space, based on the provided screenshot. <!--- Tools generated by update-readme.js --> <details> <summary><b>Interactions</b></summary> <!-- NOTE: This has been generated via update-readme.js --> - **browser_snapshot** - Title: Page snapshot - Description: Capture accessibility snapshot of the current page, this is better than screenshot - Parameters: None - Read-only: **true** <!-- NOTE: This has been generated via update-readme.js --> - **browser_click** - Title: Click - Description: Perform click on a web page - Parameters: - `element` (string): Human-readable element description used to obtain permission to interact with the element - `ref` (string): Exact target element reference from the page snapshot - `doubleClick` (boolean, optional): Whether to perform a double click instead of a single click - Read-only: **false** <!-- NOTE: This has been generated via update-readme.js --> - **browser_drag** - Title: Drag mouse - Description: Perform drag and drop between two elements - Parameters: - `startElement` (string): Human-readable source element description used to obtain the permission to interact with the element - `startRef` (string): Exact source element reference from the page snapshot - `endElement` (string): Human-readable target element description used to obtain the permission to interact with the element - `endRef` (string): Exact target element reference from the page snapshot - Read-only: **false** <!-- NOTE: This has been generated via update-readme.js --> - **browser_hover** - Title: Hover mouse - Description: Hover over element on page - Parameters: - `element` (string): Human-readable element description used to obtain permission to interact with the element - `ref` (string): Exact target element reference from the page snapshot - Read-only: **true** <!-- NOTE: This has been generated via update-readme.js --> - **browser_type** - Title: Type text - Description: Type text into editable element - Parameters: - `element` (string): Human-readable element description used to obtain permission to interact with the element - `ref` (string): Exact target element reference from the page snapshot - `text` (string): Text to type into the element - `submit` (boolean, optional): Whether to submit entered text (press Enter after) - `slowly` (boolean, optional): Whether to type one character at a time. Useful for triggering key handlers in the page. By default entire text is filled in at once. - Read-only: **false** <!-- NOTE: This has been generated via update-readme.js --> - **browser_select_option** - Title: Select option - Description: Select an option in a dropdown - Parameters: - `element` (string): Human-readable element description used to obtain permission to interact with the element - `ref` (string): Exact target element reference from the page snapshot - `values` (array): Array of values to select in the dropdown. This can be a single value or multiple values. - Read-only: **false** <!-- NOTE: This has been generated via update-readme.js --> - **browser_press_key** - Title: Press a key - Description: Press a key on the keyboard - Parameters: - `key` (string): Name of the key to press or a character to generate, such as `ArrowLeft` or `a` - Read-only: **false** <!-- NOTE: This has been generated via update-readme.js --> - **browser_wait_for** - Title: Wait for - Description: Wait for text to appear or disappear or a specified time to pass - Parameters: - `time` (number, optional): The time to wait in seconds - `text` (string, optional): The text to wait for - `textGone` (string, optional): The text to wait for to disappear - Read-only: **true** <!-- NOTE: This has been generated via update-readme.js --> - **browser_file_upload** - Title: Upload files - Description: Upload one or multiple files - Parameters: - `paths` (array): The absolute paths to the files to upload. Can be a single file or multiple files. - Read-only: **false** <!-- NOTE: This has been generated via update-readme.js --> - **browser_handle_dialog** - Title: Handle a dialog - Description: Handle a dialog - Parameters: - `accept` (boolean): Whether to accept the dialog. - `promptText` (string, optional): The text of the prompt in case of a prompt dialog. - Read-only: **false** </details> <details> <summary><b>Navigation</b></summary> <!-- NOTE: This has been generated via update-readme.js --> - **browser_navigate** - Title: Navigate to a URL - Description: Navigate to a URL - Parameters: - `url` (string): The URL to navigate to - Read-only: **false** <!-- NOTE: This has been generated via update-readme.js --> - **browser_navigate_back** - Title: Go back - Description: Go back to the previous page - Parameters: None - Read-only: **true** <!-- NOTE: This has been generated via update-readme.js --> - **browser_navigate_forward** - Title: Go forward - Description: Go forward to the next page - Parameters: None - Read-only: **true** </details> <details> <summary><b>Resources</b></summary> <!-- NOTE: This has been generated via update-readme.js --> - **browser_take_screenshot** - Title: Take a screenshot - Description: Take a screenshot of the current page. You can't perform actions based on the screenshot, use browser_snapshot for actions. - Parameters: - `raw` (boolean, optional): Whether to return without compression (in PNG format). Default is false, which returns a JPEG image. - `filename` (string, optional): File name to save the screenshot to. Defaults to `page-{timestamp}.{png|jpeg}` if not specified. - `element` (string, optional): Human-readable element description used to obtain permission to screenshot the element. If not provided, the screenshot will be taken of viewport. If element is provided, ref must be provided too. - `ref` (string, optional): Exact target element reference from the page snapshot. If not provided, the screenshot will be taken of viewport. If ref is provided, element must be provided too. - Read-only: **true** <!-- NOTE: This has been generated via update-readme.js --> - **browser_pdf_save** - Title: Save as PDF - Description: Save page as PDF - Parameters: - `filename` (string, optional): File name to save the pdf to. Defaults to `page-{timestamp}.pdf` if not specified. - Read-only: **true** <!-- NOTE: This has been generated via update-readme.js --> - **browser_network_requests** - Title: List network requests - Description: Returns all network requests since loading the page - Parameters: None - Read-only: **true** <!-- NOTE: This has been generated via update-readme.js --> - **browser_console_messages** - Title: Get console messages - Description: Returns all console messages - Parameters: None - Read-only: **true** </details> <details> <summary><b>Utilities</b></summary> <!-- NOTE: This has been generated via update-readme.js --> - **browser_install** - Title: Install the browser specified in the config - Description: Install the browser specified in the config. Call this if you get an error about the browser not being installed. - Parameters: None - Read-only: **false** <!-- NOTE: This has been generated via update-readme.js --> - **browser_close** - Title: Close browser - Description: Close the page - Parameters: None - Read-only: **true** <!-- NOTE: This has been generated via update-readme.js --> - **browser_resize** - Title: Resize browser window - Description: Resize the browser window - Parameters: - `width` (number): Width of the browser window - `height` (number): Height of the browser window - Read-only: **true** </details> <details> <summary><b>Tabs</b></summary> <!-- NOTE: This has been generated via update-readme.js --> - **browser_tab_list** - Title: List tabs - Description: List browser tabs - Parameters: None - Read-only: **true** <!-- NOTE: This has been generated via update-readme.js --> - **browser_tab_new** - Title: Open a new tab - Description: Open a new tab - Parameters: - `url` (string, optional): The URL to navigate to in the new tab. If not provided, the new tab will be blank. - Read-only: **true** <!-- NOTE: This has been generated via update-readme.js --> - **browser_tab_select** - Title: Select a tab - Description: Select a tab by index - Parameters: - `index` (number): The index of the tab to select - Read-only: **true** <!-- NOTE: This has been generated via update-readme.js --> - **browser_tab_close** - Title: Close a tab - Description: Close a tab - Parameters: - `index` (number, optional): The index of the tab to close. Closes current tab if not provided. - Read-only: **false** </details> <details> <summary><b>Testing</b></summary> <!-- NOTE: This has been generated via update-readme.js --> - **browser_generate_playwright_test** - Title: Generate a Playwright test - Description: Generate a Playwright test for given scenario - Parameters: - `name` (string): The name of the test - `description` (string): The description of the test - `steps` (array): The steps of the test - Read-only: **true** </details> <details> <summary><b>Vision mode</b></summary> <!-- NOTE: This has been generated via update-readme.js --> - **browser_screen_capture** - Title: Take a screenshot - Description: Take a screenshot of the current page - Parameters: None - Read-only: **true** <!-- NOTE: This has been generated via update-readme.js --> - **browser_screen_move_mouse** - Title: Move mouse - Description: Move mouse to a given position - Parameters: - `element` (string): Human-readable element description used to obtain permission to interact with the element - `x` (number): X coordinate - `y` (number): Y coordinate - Read-only: **true** <!-- NOTE: This has been generated via update-readme.js --> - **browser_screen_click** - Title: Click - Description: Click left mouse button - Parameters: - `element` (string): Human-readable element description used to obtain permission to interact with the element - `x` (number): X coordinate - `y` (number): Y coordinate - Read-only: **false** <!-- NOTE: This has been generated via update-readme.js --> - **browser_screen_drag** - Title: Drag mouse - Description: Drag left mouse button - Parameters: - `element` (string): Human-readable element description used to obtain permission to interact with the element - `startX` (number): Start X coordinate - `startY` (number): Start Y coordinate - `endX` (number): End X coordinate - `endY` (number): End Y coordinate - Read-only: **false** <!-- NOTE: This has been generated via update-readme.js --> - **browser_screen_type** - Title: Type text - Description: Type text - Parameters: - `text` (string): Text to type into the element - `submit` (boolean, optional): Whether to submit entered text (press Enter after) - Read-only: **false** <!-- NOTE: This has been generated via update-readme.js --> - **browser_press_key** - Title: Press a key - Description: Press a key on the keyboard - Parameters: - `key` (string): Name of the key to press or a character to generate, such as `ArrowLeft` or `a` - Read-only: **false** <!-- NOTE: This has been generated via update-readme.js --> - **browser_wait_for** - Title: Wait for - Description: Wait for text to appear or disappear or a specified time to pass - Parameters: - `time` (number, optional): The time to wait in seconds - `text` (string, optional): The text to wait for - `textGone` (string, optional): The text to wait for to disappear - Read-only: **true** <!-- NOTE: This has been generated via update-readme.js --> - **browser_file_upload** - Title: Upload files - Description: Upload one or multiple files - Parameters: - `paths` (array): The absolute paths to the files to upload. Can be a single file or multiple files. - Read-only: **false** <!-- NOTE: This has been generated via update-readme.js --> - **browser_handle_dialog** - Title: Handle a dialog - Description: Handle a dialog - Parameters: - `accept` (boolean): Whether to accept the dialog. - `promptText` (string, optional): The text of the prompt in case of a prompt dialog. - Read-only: **false** </details> <!--- End of tools generated section --> ``` -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- ```markdown <!-- BEGIN MICROSOFT SECURITY.MD V0.0.9 BLOCK --> ## Security Microsoft takes the security of our software products and services seriously, which includes all source code repositories managed through our GitHub organizations, which include [Microsoft](https://github.com/Microsoft), [Azure](https://github.com/Azure), [DotNet](https://github.com/dotnet), [AspNet](https://github.com/aspnet) and [Xamarin](https://github.com/xamarin). If you believe you have found a security vulnerability in any Microsoft-owned repository that meets [Microsoft's definition of a security vulnerability](https://aka.ms/security.md/definition), please report it to us as described below. ## Reporting Security Issues **Please do not report security vulnerabilities through public GitHub issues.** Instead, please report them to the Microsoft Security Response Center (MSRC) at [https://msrc.microsoft.com/create-report](https://aka.ms/security.md/msrc/create-report). If you prefer to submit without logging in, send email to [[email protected]](mailto:[email protected]). If possible, encrypt your message with our PGP key; please download it from the [Microsoft Security Response Center PGP Key page](https://aka.ms/security.md/msrc/pgp). You should receive a response within 24 hours. If for some reason you do not, please follow up via email to ensure we received your original message. Additional information can be found at [microsoft.com/msrc](https://www.microsoft.com/msrc). Please include the requested information listed below (as much as you can provide) to help us better understand the nature and scope of the possible issue: * Type of issue (e.g. buffer overflow, SQL injection, cross-site scripting, etc.) * Full paths of source file(s) related to the manifestation of the issue * The location of the affected source code (tag/branch/commit or direct URL) * Any special configuration required to reproduce the issue * Step-by-step instructions to reproduce the issue * Proof-of-concept or exploit code (if possible) * Impact of the issue, including how an attacker might exploit the issue This information will help us triage your report more quickly. If you are reporting for a bug bounty, more complete reports can contribute to a higher bounty award. Please visit our [Microsoft Bug Bounty Program](https://aka.ms/security.md/msrc/bounty) page for more details about our active programs. ## Preferred Languages We prefer all communications to be in English. ## Policy Microsoft follows the principle of [Coordinated Vulnerability Disclosure](https://aka.ms/security.md/cvd). <!-- END MICROSOFT SECURITY.MD BLOCK --> ``` -------------------------------------------------------------------------------- /tsconfig.all.json: -------------------------------------------------------------------------------- ```json { "extends": "./tsconfig.json", "include": ["**/*.ts", "**/*.js"], } ``` -------------------------------------------------------------------------------- /cloudflare/tsconfig.json: -------------------------------------------------------------------------------- ```json { "compilerOptions": { "module": "ESNext", "moduleResolution": "Bundler", "noEmit": true, "types": [ "@cloudflare/workers-types", ] } } ``` -------------------------------------------------------------------------------- /cloudflare/example/tsconfig.json: -------------------------------------------------------------------------------- ```json { "compilerOptions": { "module": "ESNext", "moduleResolution": "Bundler", "noEmit": true, "skipLibCheck": true }, "include": [ "worker-configuration.d.ts", ] } ``` -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- ```json { "compilerOptions": { "target": "ESNext", "esModuleInterop": true, "moduleResolution": "nodenext", "strict": true, "module": "NodeNext", "rootDir": "src", "outDir": "./lib", "resolveJsonModule": true }, "include": [ "src", ], } ``` -------------------------------------------------------------------------------- /cloudflare/example/wrangler.toml: -------------------------------------------------------------------------------- ```toml name = "playwright-mcp-example" main = "src/index.ts" compatibility_date = "2025-03-10" compatibility_flags = ["nodejs_compat"] [browser] binding = "BROWSER" [[migrations]] tag = "v1" new_sqlite_classes = ["PlaywrightMCP"] [[durable_objects.bindings]] name = "MCP_OBJECT" class_name = "PlaywrightMCP" ``` -------------------------------------------------------------------------------- /utils/generate-links.js: -------------------------------------------------------------------------------- ```javascript 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); ``` -------------------------------------------------------------------------------- /.github/workflows/cf_ci.yml: -------------------------------------------------------------------------------- ```yaml name: "Playwright MCP for Cloudflare - CI" on: push: branches: [ main ] pull_request: branches: [ main ] jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: '20.x' registry-url: 'https://registry.npmjs.org' - run: | npm ci cd cloudflare npm run build ``` -------------------------------------------------------------------------------- /examples/generate-test.md: -------------------------------------------------------------------------------- ```markdown Use Playwright tools to generate test for scenario: ## GitHub PR Checks Navigation Checklist 1. Open the [Microsoft Playwright GitHub repository](https://github.com/microsoft/playwright). 2. Click on the **Pull requests** tab. 3. Find and open the pull request titled **"chore: make noWaitAfter a default"**. 4. Switch to the **Checks** tab for that pull request. 5. Expand the **infra** check suite to view its jobs. 6. Click on the **docs & lint** job to view its details. ``` -------------------------------------------------------------------------------- /utils/copyright.js: -------------------------------------------------------------------------------- ```javascript /** * 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. */ ``` -------------------------------------------------------------------------------- /cloudflare/example/src/index.ts: -------------------------------------------------------------------------------- ```typescript import { env } from 'cloudflare:workers'; import { createMcpAgent } from '@cloudflare/playwright-mcp'; export const PlaywrightMCP = createMcpAgent(env.BROWSER); export default { fetch(request: Request, env: Env, ctx: ExecutionContext) { const { pathname } = new URL(request.url); switch (pathname) { case '/sse': case '/sse/message': return PlaywrightMCP.serveSSE('/sse').fetch(request, env, ctx); case '/mcp': return PlaywrightMCP.serve('/mcp').fetch(request, env, ctx); default: return new Response('Not Found', { status: 404 }); } }, }; ``` -------------------------------------------------------------------------------- /cloudflare/example/package.json: -------------------------------------------------------------------------------- ```json { "name": "@cloudflare/playwright-mcp-template", "version": "0.0.1-next", "description": "Cloudflare Playwright Tools for MCP - Template", "repository": { "type": "git", "url": "git+https://github.com/cloudflare/playwright-mcp.git" }, "homepage": "https://github.com/cloudflare/playwright-mcp", "engines": { "node": ">=18" }, "license": "Apache-2.0", "scripts": { "build": "tsc", "deploy": "wrangler deploy" }, "dependencies": { "@cloudflare/playwright-mcp": "^0.0.5" }, "devDependencies": { "@types/node": "^22.14.1", "typescript": "^5.8.2", "wrangler": "^4.26.0" } } ``` -------------------------------------------------------------------------------- /cli.js: -------------------------------------------------------------------------------- ```javascript #!/usr/bin/env node /** * Copyright (c) Microsoft Corporation. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import './lib/program.js'; ``` -------------------------------------------------------------------------------- /cloudflare/src/package.ts: -------------------------------------------------------------------------------- ```typescript /** * 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 json from '../package.json'; export const packageJSON = json; ``` -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- ```javascript #!/usr/bin/env node /** * Copyright (c) Microsoft Corporation. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { createConnection } from './lib/index.js'; export { createConnection }; ``` -------------------------------------------------------------------------------- /src/package.ts: -------------------------------------------------------------------------------- ```typescript /** * Copyright (c) Microsoft Corporation. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import fs from 'node:fs'; import url from 'node:url'; import path from 'node:path'; const __filename = url.fileURLToPath(import.meta.url); export const packageJSON = JSON.parse(fs.readFileSync(path.join(path.dirname(__filename), '..', 'package.json'), 'utf8')); ``` -------------------------------------------------------------------------------- /tests/install.spec.ts: -------------------------------------------------------------------------------- ```typescript /** * Copyright (c) Microsoft Corporation. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { test, expect } from './fixtures.js'; test('browser_install', async ({ client, mcpBrowser }) => { test.skip(mcpBrowser !== 'chromium', 'Test only chromium'); expect(await client.callTool({ name: 'browser_install', })).toContainTextContent(`No open pages available.`); }); ``` -------------------------------------------------------------------------------- /cloudflare/package.json: -------------------------------------------------------------------------------- ```json { "name": "@cloudflare/playwright-mcp", "version": "0.0.1-next", "description": "Cloudflare Playwright Tools for MCP", "type": "module", "repository": { "type": "git", "url": "git+https://github.com/cloudflare/playwright-mcp.git" }, "homepage": "https://playwright.dev", "engines": { "node": ">=18" }, "license": "Apache-2.0", "scripts": { "build": "npm ci && npx vite build" }, "exports": { "./package.json": "./package.json", ".": { "types": "./index.d.ts", "import": "./lib/esm/index.js", "require": "./lib/cjs/index.js", "default": "./lib/esm/index.js" } }, "dependencies": { "@cloudflare/playwright": "^0.0.11", "@modelcontextprotocol/sdk": "^1.17.0", "agents": "^0.0.109", "yaml": "^2.8.0", "zod-to-json-schema": "^3.24.6" }, "devDependencies": { "@cloudflare/workers-types": "^4.20250725.0", "vite": "^7.0.6" } } ``` -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- ```typescript #!/usr/bin/env node /** * Copyright (c) Microsoft Corporation. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import type { Server } from '@modelcontextprotocol/sdk/server/index.js'; import type { Config } from './config.js'; import type { BrowserContext } from 'playwright'; export type Connection = { server: Server; close(): Promise<void>; }; export declare function createConnection(config?: Config, contextGetter?: () => Promise<BrowserContext>): Promise<Connection>; export {}; ``` -------------------------------------------------------------------------------- /cloudflare/src/index.ts: -------------------------------------------------------------------------------- ```typescript import { McpAgent } from 'agents/mcp'; import { env } from 'cloudflare:workers'; import type { BrowserEndpoint } from '@cloudflare/playwright'; import { endpointURLString } from '@cloudflare/playwright'; import { createConnection } from '../../src/index.js'; import { ToolCapability } from '../../config.js'; import type { Server } from '@modelcontextprotocol/sdk/server/index.js'; type Options = { vision?: boolean; capabilities?: ToolCapability[]; }; export function createMcpAgent(endpoint: BrowserEndpoint, options?: Options): typeof McpAgent<typeof env, {}, {}> { const cdpEndpoint = typeof endpoint === 'string' ? endpoint : endpoint instanceof URL ? endpoint.toString() : endpointURLString(endpoint); const connection = createConnection({ capabilities: ['core', 'tabs', 'pdf', 'history', 'wait', 'files', 'testing'], browser: { cdpEndpoint, }, ...options, }); return class PlaywrightMcpAgent extends McpAgent<typeof env, {}, {}> { server = connection.then(server => server.server as unknown as Server); async init() { // do nothing } }; } ``` -------------------------------------------------------------------------------- /tests/trace.spec.ts: -------------------------------------------------------------------------------- ```typescript /** * Copyright (c) Microsoft Corporation. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import fs from 'fs'; import path from 'path'; import { test, expect } from './fixtures.js'; test('check that trace is saved', async ({ startClient, server, mcpMode }, testInfo) => { const outputDir = testInfo.outputPath('output'); const { client } = await startClient({ args: ['--save-trace', `--output-dir=${outputDir}`], }); expect(await client.callTool({ name: 'browser_navigate', arguments: { url: server.HELLO_WORLD }, })).toContainTextContent(`Navigate to http://localhost`); expect(fs.existsSync(path.join(outputDir, 'traces', 'trace.trace'))).toBeTruthy(); }); ``` -------------------------------------------------------------------------------- /tests/library.spec.ts: -------------------------------------------------------------------------------- ```typescript /** * Copyright (c) Microsoft Corporation. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { test, expect } from './fixtures.js'; import fs from 'node:fs/promises'; import child_process from 'node:child_process'; test('library can be used from CommonJS', { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright-mcp/issues/456' } }, async ({}, testInfo) => { const file = testInfo.outputPath('main.cjs'); await fs.writeFile(file, ` import('@playwright/mcp') .then(playwrightMCP => playwrightMCP.createConnection()) .then(() => console.log('OK')); `); expect(child_process.execSync(`node ${file}`, { encoding: 'utf-8' })).toContain('OK'); }); ``` -------------------------------------------------------------------------------- /tests/console.spec.ts: -------------------------------------------------------------------------------- ```typescript /** * Copyright (c) Microsoft Corporation. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { test, expect } from './fixtures.js'; test('browser_console_messages', async ({ client, server }) => { server.setContent('/', ` <!DOCTYPE html> <html> <script> console.log("Hello, world!"); console.error("Error"); </script> </html> `, 'text/html'); await client.callTool({ name: 'browser_navigate', arguments: { url: server.PREFIX, }, }); const resource = await client.callTool({ name: 'browser_console_messages', }); expect(resource).toHaveTextContent([ '[LOG] Hello, world!', '[ERROR] Error', ].join('\n')); }); ``` -------------------------------------------------------------------------------- /tests/webdriver.spec.ts: -------------------------------------------------------------------------------- ```typescript /** * 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'); }); ``` -------------------------------------------------------------------------------- /.github/workflows/cf_publish_release_npm.yml: -------------------------------------------------------------------------------- ```yaml name: "Playwright MCP for Cloudflare - Publish NPM" on: release: types: [published] jobs: cf-release: name: "Publish Playwright MCP for Cloudflare to NPM" runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: "20.x" registry-url: "https://registry.npmjs.org" - name: Determine version id: version run: | if [[ "${GITHUB_REF}" == refs/tags/* ]]; then echo "Triggered by tag: ${GITHUB_REF#refs/tags/}" VERSION="${GITHUB_REF#refs/tags/}" else echo "❌ Unexpected trigger: ${GITHUB_REF}" exit 1 fi # Set the output for later steps echo "VERSION=$VERSION" >> "$GITHUB_OUTPUT" - name: Run npm ci run: npm ci - name: Run playwright-mcp-cloudflare build run: | cd cloudflare npm version ${{ steps.version.outputs.VERSION }} --no-git-tag-version npm run build - name: Publish to npm run: | cd cloudflare cp ../README.md . npm publish env: NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }} ``` -------------------------------------------------------------------------------- /tests/device.spec.ts: -------------------------------------------------------------------------------- ```typescript /** * 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, mcpMode }) => { 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`); }); ``` -------------------------------------------------------------------------------- /tests/network.spec.ts: -------------------------------------------------------------------------------- ```typescript /** * Copyright (c) Microsoft Corporation. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { test, expect } from './fixtures.js'; test('browser_network_requests', async ({ client, server }) => { server.setContent('/', ` <button onclick="fetch('/json')">Click me</button> `, 'text/html'); server.setContent('/json', JSON.stringify({ name: 'John Doe' }), 'application/json'); await client.callTool({ name: 'browser_navigate', arguments: { url: server.PREFIX, }, }); await client.callTool({ name: 'browser_click', arguments: { element: 'Click me button', ref: 'e2', }, }); await expect.poll(() => client.callTool({ name: 'browser_network_requests', })).toHaveTextContent(`[GET] ${`${server.PREFIX}`} => [200] OK [GET] ${`${server.PREFIX}json`} => [200] OK`); }); ``` -------------------------------------------------------------------------------- /src/tools/console.ts: -------------------------------------------------------------------------------- ```typescript /** * Copyright (c) Microsoft Corporation. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { z } from 'zod'; import { defineTool } from './tool.js'; const console = defineTool({ capability: 'core', schema: { name: 'browser_console_messages', title: 'Get console messages', description: 'Returns all console messages', inputSchema: z.object({}), type: 'readOnly', }, handle: async context => { const messages = context.currentTabOrDie().consoleMessages(); const log = messages.map(message => `[${message.type().toUpperCase()}] ${message.text()}`).join('\n'); return { code: [`// <internal code to get console messages>`], action: async () => { return { content: [{ type: 'text', text: log }] }; }, captureSnapshot: false, waitForNetwork: false, }; }, }); export default [ console, ]; ``` -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- ```typescript /** * 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 { defineConfig } from '@playwright/test'; import type { TestOptions } from './tests/fixtures.js'; export default defineConfig<TestOptions>({ testDir: './tests', fullyParallel: true, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, workers: process.env.CI ? 1 : undefined, reporter: 'list', projects: [ { name: 'chrome' }, { name: 'msedge', use: { mcpBrowser: 'msedge' } }, { name: 'chromium', use: { mcpBrowser: 'chromium' } }, ...process.env.MCP_IN_DOCKER ? [{ name: 'chromium-docker', grep: /browser_navigate|browser_click/, use: { mcpBrowser: 'chromium', mcpMode: 'docker' as const } }] : [], { name: 'firefox', use: { mcpBrowser: 'firefox' } }, { name: 'webkit', use: { mcpBrowser: 'webkit' } }, ], }); ``` -------------------------------------------------------------------------------- /src/fileUtils.ts: -------------------------------------------------------------------------------- ```typescript /** * Copyright (c) Microsoft Corporation. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import os from 'node:os'; import path from 'node:path'; import type { FullConfig } from './config.js'; export function cacheDir() { let cacheDirectory: string; if (process.platform === 'linux') cacheDirectory = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache'); else if (process.platform === 'darwin') cacheDirectory = path.join(os.homedir(), 'Library', 'Caches'); else if (process.platform === 'win32') cacheDirectory = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'); else throw new Error('Unsupported platform: ' + process.platform); return path.join(cacheDirectory, 'ms-playwright'); } export async function userDataDir(browserConfig: FullConfig['browser']) { return path.join(cacheDir(), 'ms-playwright', `mcp-${browserConfig.launchOptions?.channel ?? browserConfig?.browserName}-profile`); } ``` -------------------------------------------------------------------------------- /cloudflare/index.d.ts: -------------------------------------------------------------------------------- ```typescript #!/usr/bin/env node /** * Copyright (c) Microsoft Corporation. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { env } from 'cloudflare:workers'; import { McpAgent } from 'agents/mcp'; import { BrowserEndpoint } from '@cloudflare/playwright'; type ToolCapability = 'core' | 'tabs' | 'pdf' | 'history' | 'wait' | 'files'; type Options = { /** * 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. */ capabilities?: ToolCapability[]; }; export declare function createMcpAgent(endpoint: BrowserEndpoint, options?: Options): typeof McpAgent<typeof env, {}, {}>; export {}; ``` -------------------------------------------------------------------------------- /src/tools/keyboard.ts: -------------------------------------------------------------------------------- ```typescript /** * Copyright (c) Microsoft Corporation. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { z } from 'zod'; import { defineTool, type ToolFactory } from './tool.js'; const pressKey: ToolFactory = captureSnapshot => defineTool({ capability: 'core', schema: { name: 'browser_press_key', title: 'Press a key', description: 'Press a key on the keyboard', inputSchema: z.object({ key: z.string().describe('Name of the key to press or a character to generate, such as `ArrowLeft` or `a`'), }), type: 'destructive', }, handle: async (context, params) => { const tab = context.currentTabOrDie(); const code = [ `// Press ${params.key}`, `await page.keyboard.press('${params.key}');`, ]; const action = () => tab.page.keyboard.press(params.key); return { code, action, captureSnapshot, waitForNetwork: true }; }, }); export default (captureSnapshot: boolean) => [ pressKey(captureSnapshot), ]; ``` -------------------------------------------------------------------------------- /tests/iframes.spec.ts: -------------------------------------------------------------------------------- ```typescript /** * Copyright (c) Microsoft Corporation. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { test, expect } from './fixtures.js'; test('stitched aria frames', async ({ client }) => { expect(await client.callTool({ name: 'browser_navigate', arguments: { url: `data:text/html,<h1>Hello</h1><iframe src="data:text/html,<button>World</button><main><iframe src='data:text/html,<p>Nested</p>'></iframe></main>"></iframe><iframe src="data:text/html,<h1>Should be invisible</h1>" style="display: none;"></iframe>`, }, })).toContainTextContent(` \`\`\`yaml - generic [active] [ref=e1]: - heading "Hello" [level=1] [ref=e2] - iframe [ref=e3]: - generic [active] [ref=f1e1]: - button "World" [ref=f1e2] - main [ref=f1e3]: - iframe [ref=f1e4]: - paragraph [ref=f2e2]: Nested \`\`\``); expect(await client.callTool({ name: 'browser_click', arguments: { element: 'World', ref: 'f1e2', }, })).toContainTextContent(`// Click World`); }); ``` -------------------------------------------------------------------------------- /src/pageSnapshot.ts: -------------------------------------------------------------------------------- ```typescript /** * Copyright (c) Microsoft Corporation. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import * as playwright from 'playwright'; import { callOnPageNoTrace } from './tools/utils.js'; type PageEx = playwright.Page & { _snapshotForAI: () => Promise<string>; }; export class PageSnapshot { private _page: playwright.Page; private _text!: string; constructor(page: playwright.Page) { this._page = page; } static async create(page: playwright.Page): Promise<PageSnapshot> { const snapshot = new PageSnapshot(page); await snapshot._build(); return snapshot; } text(): string { return this._text; } private async _build() { const snapshot = await callOnPageNoTrace(this._page, page => (page as PageEx)._snapshotForAI()); this._text = [ `- Page Snapshot`, '```yaml', snapshot, '```', ].join('\n'); } refLocator(params: { element: string, ref: string }): playwright.Locator { return this._page.locator(`aria-ref=${params.ref}`).describe(params.element); } } ``` -------------------------------------------------------------------------------- /src/tools/pdf.ts: -------------------------------------------------------------------------------- ```typescript /** * Copyright (c) Microsoft Corporation. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { z } from 'zod'; import { defineTool } from './tool.js'; import * as javascript from '../javascript.js'; import { outputFile } from '../config.js'; const pdfSchema = z.object({ filename: z.string().optional().describe('File name to save the pdf to. Defaults to `page-{timestamp}.pdf` if not specified.'), }); const pdf = defineTool({ capability: 'pdf', schema: { name: 'browser_pdf_save', title: 'Save as PDF', description: 'Save page as PDF', inputSchema: pdfSchema, type: 'readOnly', }, handle: async (context, params) => { const tab = context.currentTabOrDie(); const fileName = await outputFile(context.config, params.filename ?? `page-${new Date().toISOString()}.pdf`); const code = [ `// Save page as ${fileName}`, `await page.pdf(${javascript.formatObject({ path: fileName })});`, ]; return { code, action: async () => tab.page.pdf({ path: fileName }).then(() => {}), captureSnapshot: false, waitForNetwork: false, }; }, }); export default [ pdf, ]; ``` -------------------------------------------------------------------------------- /src/tools/files.ts: -------------------------------------------------------------------------------- ```typescript /** * Copyright (c) Microsoft Corporation. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { z } from 'zod'; import { defineTool, type ToolFactory } from './tool.js'; const uploadFile: ToolFactory = captureSnapshot => defineTool({ capability: 'files', schema: { name: 'browser_file_upload', title: 'Upload files', description: 'Upload one or multiple files', inputSchema: z.object({ paths: z.array(z.string()).describe('The absolute paths to the files to upload. Can be a single file or multiple files.'), }), type: 'destructive', }, handle: async (context, params) => { const modalState = context.modalStates().find(state => state.type === 'fileChooser'); if (!modalState) throw new Error('No file chooser visible'); const code = [ `// <internal code to chose files ${params.paths.join(', ')}`, ]; const action = async () => { await modalState.fileChooser.setFiles(params.paths); context.clearModalState(modalState); }; return { code, action, captureSnapshot, waitForNetwork: true, }; }, clearsModalState: 'fileChooser', }); export default (captureSnapshot: boolean) => [ uploadFile(captureSnapshot), ]; ``` -------------------------------------------------------------------------------- /src/tools/network.ts: -------------------------------------------------------------------------------- ```typescript /** * Copyright (c) Microsoft Corporation. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { z } from 'zod'; import { defineTool } from './tool.js'; import type * as playwright from 'playwright'; const requests = defineTool({ capability: 'core', schema: { name: 'browser_network_requests', title: 'List network requests', description: 'Returns all network requests since loading the page', inputSchema: z.object({}), type: 'readOnly', }, handle: async context => { const requests = context.currentTabOrDie().requests(); const log = [...requests.entries()].map(([request, response]) => renderRequest(request, response)).join('\n'); return { code: [`// <internal code to list network requests>`], action: async () => { return { content: [{ type: 'text', text: log }] }; }, captureSnapshot: false, waitForNetwork: false, }; }, }); function renderRequest(request: playwright.Request, response: playwright.Response | null) { const result: string[] = []; result.push(`[${request.method().toUpperCase()}] ${request.url()}`); if (response) result.push(`=> [${response.status()}] ${response.statusText()}`); return result.join(' '); } export default [ requests, ]; ``` -------------------------------------------------------------------------------- /src/tools.ts: -------------------------------------------------------------------------------- ```typescript /** * Copyright (c) Microsoft Corporation. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import common from './tools/common.js'; import console from './tools/console.js'; import dialogs from './tools/dialogs.js'; import files from './tools/files.js'; import install from './tools/install.js'; import keyboard from './tools/keyboard.js'; import navigate from './tools/navigate.js'; import network from './tools/network.js'; import pdf from './tools/pdf.js'; import snapshot from './tools/snapshot.js'; import tabs from './tools/tabs.js'; import screenshot from './tools/screenshot.js'; import testing from './tools/testing.js'; import vision from './tools/vision.js'; import wait from './tools/wait.js'; import type { Tool } from './tools/tool.js'; export const snapshotTools: Tool<any>[] = [ ...common(true), ...console, ...dialogs(true), ...files(true), ...install, ...keyboard(true), ...navigate(true), ...network, ...pdf, ...screenshot, ...snapshot, ...tabs(true), ...testing, ...wait(true), ]; export const visionTools: Tool<any>[] = [ ...common(false), ...console, ...dialogs(false), ...files(false), ...install, ...keyboard(false), ...navigate(false), ...network, ...pdf, ...tabs(false), ...testing, ...vision, ...wait(false), ]; ``` -------------------------------------------------------------------------------- /src/tools/dialogs.ts: -------------------------------------------------------------------------------- ```typescript /** * Copyright (c) Microsoft Corporation. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { z } from 'zod'; import { defineTool, type ToolFactory } from './tool.js'; const handleDialog: ToolFactory = captureSnapshot => defineTool({ capability: 'core', schema: { name: 'browser_handle_dialog', title: 'Handle a dialog', description: 'Handle a dialog', inputSchema: z.object({ accept: z.coerce.boolean().describe('Whether to accept the dialog.'), promptText: z.string().optional().describe('The text of the prompt in case of a prompt dialog.'), }), type: 'destructive', }, handle: async (context, params) => { const dialogState = context.modalStates().find(state => state.type === 'dialog'); if (!dialogState) throw new Error('No dialog visible'); if (params.accept) await dialogState.dialog.accept(params.promptText); else await dialogState.dialog.dismiss(); context.clearModalState(dialogState); const code = [ `// <internal code to handle "${dialogState.dialog.type()}" dialog>`, ]; return { code, captureSnapshot, waitForNetwork: false, }; }, clearsModalState: 'dialog', }); export default (captureSnapshot: boolean) => [ handleDialog(captureSnapshot), ]; ``` -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- ```typescript /** * 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 { createConnection as createConnectionImpl } from './connection.js'; import type { Connection } from '../index.js'; import { resolveConfig } from './config.js'; import { contextFactory } from './browserContextFactory.js'; import type { Config } from '../config.js'; import type { BrowserContext } from 'playwright'; import type { BrowserContextFactory } from './browserContextFactory.js'; export async function createConnection(userConfig: Config = {}, contextGetter?: () => Promise<BrowserContext>): Promise<Connection> { const config = await resolveConfig(userConfig); const factory = contextGetter ? new SimpleBrowserContextFactory(contextGetter) : contextFactory(config.browser); return createConnectionImpl(config, factory); } class SimpleBrowserContextFactory implements BrowserContextFactory { private readonly _contextGetter: () => Promise<BrowserContext>; constructor(contextGetter: () => Promise<BrowserContext>) { this._contextGetter = contextGetter; } async createContext(): Promise<{ browserContext: BrowserContext, close: () => Promise<void> }> { const browserContext = await this._contextGetter(); return { browserContext, close: () => browserContext.close() }; } } ``` -------------------------------------------------------------------------------- /tests/headed.spec.ts: -------------------------------------------------------------------------------- ```typescript /** * Copyright (c) Microsoft Corporation. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { test, expect } from './fixtures.js'; for (const mcpHeadless of [false, true]) { test.describe(`mcpHeadless: ${mcpHeadless}`, () => { test.use({ mcpHeadless }); test.skip(process.platform === 'linux', 'Auto-detection wont let this test run on linux'); test.skip(({ mcpMode, mcpHeadless }) => mcpMode === 'docker' && !mcpHeadless, 'Headed mode is not supported in docker'); test('browser', async ({ client, server, mcpBrowser }) => { test.skip(!['chrome', 'msedge', 'chromium'].includes(mcpBrowser ?? ''), 'Only chrome is supported for this test'); server.route('/', (req, res) => { res.writeHead(200, { 'Content-Type': 'text/html' }); res.end(` <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`); }); }); } ``` -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- ```json { "name": "@playwright/mcp", "version": "0.0.30", "description": "Playwright Tools for MCP", "type": "module", "repository": { "type": "git", "url": "git+https://github.com/microsoft/playwright-mcp.git" }, "homepage": "https://playwright.dev", "engines": { "node": ">=18" }, "author": { "name": "Microsoft Corporation" }, "license": "Apache-2.0", "scripts": { "build": "tsc", "lint": "npm run update-readme && eslint . && tsc --noEmit", "update-readme": "node utils/update-readme.js", "watch": "tsc --watch", "test": "playwright test", "ctest": "playwright test --project=chrome", "ftest": "playwright test --project=firefox", "wtest": "playwright test --project=webkit", "run-server": "node lib/browserServer.js", "clean": "rm -rf lib", "npm-publish": "npm run clean && npm run build && npm run test && npm publish" }, "exports": { "./package.json": "./package.json", ".": { "types": "./index.d.ts", "default": "./index.js" } }, "dependencies": { "@modelcontextprotocol/sdk": "^1.11.0", "commander": "^13.1.0", "debug": "^4.4.1", "mime": "^4.0.7", "playwright": "1.54.1", "ws": "^8.18.1", "zod-to-json-schema": "^3.24.4" }, "devDependencies": { "@eslint/eslintrc": "^3.2.0", "@eslint/js": "^9.19.0", "@playwright/test": "1.54.1", "@stylistic/eslint-plugin": "^3.0.1", "@types/chrome": "^0.0.315", "@types/debug": "^4.1.12", "@types/node": "^22.13.10", "@types/ws": "^8.18.1", "@typescript-eslint/eslint-plugin": "^8.26.1", "@typescript-eslint/parser": "^8.26.1", "@typescript-eslint/utils": "^8.26.1", "eslint": "^9.19.0", "eslint-plugin-import": "^2.31.0", "eslint-plugin-notice": "^1.0.0", "typescript": "^5.8.2" }, "bin": { "mcp-server-playwright": "cli.js" } } ``` -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- ```typescript /** * 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 { createConnection } from './connection.js'; import { contextFactory } from './browserContextFactory.js'; import type { FullConfig } from './config.js'; import type { Connection } from './connection.js'; import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; import type { BrowserContextFactory } from './browserContextFactory.js'; export class Server { readonly config: FullConfig; private _connectionList: Connection[] = []; private _browserConfig: FullConfig['browser']; private _contextFactory: BrowserContextFactory; constructor(config: FullConfig) { this.config = config; this._browserConfig = config.browser; this._contextFactory = contextFactory(this._browserConfig); } async createConnection(transport: Transport): Promise<Connection> { const connection = createConnection(this.config, this._contextFactory); this._connectionList.push(connection); await connection.server.connect(transport); return connection; } setupExitWatchdog() { let isExiting = false; const handleExit = async () => { if (isExiting) return; isExiting = true; setTimeout(() => process.exit(0), 15000); await Promise.all(this._connectionList.map(connection => connection.close())); process.exit(0); }; process.stdin.on('close', handleExit); process.on('SIGINT', handleExit); process.on('SIGTERM', handleExit); } } ``` -------------------------------------------------------------------------------- /src/tools/tool.ts: -------------------------------------------------------------------------------- ```typescript /** * 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 { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types.js'; import type { z } from 'zod'; import type { Context } from '../context.js'; import type * as playwright from 'playwright'; import type { ToolCapability } from '../../config.js'; export type ToolSchema<Input extends InputType> = { name: string; title: string; description: string; inputSchema: Input; type: 'readOnly' | 'destructive'; }; type InputType = z.Schema; export type FileUploadModalState = { type: 'fileChooser'; description: string; fileChooser: playwright.FileChooser; }; export type DialogModalState = { type: 'dialog'; description: string; dialog: playwright.Dialog; }; export type ModalState = FileUploadModalState | DialogModalState; export type ToolActionResult = { content?: (ImageContent | TextContent)[] } | undefined | void; export type ToolResult = { code: string[]; action?: () => Promise<ToolActionResult>; captureSnapshot: boolean; waitForNetwork: boolean; resultOverride?: ToolActionResult; }; export type Tool<Input extends InputType = InputType> = { capability: ToolCapability; schema: ToolSchema<Input>; clearsModalState?: ModalState['type']; handle: (context: Context, params: z.output<Input>) => Promise<ToolResult>; }; export type ToolFactory = (snapshot: boolean) => Tool<any>; export function defineTool<Input extends InputType>(tool: Tool<Input>): Tool<Input> { return tool; } ``` -------------------------------------------------------------------------------- /src/tools/install.ts: -------------------------------------------------------------------------------- ```typescript /** * Copyright (c) Microsoft Corporation. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { fork } from 'child_process'; import path from 'path'; import { z } from 'zod'; import { defineTool } from './tool.js'; import { fileURLToPath } from 'node:url'; const install = defineTool({ capability: 'install', schema: { name: 'browser_install', title: 'Install the browser specified in the config', description: 'Install the browser specified in the config. Call this if you get an error about the browser not being installed.', inputSchema: z.object({}), type: 'destructive', }, handle: async context => { const channel = context.config.browser?.launchOptions?.channel ?? context.config.browser?.browserName ?? 'chrome'; const cliUrl = import.meta.resolve('playwright/package.json'); const cliPath = path.join(fileURLToPath(cliUrl), '..', 'cli.js'); const child = fork(cliPath, ['install', channel], { stdio: 'pipe', }); const output: string[] = []; child.stdout?.on('data', data => output.push(data.toString())); child.stderr?.on('data', data => output.push(data.toString())); await new Promise<void>((resolve, reject) => { child.on('close', code => { if (code === 0) resolve(); else reject(new Error(`Failed to install browser: ${output.join('')}`)); }); }); return { code: [`// Browser ${channel} installed`], captureSnapshot: false, waitForNetwork: false, }; }, }); export default [ install, ]; ``` -------------------------------------------------------------------------------- /src/tools/common.ts: -------------------------------------------------------------------------------- ```typescript /** * Copyright (c) Microsoft Corporation. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { z } from 'zod'; import { defineTool, type ToolFactory } from './tool.js'; const close = defineTool({ capability: 'core', schema: { name: 'browser_close', title: 'Close browser', description: 'Close the page', inputSchema: z.object({}), type: 'readOnly', }, handle: async context => { await context.close(); return { code: [`await page.close()`], captureSnapshot: false, waitForNetwork: false, }; }, }); const resize: ToolFactory = captureSnapshot => defineTool({ capability: 'core', schema: { name: 'browser_resize', title: 'Resize browser window', description: 'Resize the browser window', inputSchema: z.object({ width: z.coerce.number().describe('Width of the browser window'), height: z.coerce.number().describe('Height of the browser window'), }), type: 'readOnly', }, handle: async (context, params) => { const tab = context.currentTabOrDie(); const code = [ `// Resize browser window to ${params.width}x${params.height}`, `await page.setViewportSize({ width: ${params.width}, height: ${params.height} });` ]; const action = async () => { await tab.page.setViewportSize({ width: params.width, height: params.height }); }; return { code, action, captureSnapshot, waitForNetwork: true }; }, }); export default (captureSnapshot: boolean) => [ close, resize(captureSnapshot) ]; ``` -------------------------------------------------------------------------------- /src/javascript.ts: -------------------------------------------------------------------------------- ```typescript /** * Copyright (c) Microsoft Corporation. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // adapted from: // - https://github.com/microsoft/playwright/blob/76ee48dc9d4034536e3ec5b2c7ce8be3b79418a8/packages/playwright-core/src/utils/isomorphic/stringUtils.ts // - https://github.com/microsoft/playwright/blob/76ee48dc9d4034536e3ec5b2c7ce8be3b79418a8/packages/playwright-core/src/server/codegen/javascript.ts // NOTE: this function should not be used to escape any selectors. export function escapeWithQuotes(text: string, char: string = '\'') { const stringified = JSON.stringify(text); const escapedText = stringified.substring(1, stringified.length - 1).replace(/\\"/g, '"'); if (char === '\'') return char + escapedText.replace(/[']/g, '\\\'') + char; if (char === '"') return char + escapedText.replace(/["]/g, '\\"') + char; if (char === '`') return char + escapedText.replace(/[`]/g, '`') + char; throw new Error('Invalid escape char'); } export function quote(text: string) { return escapeWithQuotes(text, '\''); } export function formatObject(value: any, indent = ' '): string { if (typeof value === 'string') return quote(value); if (Array.isArray(value)) return `[${value.map(o => formatObject(o)).join(', ')}]`; if (typeof value === 'object') { const keys = Object.keys(value).filter(key => value[key] !== undefined).sort(); if (!keys.length) return '{}'; const tokens: string[] = []; for (const key of keys) tokens.push(`${key}: ${formatObject(value[key])}`); return `{\n${indent}${tokens.join(`,\n${indent}`)}\n}`; } return String(value); } ``` -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- ```yaml name: CI on: push: branches: [ main ] pull_request: branches: [ main ] jobs: lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Use Node.js 18 uses: actions/setup-node@v4 with: node-version: '18' cache: 'npm' - name: Install dependencies run: npm ci - run: npm run build - name: Run ESLint run: npm run lint - name: Ensure no changes run: git diff --exit-code test: strategy: fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 - name: Use Node.js 18 uses: actions/setup-node@v4 with: # https://github.com/microsoft/playwright-mcp/issues/344 node-version: '18.19' cache: 'npm' - name: Install dependencies run: npm ci - name: Playwright install run: npx playwright install --with-deps - name: Install MS Edge # MS Edge is not preinstalled on macOS runners. if: ${{ matrix.os == 'macos-latest' }} run: npx playwright install msedge - name: Build run: npm run build - name: Run tests run: npm test test_docker: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Use Node.js 18 uses: actions/setup-node@v4 with: node-version: '18' cache: 'npm' - name: Install dependencies run: npm ci - name: Playwright install run: npx playwright install --with-deps chromium - name: Build run: npm run build - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Build and push uses: docker/build-push-action@v6 with: tags: playwright-mcp-dev:latest cache-from: type=gha cache-to: type=gha,mode=max load: true - name: Run tests shell: bash run: | # Used for the Docker tests to share the test-results folder with the container. umask 0000 npm run test -- --project=chromium-docker env: MCP_IN_DOCKER: 1 ``` -------------------------------------------------------------------------------- /src/tools/testing.ts: -------------------------------------------------------------------------------- ```typescript /** * Copyright (c) Microsoft Corporation. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { z } from 'zod'; import { defineTool } from './tool.js'; const generateTestSchema = z.object({ name: z.string().describe('The name of the test'), description: z.string().describe('The description of the test'), steps: z.array(z.string()).describe('The steps of the test'), }); const generateTest = defineTool({ capability: 'testing', schema: { name: 'browser_generate_playwright_test', title: 'Generate a Playwright test', description: 'Generate a Playwright test for given scenario', inputSchema: generateTestSchema, type: 'readOnly', }, handle: async (context, params) => { return { resultOverride: { content: [{ type: 'text', text: instructions(params), }], }, code: [], captureSnapshot: false, waitForNetwork: false, }; }, }); const instructions = (params: { name: string, description: string, steps: string[] }) => [ `## Instructions`, `- You are a playwright test generator.`, `- You are given a scenario and you need to generate a playwright test for it.`, '- DO NOT generate test code based on the scenario alone. DO run steps one by one using the tools provided instead.', '- Only after all steps are completed, emit a Playwright TypeScript test that uses @playwright/test based on message history', '- Save generated test file in the tests directory', `Test name: ${params.name}`, `Description: ${params.description}`, `Steps:`, ...params.steps.map((step, index) => `- ${index + 1}. ${step}`), ].join('\n'); export default [ generateTest, ]; ``` -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- ```dockerfile ARG PLAYWRIGHT_BROWSERS_PATH=/ms-playwright # ------------------------------ # Base # ------------------------------ # Base stage: Contains only the minimal dependencies required for runtime # (node_modules and Playwright system dependencies) FROM node:22-bookworm-slim AS base ARG PLAYWRIGHT_BROWSERS_PATH ENV PLAYWRIGHT_BROWSERS_PATH=${PLAYWRIGHT_BROWSERS_PATH} # Set the working directory WORKDIR /app RUN --mount=type=cache,target=/root/.npm,sharing=locked,id=npm-cache \ --mount=type=bind,source=package.json,target=package.json \ --mount=type=bind,source=package-lock.json,target=package-lock.json \ npm ci --omit=dev && \ # Install system dependencies for playwright npx -y playwright-core install-deps chromium # ------------------------------ # Builder # ------------------------------ FROM base AS builder RUN --mount=type=cache,target=/root/.npm,sharing=locked,id=npm-cache \ --mount=type=bind,source=package.json,target=package.json \ --mount=type=bind,source=package-lock.json,target=package-lock.json \ npm ci # Copy the rest of the app COPY *.json *.js *.ts . COPY src src/ # Build the app RUN npm run build # ------------------------------ # Browser # ------------------------------ # Cache optimization: # - Browser is downloaded only when node_modules or Playwright system dependencies change # - Cache is reused when only source code changes FROM base AS browser RUN npx -y playwright-core install --no-shell chromium # ------------------------------ # Runtime # ------------------------------ FROM base ARG PLAYWRIGHT_BROWSERS_PATH ARG USERNAME=node ENV NODE_ENV=production # Set the correct ownership for the runtime user on production `node_modules` RUN chown -R ${USERNAME}:${USERNAME} node_modules USER ${USERNAME} COPY --from=browser --chown=${USERNAME}:${USERNAME} ${PLAYWRIGHT_BROWSERS_PATH} ${PLAYWRIGHT_BROWSERS_PATH} COPY --chown=${USERNAME}:${USERNAME} cli.js package.json ./ COPY --from=builder --chown=${USERNAME}:${USERNAME} /app/lib /app/lib # Run in headless and only with chromium (other browsers need more dependencies not included in this image) ENTRYPOINT ["node", "cli.js", "--headless", "--browser", "chromium", "--no-sandbox"] ``` -------------------------------------------------------------------------------- /tests/config.spec.ts: -------------------------------------------------------------------------------- ```typescript /** * Copyright (c) Microsoft Corporation. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import fs from 'node:fs'; import { Config } from '../config.js'; import { test, expect } from './fixtures.js'; test('config user data dir', async ({ startClient, server, mcpMode }, testInfo) => { server.setContent('/', ` <title>Title</title> <body>Hello, world!</body> `, 'text/html'); const config: Config = { browser: { userDataDir: testInfo.outputPath('user-data-dir'), }, }; const configPath = testInfo.outputPath('config.json'); await fs.promises.writeFile(configPath, JSON.stringify(config, null, 2)); const { client } = await startClient({ args: ['--config', configPath] }); expect(await client.callTool({ name: 'browser_navigate', arguments: { url: server.PREFIX }, })).toContainTextContent(`Hello, world!`); const files = await fs.promises.readdir(config.browser!.userDataDir!); expect(files.length).toBeGreaterThan(0); }); test.describe(() => { test.use({ mcpBrowser: '' }); test('browserName', { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright-mcp/issues/458' } }, async ({ startClient, mcpMode }, testInfo) => { const config: Config = { browser: { browserName: 'firefox', }, }; const configPath = testInfo.outputPath('config.json'); await fs.promises.writeFile(configPath, JSON.stringify(config, null, 2)); const { client } = await startClient({ args: ['--config', configPath] }); expect(await client.callTool({ name: 'browser_navigate', arguments: { url: 'data:text/html,<script>document.title = navigator.userAgent</script>' }, })).toContainTextContent(`Firefox`); }); }); ``` -------------------------------------------------------------------------------- /tests/wait.spec.ts: -------------------------------------------------------------------------------- ```typescript /** * Copyright (c) Microsoft Corporation. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { test, expect } from './fixtures.js'; test('browser_wait_for(text)', async ({ client, server }) => { server.setContent('/', ` <script> function update() { setTimeout(() => { document.querySelector('div').textContent = 'Text to appear'; }, 1000); } </script> <body> <button onclick="update()">Click me</button> <div>Text to disappear</div> </body> `, 'text/html'); await client.callTool({ name: 'browser_navigate', arguments: { url: server.PREFIX }, }); await client.callTool({ name: 'browser_click', arguments: { element: 'Click me', ref: 'e2', }, }); expect(await client.callTool({ name: 'browser_wait_for', arguments: { text: 'Text to appear' }, })).toContainTextContent(`- generic [ref=e3]: Text to appear`); }); test('browser_wait_for(textGone)', async ({ client, server }) => { server.setContent('/', ` <script> function update() { setTimeout(() => { document.querySelector('div').textContent = 'Text to appear'; }, 1000); } </script> <body> <button onclick="update()">Click me</button> <div>Text to disappear</div> </body> `, 'text/html'); await client.callTool({ name: 'browser_navigate', arguments: { url: server.PREFIX }, }); await client.callTool({ name: 'browser_click', arguments: { element: 'Click me', ref: 'e2', }, }); expect(await client.callTool({ name: 'browser_wait_for', arguments: { textGone: 'Text to disappear' }, })).toContainTextContent(`- generic [ref=e3]: Text to appear`); }); ``` -------------------------------------------------------------------------------- /src/tools/wait.ts: -------------------------------------------------------------------------------- ```typescript /** * Copyright (c) Microsoft Corporation. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { z } from 'zod'; import { defineTool, type ToolFactory } from './tool.js'; const wait: ToolFactory = captureSnapshot => defineTool({ capability: 'wait', schema: { name: 'browser_wait_for', title: 'Wait for', description: 'Wait for text to appear or disappear or a specified time to pass', inputSchema: z.object({ time: z.coerce.number().optional().describe('The time to wait in seconds'), text: z.string().optional().describe('The text to wait for'), textGone: z.string().optional().describe('The text to wait for to disappear'), }), type: 'readOnly', }, handle: async (context, params) => { if (!params.text && !params.textGone && !params.time) throw new Error('Either time, text or textGone must be provided'); const code: string[] = []; if (params.time) { code.push(`await new Promise(f => setTimeout(f, ${params.time!} * 1000));`); await new Promise(f => setTimeout(f, Math.min(10000, params.time! * 1000))); } const tab = context.currentTabOrDie(); const locator = params.text ? tab.page.getByText(params.text).first() : undefined; const goneLocator = params.textGone ? tab.page.getByText(params.textGone).first() : undefined; if (goneLocator) { code.push(`await page.getByText(${JSON.stringify(params.textGone)}).first().waitFor({ state: 'hidden' });`); await goneLocator.waitFor({ state: 'hidden' }); } if (locator) { code.push(`await page.getByText(${JSON.stringify(params.text)}).first().waitFor({ state: 'visible' });`); await locator.waitFor({ state: 'visible' }); } return { code, captureSnapshot, waitForNetwork: false, }; }, }); export default (captureSnapshot: boolean) => [ wait(captureSnapshot), ]; ``` -------------------------------------------------------------------------------- /src/tools/navigate.ts: -------------------------------------------------------------------------------- ```typescript /** * Copyright (c) Microsoft Corporation. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { z } from 'zod'; import { defineTool, type ToolFactory } from './tool.js'; const navigate: ToolFactory = captureSnapshot => defineTool({ capability: 'core', schema: { name: 'browser_navigate', title: 'Navigate to a URL', description: 'Navigate to a URL', inputSchema: z.object({ url: z.string().describe('The URL to navigate to'), }), type: 'destructive', }, handle: async (context, params) => { const tab = await context.ensureTab(); await tab.navigate(params.url); const code = [ `// Navigate to ${params.url}`, `await page.goto('${params.url}');`, ]; return { code, captureSnapshot, waitForNetwork: false, }; }, }); const goBack: ToolFactory = captureSnapshot => defineTool({ capability: 'history', schema: { name: 'browser_navigate_back', title: 'Go back', description: 'Go back to the previous page', inputSchema: z.object({}), type: 'readOnly', }, handle: async context => { const tab = await context.ensureTab(); await tab.page.goBack(); const code = [ `// Navigate back`, `await page.goBack();`, ]; return { code, captureSnapshot, waitForNetwork: false, }; }, }); const goForward: ToolFactory = captureSnapshot => defineTool({ capability: 'history', schema: { name: 'browser_navigate_forward', title: 'Go forward', description: 'Go forward to the next page', inputSchema: z.object({}), type: 'readOnly', }, handle: async context => { const tab = context.currentTabOrDie(); await tab.page.goForward(); const code = [ `// Navigate forward`, `await page.goForward();`, ]; return { code, captureSnapshot, waitForNetwork: false, }; }, }); export default (captureSnapshot: boolean) => [ navigate(captureSnapshot), goBack(captureSnapshot), goForward(captureSnapshot), ]; ``` -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- ```yaml name: Publish on: release: types: [published] jobs: publish-npm: runs-on: ubuntu-latest permissions: contents: read id-token: write # Needed for npm provenance steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 18 registry-url: https://registry.npmjs.org/ - run: npm ci - run: npx playwright install --with-deps - run: npm run build - run: npm run lint - run: npm run ctest - run: npm publish --provenance env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} publish-docker: runs-on: ubuntu-latest permissions: contents: read id-token: write # Needed for OIDC login to Azure environment: allow-publishing-docker-to-acr steps: - uses: actions/checkout@v4 - name: Set up QEMU # Needed for multi-platform builds (e.g., arm64 on amd64 runner) uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx # Needed for multi-platform builds uses: docker/setup-buildx-action@v3 - name: Azure Login via OIDC uses: azure/login@v2 with: client-id: ${{ secrets.AZURE_DOCKER_CLIENT_ID }} tenant-id: ${{ secrets.AZURE_DOCKER_TENANT_ID }} subscription-id: ${{ secrets.AZURE_DOCKER_SUBSCRIPTION_ID }} - name: Login to ACR run: az acr login --name playwright - name: Build and push Docker image id: build-push uses: docker/build-push-action@v6 with: context: . file: ./Dockerfile # Adjust path if your Dockerfile is elsewhere platforms: linux/amd64,linux/arm64 push: true tags: | playwright.azurecr.io/public/playwright/mcp:${{ github.event.release.tag_name }} playwright.azurecr.io/public/playwright/mcp:latest - uses: oras-project/setup-oras@v1 - name: Set oras tags run: | attach_eol_manifest() { local image="$1" local today=$(date -u +'%Y-%m-%d') # oras is re-using Docker credentials, so we don't need to login. # Following the advice in https://portal.microsofticm.com/imp/v3/incidents/incident/476783820/summary oras attach --artifact-type application/vnd.microsoft.artifact.lifecycle --annotation "vnd.microsoft.artifact.lifecycle.end-of-life.date=$today" $image } # for each tag, attach the eol manifest for tag in $(echo ${{ steps.build-push.outputs.metadata['image.name'] }} | tr ',' '\n'); do attach_eol_manifest $tag done ``` -------------------------------------------------------------------------------- /tests/browser-server.spec.ts: -------------------------------------------------------------------------------- ```typescript /** * Copyright (c) Microsoft Corporation. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import path from 'path'; import url from 'node:url'; import { spawn } from 'child_process'; import { test as baseTest, expect } from './fixtures.js'; import type { ChildProcess } from 'child_process'; const __filename = url.fileURLToPath(import.meta.url); const test = baseTest.extend<{ agentEndpoint: (options?: { args?: string[] }) => Promise<{ url: URL, stdout: () => string }> }>({ agentEndpoint: async ({}, use) => { let cp: ChildProcess | undefined; await use(async (options?: { args?: string[] }) => { if (cp) throw new Error('Process already running'); cp = spawn('node', [ path.join(path.dirname(__filename), '../lib/browserServer.js'), ...(options?.args || []), ], { stdio: 'pipe', env: { ...process.env, DEBUG: 'pw:mcp:test', DEBUG_COLORS: '0', DEBUG_HIDE_DATE: '1', }, }); let stdout = ''; const url = await new Promise<string>(resolve => cp!.stdout?.on('data', data => { stdout += data.toString(); const match = stdout.match(/Listening on (http:\/\/.*)/); if (match) resolve(match[1]); })); return { url: new URL(url), stdout: () => stdout }; }); cp?.kill('SIGTERM'); }, }); test.skip(({ mcpBrowser }) => mcpBrowser !== 'chrome', 'Agent is CDP-only for now'); test('browser lifecycle', async ({ agentEndpoint, startClient, server }) => { const { url: agentUrl } = await agentEndpoint(); const { client: client1 } = await startClient({ args: ['--browser-agent', agentUrl.toString()] }); expect(await client1.callTool({ name: 'browser_navigate', arguments: { url: server.HELLO_WORLD }, })).toContainTextContent('Hello, world!'); const { client: client2 } = await startClient({ args: ['--browser-agent', agentUrl.toString()] }); expect(await client2.callTool({ name: 'browser_navigate', arguments: { url: server.HELLO_WORLD }, })).toContainTextContent('Hello, world!'); await client1.close(); await client2.close(); }); ``` -------------------------------------------------------------------------------- /tests/pdf.spec.ts: -------------------------------------------------------------------------------- ```typescript /** * Copyright (c) Microsoft Corporation. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import fs from 'fs'; import { test, expect } from './fixtures.js'; test('save as pdf unavailable', async ({ startClient, server }) => { const { client } = await startClient({ args: ['--caps="no-pdf"'] }); await client.callTool({ name: 'browser_navigate', arguments: { url: server.HELLO_WORLD }, }); expect(await client.callTool({ name: 'browser_pdf_save', })).toHaveTextContent(/Tool \"browser_pdf_save\" not found/); }); test('save as pdf', async ({ startClient, mcpBrowser, server }, testInfo) => { const { client } = await startClient({ config: { outputDir: testInfo.outputPath('output') }, }); test.skip(!!mcpBrowser && !['chromium', 'chrome', 'msedge'].includes(mcpBrowser), 'Save as PDF is only supported in Chromium.'); expect(await client.callTool({ name: 'browser_navigate', arguments: { url: server.HELLO_WORLD }, })).toContainTextContent(`- generic [active] [ref=e1]: Hello, world!`); const response = await client.callTool({ name: 'browser_pdf_save', }); expect(response).toHaveTextContent(/Save page as.*page-[^:]+.pdf/); }); test('save as pdf (filename: output.pdf)', async ({ startClient, mcpBrowser, server }, testInfo) => { const outputDir = testInfo.outputPath('output'); test.skip(!!mcpBrowser && !['chromium', 'chrome', 'msedge'].includes(mcpBrowser), 'Save as PDF is only supported in Chromium.'); const { client } = await startClient({ config: { outputDir }, }); expect(await client.callTool({ name: 'browser_navigate', arguments: { url: server.HELLO_WORLD }, })).toContainTextContent(`- generic [active] [ref=e1]: Hello, world!`); expect(await client.callTool({ name: 'browser_pdf_save', arguments: { filename: 'output.pdf', }, })).toEqual({ content: [ { type: 'text', text: expect.stringContaining(`output.pdf`), }, ], }); const files = [...fs.readdirSync(outputDir)]; expect(fs.existsSync(outputDir)).toBeTruthy(); const pdfFiles = files.filter(f => f.endsWith('.pdf')); expect(pdfFiles).toHaveLength(1); expect(pdfFiles[0]).toMatch(/^output.pdf$/); }); ``` -------------------------------------------------------------------------------- /tests/request-blocking.spec.ts: -------------------------------------------------------------------------------- ```typescript /** * Copyright (c) Microsoft Corporation. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { test, expect } from './fixtures.ts'; const BLOCK_MESSAGE = /Blocked by Web Inspector|NS_ERROR_FAILURE|net::ERR_BLOCKED_BY_CLIENT/g; const fetchPage = async (client: Client, url: string) => { const result = await client.callTool({ name: 'browser_navigate', arguments: { url, }, }); return JSON.stringify(result, null, 2); }; test('default to allow all', async ({ server, client }) => { server.setContent('/ppp', 'content:PPP', 'text/html'); const result = await fetchPage(client, server.PREFIX + 'ppp'); expect(result).toContain('content:PPP'); }); test('blocked works', async ({ startClient }) => { const { client } = await startClient({ args: ['--blocked-origins', 'microsoft.com;example.com;playwright.dev'] }); const result = await fetchPage(client, 'https://example.com/'); expect(result).toMatch(BLOCK_MESSAGE); }); test('allowed works', async ({ server, startClient }) => { server.setContent('/ppp', 'content:PPP', 'text/html'); const { client } = await startClient({ args: ['--allowed-origins', `microsoft.com;${new URL(server.PREFIX).host};playwright.dev`] }); const result = await fetchPage(client, server.PREFIX + 'ppp'); expect(result).toContain('content:PPP'); }); test('blocked takes precedence', async ({ startClient }) => { const { client } = await startClient({ args: [ '--blocked-origins', 'example.com', '--allowed-origins', 'example.com', ], }); const result = await fetchPage(client, 'https://example.com/'); expect(result).toMatch(BLOCK_MESSAGE); }); test('allowed without blocked blocks all non-explicitly specified origins', async ({ startClient }) => { const { client } = await startClient({ args: ['--allowed-origins', 'playwright.dev'], }); const result = await fetchPage(client, 'https://example.com/'); expect(result).toMatch(BLOCK_MESSAGE); }); test('blocked without allowed allows non-explicitly specified origins', async ({ server, startClient }) => { server.setContent('/ppp', 'content:PPP', 'text/html'); const { client } = await startClient({ args: ['--blocked-origins', 'example.com'], }); const result = await fetchPage(client, server.PREFIX + 'ppp'); expect(result).toContain('content:PPP'); }); ``` -------------------------------------------------------------------------------- /tests/capabilities.spec.ts: -------------------------------------------------------------------------------- ```typescript /** * Copyright (c) Microsoft Corporation. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { test, expect } from './fixtures.js'; test('test snapshot tool list', async ({ client }) => { const { tools } = await client.listTools(); expect(new Set(tools.map(t => t.name))).toEqual(new Set([ 'browser_click', 'browser_console_messages', 'browser_drag', 'browser_file_upload', 'browser_generate_playwright_test', 'browser_handle_dialog', 'browser_hover', 'browser_select_option', 'browser_type', 'browser_close', 'browser_install', 'browser_navigate_back', 'browser_navigate_forward', 'browser_navigate', 'browser_network_requests', 'browser_pdf_save', 'browser_press_key', 'browser_resize', 'browser_snapshot', 'browser_tab_close', 'browser_tab_list', 'browser_tab_new', 'browser_tab_select', 'browser_take_screenshot', 'browser_wait_for', ])); }); test('test vision tool list', async ({ visionClient }) => { const { tools: visionTools } = await visionClient.listTools(); expect(new Set(visionTools.map(t => t.name))).toEqual(new Set([ 'browser_close', 'browser_console_messages', 'browser_file_upload', 'browser_generate_playwright_test', 'browser_handle_dialog', 'browser_install', 'browser_navigate_back', 'browser_navigate_forward', 'browser_navigate', 'browser_network_requests', 'browser_pdf_save', 'browser_press_key', 'browser_resize', 'browser_screen_capture', 'browser_screen_click', 'browser_screen_drag', 'browser_screen_move_mouse', 'browser_screen_type', 'browser_tab_close', 'browser_tab_list', 'browser_tab_new', 'browser_tab_select', 'browser_wait_for', ])); }); test('test capabilities', async ({ startClient }) => { const { client } = await startClient({ args: ['--caps="core"'], }); const { tools } = await client.listTools(); const toolNames = tools.map(t => t.name); expect(toolNames).not.toContain('browser_file_upload'); expect(toolNames).not.toContain('browser_pdf_save'); expect(toolNames).not.toContain('browser_screen_capture'); expect(toolNames).not.toContain('browser_screen_click'); expect(toolNames).not.toContain('browser_screen_drag'); expect(toolNames).not.toContain('browser_screen_move_mouse'); expect(toolNames).not.toContain('browser_screen_type'); }); ``` -------------------------------------------------------------------------------- /cloudflare/vite.config.ts: -------------------------------------------------------------------------------- ```typescript import path from 'path'; import { defineConfig } from 'vite'; // https://vitejs.dev/config/ export default defineConfig({ resolve: { alias: { // https://workers-nodejs-compat-matrix.pages.dev/ 'async_hooks': 'node:async_hooks', 'assert': 'node:assert', 'buffer': 'node:buffer', 'child_process': 'node:child_process', 'constants': 'node:constants', 'crypto': 'node:crypto', 'dns': 'node:dns', 'events': 'node:events', 'http': 'node:http', 'http2': 'node:http2', 'https': 'node:https', 'inspector': 'node:inspector', 'module': 'node:module', 'net': 'node:net', 'os': 'node:os', 'path': 'node:path', 'process': 'node:process', 'readline': 'node:readline', 'stream': 'node:stream', 'tls': 'node:tls', 'url': 'node:url', 'util': 'node:util', 'zlib': 'node:zlib', 'playwright-core': '@cloudflare/playwright', 'playwright': '@cloudflare/playwright/test', 'node:fs': '@cloudflare/playwright/fs', 'fs': '@cloudflare/playwright/fs', './package.js': path.resolve(__dirname, './src/package.ts'), }, }, build: { assetsInlineLimit: 0, // skip code obfuscation minify: false, lib: { name: '@cloudflare/playwright', entry: [ path.resolve(__dirname, './src/index.ts'), ], }, // prevents __defProp, __defNormalProp, __publicField in compiled code target: 'esnext', rollupOptions: { output: [ { format: 'es', dir: 'lib/esm', preserveModules: true, preserveModulesRoot: 'src', entryFileNames: '[name].js', chunkFileNames: '[name].js', }, { format: 'cjs', dir: 'lib/cjs', preserveModules: true, preserveModulesRoot: 'src', entryFileNames: '[name].js', chunkFileNames: '[name].js', exports: 'named', }, ], external: [ 'node:async_hooks', 'node:assert', 'node:browser', 'node:buffer', 'node:child_process', 'node:constants', 'node:crypto', 'node:dns', 'node:events', 'node:http', 'node:http2', 'node:https', 'node:inspector', 'node:module', 'node:net', 'node:os', 'node:path', 'node:process', 'node:readline', 'node:stream', 'node:timers', 'node:tls', 'node:url', 'node:util', 'node:zlib', '@cloudflare/playwright', '@cloudflare/playwright/test', '@cloudflare/playwright/fs', 'cloudflare:workers', /@modelcontextprotocol\/sdk\/.*/, 'agents/mcp', 'yaml', 'zod', 'zod-to-json-schema', ] }, commonjsOptions: { transformMixedEsModules: true, extensions: ['.ts', '.js'], include: [ path.resolve(__dirname, '../src/**/*'), /node_modules/, ], } }, }); ``` -------------------------------------------------------------------------------- /tests/cdp.spec.ts: -------------------------------------------------------------------------------- ```typescript /** * Copyright (c) Microsoft Corporation. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import url from 'node:url'; import path from 'node:path'; import { spawnSync } from 'node:child_process'; import { test, expect } from './fixtures.js'; test('cdp server', async ({ cdpServer, startClient, server }) => { await cdpServer.start(); const { client } = await startClient({ args: [`--cdp-endpoint=${cdpServer.endpoint}`] }); expect(await client.callTool({ name: 'browser_navigate', arguments: { url: server.HELLO_WORLD }, })).toContainTextContent(`- generic [active] [ref=e1]: Hello, world!`); }); test('cdp server reuse tab', async ({ cdpServer, startClient, server }) => { const browserContext = await cdpServer.start(); const { client } = await startClient({ args: [`--cdp-endpoint=${cdpServer.endpoint}`] }); const [page] = browserContext.pages(); await page.goto(server.HELLO_WORLD); expect(await client.callTool({ name: 'browser_click', arguments: { element: 'Hello, world!', ref: 'f0', }, })).toHaveTextContent(`Error: No current snapshot available. Capture a snapshot or navigate to a new location first.`); expect(await client.callTool({ name: 'browser_snapshot', })).toHaveTextContent(` - Ran Playwright code: \`\`\`js // <internal code to capture accessibility snapshot> \`\`\` - Page URL: ${server.HELLO_WORLD} - Page Title: Title - Page Snapshot \`\`\`yaml - generic [active] [ref=e1]: Hello, world! \`\`\` `); }); test('should throw connection error and allow re-connecting', async ({ cdpServer, startClient, server }) => { const { client } = await startClient({ args: [`--cdp-endpoint=${cdpServer.endpoint}`] }); server.setContent('/', ` <title>Title</title> <body>Hello, world!</body> `, 'text/html'); expect(await client.callTool({ name: 'browser_navigate', arguments: { url: server.PREFIX }, })).toContainTextContent(`Error: browserType.connectOverCDP: connect ECONNREFUSED`); await cdpServer.start(); expect(await client.callTool({ name: 'browser_navigate', arguments: { url: server.PREFIX }, })).toContainTextContent(`- generic [active] [ref=e1]: Hello, world!`); }); // NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename. const __filename = url.fileURLToPath(import.meta.url); test('does not support --device', async () => { const result = spawnSync('node', [ path.join(__filename, '../../cli.js'), '--device=Pixel 5', '--cdp-endpoint=http://localhost:1234', ]); expect(result.error).toBeUndefined(); expect(result.status).toBe(1); expect(result.stderr.toString()).toContain('Device emulation is not supported with cdpEndpoint.'); }); ``` -------------------------------------------------------------------------------- /src/tools/utils.ts: -------------------------------------------------------------------------------- ```typescript /** * 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'; import type { Context } from '../context.js'; import type { Tab } from '../tab.js'; export async function waitForCompletion<R>(context: Context, tab: Tab, callback: () => Promise<R>): Promise<R> { const requests = new Set<playwright.Request>(); let frameNavigated = false; let waitCallback: () => void = () => {}; const waitBarrier = new Promise<void>(f => { waitCallback = f; }); const requestListener = (request: playwright.Request) => requests.add(request); const requestFinishedListener = (request: playwright.Request) => { requests.delete(request); if (!requests.size) waitCallback(); }; const frameNavigateListener = (frame: playwright.Frame) => { if (frame.parentFrame()) return; frameNavigated = true; dispose(); clearTimeout(timeout); void tab.waitForLoadState('load').then(waitCallback); }; const onTimeout = () => { dispose(); waitCallback(); }; tab.page.on('request', requestListener); tab.page.on('requestfinished', requestFinishedListener); tab.page.on('framenavigated', frameNavigateListener); const timeout = setTimeout(onTimeout, 10000); const dispose = () => { tab.page.off('request', requestListener); tab.page.off('requestfinished', requestFinishedListener); tab.page.off('framenavigated', frameNavigateListener); clearTimeout(timeout); }; try { const result = await callback(); if (!requests.size && !frameNavigated) waitCallback(); await waitBarrier; await context.waitForTimeout(1000); return result; } finally { dispose(); } } export function sanitizeForFilePath(s: string) { const sanitize = (s: string) => s.replace(/[\x00-\x2C\x2E-\x2F\x3A-\x40\x5B-\x60\x7B-\x7F]+/g, '-'); const separator = s.lastIndexOf('.'); if (separator === -1) return sanitize(s); return sanitize(s.substring(0, separator)) + '.' + sanitize(s.substring(separator + 1)); } export async function generateLocator(locator: playwright.Locator): Promise<string> { try { return await (locator as any)._generateLocatorString(); } catch (e) { if (e instanceof Error && /locator._generateLocatorString: No element matching locator/.test(e.message)) throw new Error('Ref not found, likely because element was removed. Use browser_snapshot to see what elements are currently on the page.'); throw e; } } export async function callOnPageNoTrace<T>(page: playwright.Page, callback: (page: playwright.Page) => Promise<T>): Promise<T> { return await (page as any)._wrapApiCall(() => callback(page), { internal: true }); } ``` -------------------------------------------------------------------------------- /src/tools/tabs.ts: -------------------------------------------------------------------------------- ```typescript /** * Copyright (c) Microsoft Corporation. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { z } from 'zod'; import { defineTool, type ToolFactory } from './tool.js'; const listTabs = defineTool({ capability: 'tabs', schema: { name: 'browser_tab_list', title: 'List tabs', description: 'List browser tabs', inputSchema: z.object({}), type: 'readOnly', }, handle: async context => { await context.ensureTab(); return { code: [`// <internal code to list tabs>`], captureSnapshot: false, waitForNetwork: false, resultOverride: { content: [{ type: 'text', text: await context.listTabsMarkdown(), }], }, }; }, }); const selectTab: ToolFactory = captureSnapshot => defineTool({ capability: 'tabs', schema: { name: 'browser_tab_select', title: 'Select a tab', description: 'Select a tab by index', inputSchema: z.object({ index: z.coerce.number().describe('The index of the tab to select'), }), type: 'readOnly', }, handle: async (context, params) => { await context.selectTab(params.index); const code = [ `// <internal code to select tab ${params.index}>`, ]; return { code, captureSnapshot, waitForNetwork: false }; }, }); const newTab: ToolFactory = captureSnapshot => defineTool({ capability: 'tabs', schema: { name: 'browser_tab_new', title: 'Open a new tab', description: 'Open a new tab', inputSchema: z.object({ url: z.string().optional().describe('The URL to navigate to in the new tab. If not provided, the new tab will be blank.'), }), type: 'readOnly', }, handle: async (context, params) => { await context.newTab(); if (params.url) await context.currentTabOrDie().navigate(params.url); const code = [ `// <internal code to open a new tab>`, ]; return { code, captureSnapshot, waitForNetwork: false }; }, }); const closeTab: ToolFactory = captureSnapshot => defineTool({ capability: 'tabs', schema: { name: 'browser_tab_close', title: 'Close a tab', description: 'Close a tab', inputSchema: z.object({ index: z.coerce.number().optional().describe('The index of the tab to close. Closes current tab if not provided.'), }), type: 'destructive', }, handle: async (context, params) => { await context.closeTab(params.index); const code = [ `// <internal code to close tab ${params.index}>`, ]; return { code, captureSnapshot, waitForNetwork: false }; }, }); export default (captureSnapshot: boolean) => [ listTabs, newTab(captureSnapshot), selectTab(captureSnapshot), closeTab(captureSnapshot), ]; ``` -------------------------------------------------------------------------------- /src/manualPromise.ts: -------------------------------------------------------------------------------- ```typescript /** * Copyright (c) Microsoft Corporation. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ export class ManualPromise<T = void> extends Promise<T> { private _resolve!: (t: T) => void; private _reject!: (e: Error) => void; private _isDone: boolean; constructor() { let resolve: (t: T) => void; let reject: (e: Error) => void; super((f, r) => { resolve = f; reject = r; }); this._isDone = false; this._resolve = resolve!; this._reject = reject!; } isDone() { return this._isDone; } resolve(t: T) { this._isDone = true; this._resolve(t); } reject(e: Error) { this._isDone = true; this._reject(e); } static override get [Symbol.species]() { return Promise; } override get [Symbol.toStringTag]() { return 'ManualPromise'; } } export class LongStandingScope { private _terminateError: Error | undefined; private _closeError: Error | undefined; private _terminatePromises = new Map<ManualPromise<Error>, string[]>(); private _isClosed = false; reject(error: Error) { this._isClosed = true; this._terminateError = error; for (const p of this._terminatePromises.keys()) p.resolve(error); } close(error: Error) { this._isClosed = true; this._closeError = error; for (const [p, frames] of this._terminatePromises) p.resolve(cloneError(error, frames)); } isClosed() { return this._isClosed; } static async raceMultiple<T>(scopes: LongStandingScope[], promise: Promise<T>): Promise<T> { return Promise.race(scopes.map(s => s.race(promise))); } async race<T>(promise: Promise<T> | Promise<T>[]): Promise<T> { return this._race(Array.isArray(promise) ? promise : [promise], false) as Promise<T>; } async safeRace<T>(promise: Promise<T>, defaultValue?: T): Promise<T> { return this._race([promise], true, defaultValue); } private async _race(promises: Promise<any>[], safe: boolean, defaultValue?: any): Promise<any> { const terminatePromise = new ManualPromise<Error>(); const frames = captureRawStack(); if (this._terminateError) terminatePromise.resolve(this._terminateError); if (this._closeError) terminatePromise.resolve(cloneError(this._closeError, frames)); this._terminatePromises.set(terminatePromise, frames); try { return await Promise.race([ terminatePromise.then(e => safe ? defaultValue : Promise.reject(e)), ...promises ]); } finally { this._terminatePromises.delete(terminatePromise); } } } function cloneError(error: Error, frames: string[]) { const clone = new Error(); clone.name = error.name; clone.message = error.message; clone.stack = [error.name + ':' + error.message, ...frames].join('\n'); return clone; } function captureRawStack(): string[] { const stackTraceLimit = Error.stackTraceLimit; Error.stackTraceLimit = 50; const error = new Error(); const stack = error.stack || ''; Error.stackTraceLimit = stackTraceLimit; return stack.split('\n'); } ``` -------------------------------------------------------------------------------- /src/connection.ts: -------------------------------------------------------------------------------- ```typescript /** * Copyright (c) Microsoft Corporation. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { Server as McpServer } from '@modelcontextprotocol/sdk/server/index.js'; import { CallToolRequestSchema, ListToolsRequestSchema, Tool as McpTool } from '@modelcontextprotocol/sdk/types.js'; import { zodToJsonSchema } from 'zod-to-json-schema'; import { Context } from './context.js'; import { snapshotTools, visionTools } from './tools.js'; import { packageJSON } from './package.js'; import { FullConfig } from './config.js'; import type { BrowserContextFactory } from './browserContextFactory.js'; export function createConnection(config: FullConfig, browserContextFactory: BrowserContextFactory): Connection { const allTools = config.vision ? visionTools : snapshotTools; const tools = allTools.filter(tool => !config.capabilities || tool.capability === 'core' || config.capabilities.includes(tool.capability)); const context = new Context(tools, config, browserContextFactory); const server = new McpServer({ name: 'Playwright', version: packageJSON.version }, { capabilities: { tools: {}, } }); server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: tools.map(tool => ({ name: tool.schema.name, description: tool.schema.description, inputSchema: zodToJsonSchema(tool.schema.inputSchema), annotations: { title: tool.schema.title, readOnlyHint: tool.schema.type === 'readOnly', destructiveHint: tool.schema.type === 'destructive', openWorldHint: true, }, })) as McpTool[], }; }); server.setRequestHandler(CallToolRequestSchema, async request => { const errorResult = (...messages: string[]) => ({ content: [{ type: 'text', text: messages.join('\n') }], isError: true, }); const tool = tools.find(tool => tool.schema.name === request.params.name); if (!tool) return errorResult(`Tool "${request.params.name}" not found`); const modalStates = context.modalStates().map(state => state.type); if (tool.clearsModalState && !modalStates.includes(tool.clearsModalState)) return errorResult(`The tool "${request.params.name}" can only be used when there is related modal state present.`, ...context.modalStatesMarkdown()); if (!tool.clearsModalState && modalStates.length) return errorResult(`Tool "${request.params.name}" does not handle the modal state.`, ...context.modalStatesMarkdown()); try { return await context.run(tool, request.params.arguments); } catch (error) { return errorResult(String(error)); } }); return new Connection(server, context); } export class Connection { readonly server: McpServer; readonly context: Context; constructor(server: McpServer, context: Context) { this.server = server; this.context = context; this.server.oninitialized = () => { this.context.clientVersion = this.server.getClientVersion(); }; } async close() { await this.server.close(); await this.context.close(); } } ``` -------------------------------------------------------------------------------- /config.d.ts: -------------------------------------------------------------------------------- ```typescript /** * Copyright (c) Microsoft Corporation. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import type * as playwright from 'playwright'; export type ToolCapability = 'core' | 'tabs' | 'pdf' | 'history' | 'wait' | 'files' | 'install' | 'testing'; export type Config = { /** * The browser to use. */ browser?: { /** * Use browser agent (experimental). */ browserAgent?: string; /** * The type of browser to use. */ browserName?: 'chromium' | 'firefox' | 'webkit'; /** * Keep the browser profile in memory, do not save it to disk. */ isolated?: boolean; /** * Path to a user data directory for browser profile persistence. * Temporary directory is created by default. */ userDataDir?: string; /** * Launch options passed to * @see https://playwright.dev/docs/api/class-browsertype#browser-type-launch-persistent-context * * This is useful for settings options like `channel`, `headless`, `executablePath`, etc. */ launchOptions?: playwright.LaunchOptions; /** * Context options for the browser context. * * This is useful for settings options like `viewport`. */ contextOptions?: playwright.BrowserContextOptions; /** * Chrome DevTools Protocol endpoint to connect to an existing browser instance in case of Chromium family browsers. */ cdpEndpoint?: string; /** * Remote endpoint to connect to an existing Playwright server. */ remoteEndpoint?: string; }, server?: { /** * The port to listen on for SSE or MCP transport. */ port?: number; /** * The host to bind the server to. Default is localhost. Use 0.0.0.0 to bind to all interfaces. */ host?: string; }, /** * List of enabled tool capabilities. Possible values: * - 'core': Core browser automation features. * - 'tabs': Tab management features. * - 'pdf': PDF generation and manipulation. * - 'history': Browser history access. * - 'wait': Wait and timing utilities. * - 'files': File upload/download support. * - 'install': Browser installation utilities. */ capabilities?: ToolCapability[]; /** * Run server that uses screenshots (Aria snapshots are used by default). */ vision?: boolean; /** * Whether to save the Playwright trace of the session into the output directory. */ saveTrace?: boolean; /** * The directory to save output files. */ outputDir?: string; network?: { /** * List of origins to allow the browser to request. Default is to allow all. Origins matching both `allowedOrigins` and `blockedOrigins` will be blocked. */ allowedOrigins?: string[]; /** * List of origins to block the browser to request. Origins matching both `allowedOrigins` and `blockedOrigins` will be blocked. */ blockedOrigins?: string[]; }; /** * Whether to send image responses to the client. Can be "allow", "omit", or "auto". Defaults to "auto", which sends images if the client can display them. */ imageResponses?: 'allow' | 'omit' | 'auto'; }; ``` -------------------------------------------------------------------------------- /src/tools/screenshot.ts: -------------------------------------------------------------------------------- ```typescript /** * Copyright (c) Microsoft Corporation. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { z } from 'zod'; import { defineTool } from './tool.js'; import * as javascript from '../javascript.js'; import { outputFile } from '../config.js'; import { generateLocator } from './utils.js'; import type * as playwright from 'playwright'; const screenshotSchema = z.object({ raw: z.coerce.boolean().optional().describe('Whether to return without compression (in PNG format). Default is false, which returns a JPEG image.'), filename: z.string().optional().describe('File name to save the screenshot to. Defaults to `page-{timestamp}.{png|jpeg}` if not specified.'), element: z.string().optional().describe('Human-readable element description used to obtain permission to screenshot the element. If not provided, the screenshot will be taken of viewport. If element is provided, ref must be provided too.'), ref: z.string().optional().describe('Exact target element reference from the page snapshot. If not provided, the screenshot will be taken of viewport. If ref is provided, element must be provided too.'), }).refine(data => { return !!data.element === !!data.ref; }, { message: 'Both element and ref must be provided or neither.', path: ['ref', 'element'] }); const screenshot = defineTool({ capability: 'core', schema: { name: 'browser_take_screenshot', title: 'Take a screenshot', description: `Take a screenshot of the current page. You can't perform actions based on the screenshot, use browser_snapshot for actions.`, inputSchema: screenshotSchema, type: 'readOnly', }, handle: async (context, params) => { const tab = context.currentTabOrDie(); const snapshot = tab.snapshotOrDie(); const fileType = params.raw ? 'png' : 'jpeg'; const fileName = await outputFile(context.config, params.filename ?? `page-${new Date().toISOString()}.${fileType}`); const options: playwright.PageScreenshotOptions = { type: fileType, quality: fileType === 'png' ? undefined : 50, scale: 'css', path: fileName }; const isElementScreenshot = params.element && params.ref; const code = [ `// Screenshot ${isElementScreenshot ? params.element : 'viewport'} and save it as ${fileName}`, ]; const locator = params.ref ? snapshot.refLocator({ element: params.element || '', ref: params.ref }) : null; if (locator) code.push(`await page.${await generateLocator(locator)}.screenshot(${javascript.formatObject(options)});`); else code.push(`await page.screenshot(${javascript.formatObject(options)});`); const includeBase64 = context.clientSupportsImages(); const action = async () => { const screenshot = locator ? await locator.screenshot(options) : await tab.page.screenshot(options); return { content: includeBase64 ? [{ type: 'image' as 'image', data: screenshot.toString('base64'), mimeType: fileType === 'png' ? 'image/png' : 'image/jpeg', }] : [] }; }; return { code, action, captureSnapshot: true, waitForNetwork: false, }; } }); export default [ screenshot, ]; ``` -------------------------------------------------------------------------------- /src/tab.ts: -------------------------------------------------------------------------------- ```typescript /** * Copyright (c) Microsoft Corporation. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import * as playwright from 'playwright'; import { PageSnapshot } from './pageSnapshot.js'; import type { Context } from './context.js'; import { callOnPageNoTrace } from './tools/utils.js'; export class Tab { readonly context: Context; readonly page: playwright.Page; private _consoleMessages: playwright.ConsoleMessage[] = []; private _requests: Map<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._consoleMessages.push(event)); page.on('request', request => this._requests.set(request, null)); page.on('response', response => this._requests.set(response.request(), response)); page.on('close', () => this._onClose()); page.on('filechooser', chooser => { this.context.setModalState({ type: 'fileChooser', description: 'File chooser', fileChooser: chooser, }, this); }); page.on('dialog', dialog => this.context.dialogShown(this, dialog)); page.on('download', download => { void this.context.downloadStarted(this, download); }); page.setDefaultNavigationTimeout(60000); page.setDefaultTimeout(5000); } private _clearCollectedArtifacts() { this._consoleMessages.length = 0; this._requests.clear(); } private _onClose() { this._clearCollectedArtifacts(); this._onPageClose(this); } async title(): Promise<string> { return await callOnPageNoTrace(this.page, page => page.title()); } async waitForLoadState(state: 'load', options?: { timeout?: number }): Promise<void> { await callOnPageNoTrace(this.page, page => page.waitForLoadState(state, options).catch(() => {})); } async navigate(url: string) { this._clearCollectedArtifacts(); const downloadEvent = callOnPageNoTrace(this.page, page => page.waitForEvent('download').catch(() => {})); try { await this.page.goto(url, { waitUntil: 'domcontentloaded' }); } catch (_e: unknown) { const e = _e as Error; const mightBeDownload = e.message.includes('net::ERR_ABORTED') // chromium || e.message.includes('Download is starting'); // firefox + webkit if (!mightBeDownload) throw e; // on chromium, the download event is fired *after* page.goto rejects, so we wait a lil bit const download = await Promise.race([ downloadEvent, new Promise(resolve => setTimeout(resolve, 1000)), ]); if (!download) throw e; } // Cap load event to 5 seconds, the page is operational at this point. await this.waitForLoadState('load', { timeout: 5000 }); } hasSnapshot(): boolean { return !!this._snapshot; } snapshotOrDie(): PageSnapshot { if (!this._snapshot) throw new Error('No snapshot available'); return this._snapshot; } consoleMessages(): playwright.ConsoleMessage[] { return this._consoleMessages; } requests(): Map<playwright.Request, playwright.Response | null> { return this._requests; } async captureSnapshot() { this._snapshot = await PageSnapshot.create(this.page); } } ``` -------------------------------------------------------------------------------- /tests/files.spec.ts: -------------------------------------------------------------------------------- ```typescript /** * Copyright (c) Microsoft Corporation. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { test, expect } from './fixtures.js'; import fs from 'fs/promises'; test('browser_file_upload', async ({ client, server }, testInfo) => { server.setContent('/', ` <input type="file" /> <button>Button</button> `, 'text/html'); expect(await client.callTool({ name: 'browser_navigate', arguments: { url: server.PREFIX }, })).toContainTextContent(` \`\`\`yaml - generic [active] [ref=e1]: - button "Choose File" [ref=e2] - button "Button" [ref=e3] \`\`\``); { expect(await client.callTool({ name: 'browser_file_upload', arguments: { paths: [] }, })).toHaveTextContent(` The tool "browser_file_upload" can only be used when there is related modal state present. ### Modal state - There is no modal state present `.trim()); } expect(await client.callTool({ name: 'browser_click', arguments: { element: 'Textbox', ref: 'e2', }, })).toContainTextContent(`### Modal state - [File chooser]: can be handled by the "browser_file_upload" tool`); const filePath = testInfo.outputPath('test.txt'); await fs.writeFile(filePath, 'Hello, world!'); { const response = await client.callTool({ name: 'browser_file_upload', arguments: { paths: [filePath], }, }); expect(response).not.toContainTextContent('### Modal state'); } { const response = await client.callTool({ name: 'browser_click', arguments: { element: 'Textbox', ref: 'e2', }, }); expect(response).toContainTextContent('- [File chooser]: can be handled by the \"browser_file_upload\" tool'); } { const response = await client.callTool({ name: 'browser_click', arguments: { element: 'Button', ref: 'e3', }, }); expect(response).toContainTextContent(`Tool "browser_click" does not handle the modal state. ### Modal state - [File chooser]: can be handled by the "browser_file_upload" tool`); } }); test('clicking on download link emits download', async ({ startClient, server, mcpMode }, testInfo) => { const { client } = await startClient({ config: { outputDir: testInfo.outputPath('output') }, }); server.setContent('/', `<a href="/download" download="test.txt">Download</a>`, 'text/html'); server.setContent('/download', 'Data', 'text/plain'); expect(await client.callTool({ name: 'browser_navigate', arguments: { url: server.PREFIX }, })).toContainTextContent('- link "Download" [ref=e2]'); await client.callTool({ name: 'browser_click', arguments: { element: 'Download link', ref: 'e2', }, }); await expect.poll(() => client.callTool({ name: 'browser_snapshot' })).toContainTextContent(` ### Downloads - Downloaded file test.txt to ${testInfo.outputPath('output', 'test.txt')}`); }); test('navigating to download link emits download', async ({ startClient, server, mcpBrowser, mcpMode }, testInfo) => { const { client } = await startClient({ config: { outputDir: testInfo.outputPath('output') }, }); test.skip(mcpBrowser === 'webkit' && process.platform === 'linux', 'https://github.com/microsoft/playwright/blob/8e08fdb52c27bb75de9bf87627bf740fadab2122/tests/library/download.spec.ts#L436'); server.route('/download', (req, res) => { res.writeHead(200, { 'Content-Type': 'text/plain', 'Content-Disposition': 'attachment; filename=test.txt', }); res.end('Hello world!'); }); expect(await client.callTool({ name: 'browser_navigate', arguments: { url: server.PREFIX + 'download', }, })).toContainTextContent('### Downloads'); }); ``` -------------------------------------------------------------------------------- /tests/tabs.spec.ts: -------------------------------------------------------------------------------- ```typescript /** * Copyright (c) Microsoft Corporation. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { test, expect } from './fixtures.js'; import type { Client } from '@modelcontextprotocol/sdk/client/index.js'; async function createTab(client: Client, title: string, body: string) { return await client.callTool({ name: 'browser_tab_new', arguments: { url: `data:text/html,<title>${title}</title><body>${body}</body>`, }, }); } test('list initial tabs', async ({ client }) => { expect(await client.callTool({ name: 'browser_tab_list', })).toHaveTextContent(`### Open tabs - 1: (current) [] (about:blank)`); }); test('list first tab', async ({ client }) => { await createTab(client, 'Tab one', 'Body one'); expect(await client.callTool({ name: 'browser_tab_list', })).toHaveTextContent(`### Open tabs - 1: [] (about:blank) - 2: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)`); }); test('create new tab', async ({ client }) => { expect(await createTab(client, 'Tab one', 'Body one')).toHaveTextContent(` - Ran Playwright code: \`\`\`js // <internal code to open a new tab> \`\`\` ### Open tabs - 1: [] (about:blank) - 2: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>) ### Current tab - Page URL: data:text/html,<title>Tab one</title><body>Body one</body> - Page Title: Tab one - Page Snapshot \`\`\`yaml - generic [active] [ref=e1]: Body one \`\`\``); expect(await createTab(client, 'Tab two', 'Body two')).toHaveTextContent(` - Ran Playwright code: \`\`\`js // <internal code to open a new tab> \`\`\` ### Open tabs - 1: [] (about:blank) - 2: [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>) - 3: (current) [Tab two] (data:text/html,<title>Tab two</title><body>Body two</body>) ### Current tab - Page URL: data:text/html,<title>Tab two</title><body>Body two</body> - Page Title: Tab two - Page Snapshot \`\`\`yaml - generic [active] [ref=e1]: Body two \`\`\``); }); test('select tab', async ({ client }) => { await createTab(client, 'Tab one', 'Body one'); await createTab(client, 'Tab two', 'Body two'); expect(await client.callTool({ name: 'browser_tab_select', arguments: { index: 2, }, })).toHaveTextContent(` - Ran Playwright code: \`\`\`js // <internal code to select tab 2> \`\`\` ### Open tabs - 1: [] (about:blank) - 2: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>) - 3: [Tab two] (data:text/html,<title>Tab two</title><body>Body two</body>) ### Current tab - Page URL: data:text/html,<title>Tab one</title><body>Body one</body> - Page Title: Tab one - Page Snapshot \`\`\`yaml - generic [active] [ref=e1]: Body one \`\`\``); }); test('close tab', async ({ client }) => { await createTab(client, 'Tab one', 'Body one'); await createTab(client, 'Tab two', 'Body two'); expect(await client.callTool({ name: 'browser_tab_close', arguments: { index: 3, }, })).toHaveTextContent(` - Ran Playwright code: \`\`\`js // <internal code to close tab 3> \`\`\` ### Open tabs - 1: [] (about:blank) - 2: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>) ### Current tab - Page URL: data:text/html,<title>Tab one</title><body>Body one</body> - Page Title: Tab one - Page Snapshot \`\`\`yaml - generic [active] [ref=e1]: Body one \`\`\``); }); test('reuse first tab when navigating', async ({ startClient, cdpServer, server }) => { const browserContext = await cdpServer.start(); const pages = browserContext.pages(); const { client } = await startClient({ args: [`--cdp-endpoint=${cdpServer.endpoint}`] }); await client.callTool({ name: 'browser_navigate', arguments: { url: server.HELLO_WORLD }, }); expect(pages.length).toBe(1); expect(await pages[0].title()).toBe('Title'); }); ``` -------------------------------------------------------------------------------- /tests/launch.spec.ts: -------------------------------------------------------------------------------- ```typescript /** * Copyright (c) Microsoft Corporation. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import fs from 'fs'; import { test, expect, formatOutput } from './fixtures.js'; test('test reopen browser', async ({ startClient, server, mcpMode }) => { const { client, stderr } = await startClient(); await client.callTool({ name: 'browser_navigate', arguments: { url: server.HELLO_WORLD }, }); expect(await client.callTool({ name: 'browser_close', })).toContainTextContent('No open pages available'); expect(await client.callTool({ name: 'browser_navigate', arguments: { url: server.HELLO_WORLD }, })).toContainTextContent(`- generic [active] [ref=e1]: Hello, world!`); await client.close(); if (process.platform === 'win32') return; await expect.poll(() => formatOutput(stderr()), { timeout: 0 }).toEqual([ 'create context', 'create browser context (persistent)', 'lock user data dir', 'close context', 'close browser context (persistent)', 'release user data dir', 'close browser context complete (persistent)', 'create browser context (persistent)', 'lock user data dir', 'close context', 'close browser context (persistent)', 'release user data dir', 'close browser context complete (persistent)', ]); }); test('executable path', async ({ startClient, server }) => { const { client } = await startClient({ args: [`--executable-path=bogus`] }); const response = await client.callTool({ name: 'browser_navigate', arguments: { url: server.HELLO_WORLD }, }); expect(response).toContainTextContent(`executable doesn't exist`); }); test('persistent context', async ({ startClient, server }) => { server.setContent('/', ` <body> </body> <script> document.body.textContent = localStorage.getItem('test') ? 'Storage: YES' : 'Storage: NO'; localStorage.setItem('test', 'test'); </script> `, 'text/html'); const { client } = await startClient(); const response = await client.callTool({ name: 'browser_navigate', arguments: { url: server.PREFIX }, }); expect(response).toContainTextContent(`Storage: NO`); await new Promise(resolve => setTimeout(resolve, 3000)); await client.callTool({ name: 'browser_close', }); const { client: client2 } = await startClient(); const response2 = await client2.callTool({ name: 'browser_navigate', arguments: { url: server.PREFIX }, }); expect(response2).toContainTextContent(`Storage: YES`); }); test('isolated context', async ({ startClient, server }) => { server.setContent('/', ` <body> </body> <script> document.body.textContent = localStorage.getItem('test') ? 'Storage: YES' : 'Storage: NO'; localStorage.setItem('test', 'test'); </script> `, 'text/html'); const { client: client1 } = await startClient({ args: [`--isolated`] }); const response = await client1.callTool({ name: 'browser_navigate', arguments: { url: server.PREFIX }, }); expect(response).toContainTextContent(`Storage: NO`); await client1.callTool({ name: 'browser_close', }); const { client: client2 } = await startClient({ args: [`--isolated`] }); const response2 = await client2.callTool({ name: 'browser_navigate', arguments: { url: server.PREFIX }, }); expect(response2).toContainTextContent(`Storage: NO`); }); test('isolated context with storage state', async ({ startClient, server }, testInfo) => { const storageStatePath = testInfo.outputPath('storage-state.json'); await fs.promises.writeFile(storageStatePath, JSON.stringify({ origins: [ { origin: server.PREFIX, localStorage: [{ name: 'test', value: 'session-value' }], }, ], })); server.setContent('/', ` <body> </body> <script> document.body.textContent = 'Storage: ' + localStorage.getItem('test'); </script> `, 'text/html'); const { client } = await startClient({ args: [ `--isolated`, `--storage-state=${storageStatePath}`, ] }); const response = await client.callTool({ name: 'browser_navigate', arguments: { url: server.PREFIX }, }); expect(response).toContainTextContent(`Storage: session-value`); }); ``` -------------------------------------------------------------------------------- /src/program.ts: -------------------------------------------------------------------------------- ```typescript /** * 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 { program } from 'commander'; // @ts-ignore import { startTraceViewerServer } from 'playwright-core/lib/server'; import { startHttpServer, startHttpTransport, startStdioTransport } from './transport.js'; import { resolveCLIConfig } from './config.js'; import { Server } from './server.js'; import { packageJSON } from './package.js'; program .version('Version ' + packageJSON.version) .name(packageJSON.name) .option('--allowed-origins <origins>', 'semicolon-separated list of origins to allow the browser to request. Default is to allow all.', semicolonSeparatedList) .option('--blocked-origins <origins>', 'semicolon-separated list of origins to block the browser from requesting. Blocklist is evaluated before allowlist. If used without the allowlist, requests not matching the blocklist are still allowed.', semicolonSeparatedList) .option('--block-service-workers', 'block service workers') .option('--browser <browser>', 'browser or chrome channel to use, possible values: chrome, firefox, webkit, msedge.') .option('--browser-agent <endpoint>', 'Use browser agent (experimental).') .option('--caps <caps>', 'comma-separated list of capabilities to enable, possible values: tabs, pdf, history, wait, files, install. Default is all.') .option('--cdp-endpoint <endpoint>', 'CDP endpoint to connect to.') .option('--config <path>', 'path to the configuration file.') .option('--device <device>', 'device to emulate, for example: "iPhone 15"') .option('--executable-path <path>', 'path to the browser executable.') .option('--headless', 'run browser in headless mode, headed by default') .option('--host <host>', 'host to bind server to. Default is localhost. Use 0.0.0.0 to bind to all interfaces.') .option('--ignore-https-errors', 'ignore https errors') .option('--isolated', 'keep the browser profile in memory, do not save it to disk.') .option('--image-responses <mode>', 'whether to send image responses to the client. Can be "allow", "omit", or "auto". Defaults to "auto", which sends images if the client can display them.') .option('--no-sandbox', 'disable the sandbox for all process types that are normally sandboxed.') .option('--output-dir <path>', 'path to the directory for output files.') .option('--port <port>', 'port to listen on for SSE transport.') .option('--proxy-bypass <bypass>', 'comma-separated domains to bypass proxy, for example ".com,chromium.org,.domain.com"') .option('--proxy-server <proxy>', 'specify proxy server, for example "http://myproxy:3128" or "socks5://myproxy:8080"') .option('--save-trace', 'Whether to save the Playwright Trace of the session into the output directory.') .option('--storage-state <path>', 'path to the storage state file for isolated sessions.') .option('--user-agent <ua string>', 'specify user agent string') .option('--user-data-dir <path>', 'path to the user data directory. If not specified, a temporary directory will be created.') .option('--viewport-size <size>', 'specify browser viewport size in pixels, for example "1280, 720"') .option('--vision', 'Run server that uses screenshots (Aria snapshots are used by default)') .action(async options => { const config = await resolveCLIConfig(options); const httpServer = config.server.port !== undefined ? await startHttpServer(config.server) : undefined; const server = new Server(config); server.setupExitWatchdog(); if (httpServer) startHttpTransport(httpServer, server); else await startStdioTransport(server); if (config.saveTrace) { const server = await startTraceViewerServer(); const urlPrefix = server.urlPrefix('human-readable'); const url = urlPrefix + '/trace/index.html?trace=' + config.browser.launchOptions.tracesDir + '/trace.json'; // eslint-disable-next-line no-console console.error('\nTrace viewer listening on ' + url); } }); function semicolonSeparatedList(value: string): string[] { return value.split(';').map(v => v.trim()); } void program.parseAsync(process.argv); ``` -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- ``` /** * 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 typescriptEslint from "@typescript-eslint/eslint-plugin"; import tsParser from "@typescript-eslint/parser"; import notice from "eslint-plugin-notice"; import path from "path"; import { fileURLToPath } from "url"; import stylistic from "@stylistic/eslint-plugin"; import importRules from "eslint-plugin-import"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const plugins = { "@stylistic": stylistic, "@typescript-eslint": typescriptEslint, notice, import: importRules, }; 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" }, ], /** * Enforced rules */ // syntax preferences "object-curly-spacing": ["error", "always"], quotes: [ 2, "single", { avoidEscape: true, allowTemplateLiterals: true, }, ], "jsx-quotes": [2, "prefer-single"], "no-extra-semi": 2, "@stylistic/semi": [2], "comma-style": [2, "last"], "wrap-iife": [2, "inside"], "spaced-comment": [ 2, "always", { markers: ["*"], }, ], eqeqeq: [2], "accessor-pairs": [ 2, { getWithoutSet: false, setWithoutGet: false, }, ], "brace-style": [2, "1tbs", { allowSingleLine: true }], curly: [2, "multi-or-nest", "consistent"], "new-parens": 2, "arrow-parens": [2, "as-needed"], "prefer-const": 2, "quote-props": [2, "consistent"], "nonblock-statement-body-position": [2, "below"], // anti-patterns "no-var": 2, "no-with": 2, "no-multi-str": 2, "no-caller": 2, "no-implied-eval": 2, "no-labels": 2, "no-new-object": 2, "no-octal-escape": 2, "no-self-compare": 2, "no-shadow-restricted-names": 2, "no-cond-assign": 2, "no-debugger": 2, "no-dupe-keys": 2, "no-duplicate-case": 2, "no-empty-character-class": 2, "no-unreachable": 2, "no-unsafe-negation": 2, radix: 2, "valid-typeof": 2, "no-implicit-globals": [2], "no-unused-expressions": [ 2, { allowShortCircuit: true, allowTernary: true, allowTaggedTemplates: true }, ], "no-proto": 2, // es2015 features "require-yield": 2, "template-curly-spacing": [2, "never"], // spacing details "space-infix-ops": 2, "space-in-parens": [2, "never"], "array-bracket-spacing": [2, "never"], "comma-spacing": [2, { before: false, after: true }], "keyword-spacing": [2, "always"], "space-before-function-paren": [ 2, { anonymous: "never", named: "never", asyncArrow: "always", }, ], "no-whitespace-before-property": 2, "keyword-spacing": [ 2, { overrides: { if: { after: true }, else: { after: true }, for: { after: true }, while: { after: true }, do: { after: true }, switch: { after: true }, return: { after: true }, }, }, ], "arrow-spacing": [ 2, { after: true, before: true, }, ], "@stylistic/func-call-spacing": 2, "@stylistic/type-annotation-spacing": 2, // file whitespace "no-multiple-empty-lines": [2, { max: 2, maxEOF: 0 }], "no-mixed-spaces-and-tabs": 2, "no-trailing-spaces": 2, "linebreak-style": [process.platform === "win32" ? 0 : 2, "unix"], indent: [ 2, 2, { SwitchCase: 1, CallExpression: { arguments: 2 }, MemberExpression: 2 }, ], "key-spacing": [ 2, { beforeColon: false, }, ], "eol-last": 2, // copyright "notice/notice": [ 2, { mustMatch: "Copyright", templateFile: path.join(__dirname, "utils", "copyright.js"), }, ], // 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 [ { ignores: ["**/*.js"], }, { files: ["**/*.ts", "**/*.tsx"], plugins, languageOptions, rules: baseRules, }, { files: ['cloudflare/**/*'], rules: { 'notice/notice': 'off', }, }, ]; ``` -------------------------------------------------------------------------------- /src/transport.ts: -------------------------------------------------------------------------------- ```typescript /** * Copyright (c) Microsoft Corporation. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import http from 'node:http'; import assert from 'node:assert'; import crypto from 'node:crypto'; import debug from 'debug'; import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import type { AddressInfo } from 'node:net'; import type { Server } from './server.js'; export async function startStdioTransport(server: Server) { await server.createConnection(new StdioServerTransport()); } const testDebug = debug('pw:mcp:test'); async function handleSSE(server: Server, req: http.IncomingMessage, res: http.ServerResponse, url: URL, sessions: Map<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); testDebug(`create SSE session: ${transport.sessionId}`); const connection = await server.createConnection(transport); res.on('close', () => { testDebug(`delete SSE session: ${transport.sessionId}`); sessions.delete(transport.sessionId); // eslint-disable-next-line no-console void connection.close().catch(e => console.error(e)); }); return; } res.statusCode = 405; res.end('Method not allowed'); } async function handleStreamable(server: Server, req: http.IncomingMessage, res: http.ServerResponse, sessions: Map<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); }; await server.createConnection(transport); await transport.handleRequest(req, res); return; } res.statusCode = 400; res.end('Invalid request'); } export async function startHttpServer(config: { host?: string, port?: number }): Promise<http.Server> { const { host, port } = config; const httpServer = http.createServer(); await new Promise<void>((resolve, reject) => { httpServer.on('error', reject); httpServer.listen(port, host, () => { resolve(); httpServer.removeListener('error', reject); }); }); return httpServer; } export function startHttpTransport(httpServer: http.Server, mcpServer: Server) { const sseSessions = new Map<string, SSEServerTransport>(); const streamableSessions = new Map<string, StreamableHTTPServerTransport>(); httpServer.on('request', async (req, res) => { const url = new URL(`http://localhost${req.url}`); if (url.pathname.startsWith('/mcp')) await handleStreamable(mcpServer, req, res, streamableSessions); else await handleSSE(mcpServer, req, res, url, sseSessions); }); const url = httpAddressToString(httpServer.address()); const message = [ `Listening on ${url}`, 'Put this in your client config:', JSON.stringify({ 'mcpServers': { 'playwright': { 'url': `${url}/sse` } } }, undefined, 2), 'If your client supports streamable HTTP, you can use the /mcp endpoint instead.', ].join('\n'); // eslint-disable-next-line no-console console.error(message); } export function httpAddressToString(address: string | AddressInfo | null): string { assert(address, 'Could not bind server socket'); if (typeof address === 'string') return address; const resolvedPort = address.port; let resolvedHost = address.family === 'IPv4' ? address.address : `[${address.address}]`; if (resolvedHost === '0.0.0.0' || resolvedHost === '[::]') resolvedHost = 'localhost'; return `http://${resolvedHost}:${resolvedPort}`; } ``` -------------------------------------------------------------------------------- /tests/testserver/index.ts: -------------------------------------------------------------------------------- ```typescript /** * Copyright 2017 Google Inc. All rights reserved. * Modifications copyright (c) Microsoft Corporation. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import fs from 'fs'; import url from 'node:url'; import http from 'http'; import https from 'https'; import path from 'path'; import debug from 'debug'; const fulfillSymbol = Symbol('fulfil callback'); const rejectSymbol = Symbol('reject callback'); // NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename. const __filename = url.fileURLToPath(import.meta.url); export class TestServer { private _server: http.Server; readonly debugServer: any; private _routes = new Map<string, (request: http.IncomingMessage, response: http.ServerResponse) => any>(); private _csp = new Map<string, string>(); private _extraHeaders = new Map<string, object>(); private _requestSubscribers = new Map<string, Promise<any>>(); readonly PORT: number; readonly PREFIX: string; readonly CROSS_PROCESS_PREFIX: string; readonly HELLO_WORLD: string; static async create(port: number): Promise<TestServer> { const server = new TestServer(port); await new Promise(x => server._server.once('listening', x)); return server; } static async createHTTPS(port: number): Promise<TestServer> { const server = new TestServer(port, { key: await fs.promises.readFile(path.join(path.dirname(__filename), 'key.pem')), cert: await fs.promises.readFile(path.join(path.dirname(__filename), 'cert.pem')), passphrase: 'aaaa', }); await new Promise(x => server._server.once('listening', x)); return server; } constructor(port: number, sslOptions?: object) { if (sslOptions) this._server = https.createServer(sslOptions, this._onRequest.bind(this)); else this._server = http.createServer(this._onRequest.bind(this)); this._server.listen(port); this.debugServer = debug('pw:testserver'); const cross_origin = '127.0.0.1'; const same_origin = 'localhost'; const protocol = sslOptions ? 'https' : 'http'; this.PORT = port; this.PREFIX = `${protocol}://${same_origin}:${port}/`; this.CROSS_PROCESS_PREFIX = `${protocol}://${cross_origin}:${port}/`; this.HELLO_WORLD = `${this.PREFIX}hello-world`; } setCSP(path: string, csp: string) { this._csp.set(path, csp); } setExtraHeaders(path: string, object: Record<string, string>) { this._extraHeaders.set(path, object); } async stop() { this.reset(); await new Promise(x => this._server.close(x)); } route(path: string, handler: (request: http.IncomingMessage, response: http.ServerResponse) => any) { this._routes.set(path, handler); } setContent(path: string, content: string, mimeType: string) { this.route(path, (req, res) => { res.writeHead(200, { 'Content-Type': mimeType }); res.end(mimeType === 'text/html' ? `<!DOCTYPE html>${content}` : content); }); } redirect(from: string, to: string) { this.route(from, (req, res) => { const headers = this._extraHeaders.get(req.url!) || {}; res.writeHead(302, { ...headers, location: to }); res.end(); }); } waitForRequest(path: string): Promise<http.IncomingMessage> { let promise = this._requestSubscribers.get(path); if (promise) return promise; let fulfill, reject; promise = new Promise((f, r) => { fulfill = f; reject = r; }); promise[fulfillSymbol] = fulfill; promise[rejectSymbol] = reject; this._requestSubscribers.set(path, promise); return promise; } reset() { this._routes.clear(); this._csp.clear(); this._extraHeaders.clear(); this._server.closeAllConnections(); const error = new Error('Static Server has been reset'); for (const subscriber of this._requestSubscribers.values()) subscriber[rejectSymbol].call(null, error); this._requestSubscribers.clear(); this.setContent('/favicon.ico', '', 'image/x-icon'); this.setContent('/', ``, 'text/html'); this.setContent('/hello-world', ` <title>Title</title> <body>Hello, world!</body> `, 'text/html'); } _onRequest(request: http.IncomingMessage, response: http.ServerResponse) { request.on('error', error => { if ((error as any).code === 'ECONNRESET') response.end(); else throw error; }); (request as any).postBody = new Promise(resolve => { const chunks: Buffer[] = []; request.on('data', chunk => { chunks.push(chunk); }); request.on('end', () => resolve(Buffer.concat(chunks))); }); const path = request.url || '/'; this.debugServer(`request ${request.method} ${path}`); // Notify request subscriber. if (this._requestSubscribers.has(path)) { this._requestSubscribers.get(path)![fulfillSymbol].call(null, request); this._requestSubscribers.delete(path); } const handler = this._routes.get(path); if (handler) { handler.call(null, request, response); } else { response.writeHead(404); response.end(); } } } ``` -------------------------------------------------------------------------------- /src/tools/vision.ts: -------------------------------------------------------------------------------- ```typescript /** * Copyright (c) Microsoft Corporation. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { z } from 'zod'; import { defineTool } from './tool.js'; import * as javascript from '../javascript.js'; const elementSchema = z.object({ element: z.string().describe('Human-readable element description used to obtain permission to interact with the element'), }); const screenshot = defineTool({ capability: 'core', schema: { name: 'browser_screen_capture', title: 'Take a screenshot', description: 'Take a screenshot of the current page', inputSchema: z.object({}), type: 'readOnly', }, handle: async context => { const tab = await context.ensureTab(); const options = { type: 'jpeg' as 'jpeg', quality: 50, scale: 'css' as 'css' }; const code = [ `// Take a screenshot of the current page`, `await page.screenshot(${javascript.formatObject(options)});`, ]; const action = () => tab.page.screenshot(options).then(buffer => { return { content: [{ type: 'image' as 'image', data: buffer.toString('base64'), mimeType: 'image/jpeg' }], }; }); return { code, action, captureSnapshot: false, waitForNetwork: false }; }, }); const moveMouse = defineTool({ capability: 'core', schema: { name: 'browser_screen_move_mouse', title: 'Move mouse', description: 'Move mouse to a given position', inputSchema: elementSchema.extend({ x: z.coerce.number().describe('X coordinate'), y: z.coerce.number().describe('Y coordinate'), }), type: 'readOnly', }, handle: async (context, params) => { const tab = context.currentTabOrDie(); const code = [ `// Move mouse to (${params.x}, ${params.y})`, `await page.mouse.move(${params.x}, ${params.y});`, ]; const action = () => tab.page.mouse.move(params.x, params.y); return { code, action, captureSnapshot: false, waitForNetwork: false }; }, }); const click = defineTool({ capability: 'core', schema: { name: 'browser_screen_click', title: 'Click', description: 'Click left mouse button', inputSchema: elementSchema.extend({ x: z.coerce.number().describe('X coordinate'), y: z.coerce.number().describe('Y coordinate'), }), type: 'destructive', }, handle: async (context, params) => { const tab = context.currentTabOrDie(); const code = [ `// Click mouse at coordinates (${params.x}, ${params.y})`, `await page.mouse.move(${params.x}, ${params.y});`, `await page.mouse.down();`, `await page.mouse.up();`, ]; const action = async () => { await tab.page.mouse.move(params.x, params.y); await tab.page.mouse.down(); await tab.page.mouse.up(); }; return { code, action, captureSnapshot: false, waitForNetwork: true, }; }, }); const drag = defineTool({ capability: 'core', schema: { name: 'browser_screen_drag', title: 'Drag mouse', description: 'Drag left mouse button', inputSchema: elementSchema.extend({ startX: z.coerce.number().describe('Start X coordinate'), startY: z.coerce.number().describe('Start Y coordinate'), endX: z.coerce.number().describe('End X coordinate'), endY: z.coerce.number().describe('End Y coordinate'), }), type: 'destructive', }, handle: async (context, params) => { const tab = context.currentTabOrDie(); const code = [ `// Drag mouse from (${params.startX}, ${params.startY}) to (${params.endX}, ${params.endY})`, `await page.mouse.move(${params.startX}, ${params.startY});`, `await page.mouse.down();`, `await page.mouse.move(${params.endX}, ${params.endY});`, `await page.mouse.up();`, ]; const action = async () => { await tab.page.mouse.move(params.startX, params.startY); await tab.page.mouse.down(); await tab.page.mouse.move(params.endX, params.endY); await tab.page.mouse.up(); }; return { code, action, captureSnapshot: false, waitForNetwork: true, }; }, }); const type = defineTool({ capability: 'core', schema: { name: 'browser_screen_type', title: 'Type text', description: 'Type text', inputSchema: z.object({ text: z.string().describe('Text to type into the element'), submit: z.coerce.boolean().optional().describe('Whether to submit entered text (press Enter after)'), }), type: 'destructive', }, handle: async (context, params) => { const tab = context.currentTabOrDie(); const code = [ `// Type ${params.text}`, `await page.keyboard.type('${params.text}');`, ]; const action = async () => { await tab.page.keyboard.type(params.text); if (params.submit) await tab.page.keyboard.press('Enter'); }; if (params.submit) { code.push(`// Submit text`); code.push(`await page.keyboard.press('Enter');`); } return { code, action, captureSnapshot: false, waitForNetwork: true, }; }, }); export default [ screenshot, moveMouse, click, drag, type, ]; ``` -------------------------------------------------------------------------------- /tests/dialogs.spec.ts: -------------------------------------------------------------------------------- ```typescript /** * Copyright (c) Microsoft Corporation. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { test, expect } from './fixtures.js'; // https://github.com/microsoft/playwright/issues/35663 test.skip(({ mcpBrowser, mcpHeadless }) => mcpBrowser === 'webkit' && mcpHeadless); test('alert dialog', async ({ client, server }) => { server.setContent('/', `<button onclick="alert('Alert')">Button</button>`, 'text/html'); expect(await client.callTool({ name: 'browser_navigate', arguments: { url: server.PREFIX }, })).toContainTextContent('- button "Button" [ref=e2]'); expect(await client.callTool({ name: 'browser_click', arguments: { element: 'Button', ref: 'e2', }, })).toHaveTextContent(`- Ran Playwright code: \`\`\`js // Click Button await page.getByRole('button', { name: 'Button' }).click(); \`\`\` ### Modal state - ["alert" dialog with message "Alert"]: can be handled by the "browser_handle_dialog" tool`); const result = await client.callTool({ name: 'browser_handle_dialog', arguments: { accept: true, }, }); expect(result).not.toContainTextContent('### Modal state'); expect(result).toHaveTextContent(`- Ran Playwright code: \`\`\`js // <internal code to handle "alert" dialog> \`\`\` - Page URL: ${server.PREFIX} - Page Title: - Page Snapshot \`\`\`yaml - button "Button" [active] [ref=e2] \`\`\` `); }); test('two alert dialogs', async ({ client, server }) => { test.fixme(true, 'Race between the dialog and ariaSnapshot'); server.setContent('/', ` <title>Title</title> <body> <button onclick="alert('Alert 1');alert('Alert 2');">Button</button> </body> `, 'text/html'); expect(await client.callTool({ name: 'browser_navigate', arguments: { url: server.PREFIX }, })).toContainTextContent('- button "Button" [ref=e2]'); expect(await client.callTool({ name: 'browser_click', arguments: { element: 'Button', ref: 'e2', }, })).toHaveTextContent(`- Ran Playwright code: \`\`\`js // Click Button await page.getByRole('button', { name: 'Button' }).click(); \`\`\` ### Modal state - ["alert" dialog with message "Alert 1"]: can be handled by the "browser_handle_dialog" tool`); const result = await client.callTool({ name: 'browser_handle_dialog', arguments: { accept: true, }, }); expect(result).not.toContainTextContent('### Modal state'); }); test('confirm dialog (true)', async ({ client, server }) => { server.setContent('/', ` <title>Title</title> <body> <button onclick="document.body.textContent = confirm('Confirm')">Button</button> </body> `, 'text/html'); expect(await client.callTool({ name: 'browser_navigate', arguments: { url: server.PREFIX }, })).toContainTextContent('- button "Button" [ref=e2]'); expect(await client.callTool({ name: 'browser_click', arguments: { element: 'Button', ref: 'e2', }, })).toContainTextContent(`### Modal state - ["confirm" dialog with message "Confirm"]: can be handled by the "browser_handle_dialog" tool`); const result = await client.callTool({ name: 'browser_handle_dialog', arguments: { accept: true, }, }); expect(result).not.toContainTextContent('### Modal state'); expect(result).toContainTextContent('// <internal code to handle "confirm" dialog>'); expect(result).toContainTextContent(`- Page Snapshot \`\`\`yaml - generic [active] [ref=e1]: "true" \`\`\``); }); test('confirm dialog (false)', async ({ client, server }) => { server.setContent('/', ` <title>Title</title> <body> <button onclick="document.body.textContent = confirm('Confirm')">Button</button> </body> `, 'text/html'); expect(await client.callTool({ name: 'browser_navigate', arguments: { url: server.PREFIX }, })).toContainTextContent('- button "Button" [ref=e2]'); expect(await client.callTool({ name: 'browser_click', arguments: { element: 'Button', ref: 'e2', }, })).toContainTextContent(`### Modal state - ["confirm" dialog with message "Confirm"]: can be handled by the "browser_handle_dialog" tool`); const result = await client.callTool({ name: 'browser_handle_dialog', arguments: { accept: false, }, }); expect(result).toContainTextContent(`- Page Snapshot \`\`\`yaml - generic [active] [ref=e1]: "false" \`\`\``); }); test('prompt dialog', async ({ client, server }) => { server.setContent('/', ` <title>Title</title> <body> <button onclick="document.body.textContent = prompt('Prompt')">Button</button> </body> `, 'text/html'); expect(await client.callTool({ name: 'browser_navigate', arguments: { url: server.PREFIX }, })).toContainTextContent('- button "Button" [ref=e2]'); expect(await client.callTool({ name: 'browser_click', arguments: { element: 'Button', ref: 'e2', }, })).toContainTextContent(`### Modal state - ["prompt" dialog with message "Prompt"]: can be handled by the "browser_handle_dialog" tool`); const result = await client.callTool({ name: 'browser_handle_dialog', arguments: { accept: true, promptText: 'Answer', }, }); expect(result).toContainTextContent(`- Page Snapshot \`\`\`yaml - generic [active] [ref=e1]: Answer \`\`\``); }); ``` -------------------------------------------------------------------------------- /src/browserServer.ts: -------------------------------------------------------------------------------- ```typescript /** * Copyright (c) Microsoft Corporation. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /* eslint-disable no-console */ import net from 'net'; import { program } from 'commander'; import playwright from 'playwright'; import { HttpServer } from './httpServer.js'; import { packageJSON } from './package.js'; import type http from 'http'; export type LaunchBrowserRequest = { browserType: string; userDataDir: string; launchOptions: playwright.LaunchOptions; contextOptions: playwright.BrowserContextOptions; }; export type BrowserInfo = { browserType: string; userDataDir: string; cdpPort: number; launchOptions: playwright.LaunchOptions; contextOptions: playwright.BrowserContextOptions; error?: string; }; type BrowserEntry = { browser?: playwright.Browser; info: BrowserInfo; }; class BrowserServer { private _server = new HttpServer(); private _entries: BrowserEntry[] = []; constructor() { this._setupExitHandler(); } async start(port: number) { await this._server.start({ port }); this._server.routePath('/json/list', (req, res) => { this._handleJsonList(res); }); this._server.routePath('/json/launch', async (req, res) => { void this._handleLaunchBrowser(req, res).catch(e => console.error(e)); }); this._setEntries([]); } private _handleJsonList(res: http.ServerResponse) { const list = this._entries.map(browser => browser.info); res.end(JSON.stringify(list)); } private async _handleLaunchBrowser(req: http.IncomingMessage, res: http.ServerResponse) { const request = await readBody<LaunchBrowserRequest>(req); let info = this._entries.map(entry => entry.info).find(info => info.userDataDir === request.userDataDir); if (!info || info.error) info = await this._newBrowser(request); res.end(JSON.stringify(info)); } private async _newBrowser(request: LaunchBrowserRequest): Promise<BrowserInfo> { const cdpPort = await findFreePort(); (request.launchOptions as any).cdpPort = cdpPort; const info: BrowserInfo = { browserType: request.browserType, userDataDir: request.userDataDir, cdpPort, launchOptions: request.launchOptions, contextOptions: request.contextOptions, }; const browserType = playwright[request.browserType as 'chromium' | 'firefox' | 'webkit']; const { browser, error } = await browserType.launchPersistentContext(request.userDataDir, { ...request.launchOptions, ...request.contextOptions, handleSIGINT: false, handleSIGTERM: false, }).then(context => { return { browser: context.browser()!, error: undefined }; }).catch(error => { return { browser: undefined, error: error.message }; }); this._setEntries([...this._entries, { browser, info: { browserType: request.browserType, userDataDir: request.userDataDir, cdpPort, launchOptions: request.launchOptions, contextOptions: request.contextOptions, error, }, }]); browser?.on('disconnected', () => { this._setEntries(this._entries.filter(entry => entry.browser !== browser)); }); return info; } private _updateReport() { // Clear the current line and move cursor to top of screen process.stdout.write('\x1b[2J\x1b[H'); process.stdout.write(`Playwright Browser Server v${packageJSON.version}\n`); process.stdout.write(`Listening on ${this._server.urlPrefix('human-readable')}\n\n`); if (this._entries.length === 0) { process.stdout.write('No browsers currently running\n'); return; } process.stdout.write('Running browsers:\n'); for (const entry of this._entries) { const status = entry.browser ? 'running' : 'error'; const statusColor = entry.browser ? '\x1b[32m' : '\x1b[31m'; // green for running, red for error process.stdout.write(`${statusColor}${entry.info.browserType}\x1b[0m (${entry.info.userDataDir}) - ${statusColor}${status}\x1b[0m\n`); if (entry.info.error) process.stdout.write(` Error: ${entry.info.error}\n`); } } private _setEntries(entries: BrowserEntry[]) { this._entries = entries; this._updateReport(); } private _setupExitHandler() { let isExiting = false; const handleExit = async () => { if (isExiting) return; isExiting = true; setTimeout(() => process.exit(0), 15000); for (const entry of this._entries) await entry.browser?.close().catch(() => {}); process.exit(0); }; process.stdin.on('close', handleExit); process.on('SIGINT', handleExit); process.on('SIGTERM', handleExit); } } program .name('browser-agent') .option('-p, --port <port>', 'Port to listen on', '9224') .action(async options => { await main(options); }); void program.parseAsync(process.argv); async function main(options: { port: string }) { const server = new BrowserServer(); await server.start(+options.port); } function readBody<T>(req: http.IncomingMessage): Promise<T> { return new Promise((resolve, reject) => { const chunks: Buffer[] = []; req.on('data', (chunk: Buffer) => chunks.push(chunk)); req.on('end', () => resolve(JSON.parse(Buffer.concat(chunks).toString()))); }); } async function findFreePort(): Promise<number> { return new Promise((resolve, reject) => { const server = net.createServer(); server.listen(0, () => { const { port } = server.address() as net.AddressInfo; server.close(() => resolve(port)); }); server.on('error', reject); }); } ```