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