├── .github └── workflows │ ├── ci.yml │ └── publish.yml ├── .gitignore ├── .npmignore ├── Dockerfile ├── LICENSE ├── README.md ├── SECURITY.md ├── cli.js ├── eslint.config.mjs ├── examples.md ├── index.d.ts ├── index.js ├── package-lock.json ├── package.json ├── playwright.config.ts ├── src ├── context.ts ├── index.ts ├── javascript.ts ├── manualPromise.ts ├── program.ts ├── resources │ └── resource.ts ├── server.ts └── tools │ ├── common.ts │ ├── console.ts │ ├── dialogs.ts │ ├── files.ts │ ├── install.ts │ ├── keyboard.ts │ ├── navigate.ts │ ├── network.ts │ ├── pdf.ts │ ├── screen.ts │ ├── snapshot.ts │ ├── tabs.ts │ ├── tool.ts │ └── utils.ts ├── tests ├── capabilities.spec.ts ├── cdp.spec.ts ├── console.spec.ts ├── core.spec.ts ├── dialogs.spec.ts ├── files.spec.ts ├── fixtures.ts ├── iframes.spec.ts ├── launch.spec.ts ├── network.spec.ts ├── pdf.spec.ts ├── screenshot.spec.ts ├── sse.spec.ts ├── tabs.spec.ts └── testserver │ ├── cert.pem │ ├── index.ts │ ├── key.pem │ └── san.cnf ├── tsconfig.all.json ├── tsconfig.json └── utils ├── copyright.js └── update-readme.js /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | lint: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - name: Use Node.js 18 15 | uses: actions/setup-node@v4 16 | with: 17 | node-version: '18' 18 | cache: 'npm' 19 | - name: Install dependencies 20 | run: npm ci 21 | - run: npm run build 22 | - name: Run ESLint 23 | run: npm run lint 24 | - run: npm run update-readme 25 | - name: Ensure no changes 26 | run: git diff --exit-code 27 | 28 | test: 29 | strategy: 30 | fail-fast: false 31 | matrix: 32 | os: [ubuntu-latest, macos-latest, windows-latest] 33 | runs-on: ${{ matrix.os }} 34 | 35 | steps: 36 | - uses: actions/checkout@v4 37 | 38 | - name: Use Node.js 18 39 | uses: actions/setup-node@v4 40 | with: 41 | node-version: '18' 42 | cache: 'npm' 43 | 44 | - name: Install dependencies 45 | run: npm ci 46 | 47 | - name: Playwright install 48 | run: npx playwright install --with-deps 49 | 50 | - name: Install MS Edge 51 | if: ${{ matrix.os == 'macos-latest' }} # MS Edge is not preinstalled on macOS runners. 52 | run: npx playwright install msedge 53 | 54 | - name: Build 55 | run: npm run build 56 | 57 | - name: Install Playwright browsers 58 | run: npx playwright install --with-deps 59 | 60 | - name: Run tests 61 | run: npm test -- --forbid-only 62 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | on: 3 | release: 4 | types: [published] 5 | jobs: 6 | publish-npm: 7 | runs-on: ubuntu-latest 8 | permissions: 9 | contents: read 10 | id-token: write 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: actions/setup-node@v4 14 | with: 15 | node-version: 18 16 | registry-url: https://registry.npmjs.org/ 17 | - run: npm ci 18 | - run: npx playwright install --with-deps 19 | - run: npm run build 20 | - run: npm run lint 21 | - run: npm run ctest 22 | - run: npm publish --provenance 23 | env: 24 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | lib/ 2 | node_modules/ 3 | test-results/ 4 | .vscode/mcp.json 5 | 6 | .idea 7 | .DS_Store 8 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | **/* 2 | README.md 3 | LICENSE 4 | !lib/**/*.js 5 | !cli.js 6 | !index.* 7 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:22-bookworm-slim 2 | 3 | # Set the working directory 4 | WORKDIR /app 5 | 6 | # Copy package.json and package-lock.json at this stage to leverage the build cache 7 | COPY package*.json ./ 8 | 9 | # Install dependencies 10 | RUN npm ci 11 | 12 | # Install chromium and its dependencies, but only for headless mode 13 | RUN npx -y playwright install --with-deps --only-shell chromium 14 | 15 | # Copy the rest of the app 16 | COPY . . 17 | 18 | # Build the app 19 | RUN npm run build 20 | 21 | # Run in headless and only with chromium (other browsers need more dependencies not included in this image) 22 | ENTRYPOINT ["node", "cli.js", "--headless", "--browser", "chromium"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Portions Copyright (c) Microsoft Corporation. 190 | Portions Copyright 2017 Google Inc. 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Playwright MCP 2 | 3 | A Model Context Protocol (MCP) server that provides browser automation capabilities using [Playwright](https://playwright.dev). This server enables LLMs to interact with web pages through structured accessibility snapshots, bypassing the need for screenshots or visually-tuned models. 4 | 5 | ### Key Features 6 | 7 | - **Fast and lightweight**: Uses Playwright's accessibility tree, not pixel-based input. 8 | - **LLM-friendly**: No vision models needed, operates purely on structured data. 9 | - **Deterministic tool application**: Avoids ambiguity common with screenshot-based approaches. 10 | 11 | ### Use Cases 12 | 13 | - Web navigation and form-filling 14 | - Data extraction from structured content 15 | - Automated testing driven by LLMs 16 | - General-purpose browser interaction for agents 17 | 18 | ### Example config 19 | 20 | #### NPX 21 | 22 | ```js 23 | { 24 | "mcpServers": { 25 | "playwright": { 26 | "command": "npx", 27 | "args": [ 28 | "@playwright/mcp@latest" 29 | ] 30 | } 31 | } 32 | } 33 | ``` 34 | 35 | #### Installation in VS Code 36 | 37 | Install the Playwright MCP server in VS Code using one of these buttons: 38 | 39 | 46 | 47 | [Install in VS Code](https://insiders.vscode.dev/redirect?url=vscode%3Amcp%2Finstall%3F%257B%2522name%2522%253A%2522playwright%2522%252C%2522command%2522%253A%2522npx%2522%252C%2522args%2522%253A%255B%2522-y%2522%252C%2522%2540playwright%252Fmcp%2540latest%2522%255D%257D) [Install in VS Code Insiders](https://insiders.vscode.dev/redirect?url=vscode-insiders%3Amcp%2Finstall%3F%257B%2522name%2522%253A%2522playwright%2522%252C%2522command%2522%253A%2522npx%2522%252C%2522args%2522%253A%255B%2522-y%2522%252C%2522%2540playwright%252Fmcp%2540latest%2522%255D%257D) 48 | 49 | Alternatively, you can install the Playwright MCP server using the VS Code CLI: 50 | 51 | ```bash 52 | # For VS Code 53 | code --add-mcp '{"name":"playwright","command":"npx","args":["@playwright/mcp@latest"]}' 54 | ``` 55 | 56 | ```bash 57 | # For VS Code Insiders 58 | code-insiders --add-mcp '{"name":"playwright","command":"npx","args":["@playwright/mcp@latest"]}' 59 | ``` 60 | 61 | After installation, the Playwright MCP server will be available for use with your GitHub Copilot agent in VS Code. 62 | 63 | ### CLI Options 64 | 65 | The Playwright MCP server supports the following command-line options: 66 | 67 | - `--browser `: Browser or chrome channel to use. Possible values: 68 | - `chrome`, `firefox`, `webkit`, `msedge` 69 | - Chrome channels: `chrome-beta`, `chrome-canary`, `chrome-dev` 70 | - Edge channels: `msedge-beta`, `msedge-canary`, `msedge-dev` 71 | - Default: `chrome` 72 | - `--caps `: Comma-separated list of capabilities to enable, possible values: tabs, pdf, history, wait, files, install. Default is all. 73 | - `--cdp-endpoint `: CDP endpoint to connect to 74 | - `--executable-path `: Path to the browser executable 75 | - `--headless`: Run browser in headless mode (headed by default) 76 | - `--port `: Port to listen on for SSE transport 77 | - `--host `: Host to bind server to. Default is localhost. Use 0.0.0.0 to bind to all interfaces. 78 | - `--user-data-dir `: Path to the user data directory 79 | - `--vision`: Run server that uses screenshots (Aria snapshots are used by default) 80 | 81 | ### User data directory 82 | 83 | Playwright MCP will launch the browser with the new profile, located at 84 | 85 | ``` 86 | - `%USERPROFILE%\AppData\Local\ms-playwright\mcp-chrome-profile` on Windows 87 | - `~/Library/Caches/ms-playwright/mcp-chrome-profile` on macOS 88 | - `~/.cache/ms-playwright/mcp-chrome-profile` on Linux 89 | ``` 90 | 91 | All the logged in information will be stored in that profile, you can delete it between sessions if you'd like to clear the offline state. 92 | 93 | ### Running headless browser (Browser without GUI) 94 | 95 | This mode is useful for background or batch operations. 96 | 97 | ```js 98 | { 99 | "mcpServers": { 100 | "playwright": { 101 | "command": "npx", 102 | "args": [ 103 | "@playwright/mcp@latest", 104 | "--headless" 105 | ] 106 | } 107 | } 108 | } 109 | ``` 110 | 111 | ### Running headed browser on Linux w/o DISPLAY 112 | 113 | When running headed browser on system w/o display or from worker processes of the IDEs, 114 | run the MCP server from environment with the DISPLAY and pass the `--port` flag to enable SSE transport. 115 | 116 | ```bash 117 | npx @playwright/mcp@latest --port 8931 118 | ``` 119 | 120 | And then in MCP client config, set the `url` to the SSE endpoint: 121 | 122 | ```js 123 | { 124 | "mcpServers": { 125 | "playwright": { 126 | "url": "http://localhost:8931/sse" 127 | } 128 | } 129 | } 130 | ``` 131 | 132 | When running in a remote server, you can use the `--host` flag to bind the server to `0.0.0.0` to make it accessible from outside. 133 | 134 | ```bash 135 | npx @playwright/mcp@latest --port 8931 --host 0.0.0.0 136 | ``` 137 | 138 | In MCP client config, `$server-ip` is the IP address of the server: 139 | 140 | ```js 141 | { 142 | "mcpServers": { 143 | "playwright": { 144 | "url": "http://{$server-ip}:8931/sse" 145 | } 146 | } 147 | } 148 | ``` 149 | 150 | ### Docker 151 | 152 | **NOTE:** The Docker implementation only supports headless chromium at the moment. 153 | 154 | ```js 155 | { 156 | "mcpServers": { 157 | "playwright": { 158 | "command": "docker", 159 | "args": ["run", "-i", "--rm", "--init", "mcp/playwright"] 160 | } 161 | } 162 | } 163 | ``` 164 | 165 | ### Tool Modes 166 | 167 | The tools are available in two modes: 168 | 169 | 1. **Snapshot Mode** (default): Uses accessibility snapshots for better performance and reliability 170 | 2. **Vision Mode**: Uses screenshots for visual-based interactions 171 | 172 | To use Vision Mode, add the `--vision` flag when starting the server: 173 | 174 | ```js 175 | { 176 | "mcpServers": { 177 | "playwright": { 178 | "command": "npx", 179 | "args": [ 180 | "@playwright/mcp@latest", 181 | "--vision" 182 | ] 183 | } 184 | } 185 | } 186 | ``` 187 | 188 | Vision Mode works best with the computer use models that are able to interact with elements using 189 | X Y coordinate space, based on the provided screenshot. 190 | 191 | ### Build with Docker 192 | 193 | You can build the Docker image yourself. 194 | 195 | ``` 196 | docker build -t mcp/playwright . 197 | ``` 198 | 199 | ### Programmatic usage with custom transports 200 | 201 | ```js 202 | import http from 'http'; 203 | 204 | import { createServer } from '@playwright/mcp'; 205 | import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'; 206 | 207 | http.createServer(async (req, res) => { 208 | // ... 209 | 210 | // Creates a headless Playwright MCP server with SSE transport 211 | const mcpServer = await createServer({ headless: true }); 212 | const transport = new SSEServerTransport('/messages', res); 213 | await mcpServer.connect(transport); 214 | 215 | // ... 216 | }); 217 | 218 | ``` 219 | 220 | 221 | 222 | ### Snapshot-based Interactions 223 | 224 | 225 | 226 | - **browser_snapshot** 227 | - Description: Capture accessibility snapshot of the current page, this is better than screenshot 228 | - Parameters: None 229 | 230 | 231 | 232 | - **browser_click** 233 | - Description: Perform click on a web page 234 | - Parameters: 235 | - `element` (string): Human-readable element description used to obtain permission to interact with the element 236 | - `ref` (string): Exact target element reference from the page snapshot 237 | 238 | 239 | 240 | - **browser_drag** 241 | - Description: Perform drag and drop between two elements 242 | - Parameters: 243 | - `startElement` (string): Human-readable source element description used to obtain the permission to interact with the element 244 | - `startRef` (string): Exact source element reference from the page snapshot 245 | - `endElement` (string): Human-readable target element description used to obtain the permission to interact with the element 246 | - `endRef` (string): Exact target element reference from the page snapshot 247 | 248 | 249 | 250 | - **browser_hover** 251 | - Description: Hover over element on page 252 | - Parameters: 253 | - `element` (string): Human-readable element description used to obtain permission to interact with the element 254 | - `ref` (string): Exact target element reference from the page snapshot 255 | 256 | 257 | 258 | - **browser_type** 259 | - Description: Type text into editable element 260 | - Parameters: 261 | - `element` (string): Human-readable element description used to obtain permission to interact with the element 262 | - `ref` (string): Exact target element reference from the page snapshot 263 | - `text` (string): Text to type into the element 264 | - `submit` (boolean, optional): Whether to submit entered text (press Enter after) 265 | - `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. 266 | 267 | 268 | 269 | - **browser_select_option** 270 | - Description: Select an option in a dropdown 271 | - Parameters: 272 | - `element` (string): Human-readable element description used to obtain permission to interact with the element 273 | - `ref` (string): Exact target element reference from the page snapshot 274 | - `values` (array): Array of values to select in the dropdown. This can be a single value or multiple values. 275 | 276 | 277 | 278 | - **browser_take_screenshot** 279 | - Description: Take a screenshot of the current page. You can't perform actions based on the screenshot, use browser_snapshot for actions. 280 | - Parameters: 281 | - `raw` (boolean, optional): Whether to return without compression (in PNG format). Default is false, which returns a JPEG image. 282 | - `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. 283 | - `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. 284 | 285 | ### Vision-based Interactions 286 | 287 | 288 | 289 | - **browser_screen_capture** 290 | - Description: Take a screenshot of the current page 291 | - Parameters: None 292 | 293 | 294 | 295 | - **browser_screen_move_mouse** 296 | - Description: Move mouse to a given position 297 | - Parameters: 298 | - `element` (string): Human-readable element description used to obtain permission to interact with the element 299 | - `x` (number): X coordinate 300 | - `y` (number): Y coordinate 301 | 302 | 303 | 304 | - **browser_screen_click** 305 | - Description: Click left mouse button 306 | - Parameters: 307 | - `element` (string): Human-readable element description used to obtain permission to interact with the element 308 | - `x` (number): X coordinate 309 | - `y` (number): Y coordinate 310 | 311 | 312 | 313 | - **browser_screen_drag** 314 | - Description: Drag left mouse button 315 | - Parameters: 316 | - `element` (string): Human-readable element description used to obtain permission to interact with the element 317 | - `startX` (number): Start X coordinate 318 | - `startY` (number): Start Y coordinate 319 | - `endX` (number): End X coordinate 320 | - `endY` (number): End Y coordinate 321 | 322 | 323 | 324 | - **browser_screen_type** 325 | - Description: Type text 326 | - Parameters: 327 | - `text` (string): Text to type into the element 328 | - `submit` (boolean, optional): Whether to submit entered text (press Enter after) 329 | 330 | ### Tab Management 331 | 332 | 333 | 334 | - **browser_tab_list** 335 | - Description: List browser tabs 336 | - Parameters: None 337 | 338 | 339 | 340 | - **browser_tab_new** 341 | - Description: Open a new tab 342 | - Parameters: 343 | - `url` (string, optional): The URL to navigate to in the new tab. If not provided, the new tab will be blank. 344 | 345 | 346 | 347 | - **browser_tab_select** 348 | - Description: Select a tab by index 349 | - Parameters: 350 | - `index` (number): The index of the tab to select 351 | 352 | 353 | 354 | - **browser_tab_close** 355 | - Description: Close a tab 356 | - Parameters: 357 | - `index` (number, optional): The index of the tab to close. Closes current tab if not provided. 358 | 359 | ### Navigation 360 | 361 | 362 | 363 | - **browser_navigate** 364 | - Description: Navigate to a URL 365 | - Parameters: 366 | - `url` (string): The URL to navigate to 367 | 368 | 369 | 370 | - **browser_navigate_back** 371 | - Description: Go back to the previous page 372 | - Parameters: None 373 | 374 | 375 | 376 | - **browser_navigate_forward** 377 | - Description: Go forward to the next page 378 | - Parameters: None 379 | 380 | ### Keyboard 381 | 382 | 383 | 384 | - **browser_press_key** 385 | - Description: Press a key on the keyboard 386 | - Parameters: 387 | - `key` (string): Name of the key to press or a character to generate, such as `ArrowLeft` or `a` 388 | 389 | ### Console 390 | 391 | 392 | 393 | - **browser_console_messages** 394 | - Description: Returns all console messages 395 | - Parameters: None 396 | 397 | ### Files and Media 398 | 399 | 400 | 401 | - **browser_file_upload** 402 | - Description: Upload one or multiple files 403 | - Parameters: 404 | - `paths` (array): The absolute paths to the files to upload. Can be a single file or multiple files. 405 | 406 | 407 | 408 | - **browser_pdf_save** 409 | - Description: Save page as PDF 410 | - Parameters: None 411 | 412 | ### Utilities 413 | 414 | 415 | 416 | - **browser_close** 417 | - Description: Close the page 418 | - Parameters: None 419 | 420 | 421 | 422 | - **browser_wait** 423 | - Description: Wait for a specified time in seconds 424 | - Parameters: 425 | - `time` (number): The time to wait in seconds 426 | 427 | 428 | 429 | - **browser_resize** 430 | - Description: Resize the browser window 431 | - Parameters: 432 | - `width` (number): Width of the browser window 433 | - `height` (number): Height of the browser window 434 | 435 | 436 | 437 | - **browser_install** 438 | - Description: Install the browser specified in the config. Call this if you get an error about the browser not being installed. 439 | - Parameters: None 440 | 441 | 442 | 443 | - **browser_handle_dialog** 444 | - Description: Handle a dialog 445 | - Parameters: 446 | - `accept` (boolean): Whether to accept the dialog. 447 | - `promptText` (string, optional): The text of the prompt in case of a prompt dialog. 448 | 449 | 450 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Security 4 | 5 | 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). 6 | 7 | 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. 8 | 9 | ## Reporting Security Issues 10 | 11 | **Please do not report security vulnerabilities through public GitHub issues.** 12 | 13 | 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). 14 | 15 | If you prefer to submit without logging in, send email to [secure@microsoft.com](mailto:secure@microsoft.com). 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). 16 | 17 | 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). 18 | 19 | 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: 20 | 21 | * Type of issue (e.g. buffer overflow, SQL injection, cross-site scripting, etc.) 22 | * Full paths of source file(s) related to the manifestation of the issue 23 | * The location of the affected source code (tag/branch/commit or direct URL) 24 | * Any special configuration required to reproduce the issue 25 | * Step-by-step instructions to reproduce the issue 26 | * Proof-of-concept or exploit code (if possible) 27 | * Impact of the issue, including how an attacker might exploit the issue 28 | 29 | This information will help us triage your report more quickly. 30 | 31 | 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. 32 | 33 | ## Preferred Languages 34 | 35 | We prefer all communications to be in English. 36 | 37 | ## Policy 38 | 39 | Microsoft follows the principle of [Coordinated Vulnerability Disclosure](https://aka.ms/security.md/cvd). 40 | 41 | 42 | -------------------------------------------------------------------------------- /cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /** 3 | * Copyright (c) Microsoft Corporation. 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | 18 | require('./lib/program'); 19 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import typescriptEslint from "@typescript-eslint/eslint-plugin"; 18 | import tsParser from "@typescript-eslint/parser"; 19 | import notice from "eslint-plugin-notice"; 20 | import path from "path"; 21 | import { fileURLToPath } from "url"; 22 | import stylistic from "@stylistic/eslint-plugin"; 23 | import importRules from "eslint-plugin-import"; 24 | 25 | const __filename = fileURLToPath(import.meta.url); 26 | const __dirname = path.dirname(__filename); 27 | 28 | const plugins = { 29 | "@stylistic": stylistic, 30 | "@typescript-eslint": typescriptEslint, 31 | notice, 32 | import: importRules, 33 | }; 34 | 35 | export const baseRules = { 36 | "@typescript-eslint/no-floating-promises": "error", 37 | "@typescript-eslint/no-unused-vars": [ 38 | 2, 39 | { args: "none", caughtErrors: "none" }, 40 | ], 41 | 42 | /** 43 | * Enforced rules 44 | */ 45 | // syntax preferences 46 | "object-curly-spacing": ["error", "always"], 47 | quotes: [ 48 | 2, 49 | "single", 50 | { 51 | avoidEscape: true, 52 | allowTemplateLiterals: true, 53 | }, 54 | ], 55 | "jsx-quotes": [2, "prefer-single"], 56 | "no-extra-semi": 2, 57 | "@stylistic/semi": [2], 58 | "comma-style": [2, "last"], 59 | "wrap-iife": [2, "inside"], 60 | "spaced-comment": [ 61 | 2, 62 | "always", 63 | { 64 | markers: ["*"], 65 | }, 66 | ], 67 | eqeqeq: [2], 68 | "accessor-pairs": [ 69 | 2, 70 | { 71 | getWithoutSet: false, 72 | setWithoutGet: false, 73 | }, 74 | ], 75 | "brace-style": [2, "1tbs", { allowSingleLine: true }], 76 | curly: [2, "multi-or-nest", "consistent"], 77 | "new-parens": 2, 78 | "arrow-parens": [2, "as-needed"], 79 | "prefer-const": 2, 80 | "quote-props": [2, "consistent"], 81 | "nonblock-statement-body-position": [2, "below"], 82 | 83 | // anti-patterns 84 | "no-var": 2, 85 | "no-with": 2, 86 | "no-multi-str": 2, 87 | "no-caller": 2, 88 | "no-implied-eval": 2, 89 | "no-labels": 2, 90 | "no-new-object": 2, 91 | "no-octal-escape": 2, 92 | "no-self-compare": 2, 93 | "no-shadow-restricted-names": 2, 94 | "no-cond-assign": 2, 95 | "no-debugger": 2, 96 | "no-dupe-keys": 2, 97 | "no-duplicate-case": 2, 98 | "no-empty-character-class": 2, 99 | "no-unreachable": 2, 100 | "no-unsafe-negation": 2, 101 | radix: 2, 102 | "valid-typeof": 2, 103 | "no-implicit-globals": [2], 104 | "no-unused-expressions": [ 105 | 2, 106 | { allowShortCircuit: true, allowTernary: true, allowTaggedTemplates: true }, 107 | ], 108 | "no-proto": 2, 109 | 110 | // es2015 features 111 | "require-yield": 2, 112 | "template-curly-spacing": [2, "never"], 113 | 114 | // spacing details 115 | "space-infix-ops": 2, 116 | "space-in-parens": [2, "never"], 117 | "array-bracket-spacing": [2, "never"], 118 | "comma-spacing": [2, { before: false, after: true }], 119 | "keyword-spacing": [2, "always"], 120 | "space-before-function-paren": [ 121 | 2, 122 | { 123 | anonymous: "never", 124 | named: "never", 125 | asyncArrow: "always", 126 | }, 127 | ], 128 | "no-whitespace-before-property": 2, 129 | "keyword-spacing": [ 130 | 2, 131 | { 132 | overrides: { 133 | if: { after: true }, 134 | else: { after: true }, 135 | for: { after: true }, 136 | while: { after: true }, 137 | do: { after: true }, 138 | switch: { after: true }, 139 | return: { after: true }, 140 | }, 141 | }, 142 | ], 143 | "arrow-spacing": [ 144 | 2, 145 | { 146 | after: true, 147 | before: true, 148 | }, 149 | ], 150 | "@stylistic/func-call-spacing": 2, 151 | "@stylistic/type-annotation-spacing": 2, 152 | 153 | // file whitespace 154 | "no-multiple-empty-lines": [2, { max: 2, maxEOF: 0 }], 155 | "no-mixed-spaces-and-tabs": 2, 156 | "no-trailing-spaces": 2, 157 | "linebreak-style": [process.platform === "win32" ? 0 : 2, "unix"], 158 | indent: [ 159 | 2, 160 | 2, 161 | { SwitchCase: 1, CallExpression: { arguments: 2 }, MemberExpression: 2 }, 162 | ], 163 | "key-spacing": [ 164 | 2, 165 | { 166 | beforeColon: false, 167 | }, 168 | ], 169 | "eol-last": 2, 170 | 171 | // copyright 172 | "notice/notice": [ 173 | 2, 174 | { 175 | mustMatch: "Copyright", 176 | templateFile: path.join(__dirname, "utils", "copyright.js"), 177 | }, 178 | ], 179 | 180 | // react 181 | "react/react-in-jsx-scope": 0, 182 | }; 183 | 184 | const languageOptions = { 185 | parser: tsParser, 186 | ecmaVersion: 9, 187 | sourceType: "module", 188 | parserOptions: { 189 | project: path.join(fileURLToPath(import.meta.url), "..", "tsconfig.all.json"), 190 | } 191 | }; 192 | 193 | export default [ 194 | { 195 | ignores: ["**/*.js"], 196 | }, 197 | { 198 | files: ["**/*.ts", "**/*.tsx"], 199 | plugins, 200 | languageOptions, 201 | rules: baseRules, 202 | }, 203 | ]; 204 | -------------------------------------------------------------------------------- /examples.md: -------------------------------------------------------------------------------- 1 | # Examples 2 | 3 | Below are some examples of prompts to use with Playwright MCP. Draw inspiration from them to create your own. If you come up with a new prompt that works well, feel free to let us know in an issue or pull request. 4 | 5 | ## Write a regression test for a bug report 6 | 7 | We have received a user report about a bug on our website https://debs-obrien.github.io/playwright-movies-app. I'd like you to verify if this bug exists by trying to follow the steps. If you see different behaviour, explain how it's different. If you see the same behaviour, please write me a Playwright regression test that encapsulates the buggy user flow. While writing the test, draw inspiration from the Playwright code you ran. Here's the user report: 8 | 9 | Howdy. I love your movies site, but there's something fishy going on. I'm looking at The Garfield Movie - a spectacular film - but somehow it's showing the wrong length. The movie is 90mins long, I measured myself yesterday! But somehow your site is claiming 101mins. Am I looking at it wrong, or is there a bug? 10 | 11 | **Output:** 12 | 13 | ```ts 14 | import { test, expect } from '@playwright/test'; 15 | 16 | /** 17 | * Regression test for bug: The Garfield Movie showing incorrect runtime 18 | * User reported actual runtime is 90 minutes, but site shows 101 minutes 19 | */ 20 | test('verify The Garfield Movie runtime bug', async ({ page }) => { 21 | // Navigate to the movies app home page 22 | await page.goto('https://debs-obrien.github.io/playwright-movies-app'); 23 | 24 | // Wait for page to load completely 25 | await expect(page.getByRole('heading', { name: 'Popular', level: 1 })).toBeVisible(); 26 | 27 | // Look for The Garfield Movie and click on it 28 | const garfieldMovieLink = page.getByRole('link', { name: /poster of The Garfield Movie/i }); 29 | await expect(garfieldMovieLink).toBeVisible(); 30 | await garfieldMovieLink.click(); 31 | 32 | // Verify we're on the correct movie page 33 | await expect(page.getByRole('heading', { name: 'The Garfield Movie', level: 1 })).toBeVisible(); 34 | 35 | // Check the runtime information 36 | // The bug: Expected runtime should be 90 min, but site shows 101 min 37 | const movieInfoText = await page.locator('text=English / 101 min. / 2024').textContent(); 38 | 39 | // This test will fail because of the bug (which is what we want to demonstrate) 40 | // Once fixed, this assertion should be updated to the correct runtime (90 min) 41 | expect(movieInfoText).toContain('90 min'); 42 | 43 | // Alternative assertion that verifies the incorrect runtime is still present 44 | // Uncomment this and comment the above assertion to verify the bug exists 45 | // expect(movieInfoText).toContain('101 min'); 46 | }); 47 | ``` 48 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /** 3 | * Copyright (c) Microsoft Corporation. 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | 18 | import type { Server } from '@modelcontextprotocol/sdk/server/index.js'; 19 | 20 | type ToolCapability = 'core' | 'tabs' | 'pdf' | 'history' | 'wait' | 'files' | 'install'; 21 | 22 | type Options = { 23 | /** 24 | * The browser to use (e.g., 'chrome', 'chromium', 'firefox', 'webkit', 'msedge'). 25 | */ 26 | browser?: string; 27 | /** 28 | * Path to a user data directory for browser profile persistence. 29 | */ 30 | userDataDir?: string; 31 | /** 32 | * Whether to run the browser in headless mode (default: true). 33 | */ 34 | headless?: boolean; 35 | /** 36 | * Path to a custom browser executable. 37 | */ 38 | executablePath?: string; 39 | /** 40 | * Chrome DevTools Protocol endpoint to connect to an existing browser instance. 41 | */ 42 | cdpEndpoint?: string; 43 | /** 44 | * Enable vision capabilities (e.g., visual automation or OCR). 45 | */ 46 | vision?: boolean; 47 | /** 48 | * List of enabled tool capabilities. Possible values: 49 | * - 'core': Core browser automation features. 50 | * - 'tabs': Tab management features. 51 | * - 'pdf': PDF generation and manipulation. 52 | * - 'history': Browser history access. 53 | * - 'wait': Wait and timing utilities. 54 | * - 'files': File upload/download support. 55 | * - 'install': Browser installation utilities. 56 | */ 57 | capabilities?: ToolCapability[]; 58 | }; 59 | export declare function createServer(options?: Options): Promise; 60 | export {}; 61 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /** 3 | * Copyright (c) Microsoft Corporation. 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | 18 | const { createServer } = require('./lib/index'); 19 | module.exports = { createServer }; 20 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@playwright/mcp", 3 | "version": "0.0.15", 4 | "description": "Playwright Tools for MCP", 5 | "repository": { 6 | "type": "git", 7 | "url": "git+https://github.com/microsoft/playwright-mcp.git" 8 | }, 9 | "homepage": "https://playwright.dev", 10 | "engines": { 11 | "node": ">=18" 12 | }, 13 | "author": { 14 | "name": "Microsoft Corporation" 15 | }, 16 | "license": "Apache-2.0", 17 | "scripts": { 18 | "build": "tsc", 19 | "lint": "eslint .", 20 | "update-readme": "node utils/update-readme.js", 21 | "watch": "tsc --watch", 22 | "test": "playwright test", 23 | "ctest": "playwright test --project=chrome", 24 | "ftest": "playwright test --project=firefox", 25 | "wtest": "playwright test --project=webkit", 26 | "clean": "rm -rf lib", 27 | "npm-publish": "npm run clean && npm run build && npm run test && npm publish" 28 | }, 29 | "exports": { 30 | "./package.json": "./package.json", 31 | ".": { 32 | "types": "./index.d.ts", 33 | "default": "./index.js" 34 | } 35 | }, 36 | "dependencies": { 37 | "@modelcontextprotocol/sdk": "^1.6.1", 38 | "commander": "^13.1.0", 39 | "playwright": "1.53.0-alpha-1745357020000", 40 | "yaml": "^2.7.1", 41 | "zod-to-json-schema": "^3.24.4" 42 | }, 43 | "devDependencies": { 44 | "@eslint/eslintrc": "^3.2.0", 45 | "@eslint/js": "^9.19.0", 46 | "@playwright/test": "1.53.0-alpha-1745357020000", 47 | "@stylistic/eslint-plugin": "^3.0.1", 48 | "@types/node": "^22.13.10", 49 | "@typescript-eslint/eslint-plugin": "^8.26.1", 50 | "@typescript-eslint/parser": "^8.26.1", 51 | "@typescript-eslint/utils": "^8.26.1", 52 | "eslint": "^9.19.0", 53 | "eslint-plugin-import": "^2.31.0", 54 | "eslint-plugin-notice": "^1.0.0", 55 | "typescript": "^5.8.2" 56 | }, 57 | "bin": { 58 | "mcp-server-playwright": "cli.js" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { defineConfig } from '@playwright/test'; 18 | 19 | import type { Project } from '@playwright/test'; 20 | 21 | export default defineConfig({ 22 | testDir: './tests', 23 | fullyParallel: true, 24 | forbidOnly: !!process.env.CI, 25 | retries: process.env.CI ? 2 : 0, 26 | workers: process.env.CI ? 1 : undefined, 27 | reporter: 'list', 28 | projects: [ 29 | { name: 'chrome' }, 30 | { name: 'msedge', use: { mcpBrowser: 'msedge' } }, 31 | { name: 'chromium', use: { mcpBrowser: 'chromium' } }, 32 | { name: 'firefox', use: { mcpBrowser: 'firefox' } }, 33 | { name: 'webkit', use: { mcpBrowser: 'webkit' } }, 34 | ].filter(Boolean) as Project[], 35 | }); 36 | -------------------------------------------------------------------------------- /src/context.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import * as playwright from 'playwright'; 18 | import yaml from 'yaml'; 19 | 20 | import { waitForCompletion } from './tools/utils'; 21 | import { ManualPromise } from './manualPromise'; 22 | 23 | import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types'; 24 | import type { ModalState, Tool, ToolActionResult } from './tools/tool'; 25 | 26 | export type ContextOptions = { 27 | browserName?: 'chromium' | 'firefox' | 'webkit'; 28 | userDataDir: string; 29 | launchOptions?: playwright.LaunchOptions; 30 | cdpEndpoint?: string; 31 | remoteEndpoint?: string; 32 | }; 33 | 34 | type PageOrFrameLocator = playwright.Page | playwright.FrameLocator; 35 | 36 | type PendingAction = { 37 | dialogShown: ManualPromise; 38 | }; 39 | 40 | export class Context { 41 | readonly tools: Tool[]; 42 | readonly options: ContextOptions; 43 | private _browser: playwright.Browser | undefined; 44 | private _browserContext: playwright.BrowserContext | undefined; 45 | private _tabs: Tab[] = []; 46 | private _currentTab: Tab | undefined; 47 | private _modalStates: (ModalState & { tab: Tab })[] = []; 48 | private _pendingAction: PendingAction | undefined; 49 | 50 | constructor(tools: Tool[], options: ContextOptions) { 51 | this.tools = tools; 52 | this.options = options; 53 | } 54 | 55 | modalStates(): ModalState[] { 56 | return this._modalStates; 57 | } 58 | 59 | setModalState(modalState: ModalState, inTab: Tab) { 60 | this._modalStates.push({ ...modalState, tab: inTab }); 61 | } 62 | 63 | clearModalState(modalState: ModalState) { 64 | this._modalStates = this._modalStates.filter(state => state !== modalState); 65 | } 66 | 67 | modalStatesMarkdown(): string[] { 68 | const result: string[] = ['### Modal state']; 69 | for (const state of this._modalStates) { 70 | const tool = this.tools.find(tool => tool.clearsModalState === state.type); 71 | result.push(`- [${state.description}]: can be handled by the "${tool?.schema.name}" tool`); 72 | } 73 | return result; 74 | } 75 | 76 | tabs(): Tab[] { 77 | return this._tabs; 78 | } 79 | 80 | currentTabOrDie(): Tab { 81 | if (!this._currentTab) 82 | throw new Error('No current snapshot available. Capture a snapshot of navigate to a new location first.'); 83 | return this._currentTab; 84 | } 85 | 86 | async newTab(): Promise { 87 | const browserContext = await this._ensureBrowserContext(); 88 | const page = await browserContext.newPage(); 89 | this._currentTab = this._tabs.find(t => t.page === page)!; 90 | return this._currentTab; 91 | } 92 | 93 | async selectTab(index: number) { 94 | this._currentTab = this._tabs[index - 1]; 95 | await this._currentTab.page.bringToFront(); 96 | } 97 | 98 | async ensureTab(): Promise { 99 | const context = await this._ensureBrowserContext(); 100 | if (!this._currentTab) 101 | await context.newPage(); 102 | return this._currentTab!; 103 | } 104 | 105 | async listTabsMarkdown(): Promise { 106 | if (!this._tabs.length) 107 | return '### No tabs open'; 108 | const lines: string[] = ['### Open tabs']; 109 | for (let i = 0; i < this._tabs.length; i++) { 110 | const tab = this._tabs[i]; 111 | const title = await tab.page.title(); 112 | const url = tab.page.url(); 113 | const current = tab === this._currentTab ? ' (current)' : ''; 114 | lines.push(`- ${i + 1}:${current} [${title}] (${url})`); 115 | } 116 | return lines.join('\n'); 117 | } 118 | 119 | async closeTab(index: number | undefined) { 120 | const tab = index === undefined ? this._currentTab : this._tabs[index - 1]; 121 | await tab?.page.close(); 122 | return await this.listTabsMarkdown(); 123 | } 124 | 125 | async run(tool: Tool, params: Record | undefined) { 126 | // Tab management is done outside of the action() call. 127 | const toolResult = await tool.handle(this, tool.schema.inputSchema.parse(params)); 128 | const { code, action, waitForNetwork, captureSnapshot, resultOverride } = toolResult; 129 | const racingAction = action ? () => this._raceAgainstModalDialogs(action) : undefined; 130 | 131 | if (resultOverride) 132 | return resultOverride; 133 | 134 | if (!this._currentTab) { 135 | return { 136 | content: [{ 137 | type: 'text', 138 | text: 'No open pages available. Use the "browser_navigate" tool to navigate to a page first.', 139 | }], 140 | }; 141 | } 142 | 143 | const tab = this.currentTabOrDie(); 144 | // TODO: race against modal dialogs to resolve clicks. 145 | let actionResult: { content?: (ImageContent | TextContent)[] } | undefined; 146 | try { 147 | if (waitForNetwork) 148 | actionResult = await waitForCompletion(this, tab.page, async () => racingAction?.()) ?? undefined; 149 | else 150 | actionResult = await racingAction?.() ?? undefined; 151 | } finally { 152 | if (captureSnapshot && !this._javaScriptBlocked()) 153 | await tab.captureSnapshot(); 154 | } 155 | 156 | const result: string[] = []; 157 | result.push(`- Ran Playwright code: 158 | \`\`\`js 159 | ${code.join('\n')} 160 | \`\`\` 161 | `); 162 | 163 | if (this.modalStates().length) { 164 | result.push(...this.modalStatesMarkdown()); 165 | return { 166 | content: [{ 167 | type: 'text', 168 | text: result.join('\n'), 169 | }], 170 | }; 171 | } 172 | 173 | if (this.tabs().length > 1) 174 | result.push(await this.listTabsMarkdown(), ''); 175 | 176 | if (this.tabs().length > 1) 177 | result.push('### Current tab'); 178 | 179 | result.push( 180 | `- Page URL: ${tab.page.url()}`, 181 | `- Page Title: ${await tab.page.title()}` 182 | ); 183 | 184 | if (captureSnapshot && tab.hasSnapshot()) 185 | result.push(tab.snapshotOrDie().text()); 186 | 187 | const content = actionResult?.content ?? []; 188 | 189 | return { 190 | content: [ 191 | ...content, 192 | { 193 | type: 'text', 194 | text: result.join('\n'), 195 | } 196 | ], 197 | }; 198 | } 199 | 200 | async waitForTimeout(time: number) { 201 | if (this._currentTab && !this._javaScriptBlocked()) 202 | await this._currentTab.page.evaluate(() => new Promise(f => setTimeout(f, 1000))); 203 | else 204 | await new Promise(f => setTimeout(f, time)); 205 | } 206 | 207 | private async _raceAgainstModalDialogs(action: () => Promise): Promise { 208 | this._pendingAction = { 209 | dialogShown: new ManualPromise(), 210 | }; 211 | 212 | let result: ToolActionResult | undefined; 213 | try { 214 | await Promise.race([ 215 | action().then(r => result = r), 216 | this._pendingAction.dialogShown, 217 | ]); 218 | } finally { 219 | this._pendingAction = undefined; 220 | } 221 | return result; 222 | } 223 | 224 | private _javaScriptBlocked(): boolean { 225 | return this._modalStates.some(state => state.type === 'dialog'); 226 | } 227 | 228 | dialogShown(tab: Tab, dialog: playwright.Dialog) { 229 | this.setModalState({ 230 | type: 'dialog', 231 | description: `"${dialog.type()}" dialog with message "${dialog.message()}"`, 232 | dialog, 233 | }, tab); 234 | this._pendingAction?.dialogShown.resolve(); 235 | } 236 | 237 | private _onPageCreated(page: playwright.Page) { 238 | const tab = new Tab(this, page, tab => this._onPageClosed(tab)); 239 | this._tabs.push(tab); 240 | if (!this._currentTab) 241 | this._currentTab = tab; 242 | } 243 | 244 | private _onPageClosed(tab: Tab) { 245 | this._modalStates = this._modalStates.filter(state => state.tab !== tab); 246 | const index = this._tabs.indexOf(tab); 247 | if (index === -1) 248 | return; 249 | this._tabs.splice(index, 1); 250 | 251 | if (this._currentTab === tab) 252 | this._currentTab = this._tabs[Math.min(index, this._tabs.length - 1)]; 253 | if (this._browserContext && !this._tabs.length) 254 | void this.close(); 255 | } 256 | 257 | async close() { 258 | if (!this._browserContext) 259 | return; 260 | const browserContext = this._browserContext; 261 | const browser = this._browser; 262 | this._browserContext = undefined; 263 | this._browser = undefined; 264 | 265 | await browserContext?.close().then(async () => { 266 | await browser?.close(); 267 | }).catch(() => {}); 268 | } 269 | 270 | private async _ensureBrowserContext() { 271 | if (!this._browserContext) { 272 | const context = await this._createBrowserContext(); 273 | this._browser = context.browser; 274 | this._browserContext = context.browserContext; 275 | for (const page of this._browserContext.pages()) 276 | this._onPageCreated(page); 277 | this._browserContext.on('page', page => this._onPageCreated(page)); 278 | } 279 | return this._browserContext; 280 | } 281 | 282 | private async _createBrowserContext(): Promise<{ browser?: playwright.Browser, browserContext: playwright.BrowserContext }> { 283 | if (this.options.remoteEndpoint) { 284 | const url = new URL(this.options.remoteEndpoint); 285 | if (this.options.browserName) 286 | url.searchParams.set('browser', this.options.browserName); 287 | if (this.options.launchOptions) 288 | url.searchParams.set('launch-options', JSON.stringify(this.options.launchOptions)); 289 | const browser = await playwright[this.options.browserName ?? 'chromium'].connect(String(url)); 290 | const browserContext = await browser.newContext(); 291 | return { browser, browserContext }; 292 | } 293 | 294 | if (this.options.cdpEndpoint) { 295 | const browser = await playwright.chromium.connectOverCDP(this.options.cdpEndpoint); 296 | const browserContext = browser.contexts()[0]; 297 | return { browser, browserContext }; 298 | } 299 | 300 | const browserContext = await this._launchPersistentContext(); 301 | return { browserContext }; 302 | } 303 | 304 | private async _launchPersistentContext(): Promise { 305 | try { 306 | const browserType = this.options.browserName ? playwright[this.options.browserName] : playwright.chromium; 307 | return await browserType.launchPersistentContext(this.options.userDataDir, this.options.launchOptions); 308 | } catch (error: any) { 309 | if (error.message.includes('Executable doesn\'t exist')) 310 | throw new Error(`Browser specified in your config is not installed. Either install it (likely) or change the config.`); 311 | throw error; 312 | } 313 | } 314 | } 315 | 316 | export class Tab { 317 | readonly context: Context; 318 | readonly page: playwright.Page; 319 | private _console: playwright.ConsoleMessage[] = []; 320 | private _requests: Map = new Map(); 321 | private _snapshot: PageSnapshot | undefined; 322 | private _onPageClose: (tab: Tab) => void; 323 | 324 | constructor(context: Context, page: playwright.Page, onPageClose: (tab: Tab) => void) { 325 | this.context = context; 326 | this.page = page; 327 | this._onPageClose = onPageClose; 328 | page.on('console', event => this._console.push(event)); 329 | page.on('request', request => this._requests.set(request, null)); 330 | page.on('response', response => this._requests.set(response.request(), response)); 331 | page.on('framenavigated', frame => { 332 | if (!frame.parentFrame()) 333 | this._clearCollectedArtifacts(); 334 | }); 335 | page.on('close', () => this._onClose()); 336 | page.on('filechooser', chooser => { 337 | this.context.setModalState({ 338 | type: 'fileChooser', 339 | description: 'File chooser', 340 | fileChooser: chooser, 341 | }, this); 342 | }); 343 | page.on('dialog', dialog => this.context.dialogShown(this, dialog)); 344 | page.setDefaultNavigationTimeout(60000); 345 | page.setDefaultTimeout(5000); 346 | } 347 | 348 | private _clearCollectedArtifacts() { 349 | this._console.length = 0; 350 | this._requests.clear(); 351 | } 352 | 353 | private _onClose() { 354 | this._clearCollectedArtifacts(); 355 | this._onPageClose(this); 356 | } 357 | 358 | async navigate(url: string) { 359 | await this.page.goto(url, { waitUntil: 'domcontentloaded' }); 360 | // Cap load event to 5 seconds, the page is operational at this point. 361 | await this.page.waitForLoadState('load', { timeout: 5000 }).catch(() => {}); 362 | } 363 | 364 | hasSnapshot(): boolean { 365 | return !!this._snapshot; 366 | } 367 | 368 | snapshotOrDie(): PageSnapshot { 369 | if (!this._snapshot) 370 | throw new Error('No snapshot available'); 371 | return this._snapshot; 372 | } 373 | 374 | console(): playwright.ConsoleMessage[] { 375 | return this._console; 376 | } 377 | 378 | requests(): Map { 379 | return this._requests; 380 | } 381 | 382 | async captureSnapshot() { 383 | this._snapshot = await PageSnapshot.create(this.page); 384 | } 385 | } 386 | 387 | class PageSnapshot { 388 | private _frameLocators: PageOrFrameLocator[] = []; 389 | private _text!: string; 390 | 391 | constructor() { 392 | } 393 | 394 | static async create(page: playwright.Page): Promise { 395 | const snapshot = new PageSnapshot(); 396 | await snapshot._build(page); 397 | return snapshot; 398 | } 399 | 400 | text(): string { 401 | return this._text; 402 | } 403 | 404 | private async _build(page: playwright.Page) { 405 | const yamlDocument = await this._snapshotFrame(page); 406 | this._text = [ 407 | `- Page Snapshot`, 408 | '```yaml', 409 | yamlDocument.toString({ indentSeq: false }).trim(), 410 | '```', 411 | ].join('\n'); 412 | } 413 | 414 | private async _snapshotFrame(frame: playwright.Page | playwright.FrameLocator) { 415 | const frameIndex = this._frameLocators.push(frame) - 1; 416 | const snapshotString = await frame.locator('body').ariaSnapshot({ ref: true, emitGeneric: true }); 417 | const snapshot = yaml.parseDocument(snapshotString); 418 | 419 | const visit = async (node: any): Promise => { 420 | if (yaml.isPair(node)) { 421 | await Promise.all([ 422 | visit(node.key).then(k => node.key = k), 423 | visit(node.value).then(v => node.value = v) 424 | ]); 425 | } else if (yaml.isSeq(node) || yaml.isMap(node)) { 426 | node.items = await Promise.all(node.items.map(visit)); 427 | } else if (yaml.isScalar(node)) { 428 | if (typeof node.value === 'string') { 429 | const value = node.value; 430 | if (frameIndex > 0) 431 | node.value = value.replace('[ref=', `[ref=f${frameIndex}`); 432 | if (value.startsWith('iframe ')) { 433 | const ref = value.match(/\[ref=(.*)\]/)?.[1]; 434 | if (ref) { 435 | try { 436 | const childSnapshot = await this._snapshotFrame(frame.frameLocator(`aria-ref=${ref}`)); 437 | return snapshot.createPair(node.value, childSnapshot); 438 | } catch (error) { 439 | return snapshot.createPair(node.value, ''); 440 | } 441 | } 442 | } 443 | } 444 | } 445 | 446 | return node; 447 | }; 448 | await visit(snapshot.contents); 449 | return snapshot; 450 | } 451 | 452 | refLocator(ref: string): playwright.Locator { 453 | let frame = this._frameLocators[0]; 454 | const match = ref.match(/^f(\d+)(.*)/); 455 | if (match) { 456 | const frameIndex = parseInt(match[1], 10); 457 | frame = this._frameLocators[frameIndex]; 458 | ref = match[2]; 459 | } 460 | 461 | if (!frame) 462 | throw new Error(`Frame does not exist. Provide ref from the most current snapshot.`); 463 | 464 | return frame.locator(`aria-ref=${ref}`); 465 | } 466 | } 467 | 468 | export async function generateLocator(locator: playwright.Locator): Promise { 469 | return (locator as any)._generateLocatorString(); 470 | } 471 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import path from 'path'; 18 | import os from 'os'; 19 | import fs from 'fs'; 20 | 21 | import { createServerWithTools } from './server'; 22 | import common from './tools/common'; 23 | import console from './tools/console'; 24 | import dialogs from './tools/dialogs'; 25 | import files from './tools/files'; 26 | import install from './tools/install'; 27 | import keyboard from './tools/keyboard'; 28 | import navigate from './tools/navigate'; 29 | import network from './tools/network'; 30 | import pdf from './tools/pdf'; 31 | import snapshot from './tools/snapshot'; 32 | import tabs from './tools/tabs'; 33 | import screen from './tools/screen'; 34 | 35 | import type { Tool, ToolCapability } from './tools/tool'; 36 | import type { Server } from '@modelcontextprotocol/sdk/server/index.js'; 37 | import type { LaunchOptions } from 'playwright'; 38 | 39 | const snapshotTools: Tool[] = [ 40 | ...common(true), 41 | ...console, 42 | ...dialogs(true), 43 | ...files(true), 44 | ...install, 45 | ...keyboard(true), 46 | ...navigate(true), 47 | ...network, 48 | ...pdf, 49 | ...snapshot, 50 | ...tabs(true), 51 | ]; 52 | 53 | const screenshotTools: Tool[] = [ 54 | ...common(false), 55 | ...console, 56 | ...dialogs(false), 57 | ...files(false), 58 | ...install, 59 | ...keyboard(false), 60 | ...navigate(false), 61 | ...network, 62 | ...pdf, 63 | ...screen, 64 | ...tabs(false), 65 | ]; 66 | 67 | type Options = { 68 | browser?: string; 69 | userDataDir?: string; 70 | headless?: boolean; 71 | executablePath?: string; 72 | cdpEndpoint?: string; 73 | vision?: boolean; 74 | capabilities?: ToolCapability[]; 75 | }; 76 | 77 | const packageJSON = require('../package.json'); 78 | 79 | export async function createServer(options?: Options): Promise { 80 | let browserName: 'chromium' | 'firefox' | 'webkit'; 81 | let channel: string | undefined; 82 | switch (options?.browser) { 83 | case 'chrome': 84 | case 'chrome-beta': 85 | case 'chrome-canary': 86 | case 'chrome-dev': 87 | case 'msedge': 88 | case 'msedge-beta': 89 | case 'msedge-canary': 90 | case 'msedge-dev': 91 | browserName = 'chromium'; 92 | channel = options.browser; 93 | break; 94 | case 'chromium': 95 | browserName = 'chromium'; 96 | break; 97 | case 'firefox': 98 | browserName = 'firefox'; 99 | break; 100 | case 'webkit': 101 | browserName = 'webkit'; 102 | break; 103 | default: 104 | browserName = 'chromium'; 105 | channel = 'chrome'; 106 | } 107 | const userDataDir = options?.userDataDir ?? await createUserDataDir(browserName); 108 | 109 | const launchOptions: LaunchOptions = { 110 | headless: !!(options?.headless ?? (os.platform() === 'linux' && !process.env.DISPLAY)), 111 | channel, 112 | executablePath: options?.executablePath, 113 | }; 114 | 115 | const allTools = options?.vision ? screenshotTools : snapshotTools; 116 | const tools = allTools.filter(tool => !options?.capabilities || tool.capability === 'core' || options.capabilities.includes(tool.capability)); 117 | return createServerWithTools({ 118 | name: 'Playwright', 119 | version: packageJSON.version, 120 | tools, 121 | resources: [], 122 | browserName, 123 | userDataDir, 124 | launchOptions, 125 | cdpEndpoint: options?.cdpEndpoint, 126 | }); 127 | } 128 | 129 | async function createUserDataDir(browserName: 'chromium' | 'firefox' | 'webkit') { 130 | let cacheDirectory: string; 131 | if (process.platform === 'linux') 132 | cacheDirectory = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache'); 133 | else if (process.platform === 'darwin') 134 | cacheDirectory = path.join(os.homedir(), 'Library', 'Caches'); 135 | else if (process.platform === 'win32') 136 | cacheDirectory = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'); 137 | else 138 | throw new Error('Unsupported platform: ' + process.platform); 139 | const result = path.join(cacheDirectory, 'ms-playwright', `mcp-${browserName}-profile`); 140 | await fs.promises.mkdir(result, { recursive: true }); 141 | return result; 142 | } 143 | -------------------------------------------------------------------------------- /src/javascript.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | // adapted from: 18 | // - https://github.com/microsoft/playwright/blob/76ee48dc9d4034536e3ec5b2c7ce8be3b79418a8/packages/playwright-core/src/utils/isomorphic/stringUtils.ts 19 | // - https://github.com/microsoft/playwright/blob/76ee48dc9d4034536e3ec5b2c7ce8be3b79418a8/packages/playwright-core/src/server/codegen/javascript.ts 20 | 21 | // NOTE: this function should not be used to escape any selectors. 22 | export function escapeWithQuotes(text: string, char: string = '\'') { 23 | const stringified = JSON.stringify(text); 24 | const escapedText = stringified.substring(1, stringified.length - 1).replace(/\\"/g, '"'); 25 | if (char === '\'') 26 | return char + escapedText.replace(/[']/g, '\\\'') + char; 27 | if (char === '"') 28 | return char + escapedText.replace(/["]/g, '\\"') + char; 29 | if (char === '`') 30 | return char + escapedText.replace(/[`]/g, '`') + char; 31 | throw new Error('Invalid escape char'); 32 | } 33 | 34 | export function quote(text: string) { 35 | return escapeWithQuotes(text, '\''); 36 | } 37 | 38 | export function formatObject(value: any, indent = ' '): string { 39 | if (typeof value === 'string') 40 | return quote(value); 41 | if (Array.isArray(value)) 42 | return `[${value.map(o => formatObject(o)).join(', ')}]`; 43 | if (typeof value === 'object') { 44 | const keys = Object.keys(value).filter(key => value[key] !== undefined).sort(); 45 | if (!keys.length) 46 | return '{}'; 47 | const tokens: string[] = []; 48 | for (const key of keys) 49 | tokens.push(`${key}: ${formatObject(value[key])}`); 50 | return `{\n${indent}${tokens.join(`,\n${indent}`)}\n}`; 51 | } 52 | return String(value); 53 | } 54 | -------------------------------------------------------------------------------- /src/manualPromise.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | export class ManualPromise extends Promise { 18 | private _resolve!: (t: T) => void; 19 | private _reject!: (e: Error) => void; 20 | private _isDone: boolean; 21 | 22 | constructor() { 23 | let resolve: (t: T) => void; 24 | let reject: (e: Error) => void; 25 | super((f, r) => { 26 | resolve = f; 27 | reject = r; 28 | }); 29 | this._isDone = false; 30 | this._resolve = resolve!; 31 | this._reject = reject!; 32 | } 33 | 34 | isDone() { 35 | return this._isDone; 36 | } 37 | 38 | resolve(t: T) { 39 | this._isDone = true; 40 | this._resolve(t); 41 | } 42 | 43 | reject(e: Error) { 44 | this._isDone = true; 45 | this._reject(e); 46 | } 47 | 48 | static override get [Symbol.species]() { 49 | return Promise; 50 | } 51 | 52 | override get [Symbol.toStringTag]() { 53 | return 'ManualPromise'; 54 | } 55 | } 56 | 57 | export class LongStandingScope { 58 | private _terminateError: Error | undefined; 59 | private _closeError: Error | undefined; 60 | private _terminatePromises = new Map, string[]>(); 61 | private _isClosed = false; 62 | 63 | reject(error: Error) { 64 | this._isClosed = true; 65 | this._terminateError = error; 66 | for (const p of this._terminatePromises.keys()) 67 | p.resolve(error); 68 | } 69 | 70 | close(error: Error) { 71 | this._isClosed = true; 72 | this._closeError = error; 73 | for (const [p, frames] of this._terminatePromises) 74 | p.resolve(cloneError(error, frames)); 75 | } 76 | 77 | isClosed() { 78 | return this._isClosed; 79 | } 80 | 81 | static async raceMultiple(scopes: LongStandingScope[], promise: Promise): Promise { 82 | return Promise.race(scopes.map(s => s.race(promise))); 83 | } 84 | 85 | async race(promise: Promise | Promise[]): Promise { 86 | return this._race(Array.isArray(promise) ? promise : [promise], false) as Promise; 87 | } 88 | 89 | async safeRace(promise: Promise, defaultValue?: T): Promise { 90 | return this._race([promise], true, defaultValue); 91 | } 92 | 93 | private async _race(promises: Promise[], safe: boolean, defaultValue?: any): Promise { 94 | const terminatePromise = new ManualPromise(); 95 | const frames = captureRawStack(); 96 | if (this._terminateError) 97 | terminatePromise.resolve(this._terminateError); 98 | if (this._closeError) 99 | terminatePromise.resolve(cloneError(this._closeError, frames)); 100 | this._terminatePromises.set(terminatePromise, frames); 101 | try { 102 | return await Promise.race([ 103 | terminatePromise.then(e => safe ? defaultValue : Promise.reject(e)), 104 | ...promises 105 | ]); 106 | } finally { 107 | this._terminatePromises.delete(terminatePromise); 108 | } 109 | } 110 | } 111 | 112 | function cloneError(error: Error, frames: string[]) { 113 | const clone = new Error(); 114 | clone.name = error.name; 115 | clone.message = error.message; 116 | clone.stack = [error.name + ':' + error.message, ...frames].join('\n'); 117 | return clone; 118 | } 119 | 120 | function captureRawStack(): string[] { 121 | const stackTraceLimit = Error.stackTraceLimit; 122 | Error.stackTraceLimit = 50; 123 | const error = new Error(); 124 | const stack = error.stack || ''; 125 | Error.stackTraceLimit = stackTraceLimit; 126 | return stack.split('\n'); 127 | } 128 | -------------------------------------------------------------------------------- /src/program.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import http from 'http'; 18 | 19 | import { program } from 'commander'; 20 | import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; 21 | import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'; 22 | 23 | 24 | import { createServer } from './index'; 25 | import { ServerList } from './server'; 26 | 27 | import assert from 'assert'; 28 | import { ToolCapability } from './tools/tool'; 29 | 30 | const packageJSON = require('../package.json'); 31 | 32 | program 33 | .version('Version ' + packageJSON.version) 34 | .name(packageJSON.name) 35 | .option('--browser ', 'Browser or chrome channel to use, possible values: chrome, firefox, webkit, msedge.') 36 | .option('--caps ', 'Comma-separated list of capabilities to enable, possible values: tabs, pdf, history, wait, files, install. Default is all.') 37 | .option('--cdp-endpoint ', 'CDP endpoint to connect to.') 38 | .option('--executable-path ', 'Path to the browser executable.') 39 | .option('--headless', 'Run browser in headless mode, headed by default') 40 | .option('--port ', 'Port to listen on for SSE transport.') 41 | .option('--host ', 'Host to bind server to. Default is localhost. Use 0.0.0.0 to bind to all interfaces.') 42 | .option('--user-data-dir ', 'Path to the user data directory') 43 | .option('--vision', 'Run server that uses screenshots (Aria snapshots are used by default)') 44 | .action(async options => { 45 | const serverList = new ServerList(() => createServer({ 46 | browser: options.browser, 47 | userDataDir: options.userDataDir, 48 | headless: options.headless, 49 | executablePath: options.executablePath, 50 | vision: !!options.vision, 51 | cdpEndpoint: options.cdpEndpoint, 52 | capabilities: options.caps?.split(',').map((c: string) => c.trim() as ToolCapability), 53 | })); 54 | setupExitWatchdog(serverList); 55 | 56 | if (options.port) { 57 | startSSEServer(+options.port, options.host || 'localhost', serverList); 58 | } else { 59 | const server = await serverList.create(); 60 | await server.connect(new StdioServerTransport()); 61 | } 62 | }); 63 | 64 | function setupExitWatchdog(serverList: ServerList) { 65 | const handleExit = async () => { 66 | setTimeout(() => process.exit(0), 15000); 67 | await serverList.closeAll(); 68 | process.exit(0); 69 | }; 70 | 71 | process.stdin.on('close', handleExit); 72 | process.on('SIGINT', handleExit); 73 | process.on('SIGTERM', handleExit); 74 | } 75 | 76 | program.parse(process.argv); 77 | 78 | function startSSEServer(port: number, host: string, serverList: ServerList) { 79 | const sessions = new Map(); 80 | const httpServer = http.createServer(async (req, res) => { 81 | if (req.method === 'POST') { 82 | const searchParams = new URL(`http://localhost${req.url}`).searchParams; 83 | const sessionId = searchParams.get('sessionId'); 84 | if (!sessionId) { 85 | res.statusCode = 400; 86 | res.end('Missing sessionId'); 87 | return; 88 | } 89 | const transport = sessions.get(sessionId); 90 | if (!transport) { 91 | res.statusCode = 404; 92 | res.end('Session not found'); 93 | return; 94 | } 95 | 96 | await transport.handlePostMessage(req, res); 97 | return; 98 | } else if (req.method === 'GET') { 99 | const transport = new SSEServerTransport('/sse', res); 100 | sessions.set(transport.sessionId, transport); 101 | const server = await serverList.create(); 102 | res.on('close', () => { 103 | sessions.delete(transport.sessionId); 104 | serverList.close(server).catch(e => console.error(e)); 105 | }); 106 | await server.connect(transport); 107 | return; 108 | } else { 109 | res.statusCode = 405; 110 | res.end('Method not allowed'); 111 | } 112 | }); 113 | 114 | httpServer.listen(port, host, () => { 115 | const address = httpServer.address(); 116 | assert(address, 'Could not bind server socket'); 117 | let url: string; 118 | if (typeof address === 'string') { 119 | url = address; 120 | } else { 121 | const resolvedPort = address.port; 122 | let resolvedHost = address.family === 'IPv4' ? address.address : `[${address.address}]`; 123 | if (resolvedHost === '0.0.0.0' || resolvedHost === '[::]') 124 | resolvedHost = host === 'localhost' ? 'localhost' : resolvedHost; 125 | url = `http://${resolvedHost}:${resolvedPort}`; 126 | } 127 | console.log(`Listening on ${url}`); 128 | console.log('Put this in your client config:'); 129 | console.log(JSON.stringify({ 130 | 'mcpServers': { 131 | 'playwright': { 132 | 'url': `${url}/sse` 133 | } 134 | } 135 | }, undefined, 2)); 136 | }); 137 | } 138 | -------------------------------------------------------------------------------- /src/resources/resource.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import type { Context } from '../context'; 18 | 19 | export type ResourceSchema = { 20 | uri: string; 21 | name: string; 22 | description?: string; 23 | mimeType?: string; 24 | }; 25 | 26 | export type ResourceResult = { 27 | uri: string; 28 | mimeType?: string; 29 | text?: string; 30 | blob?: string; 31 | }; 32 | 33 | export type Resource = { 34 | schema: ResourceSchema; 35 | read: (context: Context, uri: string) => Promise; 36 | }; 37 | -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { Server } from '@modelcontextprotocol/sdk/server/index.js'; 18 | import { CallToolRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema } from '@modelcontextprotocol/sdk/types.js'; 19 | import { zodToJsonSchema } from 'zod-to-json-schema'; 20 | 21 | import { Context } from './context'; 22 | 23 | import type { Tool } from './tools/tool'; 24 | import type { Resource } from './resources/resource'; 25 | import type { ContextOptions } from './context'; 26 | 27 | type Options = ContextOptions & { 28 | name: string; 29 | version: string; 30 | tools: Tool[]; 31 | resources: Resource[], 32 | }; 33 | 34 | export function createServerWithTools(options: Options): Server { 35 | const { name, version, tools, resources } = options; 36 | const context = new Context(tools, options); 37 | const server = new Server({ name, version }, { 38 | capabilities: { 39 | tools: {}, 40 | resources: {}, 41 | } 42 | }); 43 | 44 | server.setRequestHandler(ListToolsRequestSchema, async () => { 45 | return { 46 | tools: tools.map(tool => ({ 47 | name: tool.schema.name, 48 | description: tool.schema.description, 49 | inputSchema: zodToJsonSchema(tool.schema.inputSchema) 50 | })), 51 | }; 52 | }); 53 | 54 | server.setRequestHandler(ListResourcesRequestSchema, async () => { 55 | return { resources: resources.map(resource => resource.schema) }; 56 | }); 57 | 58 | server.setRequestHandler(CallToolRequestSchema, async request => { 59 | const tool = tools.find(tool => tool.schema.name === request.params.name); 60 | if (!tool) { 61 | return { 62 | content: [{ type: 'text', text: `Tool "${request.params.name}" not found` }], 63 | isError: true, 64 | }; 65 | } 66 | 67 | const modalStates = context.modalStates().map(state => state.type); 68 | if ((tool.clearsModalState && !modalStates.includes(tool.clearsModalState)) || 69 | (!tool.clearsModalState && modalStates.length)) { 70 | const text = [ 71 | `Tool "${request.params.name}" does not handle the modal state.`, 72 | ...context.modalStatesMarkdown(), 73 | ].join('\n'); 74 | return { 75 | content: [{ type: 'text', text }], 76 | isError: true, 77 | }; 78 | } 79 | 80 | try { 81 | return await context.run(tool, request.params.arguments); 82 | } catch (error) { 83 | return { 84 | content: [{ type: 'text', text: String(error) }], 85 | isError: true, 86 | }; 87 | } 88 | }); 89 | 90 | server.setRequestHandler(ReadResourceRequestSchema, async request => { 91 | const resource = resources.find(resource => resource.schema.uri === request.params.uri); 92 | if (!resource) 93 | return { contents: [] }; 94 | 95 | const contents = await resource.read(context, request.params.uri); 96 | return { contents }; 97 | }); 98 | 99 | const oldClose = server.close.bind(server); 100 | 101 | server.close = async () => { 102 | await oldClose(); 103 | await context.close(); 104 | }; 105 | 106 | return server; 107 | } 108 | 109 | export class ServerList { 110 | private _servers: Server[] = []; 111 | private _serverFactory: () => Promise; 112 | 113 | constructor(serverFactory: () => Promise) { 114 | this._serverFactory = serverFactory; 115 | } 116 | 117 | async create() { 118 | const server = await this._serverFactory(); 119 | this._servers.push(server); 120 | return server; 121 | } 122 | 123 | async close(server: Server) { 124 | const index = this._servers.indexOf(server); 125 | if (index !== -1) 126 | this._servers.splice(index, 1); 127 | await server.close(); 128 | } 129 | 130 | async closeAll() { 131 | await Promise.all(this._servers.map(server => server.close())); 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/tools/common.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { z } from 'zod'; 18 | import { defineTool, type ToolFactory } from './tool'; 19 | 20 | const wait: ToolFactory = captureSnapshot => defineTool({ 21 | capability: 'wait', 22 | 23 | schema: { 24 | name: 'browser_wait', 25 | description: 'Wait for a specified time in seconds', 26 | inputSchema: z.object({ 27 | time: z.number().describe('The time to wait in seconds'), 28 | }), 29 | }, 30 | 31 | handle: async (context, params) => { 32 | await new Promise(f => setTimeout(f, Math.min(10000, params.time * 1000))); 33 | return { 34 | code: [`// Waited for ${params.time} seconds`], 35 | captureSnapshot, 36 | waitForNetwork: false, 37 | }; 38 | }, 39 | }); 40 | 41 | const close = defineTool({ 42 | capability: 'core', 43 | 44 | schema: { 45 | name: 'browser_close', 46 | description: 'Close the page', 47 | inputSchema: z.object({}), 48 | }, 49 | 50 | handle: async context => { 51 | await context.close(); 52 | return { 53 | code: [`// Internal to close the page`], 54 | captureSnapshot: false, 55 | waitForNetwork: false, 56 | }; 57 | }, 58 | }); 59 | 60 | const resize: ToolFactory = captureSnapshot => defineTool({ 61 | capability: 'core', 62 | schema: { 63 | name: 'browser_resize', 64 | description: 'Resize the browser window', 65 | inputSchema: z.object({ 66 | width: z.number().describe('Width of the browser window'), 67 | height: z.number().describe('Height of the browser window'), 68 | }), 69 | }, 70 | 71 | handle: async (context, params) => { 72 | const tab = context.currentTabOrDie(); 73 | 74 | const code = [ 75 | `// Resize browser window to ${params.width}x${params.height}`, 76 | `await page.setViewportSize({ width: ${params.width}, height: ${params.height} });` 77 | ]; 78 | 79 | const action = async () => { 80 | await tab.page.setViewportSize({ width: params.width, height: params.height }); 81 | }; 82 | 83 | return { 84 | code, 85 | action, 86 | captureSnapshot, 87 | waitForNetwork: true 88 | }; 89 | }, 90 | }); 91 | 92 | export default (captureSnapshot: boolean) => [ 93 | close, 94 | wait(captureSnapshot), 95 | resize(captureSnapshot) 96 | ]; 97 | -------------------------------------------------------------------------------- /src/tools/console.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { z } from 'zod'; 18 | import { defineTool } from './tool'; 19 | 20 | const console = defineTool({ 21 | capability: 'core', 22 | schema: { 23 | name: 'browser_console_messages', 24 | description: 'Returns all console messages', 25 | inputSchema: z.object({}), 26 | }, 27 | handle: async context => { 28 | const messages = context.currentTabOrDie().console(); 29 | const log = messages.map(message => `[${message.type().toUpperCase()}] ${message.text()}`).join('\n'); 30 | return { 31 | code: [`// `], 32 | action: async () => { 33 | return { 34 | content: [{ type: 'text', text: log }] 35 | }; 36 | }, 37 | captureSnapshot: false, 38 | waitForNetwork: false, 39 | }; 40 | }, 41 | }); 42 | 43 | export default [ 44 | console, 45 | ]; 46 | -------------------------------------------------------------------------------- /src/tools/dialogs.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { z } from 'zod'; 18 | import { defineTool, type ToolFactory } from './tool'; 19 | 20 | const handleDialog: ToolFactory = captureSnapshot => defineTool({ 21 | capability: 'core', 22 | 23 | schema: { 24 | name: 'browser_handle_dialog', 25 | description: 'Handle a dialog', 26 | inputSchema: z.object({ 27 | accept: z.boolean().describe('Whether to accept the dialog.'), 28 | promptText: z.string().optional().describe('The text of the prompt in case of a prompt dialog.'), 29 | }), 30 | }, 31 | 32 | handle: async (context, params) => { 33 | const dialogState = context.modalStates().find(state => state.type === 'dialog'); 34 | if (!dialogState) 35 | throw new Error('No dialog visible'); 36 | 37 | if (params.accept) 38 | await dialogState.dialog.accept(params.promptText); 39 | else 40 | await dialogState.dialog.dismiss(); 41 | 42 | context.clearModalState(dialogState); 43 | 44 | const code = [ 45 | `// `, 46 | ]; 47 | 48 | return { 49 | code, 50 | captureSnapshot, 51 | waitForNetwork: false, 52 | }; 53 | }, 54 | 55 | clearsModalState: 'dialog', 56 | }); 57 | 58 | export default (captureSnapshot: boolean) => [ 59 | handleDialog(captureSnapshot), 60 | ]; 61 | -------------------------------------------------------------------------------- /src/tools/files.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { z } from 'zod'; 18 | import { defineTool, type ToolFactory } from './tool'; 19 | 20 | const uploadFile: ToolFactory = captureSnapshot => defineTool({ 21 | capability: 'files', 22 | 23 | schema: { 24 | name: 'browser_file_upload', 25 | description: 'Upload one or multiple files', 26 | inputSchema: z.object({ 27 | paths: z.array(z.string()).describe('The absolute paths to the files to upload. Can be a single file or multiple files.'), 28 | }), 29 | }, 30 | 31 | handle: async (context, params) => { 32 | const modalState = context.modalStates().find(state => state.type === 'fileChooser'); 33 | if (!modalState) 34 | throw new Error('No file chooser visible'); 35 | 36 | const code = [ 37 | `// { 41 | await modalState.fileChooser.setFiles(params.paths); 42 | context.clearModalState(modalState); 43 | }; 44 | 45 | return { 46 | code, 47 | action, 48 | captureSnapshot, 49 | waitForNetwork: true, 50 | }; 51 | }, 52 | clearsModalState: 'fileChooser', 53 | }); 54 | 55 | export default (captureSnapshot: boolean) => [ 56 | uploadFile(captureSnapshot), 57 | ]; 58 | -------------------------------------------------------------------------------- /src/tools/install.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { fork } from 'child_process'; 18 | import path from 'path'; 19 | 20 | import { z } from 'zod'; 21 | import { defineTool } from './tool'; 22 | 23 | const install = defineTool({ 24 | capability: 'install', 25 | schema: { 26 | name: 'browser_install', 27 | description: 'Install the browser specified in the config. Call this if you get an error about the browser not being installed.', 28 | inputSchema: z.object({}), 29 | }, 30 | 31 | handle: async context => { 32 | const channel = context.options.launchOptions?.channel ?? context.options.browserName ?? 'chrome'; 33 | const cli = path.join(require.resolve('playwright/package.json'), '..', 'cli.js'); 34 | const child = fork(cli, ['install', channel], { 35 | stdio: 'pipe', 36 | }); 37 | const output: string[] = []; 38 | child.stdout?.on('data', data => output.push(data.toString())); 39 | child.stderr?.on('data', data => output.push(data.toString())); 40 | await new Promise((resolve, reject) => { 41 | child.on('close', code => { 42 | if (code === 0) 43 | resolve(); 44 | else 45 | reject(new Error(`Failed to install browser: ${output.join('')}`)); 46 | }); 47 | }); 48 | return { 49 | code: [`// Browser ${channel} installed`], 50 | captureSnapshot: false, 51 | waitForNetwork: false, 52 | }; 53 | }, 54 | }); 55 | 56 | export default [ 57 | install, 58 | ]; 59 | -------------------------------------------------------------------------------- /src/tools/keyboard.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { z } from 'zod'; 18 | import { defineTool, type ToolFactory } from './tool'; 19 | 20 | const pressKey: ToolFactory = captureSnapshot => defineTool({ 21 | capability: 'core', 22 | 23 | schema: { 24 | name: 'browser_press_key', 25 | description: 'Press a key on the keyboard', 26 | inputSchema: z.object({ 27 | key: z.string().describe('Name of the key to press or a character to generate, such as `ArrowLeft` or `a`'), 28 | }), 29 | }, 30 | 31 | handle: async (context, params) => { 32 | const tab = context.currentTabOrDie(); 33 | 34 | const code = [ 35 | `// Press ${params.key}`, 36 | `await page.keyboard.press('${params.key}');`, 37 | ]; 38 | 39 | const action = () => tab.page.keyboard.press(params.key); 40 | 41 | return { 42 | code, 43 | action, 44 | captureSnapshot, 45 | waitForNetwork: true 46 | }; 47 | }, 48 | }); 49 | 50 | export default (captureSnapshot: boolean) => [ 51 | pressKey(captureSnapshot), 52 | ]; 53 | -------------------------------------------------------------------------------- /src/tools/navigate.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { z } from 'zod'; 18 | import { defineTool, type ToolFactory } from './tool'; 19 | 20 | const navigate: ToolFactory = captureSnapshot => defineTool({ 21 | capability: 'core', 22 | 23 | schema: { 24 | name: 'browser_navigate', 25 | description: 'Navigate to a URL', 26 | inputSchema: z.object({ 27 | url: z.string().describe('The URL to navigate to'), 28 | }), 29 | }, 30 | 31 | handle: async (context, params) => { 32 | const tab = await context.ensureTab(); 33 | await tab.navigate(params.url); 34 | 35 | const code = [ 36 | `// Navigate to ${params.url}`, 37 | `await page.goto('${params.url}');`, 38 | ]; 39 | 40 | return { 41 | code, 42 | captureSnapshot, 43 | waitForNetwork: false, 44 | }; 45 | }, 46 | }); 47 | 48 | const goBack: ToolFactory = captureSnapshot => defineTool({ 49 | capability: 'history', 50 | schema: { 51 | name: 'browser_navigate_back', 52 | description: 'Go back to the previous page', 53 | inputSchema: z.object({}), 54 | }, 55 | 56 | handle: async context => { 57 | const tab = await context.ensureTab(); 58 | await tab.page.goBack(); 59 | const code = [ 60 | `// Navigate back`, 61 | `await page.goBack();`, 62 | ]; 63 | 64 | return { 65 | code, 66 | captureSnapshot, 67 | waitForNetwork: false, 68 | }; 69 | }, 70 | }); 71 | 72 | const goForward: ToolFactory = captureSnapshot => defineTool({ 73 | capability: 'history', 74 | schema: { 75 | name: 'browser_navigate_forward', 76 | description: 'Go forward to the next page', 77 | inputSchema: z.object({}), 78 | }, 79 | handle: async context => { 80 | const tab = context.currentTabOrDie(); 81 | await tab.page.goForward(); 82 | const code = [ 83 | `// Navigate forward`, 84 | `await page.goForward();`, 85 | ]; 86 | return { 87 | code, 88 | captureSnapshot, 89 | waitForNetwork: false, 90 | }; 91 | }, 92 | }); 93 | 94 | export default (captureSnapshot: boolean) => [ 95 | navigate(captureSnapshot), 96 | goBack(captureSnapshot), 97 | goForward(captureSnapshot), 98 | ]; 99 | -------------------------------------------------------------------------------- /src/tools/network.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { z } from 'zod'; 18 | import { defineTool } from './tool'; 19 | 20 | import type * as playwright from 'playwright'; 21 | 22 | const requests = defineTool({ 23 | capability: 'core', 24 | 25 | schema: { 26 | name: 'browser_network_requests', 27 | description: 'Returns all network requests since loading the page', 28 | inputSchema: z.object({}), 29 | }, 30 | 31 | handle: async context => { 32 | const requests = context.currentTabOrDie().requests(); 33 | const log = [...requests.entries()].map(([request, response]) => renderRequest(request, response)).join('\n'); 34 | return { 35 | code: [`// `], 36 | action: async () => { 37 | return { 38 | content: [{ type: 'text', text: log }] 39 | }; 40 | }, 41 | captureSnapshot: false, 42 | waitForNetwork: false, 43 | }; 44 | }, 45 | }); 46 | 47 | function renderRequest(request: playwright.Request, response: playwright.Response | null) { 48 | const result: string[] = []; 49 | result.push(`[${request.method().toUpperCase()}] ${request.url()}`); 50 | if (response) 51 | result.push(`=> [${response.status()}] ${response.statusText()}`); 52 | return result.join(' '); 53 | } 54 | 55 | export default [ 56 | requests, 57 | ]; 58 | -------------------------------------------------------------------------------- /src/tools/pdf.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import os from 'os'; 18 | import path from 'path'; 19 | 20 | import { z } from 'zod'; 21 | import { defineTool } from './tool'; 22 | 23 | import { sanitizeForFilePath } from './utils'; 24 | import * as javascript from '../javascript'; 25 | 26 | const pdf = defineTool({ 27 | capability: 'pdf', 28 | 29 | schema: { 30 | name: 'browser_pdf_save', 31 | description: 'Save page as PDF', 32 | inputSchema: z.object({}), 33 | }, 34 | 35 | handle: async context => { 36 | const tab = context.currentTabOrDie(); 37 | const fileName = path.join(os.tmpdir(), sanitizeForFilePath(`page-${new Date().toISOString()}`)) + '.pdf'; 38 | 39 | const code = [ 40 | `// Save page as ${fileName}`, 41 | `await page.pdf(${javascript.formatObject({ path: fileName })});`, 42 | ]; 43 | 44 | return { 45 | code, 46 | action: async () => tab.page.pdf({ path: fileName }).then(() => {}), 47 | captureSnapshot: false, 48 | waitForNetwork: false, 49 | }; 50 | }, 51 | }); 52 | 53 | export default [ 54 | pdf, 55 | ]; 56 | -------------------------------------------------------------------------------- /src/tools/screen.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { z } from 'zod'; 18 | import { defineTool } from './tool'; 19 | 20 | import * as javascript from '../javascript'; 21 | 22 | const elementSchema = z.object({ 23 | element: z.string().describe('Human-readable element description used to obtain permission to interact with the element'), 24 | }); 25 | 26 | const screenshot = defineTool({ 27 | capability: 'core', 28 | schema: { 29 | name: 'browser_screen_capture', 30 | description: 'Take a screenshot of the current page', 31 | inputSchema: z.object({}), 32 | }, 33 | 34 | handle: async context => { 35 | const tab = await context.ensureTab(); 36 | const options = { type: 'jpeg' as 'jpeg', quality: 50, scale: 'css' as 'css' }; 37 | 38 | const code = [ 39 | `// Take a screenshot of the current page`, 40 | `await page.screenshot(${javascript.formatObject(options)});`, 41 | ]; 42 | 43 | const action = () => tab.page.screenshot(options).then(buffer => { 44 | return { 45 | content: [{ type: 'image' as 'image', data: buffer.toString('base64'), mimeType: 'image/jpeg' }], 46 | }; 47 | }); 48 | 49 | return { 50 | code, 51 | action, 52 | captureSnapshot: false, 53 | waitForNetwork: false 54 | }; 55 | }, 56 | }); 57 | 58 | const moveMouse = defineTool({ 59 | capability: 'core', 60 | schema: { 61 | name: 'browser_screen_move_mouse', 62 | description: 'Move mouse to a given position', 63 | inputSchema: elementSchema.extend({ 64 | x: z.number().describe('X coordinate'), 65 | y: z.number().describe('Y coordinate'), 66 | }), 67 | }, 68 | 69 | handle: async (context, params) => { 70 | const tab = context.currentTabOrDie(); 71 | const code = [ 72 | `// Move mouse to (${params.x}, ${params.y})`, 73 | `await page.mouse.move(${params.x}, ${params.y});`, 74 | ]; 75 | const action = () => tab.page.mouse.move(params.x, params.y); 76 | return { 77 | code, 78 | action, 79 | captureSnapshot: false, 80 | waitForNetwork: false 81 | }; 82 | }, 83 | }); 84 | 85 | const click = defineTool({ 86 | capability: 'core', 87 | schema: { 88 | name: 'browser_screen_click', 89 | description: 'Click left mouse button', 90 | inputSchema: elementSchema.extend({ 91 | x: z.number().describe('X coordinate'), 92 | y: z.number().describe('Y coordinate'), 93 | }), 94 | }, 95 | 96 | handle: async (context, params) => { 97 | const tab = context.currentTabOrDie(); 98 | const code = [ 99 | `// Click mouse at coordinates (${params.x}, ${params.y})`, 100 | `await page.mouse.move(${params.x}, ${params.y});`, 101 | `await page.mouse.down();`, 102 | `await page.mouse.up();`, 103 | ]; 104 | const action = async () => { 105 | await tab.page.mouse.move(params.x, params.y); 106 | await tab.page.mouse.down(); 107 | await tab.page.mouse.up(); 108 | }; 109 | return { 110 | code, 111 | action, 112 | captureSnapshot: false, 113 | waitForNetwork: true, 114 | }; 115 | }, 116 | }); 117 | 118 | const drag = defineTool({ 119 | capability: 'core', 120 | 121 | schema: { 122 | name: 'browser_screen_drag', 123 | description: 'Drag left mouse button', 124 | inputSchema: elementSchema.extend({ 125 | startX: z.number().describe('Start X coordinate'), 126 | startY: z.number().describe('Start Y coordinate'), 127 | endX: z.number().describe('End X coordinate'), 128 | endY: z.number().describe('End Y coordinate'), 129 | }), 130 | }, 131 | 132 | handle: async (context, params) => { 133 | const tab = context.currentTabOrDie(); 134 | 135 | const code = [ 136 | `// Drag mouse from (${params.startX}, ${params.startY}) to (${params.endX}, ${params.endY})`, 137 | `await page.mouse.move(${params.startX}, ${params.startY});`, 138 | `await page.mouse.down();`, 139 | `await page.mouse.move(${params.endX}, ${params.endY});`, 140 | `await page.mouse.up();`, 141 | ]; 142 | 143 | const action = async () => { 144 | await tab.page.mouse.move(params.startX, params.startY); 145 | await tab.page.mouse.down(); 146 | await tab.page.mouse.move(params.endX, params.endY); 147 | await tab.page.mouse.up(); 148 | }; 149 | 150 | return { 151 | code, 152 | action, 153 | captureSnapshot: false, 154 | waitForNetwork: true, 155 | }; 156 | }, 157 | }); 158 | 159 | const type = defineTool({ 160 | capability: 'core', 161 | 162 | schema: { 163 | name: 'browser_screen_type', 164 | description: 'Type text', 165 | inputSchema: z.object({ 166 | text: z.string().describe('Text to type into the element'), 167 | submit: z.boolean().optional().describe('Whether to submit entered text (press Enter after)'), 168 | }), 169 | }, 170 | 171 | handle: async (context, params) => { 172 | const tab = context.currentTabOrDie(); 173 | 174 | const code = [ 175 | `// Type ${params.text}`, 176 | `await page.keyboard.type('${params.text}');`, 177 | ]; 178 | 179 | const action = async () => { 180 | await tab.page.keyboard.type(params.text); 181 | if (params.submit) 182 | await tab.page.keyboard.press('Enter'); 183 | }; 184 | 185 | if (params.submit) { 186 | code.push(`// Submit text`); 187 | code.push(`await page.keyboard.press('Enter');`); 188 | } 189 | 190 | return { 191 | code, 192 | action, 193 | captureSnapshot: false, 194 | waitForNetwork: true, 195 | }; 196 | }, 197 | }); 198 | 199 | export default [ 200 | screenshot, 201 | moveMouse, 202 | click, 203 | drag, 204 | type, 205 | ]; 206 | -------------------------------------------------------------------------------- /src/tools/snapshot.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import path from 'path'; 18 | import os from 'os'; 19 | 20 | import { z } from 'zod'; 21 | 22 | import { sanitizeForFilePath } from './utils'; 23 | import { generateLocator } from '../context'; 24 | import * as javascript from '../javascript'; 25 | 26 | import type * as playwright from 'playwright'; 27 | import { defineTool } from './tool'; 28 | 29 | const snapshot = defineTool({ 30 | capability: 'core', 31 | schema: { 32 | name: 'browser_snapshot', 33 | description: 'Capture accessibility snapshot of the current page, this is better than screenshot', 34 | inputSchema: z.object({}), 35 | }, 36 | 37 | handle: async context => { 38 | await context.ensureTab(); 39 | 40 | return { 41 | code: [`// `], 42 | captureSnapshot: true, 43 | waitForNetwork: false, 44 | }; 45 | }, 46 | }); 47 | 48 | const elementSchema = z.object({ 49 | element: z.string().describe('Human-readable element description used to obtain permission to interact with the element'), 50 | ref: z.string().describe('Exact target element reference from the page snapshot'), 51 | }); 52 | 53 | const click = defineTool({ 54 | capability: 'core', 55 | schema: { 56 | name: 'browser_click', 57 | description: 'Perform click on a web page', 58 | inputSchema: elementSchema, 59 | }, 60 | 61 | handle: async (context, params) => { 62 | const tab = context.currentTabOrDie(); 63 | const locator = tab.snapshotOrDie().refLocator(params.ref); 64 | 65 | const code = [ 66 | `// Click ${params.element}`, 67 | `await page.${await generateLocator(locator)}.click();` 68 | ]; 69 | 70 | return { 71 | code, 72 | action: () => locator.click(), 73 | captureSnapshot: true, 74 | waitForNetwork: true, 75 | }; 76 | }, 77 | }); 78 | 79 | const drag = defineTool({ 80 | capability: 'core', 81 | schema: { 82 | name: 'browser_drag', 83 | description: 'Perform drag and drop between two elements', 84 | inputSchema: z.object({ 85 | startElement: z.string().describe('Human-readable source element description used to obtain the permission to interact with the element'), 86 | startRef: z.string().describe('Exact source element reference from the page snapshot'), 87 | endElement: z.string().describe('Human-readable target element description used to obtain the permission to interact with the element'), 88 | endRef: z.string().describe('Exact target element reference from the page snapshot'), 89 | }), 90 | }, 91 | 92 | handle: async (context, params) => { 93 | const snapshot = context.currentTabOrDie().snapshotOrDie(); 94 | const startLocator = snapshot.refLocator(params.startRef); 95 | const endLocator = snapshot.refLocator(params.endRef); 96 | 97 | const code = [ 98 | `// Drag ${params.startElement} to ${params.endElement}`, 99 | `await page.${await generateLocator(startLocator)}.dragTo(page.${await generateLocator(endLocator)});` 100 | ]; 101 | 102 | return { 103 | code, 104 | action: () => startLocator.dragTo(endLocator), 105 | captureSnapshot: true, 106 | waitForNetwork: true, 107 | }; 108 | }, 109 | }); 110 | 111 | const hover = defineTool({ 112 | capability: 'core', 113 | schema: { 114 | name: 'browser_hover', 115 | description: 'Hover over element on page', 116 | inputSchema: elementSchema, 117 | }, 118 | 119 | handle: async (context, params) => { 120 | const snapshot = context.currentTabOrDie().snapshotOrDie(); 121 | const locator = snapshot.refLocator(params.ref); 122 | 123 | const code = [ 124 | `// Hover over ${params.element}`, 125 | `await page.${await generateLocator(locator)}.hover();` 126 | ]; 127 | 128 | return { 129 | code, 130 | action: () => locator.hover(), 131 | captureSnapshot: true, 132 | waitForNetwork: true, 133 | }; 134 | }, 135 | }); 136 | 137 | const typeSchema = elementSchema.extend({ 138 | text: z.string().describe('Text to type into the element'), 139 | submit: z.boolean().optional().describe('Whether to submit entered text (press Enter after)'), 140 | slowly: z.boolean().optional().describe('Whether to type one character at a time. Useful for triggering key handlers in the page. By default entire text is filled in at once.'), 141 | }); 142 | 143 | const type = defineTool({ 144 | capability: 'core', 145 | schema: { 146 | name: 'browser_type', 147 | description: 'Type text into editable element', 148 | inputSchema: typeSchema, 149 | }, 150 | 151 | handle: async (context, params) => { 152 | const snapshot = context.currentTabOrDie().snapshotOrDie(); 153 | const locator = snapshot.refLocator(params.ref); 154 | 155 | const code: string[] = []; 156 | const steps: (() => Promise)[] = []; 157 | 158 | if (params.slowly) { 159 | code.push(`// Press "${params.text}" sequentially into "${params.element}"`); 160 | code.push(`await page.${await generateLocator(locator)}.pressSequentially(${javascript.quote(params.text)});`); 161 | steps.push(() => locator.pressSequentially(params.text)); 162 | } else { 163 | code.push(`// Fill "${params.text}" into "${params.element}"`); 164 | code.push(`await page.${await generateLocator(locator)}.fill(${javascript.quote(params.text)});`); 165 | steps.push(() => locator.fill(params.text)); 166 | } 167 | 168 | if (params.submit) { 169 | code.push(`// Submit text`); 170 | code.push(`await page.${await generateLocator(locator)}.press('Enter');`); 171 | steps.push(() => locator.press('Enter')); 172 | } 173 | 174 | return { 175 | code, 176 | action: () => steps.reduce((acc, step) => acc.then(step), Promise.resolve()), 177 | captureSnapshot: true, 178 | waitForNetwork: true, 179 | }; 180 | }, 181 | }); 182 | 183 | const selectOptionSchema = elementSchema.extend({ 184 | values: z.array(z.string()).describe('Array of values to select in the dropdown. This can be a single value or multiple values.'), 185 | }); 186 | 187 | const selectOption = defineTool({ 188 | capability: 'core', 189 | schema: { 190 | name: 'browser_select_option', 191 | description: 'Select an option in a dropdown', 192 | inputSchema: selectOptionSchema, 193 | }, 194 | 195 | handle: async (context, params) => { 196 | const snapshot = context.currentTabOrDie().snapshotOrDie(); 197 | const locator = snapshot.refLocator(params.ref); 198 | 199 | const code = [ 200 | `// Select options [${params.values.join(', ')}] in ${params.element}`, 201 | `await page.${await generateLocator(locator)}.selectOption(${javascript.formatObject(params.values)});` 202 | ]; 203 | 204 | return { 205 | code, 206 | action: () => locator.selectOption(params.values).then(() => {}), 207 | captureSnapshot: true, 208 | waitForNetwork: true, 209 | }; 210 | }, 211 | }); 212 | 213 | const screenshotSchema = z.object({ 214 | raw: z.boolean().optional().describe('Whether to return without compression (in PNG format). Default is false, which returns a JPEG image.'), 215 | 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.'), 216 | 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.'), 217 | }).refine(data => { 218 | return !!data.element === !!data.ref; 219 | }, { 220 | message: 'Both element and ref must be provided or neither.', 221 | path: ['ref', 'element'] 222 | }); 223 | 224 | const screenshot = defineTool({ 225 | capability: 'core', 226 | schema: { 227 | name: 'browser_take_screenshot', 228 | description: `Take a screenshot of the current page. You can't perform actions based on the screenshot, use browser_snapshot for actions.`, 229 | inputSchema: screenshotSchema, 230 | }, 231 | 232 | handle: async (context, params) => { 233 | const tab = context.currentTabOrDie(); 234 | const snapshot = tab.snapshotOrDie(); 235 | const fileType = params.raw ? 'png' : 'jpeg'; 236 | const fileName = path.join(os.tmpdir(), sanitizeForFilePath(`page-${new Date().toISOString()}`)) + `.${fileType}`; 237 | const options: playwright.PageScreenshotOptions = { type: fileType, quality: fileType === 'png' ? undefined : 50, scale: 'css', path: fileName }; 238 | const isElementScreenshot = params.element && params.ref; 239 | 240 | const code = [ 241 | `// Screenshot ${isElementScreenshot ? params.element : 'viewport'} and save it as ${fileName}`, 242 | ]; 243 | 244 | const locator = params.ref ? snapshot.refLocator(params.ref) : null; 245 | 246 | if (locator) 247 | code.push(`await page.${await generateLocator(locator)}.screenshot(${javascript.formatObject(options)});`); 248 | else 249 | code.push(`await page.screenshot(${javascript.formatObject(options)});`); 250 | 251 | const action = async () => { 252 | const screenshot = locator ? await locator.screenshot(options) : await tab.page.screenshot(options); 253 | return { 254 | content: [{ 255 | type: 'image' as 'image', 256 | data: screenshot.toString('base64'), 257 | mimeType: fileType === 'png' ? 'image/png' : 'image/jpeg', 258 | }] 259 | }; 260 | }; 261 | 262 | return { 263 | code, 264 | action, 265 | captureSnapshot: true, 266 | waitForNetwork: false, 267 | }; 268 | } 269 | }); 270 | 271 | 272 | export default [ 273 | snapshot, 274 | click, 275 | drag, 276 | hover, 277 | type, 278 | selectOption, 279 | screenshot, 280 | ]; 281 | -------------------------------------------------------------------------------- /src/tools/tabs.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { z } from 'zod'; 18 | import { defineTool, type ToolFactory } from './tool'; 19 | 20 | const listTabs = defineTool({ 21 | capability: 'tabs', 22 | 23 | schema: { 24 | name: 'browser_tab_list', 25 | description: 'List browser tabs', 26 | inputSchema: z.object({}), 27 | }, 28 | 29 | handle: async context => { 30 | await context.ensureTab(); 31 | return { 32 | code: [`// `], 33 | captureSnapshot: false, 34 | waitForNetwork: false, 35 | resultOverride: { 36 | content: [{ 37 | type: 'text', 38 | text: await context.listTabsMarkdown(), 39 | }], 40 | }, 41 | }; 42 | }, 43 | }); 44 | 45 | const selectTab: ToolFactory = captureSnapshot => defineTool({ 46 | capability: 'tabs', 47 | 48 | schema: { 49 | name: 'browser_tab_select', 50 | description: 'Select a tab by index', 51 | inputSchema: z.object({ 52 | index: z.number().describe('The index of the tab to select'), 53 | }), 54 | }, 55 | 56 | handle: async (context, params) => { 57 | await context.selectTab(params.index); 58 | const code = [ 59 | `// `, 60 | ]; 61 | 62 | return { 63 | code, 64 | captureSnapshot, 65 | waitForNetwork: false 66 | }; 67 | }, 68 | }); 69 | 70 | const newTab: ToolFactory = captureSnapshot => defineTool({ 71 | capability: 'tabs', 72 | 73 | schema: { 74 | name: 'browser_tab_new', 75 | description: 'Open a new tab', 76 | inputSchema: z.object({ 77 | url: z.string().optional().describe('The URL to navigate to in the new tab. If not provided, the new tab will be blank.'), 78 | }), 79 | }, 80 | 81 | handle: async (context, params) => { 82 | await context.newTab(); 83 | if (params.url) 84 | await context.currentTabOrDie().navigate(params.url); 85 | 86 | const code = [ 87 | `// `, 88 | ]; 89 | return { 90 | code, 91 | captureSnapshot, 92 | waitForNetwork: false 93 | }; 94 | }, 95 | }); 96 | 97 | const closeTab: ToolFactory = captureSnapshot => defineTool({ 98 | capability: 'tabs', 99 | 100 | schema: { 101 | name: 'browser_tab_close', 102 | description: 'Close a tab', 103 | inputSchema: z.object({ 104 | index: z.number().optional().describe('The index of the tab to close. Closes current tab if not provided.'), 105 | }), 106 | }, 107 | 108 | handle: async (context, params) => { 109 | await context.closeTab(params.index); 110 | const code = [ 111 | `// `, 112 | ]; 113 | return { 114 | code, 115 | captureSnapshot, 116 | waitForNetwork: false 117 | }; 118 | }, 119 | }); 120 | 121 | export default (captureSnapshot: boolean) => [ 122 | listTabs, 123 | newTab(captureSnapshot), 124 | selectTab(captureSnapshot), 125 | closeTab(captureSnapshot), 126 | ]; 127 | -------------------------------------------------------------------------------- /src/tools/tool.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types'; 18 | import type { z } from 'zod'; 19 | import type { Context } from '../context'; 20 | import type * as playwright from 'playwright'; 21 | export type ToolCapability = 'core' | 'tabs' | 'pdf' | 'history' | 'wait' | 'files' | 'install'; 22 | 23 | export type ToolSchema = { 24 | name: string; 25 | description: string; 26 | inputSchema: Input; 27 | }; 28 | 29 | type InputType = z.Schema; 30 | 31 | export type FileUploadModalState = { 32 | type: 'fileChooser'; 33 | description: string; 34 | fileChooser: playwright.FileChooser; 35 | }; 36 | 37 | export type DialogModalState = { 38 | type: 'dialog'; 39 | description: string; 40 | dialog: playwright.Dialog; 41 | }; 42 | 43 | export type ModalState = FileUploadModalState | DialogModalState; 44 | 45 | export type ToolActionResult = { content?: (ImageContent | TextContent)[] } | undefined | void; 46 | 47 | export type ToolResult = { 48 | code: string[]; 49 | action?: () => Promise; 50 | captureSnapshot: boolean; 51 | waitForNetwork: boolean; 52 | resultOverride?: ToolActionResult; 53 | }; 54 | 55 | export type Tool = { 56 | capability: ToolCapability; 57 | schema: ToolSchema; 58 | clearsModalState?: ModalState['type']; 59 | handle: (context: Context, params: z.output) => Promise; 60 | }; 61 | 62 | export type ToolFactory = (snapshot: boolean) => Tool; 63 | 64 | export function defineTool(tool: Tool): Tool { 65 | return tool; 66 | } 67 | -------------------------------------------------------------------------------- /src/tools/utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import type * as playwright from 'playwright'; 18 | import type { Context } from '../context'; 19 | 20 | export async function waitForCompletion(context: Context, page: playwright.Page, callback: () => Promise): Promise { 21 | const requests = new Set(); 22 | let frameNavigated = false; 23 | let waitCallback: () => void = () => {}; 24 | const waitBarrier = new Promise(f => { waitCallback = f; }); 25 | 26 | const requestListener = (request: playwright.Request) => requests.add(request); 27 | const requestFinishedListener = (request: playwright.Request) => { 28 | requests.delete(request); 29 | if (!requests.size) 30 | waitCallback(); 31 | }; 32 | 33 | const frameNavigateListener = (frame: playwright.Frame) => { 34 | if (frame.parentFrame()) 35 | return; 36 | frameNavigated = true; 37 | dispose(); 38 | clearTimeout(timeout); 39 | void frame.waitForLoadState('load').then(() => { 40 | waitCallback(); 41 | }); 42 | }; 43 | 44 | const onTimeout = () => { 45 | dispose(); 46 | waitCallback(); 47 | }; 48 | 49 | page.on('request', requestListener); 50 | page.on('requestfinished', requestFinishedListener); 51 | page.on('framenavigated', frameNavigateListener); 52 | const timeout = setTimeout(onTimeout, 10000); 53 | 54 | const dispose = () => { 55 | page.off('request', requestListener); 56 | page.off('requestfinished', requestFinishedListener); 57 | page.off('framenavigated', frameNavigateListener); 58 | clearTimeout(timeout); 59 | }; 60 | 61 | try { 62 | const result = await callback(); 63 | if (!requests.size && !frameNavigated) 64 | waitCallback(); 65 | await waitBarrier; 66 | await context.waitForTimeout(1000); 67 | return result; 68 | } finally { 69 | dispose(); 70 | } 71 | } 72 | 73 | export function sanitizeForFilePath(s: string) { 74 | return s.replace(/[\x00-\x2C\x2E-\x2F\x3A-\x40\x5B-\x60\x7B-\x7F]+/g, '-'); 75 | } 76 | -------------------------------------------------------------------------------- /tests/capabilities.spec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { test, expect } from './fixtures'; 18 | 19 | test('test snapshot tool list', async ({ client }) => { 20 | const { tools } = await client.listTools(); 21 | expect(new Set(tools.map(t => t.name))).toEqual(new Set([ 22 | 'browser_click', 23 | 'browser_console_messages', 24 | 'browser_drag', 25 | 'browser_file_upload', 26 | 'browser_handle_dialog', 27 | 'browser_hover', 28 | 'browser_select_option', 29 | 'browser_type', 30 | 'browser_close', 31 | 'browser_install', 32 | 'browser_navigate_back', 33 | 'browser_navigate_forward', 34 | 'browser_navigate', 35 | 'browser_network_requests', 36 | 'browser_pdf_save', 37 | 'browser_press_key', 38 | 'browser_resize', 39 | 'browser_snapshot', 40 | 'browser_tab_close', 41 | 'browser_tab_list', 42 | 'browser_tab_new', 43 | 'browser_tab_select', 44 | 'browser_take_screenshot', 45 | 'browser_wait', 46 | ])); 47 | }); 48 | 49 | test('test vision tool list', async ({ visionClient }) => { 50 | const { tools: visionTools } = await visionClient.listTools(); 51 | expect(new Set(visionTools.map(t => t.name))).toEqual(new Set([ 52 | 'browser_close', 53 | 'browser_console_messages', 54 | 'browser_file_upload', 55 | 'browser_handle_dialog', 56 | 'browser_install', 57 | 'browser_navigate_back', 58 | 'browser_navigate_forward', 59 | 'browser_navigate', 60 | 'browser_network_requests', 61 | 'browser_pdf_save', 62 | 'browser_press_key', 63 | 'browser_resize', 64 | 'browser_screen_capture', 65 | 'browser_screen_click', 66 | 'browser_screen_drag', 67 | 'browser_screen_move_mouse', 68 | 'browser_screen_type', 69 | 'browser_tab_close', 70 | 'browser_tab_list', 71 | 'browser_tab_new', 72 | 'browser_tab_select', 73 | 'browser_wait', 74 | ])); 75 | }); 76 | 77 | test('test resources list', async ({ client }) => { 78 | const { resources } = await client.listResources(); 79 | expect(resources).toEqual([]); 80 | }); 81 | 82 | test('test capabilities', async ({ startClient }) => { 83 | const client = await startClient({ 84 | args: ['--caps="core"'], 85 | }); 86 | const { tools } = await client.listTools(); 87 | const toolNames = tools.map(t => t.name); 88 | expect(toolNames).not.toContain('browser_file_upload'); 89 | expect(toolNames).not.toContain('browser_pdf_save'); 90 | expect(toolNames).not.toContain('browser_screen_capture'); 91 | expect(toolNames).not.toContain('browser_screen_click'); 92 | expect(toolNames).not.toContain('browser_screen_drag'); 93 | expect(toolNames).not.toContain('browser_screen_move_mouse'); 94 | expect(toolNames).not.toContain('browser_screen_type'); 95 | }); 96 | -------------------------------------------------------------------------------- /tests/cdp.spec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { test, expect } from './fixtures'; 18 | 19 | test('cdp server', async ({ cdpEndpoint, startClient }) => { 20 | const client = await startClient({ args: [`--cdp-endpoint=${cdpEndpoint}`] }); 21 | expect(await client.callTool({ 22 | name: 'browser_navigate', 23 | arguments: { 24 | url: 'data:text/html,TitleHello, world!', 25 | }, 26 | })).toContainTextContent(`- text: Hello, world!`); 27 | }); 28 | 29 | test('cdp server reuse tab', async ({ cdpEndpoint, startClient }) => { 30 | const client = await startClient({ args: [`--cdp-endpoint=${cdpEndpoint}`] }); 31 | 32 | expect(await client.callTool({ 33 | name: 'browser_click', 34 | arguments: { 35 | element: 'Hello, world!', 36 | ref: 'f0', 37 | }, 38 | })).toHaveTextContent(`Error: No current snapshot available. Capture a snapshot of navigate to a new location first.`); 39 | 40 | expect(await client.callTool({ 41 | name: 'browser_snapshot', 42 | arguments: {}, 43 | })).toHaveTextContent(` 44 | - Ran Playwright code: 45 | \`\`\`js 46 | // 47 | \`\`\` 48 | 49 | - Page URL: data:text/html,hello world 50 | - Page Title: 51 | - Page Snapshot 52 | \`\`\`yaml 53 | - text: hello world 54 | \`\`\` 55 | `); 56 | }); 57 | -------------------------------------------------------------------------------- /tests/console.spec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { test, expect } from './fixtures'; 18 | 19 | test('browser_console_messages', async ({ client }) => { 20 | await client.callTool({ 21 | name: 'browser_navigate', 22 | arguments: { 23 | url: 'data:text/html,', 24 | }, 25 | }); 26 | 27 | const resource = await client.callTool({ 28 | name: 'browser_console_messages', 29 | arguments: {}, 30 | }); 31 | expect(resource).toHaveTextContent([ 32 | '[LOG] Hello, world!', 33 | '[ERROR] Error', 34 | ].join('\n')); 35 | }); 36 | -------------------------------------------------------------------------------- /tests/core.spec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { test, expect } from './fixtures'; 18 | 19 | test('browser_navigate', async ({ client }) => { 20 | expect(await client.callTool({ 21 | name: 'browser_navigate', 22 | arguments: { 23 | url: 'data:text/html,TitleHello, world!', 24 | }, 25 | })).toHaveTextContent(` 26 | - Ran Playwright code: 27 | \`\`\`js 28 | // Navigate to data:text/html,TitleHello, world! 29 | await page.goto('data:text/html,TitleHello, world!'); 30 | \`\`\` 31 | 32 | - Page URL: data:text/html,TitleHello, world! 33 | - Page Title: Title 34 | - Page Snapshot 35 | \`\`\`yaml 36 | - text: Hello, world! 37 | \`\`\` 38 | ` 39 | ); 40 | }); 41 | 42 | test('browser_click', async ({ client }) => { 43 | await client.callTool({ 44 | name: 'browser_navigate', 45 | arguments: { 46 | url: 'data:text/html,Title', 47 | }, 48 | }); 49 | 50 | expect(await client.callTool({ 51 | name: 'browser_click', 52 | arguments: { 53 | element: 'Submit button', 54 | ref: 's1e3', 55 | }, 56 | })).toHaveTextContent(` 57 | - Ran Playwright code: 58 | \`\`\`js 59 | // Click Submit button 60 | await page.getByRole('button', { name: 'Submit' }).click(); 61 | \`\`\` 62 | 63 | - Page URL: data:text/html,Title 64 | - Page Title: Title 65 | - Page Snapshot 66 | \`\`\`yaml 67 | - button "Submit" [ref=s2e3] 68 | \`\`\` 69 | `); 70 | }); 71 | 72 | test('browser_select_option', async ({ client }) => { 73 | await client.callTool({ 74 | name: 'browser_navigate', 75 | arguments: { 76 | url: 'data:text/html,Title', 77 | }, 78 | }); 79 | 80 | expect(await client.callTool({ 81 | name: 'browser_select_option', 82 | arguments: { 83 | element: 'Select', 84 | ref: 's1e3', 85 | values: ['bar'], 86 | }, 87 | })).toHaveTextContent(` 88 | - Ran Playwright code: 89 | \`\`\`js 90 | // Select options [bar] in Select 91 | await page.getByRole('combobox').selectOption(['bar']); 92 | \`\`\` 93 | 94 | - Page URL: data:text/html,Title 95 | - Page Title: Title 96 | - Page Snapshot 97 | \`\`\`yaml 98 | - combobox [ref=s2e3]: 99 | - option "Foo" 100 | - option "Bar" [selected] 101 | \`\`\` 102 | `); 103 | }); 104 | 105 | test('browser_select_option (multiple)', async ({ client }) => { 106 | await client.callTool({ 107 | name: 'browser_navigate', 108 | arguments: { 109 | url: 'data:text/html,Title', 110 | }, 111 | }); 112 | 113 | expect(await client.callTool({ 114 | name: 'browser_select_option', 115 | arguments: { 116 | element: 'Select', 117 | ref: 's1e3', 118 | values: ['bar', 'baz'], 119 | }, 120 | })).toHaveTextContent(` 121 | - Ran Playwright code: 122 | \`\`\`js 123 | // Select options [bar, baz] in Select 124 | await page.getByRole('listbox').selectOption(['bar', 'baz']); 125 | \`\`\` 126 | 127 | - Page URL: data:text/html,Title 128 | - Page Title: Title 129 | - Page Snapshot 130 | \`\`\`yaml 131 | - listbox [ref=s2e3]: 132 | - option "Foo" [ref=s2e4] 133 | - option "Bar" [selected] [ref=s2e5] 134 | - option "Baz" [selected] [ref=s2e6] 135 | \`\`\` 136 | `); 137 | }); 138 | 139 | test('browser_type', async ({ client }) => { 140 | await client.callTool({ 141 | name: 'browser_navigate', 142 | arguments: { 143 | url: `data:text/html,`, 144 | }, 145 | }); 146 | await client.callTool({ 147 | name: 'browser_type', 148 | arguments: { 149 | element: 'textbox', 150 | ref: 's1e3', 151 | text: 'Hi!', 152 | submit: true, 153 | }, 154 | }); 155 | expect(await client.callTool({ 156 | name: 'browser_console_messages', 157 | arguments: {}, 158 | })).toHaveTextContent('[LOG] Key pressed: Enter , Text: Hi!'); 159 | }); 160 | 161 | test('browser_type (slowly)', async ({ client }) => { 162 | await client.callTool({ 163 | name: 'browser_navigate', 164 | arguments: { 165 | url: `data:text/html,`, 166 | }, 167 | }); 168 | await client.callTool({ 169 | name: 'browser_type', 170 | arguments: { 171 | element: 'textbox', 172 | ref: 's1e3', 173 | text: 'Hi!', 174 | submit: true, 175 | slowly: true, 176 | }, 177 | }); 178 | expect(await client.callTool({ 179 | name: 'browser_console_messages', 180 | arguments: {}, 181 | })).toHaveTextContent([ 182 | '[LOG] Key pressed: H Text: ', 183 | '[LOG] Key pressed: i Text: H', 184 | '[LOG] Key pressed: ! Text: Hi', 185 | '[LOG] Key pressed: Enter Text: Hi!', 186 | ].join('\n')); 187 | }); 188 | 189 | test('browser_resize', async ({ client }) => { 190 | await client.callTool({ 191 | name: 'browser_navigate', 192 | arguments: { 193 | url: 'data:text/html,Resize Test
Waiting for resize...
', 194 | }, 195 | }); 196 | 197 | const response = await client.callTool({ 198 | name: 'browser_resize', 199 | arguments: { 200 | width: 390, 201 | height: 780, 202 | }, 203 | }); 204 | expect(response).toContainTextContent(`- Ran Playwright code: 205 | \`\`\`js 206 | // Resize browser window to 390x780 207 | await page.setViewportSize({ width: 390, height: 780 }); 208 | \`\`\``); 209 | await expect.poll(() => client.callTool({ name: 'browser_snapshot', arguments: {} })).toContainTextContent('Window size: 390x780'); 210 | }); 211 | -------------------------------------------------------------------------------- /tests/dialogs.spec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { test, expect } from './fixtures'; 18 | 19 | // https://github.com/microsoft/playwright/issues/35663 20 | test.skip(({ mcpBrowser, mcpHeadless }) => mcpBrowser === 'webkit' && mcpHeadless); 21 | 22 | test('alert dialog', async ({ client }) => { 23 | expect(await client.callTool({ 24 | name: 'browser_navigate', 25 | arguments: { 26 | url: 'data:text/html,Title', 27 | }, 28 | })).toContainTextContent('- button "Button" [ref=s1e3]'); 29 | 30 | expect(await client.callTool({ 31 | name: 'browser_click', 32 | arguments: { 33 | element: 'Button', 34 | ref: 's1e3', 35 | }, 36 | })).toHaveTextContent(`- Ran Playwright code: 37 | \`\`\`js 38 | // Click Button 39 | await page.getByRole('button', { name: 'Button' }).click(); 40 | \`\`\` 41 | 42 | ### Modal state 43 | - ["alert" dialog with message "Alert"]: can be handled by the "browser_handle_dialog" tool`); 44 | 45 | const result = await client.callTool({ 46 | name: 'browser_handle_dialog', 47 | arguments: { 48 | accept: true, 49 | }, 50 | }); 51 | 52 | expect(result).not.toContainTextContent('### Modal state'); 53 | expect(result).toHaveTextContent(`- Ran Playwright code: 54 | \`\`\`js 55 | // 56 | \`\`\` 57 | 58 | - Page URL: data:text/html,Title 59 | - Page Title: Title 60 | - Page Snapshot 61 | \`\`\`yaml 62 | - button "Button" [ref=s2e3] 63 | \`\`\` 64 | `); 65 | }); 66 | 67 | test('two alert dialogs', async ({ client }) => { 68 | test.fixme(true, 'Race between the dialog and ariaSnapshot'); 69 | expect(await client.callTool({ 70 | name: 'browser_navigate', 71 | arguments: { 72 | url: 'data:text/html,Title', 73 | }, 74 | })).toContainTextContent('- button "Button" [ref=s1e3]'); 75 | 76 | expect(await client.callTool({ 77 | name: 'browser_click', 78 | arguments: { 79 | element: 'Button', 80 | ref: 's1e3', 81 | }, 82 | })).toHaveTextContent(`- Ran Playwright code: 83 | \`\`\`js 84 | // Click Button 85 | await page.getByRole('button', { name: 'Button' }).click(); 86 | \`\`\` 87 | 88 | ### Modal state 89 | - ["alert" dialog with message "Alert 1"]: can be handled by the "browser_handle_dialog" tool`); 90 | 91 | const result = await client.callTool({ 92 | name: 'browser_handle_dialog', 93 | arguments: { 94 | accept: true, 95 | }, 96 | }); 97 | 98 | expect(result).not.toContainTextContent('### Modal state'); 99 | }); 100 | 101 | test('confirm dialog (true)', async ({ client }) => { 102 | expect(await client.callTool({ 103 | name: 'browser_navigate', 104 | arguments: { 105 | url: 'data:text/html,Title', 106 | }, 107 | })).toContainTextContent('- button "Button" [ref=s1e3]'); 108 | 109 | expect(await client.callTool({ 110 | name: 'browser_click', 111 | arguments: { 112 | element: 'Button', 113 | ref: 's1e3', 114 | }, 115 | })).toContainTextContent(`### Modal state 116 | - ["confirm" dialog with message "Confirm"]: can be handled by the "browser_handle_dialog" tool`); 117 | 118 | const result = await client.callTool({ 119 | name: 'browser_handle_dialog', 120 | arguments: { 121 | accept: true, 122 | }, 123 | }); 124 | 125 | expect(result).not.toContainTextContent('### Modal state'); 126 | expect(result).toContainTextContent('// '); 127 | expect(result).toContainTextContent(`- Page Snapshot 128 | \`\`\`yaml 129 | - text: "true" 130 | \`\`\``); 131 | }); 132 | 133 | test('confirm dialog (false)', async ({ client }) => { 134 | expect(await client.callTool({ 135 | name: 'browser_navigate', 136 | arguments: { 137 | url: 'data:text/html,Title', 138 | }, 139 | })).toContainTextContent('- button "Button" [ref=s1e3]'); 140 | 141 | expect(await client.callTool({ 142 | name: 'browser_click', 143 | arguments: { 144 | element: 'Button', 145 | ref: 's1e3', 146 | }, 147 | })).toContainTextContent(`### Modal state 148 | - ["confirm" dialog with message "Confirm"]: can be handled by the "browser_handle_dialog" tool`); 149 | 150 | const result = await client.callTool({ 151 | name: 'browser_handle_dialog', 152 | arguments: { 153 | accept: false, 154 | }, 155 | }); 156 | 157 | expect(result).toContainTextContent(`- Page Snapshot 158 | \`\`\`yaml 159 | - text: "false" 160 | \`\`\``); 161 | }); 162 | 163 | test('prompt dialog', async ({ client }) => { 164 | expect(await client.callTool({ 165 | name: 'browser_navigate', 166 | arguments: { 167 | url: 'data:text/html,Title', 168 | }, 169 | })).toContainTextContent('- button "Button" [ref=s1e3]'); 170 | 171 | expect(await client.callTool({ 172 | name: 'browser_click', 173 | arguments: { 174 | element: 'Button', 175 | ref: 's1e3', 176 | }, 177 | })).toContainTextContent(`### Modal state 178 | - ["prompt" dialog with message "Prompt"]: can be handled by the "browser_handle_dialog" tool`); 179 | 180 | const result = await client.callTool({ 181 | name: 'browser_handle_dialog', 182 | arguments: { 183 | accept: true, 184 | promptText: 'Answer', 185 | }, 186 | }); 187 | 188 | expect(result).toContainTextContent(`- Page Snapshot 189 | \`\`\`yaml 190 | - text: Answer 191 | \`\`\``); 192 | }); 193 | -------------------------------------------------------------------------------- /tests/files.spec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { test, expect } from './fixtures'; 18 | import fs from 'fs/promises'; 19 | 20 | test('browser_file_upload', async ({ client }) => { 21 | expect(await client.callTool({ 22 | name: 'browser_navigate', 23 | arguments: { 24 | url: 'data:text/html,Title', 25 | }, 26 | })).toContainTextContent(` 27 | \`\`\`yaml 28 | - generic [ref=s1e2]: 29 | - button "Choose File" [ref=s1e3] 30 | - button "Button" [ref=s1e4] 31 | \`\`\``); 32 | 33 | expect(await client.callTool({ 34 | name: 'browser_click', 35 | arguments: { 36 | element: 'Textbox', 37 | ref: 's1e3', 38 | }, 39 | })).toContainTextContent(`### Modal state 40 | - [File chooser]: can be handled by the "browser_file_upload" tool`); 41 | 42 | const filePath = test.info().outputPath('test.txt'); 43 | await fs.writeFile(filePath, 'Hello, world!'); 44 | 45 | { 46 | const response = await client.callTool({ 47 | name: 'browser_file_upload', 48 | arguments: { 49 | paths: [filePath], 50 | }, 51 | }); 52 | 53 | expect(response).not.toContainTextContent('### Modal state'); 54 | expect(response).toContainTextContent(` 55 | \`\`\`yaml 56 | - generic [ref=s3e2]: 57 | - button "Choose File" [ref=s3e3] 58 | - button "Button" [ref=s3e4] 59 | \`\`\``); 60 | } 61 | 62 | { 63 | const response = await client.callTool({ 64 | name: 'browser_click', 65 | arguments: { 66 | element: 'Textbox', 67 | ref: 's3e3', 68 | }, 69 | }); 70 | 71 | expect(response).toContainTextContent('- [File chooser]: can be handled by the \"browser_file_upload\" tool'); 72 | } 73 | 74 | { 75 | const response = await client.callTool({ 76 | name: 'browser_click', 77 | arguments: { 78 | element: 'Button', 79 | ref: 's4e4', 80 | }, 81 | }); 82 | 83 | expect(response).toContainTextContent(`Tool "browser_click" does not handle the modal state. 84 | ### Modal state 85 | - [File chooser]: can be handled by the "browser_file_upload" tool`); 86 | } 87 | }); 88 | -------------------------------------------------------------------------------- /tests/fixtures.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import path from 'path'; 18 | import { chromium } from 'playwright'; 19 | 20 | import { test as baseTest, expect as baseExpect } from '@playwright/test'; 21 | import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; 22 | import { Client } from '@modelcontextprotocol/sdk/client/index.js'; 23 | import { spawn } from 'child_process'; 24 | import { TestServer } from './testserver'; 25 | 26 | type TestFixtures = { 27 | client: Client; 28 | visionClient: Client; 29 | startClient: (options?: { args?: string[] }) => Promise; 30 | wsEndpoint: string; 31 | cdpEndpoint: string; 32 | server: TestServer; 33 | httpsServer: TestServer; 34 | }; 35 | 36 | type WorkerFixtures = { 37 | mcpHeadless: boolean; 38 | mcpBrowser: string | undefined; 39 | _workerServers: { server: TestServer, httpsServer: TestServer }; 40 | }; 41 | 42 | export const test = baseTest.extend({ 43 | 44 | client: async ({ startClient }, use) => { 45 | await use(await startClient()); 46 | }, 47 | 48 | visionClient: async ({ startClient }, use) => { 49 | await use(await startClient({ args: ['--vision'] })); 50 | }, 51 | 52 | startClient: async ({ mcpHeadless, mcpBrowser }, use, testInfo) => { 53 | const userDataDir = testInfo.outputPath('user-data-dir'); 54 | let client: StdioClientTransport | undefined; 55 | 56 | await use(async options => { 57 | const args = ['--user-data-dir', userDataDir]; 58 | if (mcpHeadless) 59 | args.push('--headless'); 60 | if (mcpBrowser) 61 | args.push(`--browser=${mcpBrowser}`); 62 | if (options?.args) 63 | args.push(...options.args); 64 | const transport = new StdioClientTransport({ 65 | command: 'node', 66 | args: [path.join(__dirname, '../cli.js'), ...args], 67 | }); 68 | const client = new Client({ name: 'test', version: '1.0.0' }); 69 | await client.connect(transport); 70 | await client.ping(); 71 | return client; 72 | }); 73 | 74 | await client?.close(); 75 | }, 76 | 77 | wsEndpoint: async ({ }, use) => { 78 | const browserServer = await chromium.launchServer(); 79 | await use(browserServer.wsEndpoint()); 80 | await browserServer.close(); 81 | }, 82 | 83 | cdpEndpoint: async ({ }, use, testInfo) => { 84 | const port = 3200 + (+process.env.TEST_PARALLEL_INDEX!); 85 | const executablePath = chromium.executablePath(); 86 | const browserProcess = spawn(executablePath, [ 87 | `--user-data-dir=${testInfo.outputPath('user-data-dir')}`, 88 | `--remote-debugging-port=${port}`, 89 | `--no-first-run`, 90 | `--no-sandbox`, 91 | `--headless`, 92 | `data:text/html,hello world`, 93 | ], { 94 | stdio: 'pipe', 95 | }); 96 | await new Promise(resolve => { 97 | browserProcess.stderr.on('data', data => { 98 | if (data.toString().includes('DevTools listening on ')) 99 | resolve(); 100 | }); 101 | }); 102 | await use(`http://localhost:${port}`); 103 | browserProcess.kill(); 104 | }, 105 | 106 | mcpHeadless: [async ({ headless }, use) => { 107 | await use(headless); 108 | }, { scope: 'worker' }], 109 | 110 | mcpBrowser: ['chrome', { option: true, scope: 'worker' }], 111 | 112 | _workerServers: [async ({}, use, workerInfo) => { 113 | const port = 8907 + workerInfo.workerIndex * 4; 114 | const server = await TestServer.create(port); 115 | 116 | const httpsPort = port + 1; 117 | const httpsServer = await TestServer.createHTTPS(httpsPort); 118 | 119 | await use({ server, httpsServer }); 120 | 121 | await Promise.all([ 122 | server.stop(), 123 | httpsServer.stop(), 124 | ]); 125 | }, { scope: 'worker' }], 126 | 127 | server: async ({ _workerServers }, use) => { 128 | _workerServers.server.reset(); 129 | await use(_workerServers.server); 130 | }, 131 | 132 | httpsServer: async ({ _workerServers }, use) => { 133 | _workerServers.httpsServer.reset(); 134 | await use(_workerServers.httpsServer); 135 | }, 136 | }); 137 | 138 | type Response = Awaited>; 139 | 140 | export const expect = baseExpect.extend({ 141 | toHaveTextContent(response: Response, content: string | RegExp) { 142 | const isNot = this.isNot; 143 | try { 144 | const text = (response.content as any)[0].text; 145 | if (typeof content === 'string') { 146 | if (isNot) 147 | baseExpect(text.trim()).not.toBe(content.trim()); 148 | else 149 | baseExpect(text.trim()).toBe(content.trim()); 150 | } else { 151 | if (isNot) 152 | baseExpect(text).not.toMatch(content); 153 | else 154 | baseExpect(text).toMatch(content); 155 | } 156 | } catch (e) { 157 | return { 158 | pass: isNot, 159 | message: () => e.message, 160 | }; 161 | } 162 | return { 163 | pass: !isNot, 164 | message: () => ``, 165 | }; 166 | }, 167 | 168 | toContainTextContent(response: Response, content: string | string[]) { 169 | const isNot = this.isNot; 170 | try { 171 | content = Array.isArray(content) ? content : [content]; 172 | const texts = (response.content as any).map(c => c.text); 173 | for (let i = 0; i < texts.length; i++) { 174 | if (isNot) 175 | expect(texts[i]).not.toContain(content[i]); 176 | else 177 | expect(texts[i]).toContain(content[i]); 178 | } 179 | } catch (e) { 180 | return { 181 | pass: isNot, 182 | message: () => e.message, 183 | }; 184 | } 185 | return { 186 | pass: !isNot, 187 | message: () => ``, 188 | }; 189 | }, 190 | }); 191 | -------------------------------------------------------------------------------- /tests/iframes.spec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { test, expect } from './fixtures'; 18 | 19 | test('stitched aria frames', async ({ client }) => { 20 | expect(await client.callTool({ 21 | name: 'browser_navigate', 22 | arguments: { 23 | url: `data:text/html,

