# Directory Structure ``` ├── .gitignore ├── deno.json ├── deno.lock ├── LICENSE ├── main.ts └── README.md ``` # Files -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- ``` playwright playwright-server ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown # Deno 2 Playwright Model Context Protocol Server Example 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. This repo uses Deno 2, which has nice ergonomics, because you can compile a binary and run it without any runtime dependencies. 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 ## How to build 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. - `deno task build-mac` - `deno task build-linux-x86_64` - `deno task build-linux-ARM64` - `deno task build-windows-x86_64` ## How to run To invoke the playwright-server binary, you need to update your `~/Library/Application\ Support/Claude/claude_desktop_config.json` to point to the binary. ```json { "mcpServers": { "playwright": { "command": "/path/to/deno2-playwright-mcp-server/playwright-server" } } } ``` ``` -------------------------------------------------------------------------------- /deno.json: -------------------------------------------------------------------------------- ```json { "tasks": { "start": "deno run --watch --allow-net --allow-env --allow-run --allow-sys --allow-read --allow-write main.ts", "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", "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", "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", "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" }, "imports": { "@modelcontextprotocol/sdk": "npm:@modelcontextprotocol/sdk@^1.0.1", "@std/encoding": "jsr:@std/encoding@^1.0.5", "playwright": "npm:playwright@^1.49.0" } } ``` -------------------------------------------------------------------------------- /main.ts: -------------------------------------------------------------------------------- ```typescript #!/usr/bin/env -S deno run --watch --allow-net --allow-env --allow-run --allow-sys --allow-read --allow-write import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { CallToolRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema, CallToolResult, TextContent, ImageContent, Tool, ReadResourceRequest, CallToolRequest, } from "@modelcontextprotocol/sdk/types.js"; import { chromium, Browser, Page } from "npm:playwright"; import { encodeBase64 } from "@std/encoding"; // Define tools similar to Puppeteer implementation but using Playwright's API const TOOLS: Tool[] = [ { name: "playwright_navigate", description: "Navigate to a URL", inputSchema: { type: "object", properties: { url: { type: "string" }, }, required: ["url"], }, }, { name: "playwright_screenshot", description: "Take a screenshot of the current page or a specific element", inputSchema: { type: "object", properties: { name: { type: "string", description: "Name for the screenshot" }, selector: { type: "string", description: "CSS selector for element to screenshot", }, width: { type: "number", description: "Width in pixels (default: 800)", }, height: { type: "number", description: "Height in pixels (default: 600)", }, }, required: ["name"], }, }, { name: "playwright_click", description: "Click an element on the page", inputSchema: { type: "object", properties: { selector: { type: "string", description: "CSS selector for element to click", }, }, required: ["selector"], }, }, { name: "playwright_fill", description: "Fill out an input field", inputSchema: { type: "object", properties: { selector: { type: "string", description: "CSS selector for input field", }, value: { type: "string", description: "Value to fill" }, }, required: ["selector", "value"], }, }, { name: "playwright_select", description: "Select an element on the page with Select tag", inputSchema: { type: "object", properties: { selector: { type: "string", description: "CSS selector for element to select", }, value: { type: "string", description: "Value to select" }, }, required: ["selector", "value"], }, }, { name: "playwright_hover", description: "Hover an element on the page", inputSchema: { type: "object", properties: { selector: { type: "string", description: "CSS selector for element to hover", }, }, required: ["selector"], }, }, { name: "playwright_evaluate", description: "Execute JavaScript in the browser console", inputSchema: { type: "object", properties: { script: { type: "string", description: "JavaScript code to execute" }, }, required: ["script"], }, }, ]; // Global state let browser: Browser | undefined; let page: Page | undefined; const consoleLogs: string[] = []; const screenshots = new Map<string, string>(); async function ensureBrowser() { if (!browser) { browser = await chromium.launch({ headless: false }); const context = await browser.newContext(); page = await context.newPage(); page.on("console", (msg) => { const logEntry = `[${msg.type()}] ${msg.text()}`; consoleLogs.push(logEntry); server.notification({ method: "notifications/resources/updated", params: { uri: "console://logs" }, }); }); } return page!; } async function handleToolCall( name: string, args: Record<string, unknown> ): Promise<{ toolResult: CallToolResult }> { const page = await ensureBrowser(); switch (name) { case "playwright_navigate": await page.goto(args.url as string); return { toolResult: { content: [ { type: "text", text: `Navigated to ${args.url}`, }, ], isError: false, }, }; case "playwright_screenshot": { const width = (args.width as number) ?? 800; const height = (args.height as number) ?? 600; await page.setViewportSize({ width, height }); const screenshot = await (args.selector ? page.locator(args.selector as string).screenshot() : page.screenshot()); const base64Screenshot = encodeBase64(screenshot); screenshots.set(args.name as string, base64Screenshot); server.notification({ method: "notifications/resources/list_changed", }); return { toolResult: { content: [ { type: "text", text: `Screenshot '${args.name}' taken at ${width}x${height}`, } as TextContent, { type: "image", data: base64Screenshot, mimeType: "image/png", } as ImageContent, ], isError: false, }, }; } case "playwright_click": try { await page.click(args.selector as string); return { toolResult: { content: [ { type: "text", text: `Clicked: ${args.selector}`, }, ], isError: false, }, }; } catch (err) { const error = err as Error; return { toolResult: { content: [ { type: "text", text: `Failed to click ${args.selector}: ${error.message}`, }, ], isError: true, }, }; } case "playwright_fill": try { await page.fill(args.selector as string, args.value as string); return { toolResult: { content: [ { type: "text", text: `Filled ${args.selector} with: ${args.value}`, }, ], isError: false, }, }; } catch (err) { const error = err as Error; return { toolResult: { content: [ { type: "text", text: `Failed to fill ${args.selector}: ${error.message}`, }, ], isError: true, }, }; } case "playwright_select": try { await page.selectOption(args.selector as string, args.value as string); return { toolResult: { content: [ { type: "text", text: `Selected ${args.selector} with: ${args.value}`, }, ], isError: false, }, }; } catch (err) { const error = err as Error; return { toolResult: { content: [ { type: "text", text: `Failed to select ${args.selector}: ${error.message}`, }, ], isError: true, }, }; } case "playwright_hover": try { await page.hover(args.selector as string); return { toolResult: { content: [ { type: "text", text: `Hovered ${args.selector}`, }, ], isError: false, }, }; } catch (err) { const error = err as Error; return { toolResult: { content: [ { type: "text", text: `Failed to hover ${args.selector}: ${error.message}`, }, ], isError: true, }, }; } case "playwright_evaluate": try { const result = await page.evaluate((script: string) => { const logs: string[] = []; const originalConsole = { ...console }; ["log", "info", "warn", "error"].forEach((method) => { (console as any)[method] = (...args: any[]) => { logs.push(`[${method}] ${args.join(" ")}`); (originalConsole as any)[method](...args); }; }); try { const result = eval(script); Object.assign(console, originalConsole); return { result, logs }; } catch (error) { Object.assign(console, originalConsole); throw error; } }, args.script as string); return { toolResult: { content: [ { type: "text", text: `Execution result:\n${JSON.stringify( result.result, null, 2 )}\n\nConsole output:\n${result.logs.join("\n")}`, }, ], isError: false, }, }; } catch (err) { const error = err as Error; return { toolResult: { content: [ { type: "text", text: `Script execution failed: ${error.message}`, }, ], isError: true, }, }; } default: return { toolResult: { content: [ { type: "text", text: `Unknown tool: ${name}`, }, ], isError: true, }, }; } } const server = new Server( { name: "example-servers/playwright", version: "0.1.0", }, { capabilities: { resources: {}, tools: {}, }, } ); // Setup request handlers server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: [ { uri: "console://logs", mimeType: "text/plain", name: "Browser console logs", }, ...Array.from(screenshots.keys()).map((name) => ({ uri: `screenshot://${name}`, mimeType: "image/png", name: `Screenshot: ${name}`, })), ], })); server.setRequestHandler( ReadResourceRequestSchema, async (request: ReadResourceRequest) => { const uri = request.params.uri; if (uri === "console://logs") { return { contents: [ { uri, mimeType: "text/plain", text: consoleLogs.join("\n"), }, ], }; } if (uri.startsWith("screenshot://")) { const name = uri.split("://")[1]; const screenshot = screenshots.get(name); if (screenshot) { return { contents: [ { uri, mimeType: "image/png", blob: screenshot, }, ], }; } } throw new Error(`Resource not found: ${uri}`); } ); server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS, })); server.setRequestHandler(CallToolRequestSchema, (request: CallToolRequest) => handleToolCall(request.params.name, request.params.arguments ?? {}) ); // Handle cleanup on exit Deno.addSignalListener("SIGINT", async () => { if (browser) { await browser.close(); } Deno.exit(0); }); // Run the server const transport = new StdioServerTransport(); await server.connect(transport); console.error("Playwright MCP server running on stdio"); ```