├── .gitignore ├── deno.json ├── LICENSE ├── README.md ├── deno.lock └── main.ts /.gitignore: -------------------------------------------------------------------------------- 1 | playwright 2 | playwright-server -------------------------------------------------------------------------------- /deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "tasks": { 3 | "start": "deno run --watch --allow-net --allow-env --allow-run --allow-sys --allow-read --allow-write main.ts", 4 | "build-mac": "deno compile --target aarch64-apple-darwin --allow-net --allow-env --allow-run --allow-sys --allow-read --allow-write --output playwright-server main.ts", 5 | "build-linux-x86_64": "deno compile --target x86_64-unknown-linux-gnu --allow-net --allow-env --allow-run --allow-sys --allow-read --allow-write --output playwright-server main.ts", 6 | "build-linux-ARM64": "deno compile --target aarch64-unknown-linux-gnu --allow-net --allow-env --allow-run --allow-sys --allow-read --allow-write --output playwright-server main.ts", 7 | "build-windows-x86_64": "deno compile --target x86_64-pc-windows-msvc --allow-net --allow-env --allow-run --allow-sys --allow-read --allow-write --output playwright-server main.ts" 8 | }, 9 | "imports": { 10 | "@modelcontextprotocol/sdk": "npm:@modelcontextprotocol/sdk@^1.0.1", 11 | "@std/encoding": "jsr:@std/encoding@^1.0.5", 12 | "playwright": "npm:playwright@^1.49.0" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Anthropic, PBC 4 | Copyright (c) 2024 Jake Dahn 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Deno 2 Playwright Model Context Protocol Server Example 2 | 3 | A Model Context Protocol server that provides browser automation capabilities using Playwright. This server enables LLMs to interact with web pages, take screenshots, and execute JavaScript in a real browser environment. 4 | 5 | This repo uses Deno 2, which has nice ergonomics, because you can compile a binary and run it without any runtime dependencies. 6 | 7 | This code is heavily based on the official Puppeteer MCP server, which you can find here: https://github.com/modelcontextprotocol/servers/tree/main/src/puppeteer 8 | 9 | ## How to build 10 | 11 | Only the mac binary build has been tested, but you should be able to build an executable binary for linux x86_64, linux ARM64, and windows x86_64. 12 | 13 | - `deno task build-mac` 14 | - `deno task build-linux-x86_64` 15 | - `deno task build-linux-ARM64` 16 | - `deno task build-windows-x86_64` 17 | 18 | ## How to run 19 | 20 | To invoke the playwright-server binary, you need to update your `~/Library/Application\ Support/Claude/claude_desktop_config.json` to point to the binary. 21 | 22 | ```json 23 | { 24 | "mcpServers": { 25 | "playwright": { 26 | "command": "/path/to/deno2-playwright-mcp-server/playwright-server" 27 | } 28 | } 29 | } 30 | ``` 31 | -------------------------------------------------------------------------------- /deno.lock: -------------------------------------------------------------------------------- 1 | { 2 | "version": "4", 3 | "specifiers": { 4 | "jsr:@std/encoding@^1.0.5": "1.0.5", 5 | "npm:@modelcontextprotocol/sdk@^1.0.1": "1.0.1", 6 | "npm:playwright@*": "1.49.0", 7 | "npm:playwright@^1.49.0": "1.49.0" 8 | }, 9 | "jsr": { 10 | "@std/encoding@1.0.5": { 11 | "integrity": "ecf363d4fc25bd85bd915ff6733a7e79b67e0e7806334af15f4645c569fefc04" 12 | } 13 | }, 14 | "npm": { 15 | "@modelcontextprotocol/sdk@1.0.1": { 16 | "integrity": "sha512-slLdFaxQJ9AlRg+hw28iiTtGvShAOgOKXcD0F91nUcRYiOMuS9ZBYjcdNZRXW9G5JQ511GRTdUy1zQVZDpJ+4w==", 17 | "dependencies": [ 18 | "content-type", 19 | "raw-body", 20 | "zod" 21 | ] 22 | }, 23 | "bytes@3.1.2": { 24 | "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==" 25 | }, 26 | "content-type@1.0.5": { 27 | "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==" 28 | }, 29 | "depd@2.0.0": { 30 | "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==" 31 | }, 32 | "fsevents@2.3.2": { 33 | "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==" 34 | }, 35 | "http-errors@2.0.0": { 36 | "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", 37 | "dependencies": [ 38 | "depd", 39 | "inherits", 40 | "setprototypeof", 41 | "statuses", 42 | "toidentifier" 43 | ] 44 | }, 45 | "iconv-lite@0.6.3": { 46 | "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", 47 | "dependencies": [ 48 | "safer-buffer" 49 | ] 50 | }, 51 | "inherits@2.0.4": { 52 | "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" 53 | }, 54 | "playwright-core@1.49.0": { 55 | "integrity": "sha512-R+3KKTQF3npy5GTiKH/T+kdhoJfJojjHESR1YEWhYuEKRVfVaxH3+4+GvXE5xyCngCxhxnykk0Vlah9v8fs3jA==" 56 | }, 57 | "playwright@1.49.0": { 58 | "integrity": "sha512-eKpmys0UFDnfNb3vfsf8Vx2LEOtflgRebl0Im2eQQnYMA4Aqd+Zw8bEOB+7ZKvN76901mRnqdsiOGKxzVTbi7A==", 59 | "dependencies": [ 60 | "fsevents", 61 | "playwright-core" 62 | ] 63 | }, 64 | "raw-body@3.0.0": { 65 | "integrity": "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==", 66 | "dependencies": [ 67 | "bytes", 68 | "http-errors", 69 | "iconv-lite", 70 | "unpipe" 71 | ] 72 | }, 73 | "safer-buffer@2.1.2": { 74 | "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" 75 | }, 76 | "setprototypeof@1.2.0": { 77 | "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" 78 | }, 79 | "statuses@2.0.1": { 80 | "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==" 81 | }, 82 | "toidentifier@1.0.1": { 83 | "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==" 84 | }, 85 | "unpipe@1.0.0": { 86 | "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==" 87 | }, 88 | "zod@3.23.8": { 89 | "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==" 90 | } 91 | }, 92 | "redirects": { 93 | "https://deno.land/std/encoding/base64.ts": "https://deno.land/std@0.224.0/encoding/base64.ts" 94 | }, 95 | "remote": { 96 | "https://deno.land/std@0.224.0/encoding/_util.ts": "beacef316c1255da9bc8e95afb1fa56ed69baef919c88dc06ae6cb7a6103d376", 97 | "https://deno.land/std@0.224.0/encoding/base64.ts": "dd59695391584c8ffc5a296ba82bcdba6dd8a84d41a6a539fbee8e5075286eaf" 98 | }, 99 | "workspace": { 100 | "dependencies": [ 101 | "jsr:@std/encoding@^1.0.5", 102 | "npm:@modelcontextprotocol/sdk@^1.0.1", 103 | "npm:playwright@^1.49.0" 104 | ] 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /main.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S deno run --watch --allow-net --allow-env --allow-run --allow-sys --allow-read --allow-write 2 | 3 | import { Server } from "@modelcontextprotocol/sdk/server/index.js"; 4 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; 5 | import { 6 | CallToolRequestSchema, 7 | ListResourcesRequestSchema, 8 | ListToolsRequestSchema, 9 | ReadResourceRequestSchema, 10 | CallToolResult, 11 | TextContent, 12 | ImageContent, 13 | Tool, 14 | ReadResourceRequest, 15 | CallToolRequest, 16 | } from "@modelcontextprotocol/sdk/types.js"; 17 | import { chromium, Browser, Page } from "npm:playwright"; 18 | import { encodeBase64 } from "@std/encoding"; 19 | 20 | // Define tools similar to Puppeteer implementation but using Playwright's API 21 | const TOOLS: Tool[] = [ 22 | { 23 | name: "playwright_navigate", 24 | description: "Navigate to a URL", 25 | inputSchema: { 26 | type: "object", 27 | properties: { 28 | url: { type: "string" }, 29 | }, 30 | required: ["url"], 31 | }, 32 | }, 33 | { 34 | name: "playwright_screenshot", 35 | description: "Take a screenshot of the current page or a specific element", 36 | inputSchema: { 37 | type: "object", 38 | properties: { 39 | name: { type: "string", description: "Name for the screenshot" }, 40 | selector: { 41 | type: "string", 42 | description: "CSS selector for element to screenshot", 43 | }, 44 | width: { 45 | type: "number", 46 | description: "Width in pixels (default: 800)", 47 | }, 48 | height: { 49 | type: "number", 50 | description: "Height in pixels (default: 600)", 51 | }, 52 | }, 53 | required: ["name"], 54 | }, 55 | }, 56 | { 57 | name: "playwright_click", 58 | description: "Click an element on the page", 59 | inputSchema: { 60 | type: "object", 61 | properties: { 62 | selector: { 63 | type: "string", 64 | description: "CSS selector for element to click", 65 | }, 66 | }, 67 | required: ["selector"], 68 | }, 69 | }, 70 | { 71 | name: "playwright_fill", 72 | description: "Fill out an input field", 73 | inputSchema: { 74 | type: "object", 75 | properties: { 76 | selector: { 77 | type: "string", 78 | description: "CSS selector for input field", 79 | }, 80 | value: { type: "string", description: "Value to fill" }, 81 | }, 82 | required: ["selector", "value"], 83 | }, 84 | }, 85 | { 86 | name: "playwright_select", 87 | description: "Select an element on the page with Select tag", 88 | inputSchema: { 89 | type: "object", 90 | properties: { 91 | selector: { 92 | type: "string", 93 | description: "CSS selector for element to select", 94 | }, 95 | value: { type: "string", description: "Value to select" }, 96 | }, 97 | required: ["selector", "value"], 98 | }, 99 | }, 100 | { 101 | name: "playwright_hover", 102 | description: "Hover an element on the page", 103 | inputSchema: { 104 | type: "object", 105 | properties: { 106 | selector: { 107 | type: "string", 108 | description: "CSS selector for element to hover", 109 | }, 110 | }, 111 | required: ["selector"], 112 | }, 113 | }, 114 | { 115 | name: "playwright_evaluate", 116 | description: "Execute JavaScript in the browser console", 117 | inputSchema: { 118 | type: "object", 119 | properties: { 120 | script: { type: "string", description: "JavaScript code to execute" }, 121 | }, 122 | required: ["script"], 123 | }, 124 | }, 125 | ]; 126 | 127 | // Global state 128 | let browser: Browser | undefined; 129 | let page: Page | undefined; 130 | const consoleLogs: string[] = []; 131 | const screenshots = new Map(); 132 | 133 | async function ensureBrowser() { 134 | if (!browser) { 135 | browser = await chromium.launch({ headless: false }); 136 | const context = await browser.newContext(); 137 | page = await context.newPage(); 138 | 139 | page.on("console", (msg) => { 140 | const logEntry = `[${msg.type()}] ${msg.text()}`; 141 | consoleLogs.push(logEntry); 142 | server.notification({ 143 | method: "notifications/resources/updated", 144 | params: { uri: "console://logs" }, 145 | }); 146 | }); 147 | } 148 | return page!; 149 | } 150 | 151 | async function handleToolCall( 152 | name: string, 153 | args: Record 154 | ): Promise<{ toolResult: CallToolResult }> { 155 | const page = await ensureBrowser(); 156 | 157 | switch (name) { 158 | case "playwright_navigate": 159 | await page.goto(args.url as string); 160 | return { 161 | toolResult: { 162 | content: [ 163 | { 164 | type: "text", 165 | text: `Navigated to ${args.url}`, 166 | }, 167 | ], 168 | isError: false, 169 | }, 170 | }; 171 | 172 | case "playwright_screenshot": { 173 | const width = (args.width as number) ?? 800; 174 | const height = (args.height as number) ?? 600; 175 | await page.setViewportSize({ width, height }); 176 | 177 | const screenshot = await (args.selector 178 | ? page.locator(args.selector as string).screenshot() 179 | : page.screenshot()); 180 | 181 | const base64Screenshot = encodeBase64(screenshot); 182 | screenshots.set(args.name as string, base64Screenshot); 183 | 184 | server.notification({ 185 | method: "notifications/resources/list_changed", 186 | }); 187 | 188 | return { 189 | toolResult: { 190 | content: [ 191 | { 192 | type: "text", 193 | text: `Screenshot '${args.name}' taken at ${width}x${height}`, 194 | } as TextContent, 195 | { 196 | type: "image", 197 | data: base64Screenshot, 198 | mimeType: "image/png", 199 | } as ImageContent, 200 | ], 201 | isError: false, 202 | }, 203 | }; 204 | } 205 | 206 | case "playwright_click": 207 | try { 208 | await page.click(args.selector as string); 209 | return { 210 | toolResult: { 211 | content: [ 212 | { 213 | type: "text", 214 | text: `Clicked: ${args.selector}`, 215 | }, 216 | ], 217 | isError: false, 218 | }, 219 | }; 220 | } catch (err) { 221 | const error = err as Error; 222 | return { 223 | toolResult: { 224 | content: [ 225 | { 226 | type: "text", 227 | text: `Failed to click ${args.selector}: ${error.message}`, 228 | }, 229 | ], 230 | isError: true, 231 | }, 232 | }; 233 | } 234 | 235 | case "playwright_fill": 236 | try { 237 | await page.fill(args.selector as string, args.value as string); 238 | return { 239 | toolResult: { 240 | content: [ 241 | { 242 | type: "text", 243 | text: `Filled ${args.selector} with: ${args.value}`, 244 | }, 245 | ], 246 | isError: false, 247 | }, 248 | }; 249 | } catch (err) { 250 | const error = err as Error; 251 | return { 252 | toolResult: { 253 | content: [ 254 | { 255 | type: "text", 256 | text: `Failed to fill ${args.selector}: ${error.message}`, 257 | }, 258 | ], 259 | isError: true, 260 | }, 261 | }; 262 | } 263 | 264 | case "playwright_select": 265 | try { 266 | await page.selectOption(args.selector as string, args.value as string); 267 | return { 268 | toolResult: { 269 | content: [ 270 | { 271 | type: "text", 272 | text: `Selected ${args.selector} with: ${args.value}`, 273 | }, 274 | ], 275 | isError: false, 276 | }, 277 | }; 278 | } catch (err) { 279 | const error = err as Error; 280 | return { 281 | toolResult: { 282 | content: [ 283 | { 284 | type: "text", 285 | text: `Failed to select ${args.selector}: ${error.message}`, 286 | }, 287 | ], 288 | isError: true, 289 | }, 290 | }; 291 | } 292 | 293 | case "playwright_hover": 294 | try { 295 | await page.hover(args.selector as string); 296 | return { 297 | toolResult: { 298 | content: [ 299 | { 300 | type: "text", 301 | text: `Hovered ${args.selector}`, 302 | }, 303 | ], 304 | isError: false, 305 | }, 306 | }; 307 | } catch (err) { 308 | const error = err as Error; 309 | return { 310 | toolResult: { 311 | content: [ 312 | { 313 | type: "text", 314 | text: `Failed to hover ${args.selector}: ${error.message}`, 315 | }, 316 | ], 317 | isError: true, 318 | }, 319 | }; 320 | } 321 | 322 | case "playwright_evaluate": 323 | try { 324 | const result = await page.evaluate((script: string) => { 325 | const logs: string[] = []; 326 | const originalConsole = { ...console }; 327 | 328 | ["log", "info", "warn", "error"].forEach((method) => { 329 | (console as any)[method] = (...args: any[]) => { 330 | logs.push(`[${method}] ${args.join(" ")}`); 331 | (originalConsole as any)[method](...args); 332 | }; 333 | }); 334 | 335 | try { 336 | const result = eval(script); 337 | Object.assign(console, originalConsole); 338 | return { result, logs }; 339 | } catch (error) { 340 | Object.assign(console, originalConsole); 341 | throw error; 342 | } 343 | }, args.script as string); 344 | 345 | return { 346 | toolResult: { 347 | content: [ 348 | { 349 | type: "text", 350 | text: `Execution result:\n${JSON.stringify( 351 | result.result, 352 | null, 353 | 2 354 | )}\n\nConsole output:\n${result.logs.join("\n")}`, 355 | }, 356 | ], 357 | isError: false, 358 | }, 359 | }; 360 | } catch (err) { 361 | const error = err as Error; 362 | return { 363 | toolResult: { 364 | content: [ 365 | { 366 | type: "text", 367 | text: `Script execution failed: ${error.message}`, 368 | }, 369 | ], 370 | isError: true, 371 | }, 372 | }; 373 | } 374 | 375 | default: 376 | return { 377 | toolResult: { 378 | content: [ 379 | { 380 | type: "text", 381 | text: `Unknown tool: ${name}`, 382 | }, 383 | ], 384 | isError: true, 385 | }, 386 | }; 387 | } 388 | } 389 | 390 | const server = new Server( 391 | { 392 | name: "example-servers/playwright", 393 | version: "0.1.0", 394 | }, 395 | { 396 | capabilities: { 397 | resources: {}, 398 | tools: {}, 399 | }, 400 | } 401 | ); 402 | 403 | // Setup request handlers 404 | server.setRequestHandler(ListResourcesRequestSchema, async () => ({ 405 | resources: [ 406 | { 407 | uri: "console://logs", 408 | mimeType: "text/plain", 409 | name: "Browser console logs", 410 | }, 411 | ...Array.from(screenshots.keys()).map((name) => ({ 412 | uri: `screenshot://${name}`, 413 | mimeType: "image/png", 414 | name: `Screenshot: ${name}`, 415 | })), 416 | ], 417 | })); 418 | 419 | server.setRequestHandler( 420 | ReadResourceRequestSchema, 421 | async (request: ReadResourceRequest) => { 422 | const uri = request.params.uri; 423 | 424 | if (uri === "console://logs") { 425 | return { 426 | contents: [ 427 | { 428 | uri, 429 | mimeType: "text/plain", 430 | text: consoleLogs.join("\n"), 431 | }, 432 | ], 433 | }; 434 | } 435 | 436 | if (uri.startsWith("screenshot://")) { 437 | const name = uri.split("://")[1]; 438 | const screenshot = screenshots.get(name); 439 | if (screenshot) { 440 | return { 441 | contents: [ 442 | { 443 | uri, 444 | mimeType: "image/png", 445 | blob: screenshot, 446 | }, 447 | ], 448 | }; 449 | } 450 | } 451 | 452 | throw new Error(`Resource not found: ${uri}`); 453 | } 454 | ); 455 | 456 | server.setRequestHandler(ListToolsRequestSchema, async () => ({ 457 | tools: TOOLS, 458 | })); 459 | 460 | server.setRequestHandler(CallToolRequestSchema, (request: CallToolRequest) => 461 | handleToolCall(request.params.name, request.params.arguments ?? {}) 462 | ); 463 | 464 | // Handle cleanup on exit 465 | Deno.addSignalListener("SIGINT", async () => { 466 | if (browser) { 467 | await browser.close(); 468 | } 469 | Deno.exit(0); 470 | }); 471 | 472 | // Run the server 473 | const transport = new StdioServerTransport(); 474 | await server.connect(transport); 475 | console.error("Playwright MCP server running on stdio"); 476 | --------------------------------------------------------------------------------