Hello

`, 24 | }, 25 | })).toContainTextContent(` 26 | \`\`\`yaml 27 | - generic [ref=s1e2]: 28 | - heading "Hello" [level=1] [ref=s1e3] 29 | - iframe [ref=s1e4]: 30 | - generic [ref=f1s1e2]: 31 | - button "World" [ref=f1s1e3] 32 | - main [ref=f1s1e4]: 33 | - iframe [ref=f1s1e5]: 34 | - paragraph [ref=f2s1e3]: Nested 35 | \`\`\``); 36 | 37 | expect(await client.callTool({ 38 | name: 'browser_click', 39 | arguments: { 40 | element: 'World', 41 | ref: 'f1s1e3', 42 | }, 43 | })).toContainTextContent(`// Click World`); 44 | }); 45 | -------------------------------------------------------------------------------- /tests/launch.spec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { test, expect } from './fixtures'; 18 | 19 | test('test reopen browser', async ({ client }) => { 20 | await client.callTool({ 21 | name: 'browser_navigate', 22 | arguments: { 23 | url: 'data:text/html,TitleHello, world!', 24 | }, 25 | }); 26 | 27 | expect(await client.callTool({ 28 | name: 'browser_close', 29 | arguments: {}, 30 | })).toContainTextContent('No open pages available'); 31 | 32 | expect(await client.callTool({ 33 | name: 'browser_navigate', 34 | arguments: { 35 | url: 'data:text/html,TitleHello, world!', 36 | }, 37 | })).toContainTextContent(`- text: Hello, world!`); 38 | }); 39 | 40 | test('executable path', async ({ startClient }) => { 41 | const client = await startClient({ args: [`--executable-path=bogus`] }); 42 | const response = await client.callTool({ 43 | name: 'browser_navigate', 44 | arguments: { 45 | url: 'data:text/html,TitleHello, world!', 46 | }, 47 | }); 48 | expect(response).toContainTextContent(`executable doesn't exist`); 49 | }); 50 | -------------------------------------------------------------------------------- /tests/network.spec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { test, expect } from './fixtures'; 18 | 19 | test('browser_network_requests', async ({ client, server }) => { 20 | server.route('/', (req, res) => { 21 | res.writeHead(200, { 'Content-Type': 'text/html' }); 22 | res.end(``); 23 | }); 24 | 25 | server.route('/json', (req, res) => { 26 | res.writeHead(200, { 'Content-Type': 'application/json' }); 27 | res.end(JSON.stringify({ name: 'John Doe' })); 28 | }); 29 | 30 | await client.callTool({ 31 | name: 'browser_navigate', 32 | arguments: { 33 | url: server.PREFIX, 34 | }, 35 | }); 36 | 37 | await client.callTool({ 38 | name: 'browser_click', 39 | arguments: { 40 | element: 'Click me button', 41 | ref: 's1e3', 42 | }, 43 | }); 44 | 45 | await expect.poll(() => client.callTool({ 46 | name: 'browser_network_requests', 47 | arguments: {}, 48 | })).toHaveTextContent(`[GET] http://localhost:${server.PORT}/json => [200] OK`); 49 | }); 50 | -------------------------------------------------------------------------------- /tests/pdf.spec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { test, expect } from './fixtures'; 18 | 19 | test('save as pdf unavailable', async ({ startClient }) => { 20 | const client = await startClient({ args: ['--caps="no-pdf"'] }); 21 | await client.callTool({ 22 | name: 'browser_navigate', 23 | arguments: { 24 | url: 'data:text/html,TitleHello, world!', 25 | }, 26 | }); 27 | 28 | expect(await client.callTool({ 29 | name: 'browser_pdf_save', 30 | })).toHaveTextContent(/Tool \"browser_pdf_save\" not found/); 31 | }); 32 | 33 | test('save as pdf', async ({ client, mcpBrowser }) => { 34 | test.skip(!!mcpBrowser && !['chromium', 'chrome', 'msedge'].includes(mcpBrowser), 'Save as PDF is only supported in Chromium.'); 35 | expect(await client.callTool({ 36 | name: 'browser_navigate', 37 | arguments: { 38 | url: 'data:text/html,TitleHello, world!', 39 | }, 40 | })).toContainTextContent(`- text: Hello, world!`); 41 | 42 | const response = await client.callTool({ 43 | name: 'browser_pdf_save', 44 | arguments: {}, 45 | }); 46 | expect(response).toHaveTextContent(/Save page as.*page-[^:]+.pdf/); 47 | }); 48 | -------------------------------------------------------------------------------- /tests/screenshot.spec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { test, expect } from './fixtures'; 18 | 19 | test('browser_take_screenshot (viewport)', async ({ client }) => { 20 | expect(await client.callTool({ 21 | name: 'browser_navigate', 22 | arguments: { 23 | url: 'data:text/html,TitleHello, world!', 24 | }, 25 | })).toContainTextContent(`Navigate to data:text/html`); 26 | 27 | expect(await client.callTool({ 28 | name: 'browser_take_screenshot', 29 | arguments: {}, 30 | })).toEqual({ 31 | content: [ 32 | { 33 | data: expect.any(String), 34 | mimeType: 'image/jpeg', 35 | type: 'image', 36 | }, 37 | { 38 | text: expect.stringContaining(`Screenshot viewport and save it as`), 39 | type: 'text', 40 | }, 41 | ], 42 | }); 43 | }); 44 | 45 | test('browser_take_screenshot (element)', async ({ client }) => { 46 | expect(await client.callTool({ 47 | name: 'browser_navigate', 48 | arguments: { 49 | url: 'data:text/html,Title', 50 | }, 51 | })).toContainTextContent(`[ref=s1e3]`); 52 | 53 | expect(await client.callTool({ 54 | name: 'browser_take_screenshot', 55 | arguments: { 56 | element: 'hello button', 57 | ref: 's1e3', 58 | }, 59 | })).toEqual({ 60 | content: [ 61 | { 62 | data: expect.any(String), 63 | mimeType: 'image/jpeg', 64 | type: 'image', 65 | }, 66 | { 67 | text: expect.stringContaining(`page.getByRole('button', { name: 'Hello, world!' }).screenshot`), 68 | type: 'text', 69 | }, 70 | ], 71 | }); 72 | }); 73 | -------------------------------------------------------------------------------- /tests/sse.spec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { spawn } from 'node:child_process'; 18 | import path from 'node:path'; 19 | import { test } from './fixtures'; 20 | 21 | test('sse transport', async () => { 22 | const cp = spawn('node', [path.join(__dirname, '../cli.js'), '--port', '0'], { stdio: 'pipe' }); 23 | try { 24 | let stdout = ''; 25 | const url = await new Promise(resolve => cp.stdout?.on('data', data => { 26 | stdout += data.toString(); 27 | const match = stdout.match(/Listening on (http:\/\/.*)/); 28 | if (match) 29 | resolve(match[1]); 30 | })); 31 | 32 | // need dynamic import b/c of some ESM nonsense 33 | const { SSEClientTransport } = await import('@modelcontextprotocol/sdk/client/sse.js'); 34 | const { Client } = await import('@modelcontextprotocol/sdk/client/index.js'); 35 | const transport = new SSEClientTransport(new URL(url)); 36 | const client = new Client({ name: 'test', version: '1.0.0' }); 37 | await client.connect(transport); 38 | await client.ping(); 39 | } finally { 40 | cp.kill(); 41 | } 42 | }); 43 | -------------------------------------------------------------------------------- /tests/tabs.spec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { chromium } from 'playwright'; 18 | 19 | import { test, expect } from './fixtures'; 20 | 21 | import type { Client } from '@modelcontextprotocol/sdk/client/index.js'; 22 | 23 | async function createTab(client: Client, title: string, body: string) { 24 | return await client.callTool({ 25 | name: 'browser_tab_new', 26 | arguments: { 27 | url: `data:text/html,${title}${body}`, 28 | }, 29 | }); 30 | } 31 | 32 | test('list initial tabs', async ({ client }) => { 33 | expect(await client.callTool({ 34 | name: 'browser_tab_list', 35 | arguments: {}, 36 | })).toHaveTextContent(`### Open tabs 37 | - 1: (current) [] (about:blank)`); 38 | }); 39 | 40 | test('list first tab', async ({ client }) => { 41 | await createTab(client, 'Tab one', 'Body one'); 42 | expect(await client.callTool({ 43 | name: 'browser_tab_list', 44 | arguments: {}, 45 | })).toHaveTextContent(`### Open tabs 46 | - 1: [] (about:blank) 47 | - 2: (current) [Tab one] (data:text/html,Tab oneBody one)`); 48 | }); 49 | 50 | test('create new tab', async ({ client }) => { 51 | expect(await createTab(client, 'Tab one', 'Body one')).toHaveTextContent(` 52 | - Ran Playwright code: 53 | \`\`\`js 54 | // 55 | \`\`\` 56 | 57 | ### Open tabs 58 | - 1: [] (about:blank) 59 | - 2: (current) [Tab one] (data:text/html,Tab oneBody one) 60 | 61 | ### Current tab 62 | - Page URL: data:text/html,Tab oneBody one 63 | - Page Title: Tab one 64 | - Page Snapshot 65 | \`\`\`yaml 66 | - text: Body one 67 | \`\`\``); 68 | 69 | expect(await createTab(client, 'Tab two', 'Body two')).toHaveTextContent(` 70 | - Ran Playwright code: 71 | \`\`\`js 72 | // 73 | \`\`\` 74 | 75 | ### Open tabs 76 | - 1: [] (about:blank) 77 | - 2: [Tab one] (data:text/html,Tab oneBody one) 78 | - 3: (current) [Tab two] (data:text/html,Tab twoBody two) 79 | 80 | ### Current tab 81 | - Page URL: data:text/html,Tab twoBody two 82 | - Page Title: Tab two 83 | - Page Snapshot 84 | \`\`\`yaml 85 | - text: Body two 86 | \`\`\``); 87 | }); 88 | 89 | test('select tab', async ({ client }) => { 90 | await createTab(client, 'Tab one', 'Body one'); 91 | await createTab(client, 'Tab two', 'Body two'); 92 | expect(await client.callTool({ 93 | name: 'browser_tab_select', 94 | arguments: { 95 | index: 2, 96 | }, 97 | })).toHaveTextContent(` 98 | - Ran Playwright code: 99 | \`\`\`js 100 | // 101 | \`\`\` 102 | 103 | ### Open tabs 104 | - 1: [] (about:blank) 105 | - 2: (current) [Tab one] (data:text/html,Tab oneBody one) 106 | - 3: [Tab two] (data:text/html,Tab twoBody two) 107 | 108 | ### Current tab 109 | - Page URL: data:text/html,Tab oneBody one 110 | - Page Title: Tab one 111 | - Page Snapshot 112 | \`\`\`yaml 113 | - text: Body one 114 | \`\`\``); 115 | }); 116 | 117 | test('close tab', async ({ client }) => { 118 | await createTab(client, 'Tab one', 'Body one'); 119 | await createTab(client, 'Tab two', 'Body two'); 120 | expect(await client.callTool({ 121 | name: 'browser_tab_close', 122 | arguments: { 123 | index: 3, 124 | }, 125 | })).toHaveTextContent(` 126 | - Ran Playwright code: 127 | \`\`\`js 128 | // 129 | \`\`\` 130 | 131 | ### Open tabs 132 | - 1: [] (about:blank) 133 | - 2: (current) [Tab one] (data:text/html,Tab oneBody one) 134 | 135 | ### Current tab 136 | - Page URL: data:text/html,Tab oneBody one 137 | - Page Title: Tab one 138 | - Page Snapshot 139 | \`\`\`yaml 140 | - text: Body one 141 | \`\`\``); 142 | }); 143 | 144 | test('reuse first tab when navigating', async ({ startClient, cdpEndpoint }) => { 145 | const browser = await chromium.connectOverCDP(cdpEndpoint); 146 | const [context] = browser.contexts(); 147 | const pages = context.pages(); 148 | 149 | const client = await startClient({ args: [`--cdp-endpoint=${cdpEndpoint}`] }); 150 | await client.callTool({ 151 | name: 'browser_navigate', 152 | arguments: { 153 | url: 'data:text/html,TitleBody', 154 | }, 155 | }); 156 | 157 | expect(pages.length).toBe(1); 158 | expect(await pages[0].title()).toBe('Title'); 159 | }); 160 | -------------------------------------------------------------------------------- /tests/testserver/cert.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIFCjCCAvKgAwIBAgIULU/gkDm8IqC7PG8u3RID0AYyP6gwDQYJKoZIhvcNAQEL 3 | BQAwGjEYMBYGA1UEAwwPcGxheXdyaWdodC10ZXN0MB4XDTIzMDgxMDIyNTc1MFoX 4 | DTMzMDgwNzIyNTc1MFowGjEYMBYGA1UEAwwPcGxheXdyaWdodC10ZXN0MIICIjAN 5 | BgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEArbS99qjKcnHr5G0Zc2xhDaOZnjQv 6 | Fbiqxf/nbXt/7WaqryzpVKu7AT1ainBvuPEo7If9DhVnfF//2pGl0gbU31OU4/mr 7 | ymQmczGEyZvOBDsZhtCif54o5OoO0BjhODNT8OWec9RT87n6RkH58MHlOi8xsPxQ 8 | 9n5U1CN/h2DyQF3aRKunEFCgtwPKWSjG+J/TAI9i0aSENXPiR8wjTrjg79s8Ehuj 9 | NN8Wk6rKLU3sepG3GIMID5vLsVa2t9xqn562sP95Ee+Xp2YX3z7oYK99QCJdzacw 10 | alhMHob1GCEKjDyxsD2IFRi7Dysiutfyzy3pMo6NALxFrwKVhWX0L4zVFIsI6JlV 11 | dK8dHmDk0MRSqgB9sWXvEfSTXADEe8rncFSFpFz4Z8RNLmn5YSzQJzokNn41DUCP 12 | dZTlTkcGTqvn5NqoY4sOV8rkFbgmTcqyijV/sebPjxCbJNcNmaSWa9FJ5IjRTpzM 13 | 38wLmxn+eKGK68n2JB3P7JP6LtsBShQEpXAF3rFfyNsP1bjquvGZVSjV8w/UwPE4 14 | kV5eq3j3D4913Zfxvzjp6PEmhStG0EQtIXvx/TRoYpaNWypIgZdbkZQp1HUIQL15 15 | D2Web4nazP3so1FC3ZgbrJZ2ozoadjLMp49NcSFdh+WRyVKuo0DIqR0zaiAzzf2D 16 | G1q7TLKimM3XBMUCAwEAAaNIMEYwCQYDVR0TBAIwADALBgNVHQ8EBAMCBeAwLAYD 17 | VR0RBCUwI4IJbG9jYWxob3N0hwR/AAABhxAAAAAAAAAAAAAAAAAAAAABMA0GCSqG 18 | SIb3DQEBCwUAA4ICAQAvC5M1JFc21WVSLPvE2iVbt4HmirO3EENdDqs+rTYG5VJG 19 | iE5ZuI6h/LjS5ptTfKovXQKaMr3pwp1pLMd/9q+6ZR1Hs9Z2wF6OZan4sb0uT32Y 20 | 1KGlj86QMiiSLdrJ/1Z9JHskHYNCep1ZTsUhGk0qqiNv+G3K2y7ZpvrT/xlnYMth 21 | KLTuSVUwM8BBEPrCRLoXuaEy0LnvMvMVepIfP8tnMIL6zqmj3hXMPe4r4OFV/C5o 22 | XX25bC7GyuPWIRYn2OWP92J1CODZD1rGRoDtmvqrQpHdeX9RYcKH0ZLZoIf5L3Hf 23 | pPUtVkw3QGtjvKeG3b9usxaV9Od2Z08vKKk1PRkXFe8gqaeyicK7YVIOMTSuspAf 24 | JeJEHns6Hg61Exbo7GwdX76xlmQ/Z43E9BPHKgLyZ9WuJ0cysqN4aCyvS9yws9to 25 | ki7iMZqJUsmE2o09n9VaEsX6uQANZtLjI9wf+IgJuueDTNrkzQkhU7pbaPMsSG40 26 | AgGY/y4BR0H8sbhNnhqtZH7RcXV9VCJoPBAe+YiuXRiXyZHWxwBRyBE3e7g4MKHg 27 | hrWtaWUAs7gbavHwjqgU63iVItDSk7t4fCiEyObjK09AaNf2DjjaSGf8YGza4bNy 28 | BjYinYJ6/eX//gp+abqfocFbBP7D9zRDgMIbVmX/Ey6TghKiLkZOdbzcpO4Wgg== 29 | -----END CERTIFICATE----- 30 | -------------------------------------------------------------------------------- /tests/testserver/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2017 Google Inc. All rights reserved. 3 | * Modifications copyright (c) Microsoft Corporation. 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | 18 | import fs from 'fs'; 19 | import http from 'http'; 20 | import https from 'https'; 21 | import path from 'path'; 22 | 23 | const fulfillSymbol = Symbol('fulfil callback'); 24 | const rejectSymbol = Symbol('reject callback'); 25 | 26 | export class TestServer { 27 | private _server: http.Server; 28 | readonly debugServer: any; 29 | private _routes = new Map any>(); 30 | private _csp = new Map(); 31 | private _extraHeaders = new Map(); 32 | private _requestSubscribers = new Map>(); 33 | readonly PORT: number; 34 | readonly PREFIX: string; 35 | readonly CROSS_PROCESS_PREFIX: string; 36 | 37 | static async create(port: number): Promise { 38 | const server = new TestServer(port); 39 | await new Promise(x => server._server.once('listening', x)); 40 | return server; 41 | } 42 | 43 | static async createHTTPS(port: number): Promise { 44 | const server = new TestServer(port, { 45 | key: await fs.promises.readFile(path.join(__dirname, 'key.pem')), 46 | cert: await fs.promises.readFile(path.join(__dirname, 'cert.pem')), 47 | passphrase: 'aaaa', 48 | }); 49 | await new Promise(x => server._server.once('listening', x)); 50 | return server; 51 | } 52 | 53 | constructor(port: number, sslOptions?: object) { 54 | if (sslOptions) 55 | this._server = https.createServer(sslOptions, this._onRequest.bind(this)); 56 | else 57 | this._server = http.createServer(this._onRequest.bind(this)); 58 | this._server.listen(port); 59 | this.debugServer = require('debug')('pw:testserver'); 60 | 61 | const cross_origin = '127.0.0.1'; 62 | const same_origin = 'localhost'; 63 | const protocol = sslOptions ? 'https' : 'http'; 64 | this.PORT = port; 65 | this.PREFIX = `${protocol}://${same_origin}:${port}`; 66 | this.CROSS_PROCESS_PREFIX = `${protocol}://${cross_origin}:${port}`; 67 | } 68 | 69 | setCSP(path: string, csp: string) { 70 | this._csp.set(path, csp); 71 | } 72 | 73 | setExtraHeaders(path: string, object: Record) { 74 | this._extraHeaders.set(path, object); 75 | } 76 | 77 | async stop() { 78 | this.reset(); 79 | await new Promise(x => this._server.close(x)); 80 | } 81 | 82 | route(path: string, handler: (request: http.IncomingMessage, response: http.ServerResponse) => any) { 83 | this._routes.set(path, handler); 84 | } 85 | 86 | redirect(from: string, to: string) { 87 | this.route(from, (req, res) => { 88 | const headers = this._extraHeaders.get(req.url!) || {}; 89 | res.writeHead(302, { ...headers, location: to }); 90 | res.end(); 91 | }); 92 | } 93 | 94 | waitForRequest(path: string): Promise { 95 | let promise = this._requestSubscribers.get(path); 96 | if (promise) 97 | return promise; 98 | let fulfill, reject; 99 | promise = new Promise((f, r) => { 100 | fulfill = f; 101 | reject = r; 102 | }); 103 | promise[fulfillSymbol] = fulfill; 104 | promise[rejectSymbol] = reject; 105 | this._requestSubscribers.set(path, promise); 106 | return promise; 107 | } 108 | 109 | reset() { 110 | this._routes.clear(); 111 | this._csp.clear(); 112 | this._extraHeaders.clear(); 113 | this._server.closeAllConnections(); 114 | const error = new Error('Static Server has been reset'); 115 | for (const subscriber of this._requestSubscribers.values()) 116 | subscriber[rejectSymbol].call(null, error); 117 | this._requestSubscribers.clear(); 118 | } 119 | 120 | _onRequest(request: http.IncomingMessage, response: http.ServerResponse) { 121 | request.on('error', error => { 122 | if ((error as any).code === 'ECONNRESET') 123 | response.end(); 124 | else 125 | throw error; 126 | }); 127 | (request as any).postBody = new Promise(resolve => { 128 | const chunks: Buffer[] = []; 129 | request.on('data', chunk => { 130 | chunks.push(chunk); 131 | }); 132 | request.on('end', () => resolve(Buffer.concat(chunks))); 133 | }); 134 | const path = request.url || '/'; 135 | this.debugServer(`request ${request.method} ${path}`); 136 | // Notify request subscriber. 137 | if (this._requestSubscribers.has(path)) { 138 | this._requestSubscribers.get(path)![fulfillSymbol].call(null, request); 139 | this._requestSubscribers.delete(path); 140 | } 141 | const handler = this._routes.get(path); 142 | if (handler) 143 | handler.call(null, request, response); 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /tests/testserver/key.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIJQgIBADANBgkqhkiG9w0BAQEFAASCCSwwggkoAgEAAoICAQCttL32qMpycevk 3 | bRlzbGENo5meNC8VuKrF/+dte3/tZqqvLOlUq7sBPVqKcG+48Sjsh/0OFWd8X//a 4 | kaXSBtTfU5Tj+avKZCZzMYTJm84EOxmG0KJ/nijk6g7QGOE4M1Pw5Z5z1FPzufpG 5 | QfnwweU6LzGw/FD2flTUI3+HYPJAXdpEq6cQUKC3A8pZKMb4n9MAj2LRpIQ1c+JH 6 | zCNOuODv2zwSG6M03xaTqsotTex6kbcYgwgPm8uxVra33Gqfnraw/3kR75enZhff 7 | Puhgr31AIl3NpzBqWEwehvUYIQqMPLGwPYgVGLsPKyK61/LPLekyjo0AvEWvApWF 8 | ZfQvjNUUiwjomVV0rx0eYOTQxFKqAH2xZe8R9JNcAMR7yudwVIWkXPhnxE0uaflh 9 | LNAnOiQ2fjUNQI91lOVORwZOq+fk2qhjiw5XyuQVuCZNyrKKNX+x5s+PEJsk1w2Z 10 | pJZr0UnkiNFOnMzfzAubGf54oYrryfYkHc/sk/ou2wFKFASlcAXesV/I2w/VuOq6 11 | 8ZlVKNXzD9TA8TiRXl6rePcPj3Xdl/G/OOno8SaFK0bQRC0he/H9NGhilo1bKkiB 12 | l1uRlCnUdQhAvXkPZZ5vidrM/eyjUULdmBuslnajOhp2Msynj01xIV2H5ZHJUq6j 13 | QMipHTNqIDPN/YMbWrtMsqKYzdcExQIDAQABAoICAGqXttpdyZ1g+vg5WpzRrNzJ 14 | v8KtExepMmI+Hq24U1BC6AqG7MfgeejQ1XaOeIBsvEgpSsgRqmdQIZjmN3Mibg59 15 | I6ih1SFlQ5L8mBd/XHSML6Xi8VSOoVmXp29bVRk/pgr1XL6HVN0DCumCIvXyhc+m 16 | lj+dFbGs5DEpd2CDxSRqcz4gd2wzjevAj7MWqsJ2kOyPEHzFD7wdWIXmZuQv3xhQ 17 | 2BPkkcon+5qx+07BupOcR1brUU8Cs4QnSgiZYXSB2GnU215+P/mhVJTR7ZcnGRz5 18 | +cXxCmy3sj4pYs1juS1FMWSM3azUeDVeqvks+vrXmXpEr5H79mbmlwo8/hMPwNDO 19 | 07HRZwa8T01aT9EYVm0lIOYjMF/2f6j6cu2apJtjXICOksR2HefRBVXQirOxRHma 20 | 9XAYfNkZ/2164ZbgFmJv9khFnegPEuth9tLVdFIeGSmsG0aX9tH63zGT2NROyyLc 21 | QXPqsDl2CxCYPRs2oiGkM9dnfP1wAOp96sq42GIuN7ykfqfRnwAIvvnLKvyCq1vR 22 | pIno3CIX6vnzt+1/Hrmv13b0L6pJPitpXwKWHv9zJKBTpN8HEzP3Qmth2Ef60/7/ 23 | CBo1PVTd1A6zcU7816flg7SCY+Vk+OxVHV3dGBIIqN9SfrQ8BPcOl6FNV5Anbrnv 24 | CpSw+LzH9n5xympDnk0BAoIBAQDjenvDfCnrNVeqx8+sYaYey4/WPVLXOQhREvRY 25 | oOtX9eqlNSi20+Wl+iuXmyj8wdHrDET7rfjCbpDQ7u105yzLw4gy4qIRDKZ1nE45 26 | YX+tm8mZgBqRnTp0DoGOArqmp3IKXJtUYmpbTz9tOfY7Usb1o1epb4winEB+Pl+8 27 | mgXOEo8xvWBzKeRA7tE73V64Mwbvbo9Ff2EguhXweQP29yBkEjT4iViayuHUmyPt 28 | hOVSMj2oFQuQGPdhAk7nUXojSGK/Zas/AGpH9CHH9De0h4m08vd3oM4vj0HwzgjU 29 | Co9aRa9SAH7EiaocOTcjDRPxWdZPHhxmrVRIYlF0MNmOAkXJAoIBAQDDfEqu4sNi 30 | pq74VXVatQqhzCILZo+o48bdgEjF7mF99mqPj8rwIDrEoEriDK861kenLc3vWKRY 31 | 5wh1iX3S896re9kUMoxx6p4heYTcsOJ9BbkcpT8bJPZx9gBJb4jJENeVf1exf6sG 32 | RhFnulpzReRRaUjX2yAkyUPfc8YcUt+Nalrg+2W0fzeLCUpABCAcj2B1Vv7qRZHj 33 | oEtlCV5Nz+iMhrwIa16g9c8wGt5DZb4PI+VIJ6EYkdsjhgqIF0T/wDq9/habGBPo 34 | mHN+/DX3hCJWN2QgoVGJskHGt0zDMgiEgXfLZ2Grl02vQtq+mW2O2vGVeUd9Y5Ew 35 | RUiY4bSRTrUdAoIBAHxL1wiP9c/By+9TUtScXssA681ioLtdPIAgXUd4VmAvzVEM 36 | ZPzRd/BjbCJg89p4hZ1rjN4Ax6ZmB9dCVpnEH6QPaYJ0d53dTa+CAvQzpDJWp6eq 37 | adobEW+M5ZmVQCwD3rpus6k+RWMzQDMMstDjgDeEU0gP3YCj5FGW/3TsrDNXzMqe 38 | 8e67ey9Hzyho43K+3xFBViPhYE8jnw1Q8quliRtlH3CWi8W5CgDD7LPCJBPvw+Tt 39 | 6u2H1tQ5EKgwyw4wZVSz1wiLz4cVjMfXWADa9pHbGQFS6pbuLlfIHObQBliLLysd 40 | ficiGcNmOAx8/uKn9gQxLc+k8iLDJkLY1mdUMpECggEAJLl87k37ltTpmg2z9k58 41 | qNjIrIugAYKJIaOwCD84YYmhi0bgQSxM3hOe/ciUQuFupKGeRpDIj0sX87zYvoDC 42 | HEUwCvNUHzKMco15wFwasJIarJ7+tALFqbMlaqZhdCSN27AIsXfikVMogewoge9n 43 | bUPyQ1sPNtn4vknptfh7tv18BTg1aytbK+ua31vnDHaDEIg/a5OWTMUYZOrVpJii 44 | f4PwX0SMioCjY84oY1EB26ZKtLt9MDh2ir3rzJVSiRl776WEaa6kTtYVHI4VNWLF 45 | cJ0HWnnz74JliQd2jFUh9IK+FqBdYPcTyREuNxBr3KKVMBeQrqW96OubL913JrU6 46 | oQKCAQEA0yzORUouT0yleWs7RmzBlT9OLD/3cBYJMf/r1F8z8OQjB8fU1jKbO1Cs 47 | q4l+o9FmI+eHkgc3xbEG0hahOFWm/hTTli9vzksxurgdawZELThRkK33uTU9pKla 48 | Okqx3Ru/iMOW2+DQUx9UB+jK+hSAgq4gGqLeJVyaBerIdLQLlvqxrwSxjvvj+wJC 49 | Y66mgRzdCi6VDF1vV0knCrQHK6tRwcPozu/k4zjJzvdbMJnKEy2S7Vh6vO8lEPJm 50 | MQtaHPpmz+F4z14b9unNIiSbHO60Q4O+BwIBCzxApQQbFg63vBLYYwEMRd7hh92s 51 | ZkZVSOEp+sYBf/tmptlKr49nO+dTjQ== 52 | -----END PRIVATE KEY----- 53 | -------------------------------------------------------------------------------- /tests/testserver/san.cnf: -------------------------------------------------------------------------------- 1 | # openssl req -new -x509 -days 3650 -key key.pem -out cert.pem -config san.cnf -extensions v3_req 2 | 3 | [req] 4 | distinguished_name = req_distinguished_name 5 | req_extensions = v3_req 6 | prompt = no 7 | 8 | [req_distinguished_name] 9 | CN = playwright-test 10 | 11 | [v3_req] 12 | basicConstraints = CA:FALSE 13 | keyUsage = nonRepudiation, digitalSignature, keyEncipherment 14 | subjectAltName = @alt_names 15 | 16 | [alt_names] 17 | DNS.1 = localhost 18 | IP.1 = 127.0.0.1 19 | IP.2 = ::1 20 | -------------------------------------------------------------------------------- /tsconfig.all.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["**/*.ts", "**/*.js"], 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "skipLibCheck": true, 5 | "esModuleInterop": true, 6 | "moduleResolution": "node", 7 | "strict": true, 8 | "module": "CommonJS", 9 | "outDir": "./lib" 10 | }, 11 | "include": [ 12 | "src", 13 | ], 14 | } 15 | -------------------------------------------------------------------------------- /utils/copyright.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Microsoft Corporation. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | -------------------------------------------------------------------------------- /utils/update-readme.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /** 3 | * Copyright (c) Microsoft Corporation. 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | // @ts-check 18 | 19 | const fs = require('node:fs'); 20 | const path = require('node:path'); 21 | const zodToJsonSchema = require('zod-to-json-schema').default; 22 | 23 | const commonTools = require('../lib/tools/common').default; 24 | const consoleTools = require('../lib/tools/console').default; 25 | const dialogsTools = require('../lib/tools/dialogs').default; 26 | const filesTools = require('../lib/tools/files').default; 27 | const installTools = require('../lib/tools/install').default; 28 | const keyboardTools = require('../lib/tools/keyboard').default; 29 | const navigateTools = require('../lib/tools/navigate').default; 30 | const pdfTools = require('../lib/tools/pdf').default; 31 | const snapshotTools = require('../lib/tools/snapshot').default; 32 | const tabsTools = require('../lib/tools/tabs').default; 33 | const screenTools = require('../lib/tools/screen').default; 34 | 35 | // Category definitions for tools 36 | const categories = { 37 | 'Snapshot-based Interactions': [ 38 | ...snapshotTools, 39 | ], 40 | 'Vision-based Interactions': [ 41 | ...screenTools 42 | ], 43 | 'Tab Management': [ 44 | ...tabsTools(true), 45 | ], 46 | 'Navigation': [ 47 | ...navigateTools(true), 48 | ], 49 | 'Keyboard': [ 50 | ...keyboardTools(true) 51 | ], 52 | 'Console': [ 53 | ...consoleTools 54 | ], 55 | 'Files and Media': [ 56 | ...filesTools(true), 57 | ...pdfTools 58 | ], 59 | 'Utilities': [ 60 | ...commonTools(true), 61 | ...installTools, 62 | ...dialogsTools(true), 63 | ], 64 | }; 65 | 66 | const kStartMarker = ``; 67 | const kEndMarker = ``; 68 | 69 | /** 70 | * @param {ParsedToolSchema} tool 71 | * @returns {string} 72 | */ 73 | function formatToolForReadme(tool) { 74 | const lines = /** @type {string[]} */ ([]); 75 | lines.push(`\n\n`); 76 | lines.push(`- **${tool.name}**\n`); 77 | lines.push(` - Description: ${tool.description}\n`); 78 | 79 | if (tool.parameters && tool.parameters.length > 0) { 80 | lines.push(` - Parameters:\n`); 81 | tool.parameters.forEach(param => { 82 | const meta = /** @type {string[]} */ ([]); 83 | if (param.type) 84 | meta.push(param.type); 85 | if (param.optional) 86 | meta.push('optional'); 87 | lines.push(` - \`${param.name}\` ${meta.length ? `(${meta.join(', ')})` : ''}: ${param.description}\n`); 88 | }); 89 | } else { 90 | lines.push(` - Parameters: None\n`); 91 | } 92 | 93 | lines.push('\n'); 94 | return lines.join(''); 95 | } 96 | 97 | /** 98 | * @typedef {{ 99 | * name: any; 100 | * description: any; 101 | * parameters: { 102 | * name: string; 103 | * description: string; 104 | * optional: boolean; 105 | * type: string; 106 | * }[]; 107 | *}} ParsedToolSchema 108 | */ 109 | 110 | /** 111 | * @param {import('../src/tools/tool').ToolSchema} schema 112 | * @returns {ParsedToolSchema} 113 | */ 114 | function processToolSchema(schema) { 115 | const inputSchema = /** @type {import('zod-to-json-schema').JsonSchema7ObjectType} */ zodToJsonSchema(schema.inputSchema || {}); 116 | if (inputSchema.type !== 'object') 117 | throw new Error(`Tool ${schema.name} input schema is not an object`); 118 | 119 | // In JSON Schema, properties are considered optional unless listed in the required array 120 | const requiredParams = inputSchema?.required || []; 121 | 122 | const parameters = Object.entries(inputSchema.properties).map(([name, prop]) => { 123 | return { 124 | name, 125 | description: prop.description || '', 126 | optional: !requiredParams.includes(name), 127 | type: /** @type {any} */ (prop).type, 128 | }; 129 | }); 130 | 131 | return { 132 | name: schema.name, 133 | description: schema.description, 134 | parameters 135 | }; 136 | } 137 | 138 | async function updateReadme() { 139 | console.log('Loading tool information from compiled modules...'); 140 | 141 | // Count the tools processed 142 | const totalTools = Object.values(categories).flat().length; 143 | console.log(`Found ${totalTools} tools`); 144 | 145 | const generatedLines = /** @type {string[]} */ ([]); 146 | 147 | for (const [category, categoryTools] of Object.entries(categories)) { 148 | generatedLines.push(`### ${category}\n\n`); 149 | for (const tool of categoryTools) { 150 | const scheme = processToolSchema(tool.schema); 151 | generatedLines.push(formatToolForReadme(scheme)); 152 | } 153 | } 154 | 155 | const readmePath = path.join(__dirname, '..', 'README.md'); 156 | const readmeContent = await fs.promises.readFile(readmePath, 'utf-8'); 157 | const startMarker = readmeContent.indexOf(kStartMarker); 158 | const endMarker = readmeContent.indexOf(kEndMarker); 159 | if (startMarker === -1 || endMarker === -1) 160 | throw new Error('Markers for generated section not found in README'); 161 | 162 | const newReadmeContent = [ 163 | readmeContent.slice(0, startMarker), 164 | kStartMarker + '\n\n', 165 | generatedLines.join(''), 166 | kEndMarker, 167 | readmeContent.slice(endMarker + kEndMarker.length), 168 | ].join(''); 169 | 170 | // Write updated README 171 | await fs.promises.writeFile(readmePath, newReadmeContent, 'utf-8'); 172 | console.log('README updated successfully'); 173 | } 174 | 175 | // Run the update 176 | updateReadme().catch(err => { 177 | console.error('Error updating README:', err); 178 | process.exit(1); 179 | }); 180 | --------------------------------------------------------------------------------