#
tokens: 4423/50000 4/4 files
lines: on (toggle) GitHub
raw markdown copy reset
# Directory Structure

```
├── .gitignore
├── deno.json
├── deno.lock
├── LICENSE
├── main.ts
└── README.md
```

# Files

--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------

```
1 | playwright
2 | playwright-server
```

--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------

```markdown
 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.json:
--------------------------------------------------------------------------------

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

--------------------------------------------------------------------------------
/main.ts:
--------------------------------------------------------------------------------

```typescript
  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<string, string>();
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<string, unknown>
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 | 
```