diff --git a/src/puppeteer/README.md b/src/puppeteer/README.md index 4350f6fb..c6132b1b 100644 --- a/src/puppeteer/README.md +++ b/src/puppeteer/README.md @@ -1,3 +1,53 @@ -# Puppeteer server +# Puppeteer -This MCP server provides **resources** and **tools** for interacting with a browser and web pages, using [Puppeteer](https://pptr.dev/). +A Model Context Protocol server that provides browser automation capabilities using Puppeteer. This server enables LLMs to interact with web pages, take screenshots, and execute JavaScript in a real browser environment. + +## Components + +### Tools + +- **puppeteer_navigate** + - Navigate to any URL in the browser + - Input: `url` (string) + +- **puppeteer_screenshot** + - Capture screenshots of the entire page or specific elements + - Inputs: + - `name` (string, required): Name for the screenshot + - `selector` (string, optional): CSS selector for element to screenshot + - `width` (number, optional, default: 800): Screenshot width + - `height` (number, optional, default: 600): Screenshot height + +- **puppeteer_click** + - Click elements on the page + - Input: `selector` (string): CSS selector for element to click + +- **puppeteer_fill** + - Fill out input fields + - Inputs: + - `selector` (string): CSS selector for input field + - `value` (string): Value to fill + +- **puppeteer_evaluate** + - Execute JavaScript in the browser console + - Input: `script` (string): JavaScript code to execute + +### Resources + +The server provides access to two types of resources: + +1. **Console Logs** (`console://logs`) + - Browser console output in text format + - Includes all console messages from the browser + +2. **Screenshots** (`screenshot://`) + - PNG images of captured screenshots + - Accessible via the screenshot name specified during capture + +## Key Features + +- Browser automation +- Console log monitoring +- Screenshot capabilities +- JavaScript execution +- Basic web interaction (navigation, clicking, form filling) \ No newline at end of file diff --git a/src/puppeteer/index.ts b/src/puppeteer/index.ts index cce6614e..c7d8959f 100644 --- a/src/puppeteer/index.ts +++ b/src/puppeteer/index.ts @@ -7,104 +7,341 @@ import { ListResourcesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema, + CallToolResult, + TextContent, + ImageContent, + Tool, } from "@modelcontextprotocol/sdk/types.js"; -import puppeteer from "puppeteer"; +import puppeteer, { Browser, Page } from "puppeteer"; -const server = new Server( +// Define the tools once to avoid repetition +const TOOLS: Tool[] = [ { - name: "example-servers/puppeteer", - version: "0.1.0", + name: "puppeteer_navigate", + description: "Navigate to a URL", + inputSchema: { + type: "object", + properties: { + url: { type: "string" }, + }, + required: ["url"], + }, }, { - capabilities: { - resources: { - listChanged: true, + name: "puppeteer_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)" }, }, - tools: {}, + required: ["name"], }, }, -); - -let browser: puppeteer.Browser | undefined; -let consoleLogs: string[] = []; - -server.setRequestHandler(ListResourcesRequestSchema, async () => { - return { - resources: [ - { - uri: "console://logs", - mimeType: "text/plain", - name: "Browser console logs", + { + name: "puppeteer_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: "puppeteer_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" }, }, - ], - }; -}); - -server.setRequestHandler(ReadResourceRequestSchema, async (request) => { - if (request.params.uri.toString() === "console://logs") { - return { - contents: [ + required: ["selector", "value"], + }, + }, + { + name: "puppeteer_evaluate", + description: "Execute JavaScript in the browser console", + inputSchema: { + type: "object", + properties: { + script: { type: "string", description: "JavaScript code to execute" }, + }, + required: ["script"], + }, + }, +]; + +class PuppeteerServer { + private browser?: Browser; + private page?: Page; + private consoleLogs: string[] = []; + private screenshots: Map = new Map(); + private server: Server; + + constructor() { + this.server = new Server({ + name: "example-servers/puppeteer", + version: "0.4.0", + }); + this.setupHandlers(); + } + + private async ensureBrowser() { + if (!this.browser) { + this.browser = await puppeteer.launch({ headless: false }); + const pages = await this.browser.pages(); + this.page = pages[0]; + + this.page.on("console", (msg) => { + const logEntry = `[${msg.type()}] ${msg.text()}`; + this.consoleLogs.push(logEntry); + this.server.notification({ + method: "notifications/resources/updated", + params: { uri: "console://logs" }, + }); + }); + } + return this.page!; + } + + private setupHandlers() { + this.server.setRequestHandler(ListResourcesRequestSchema, async () => ({ + resources: [ { uri: "console://logs", mimeType: "text/plain", - text: consoleLogs.join("\n"), + name: "Browser console logs", }, + ...Array.from(this.screenshots.keys()).map(name => ({ + uri: `screenshot://${name}`, + mimeType: "image/png", + name: `Screenshot: ${name}`, + })), ], - }; + })); + + this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => { + const uri = request.params.uri.toString(); + + if (uri === "console://logs") { + return { + contents: [{ + uri, + mimeType: "text/plain", + text: this.consoleLogs.join("\n"), + }], + }; + } + + if (uri.startsWith("screenshot://")) { + const name = uri.split("://")[1]; + const screenshot = this.screenshots.get(name); + if (screenshot) { + return { + contents: [{ + uri, + mimeType: "image/png", + blob: screenshot, + }], + }; + } + } + + throw new Error(`Resource not found: ${uri}`); + }); + + this.server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: TOOLS, + })); + + this.server.setRequestHandler(CallToolRequestSchema, async (request) => + this.handleToolCall(request.params.name, request.params.arguments ?? {}) + ); } - console.error("Resource not found:", request.params.uri); - throw new Error("Resource not found"); -}); - -server.setRequestHandler(ListToolsRequestSchema, async () => { - return { - tools: [ - { - name: "navigate", - description: "Navigate to a URL", - inputSchema: { - type: "object", - properties: { - url: { type: "string" }, + + private async handleToolCall(name: string, args: any): Promise<{ toolResult: CallToolResult }> { + const page = await this.ensureBrowser(); + + switch (name) { + case "puppeteer_navigate": + await page.goto(args.url); + return { + toolResult: { + content: [{ + type: "text", + text: `Navigated to ${args.url}`, + }], + isError: false, }, - }, - }, - ], - }; -}); + }; -server.setRequestHandler(CallToolRequestSchema, async (request) => { - if (request.params.name === "navigate") { - const url = request.params.arguments?.url as string; + case "puppeteer_screenshot": { + const width = args.width ?? 800; + const height = args.height ?? 600; + await page.setViewport({ width, height }); - if (!browser) { - browser = await puppeteer.launch({ headless: false }); + const screenshot = await (args.selector ? + (await page.$(args.selector))?.screenshot({ encoding: "base64" }) : + page.screenshot({ encoding: "base64", fullPage: false })); - const pages = await browser.pages(); - pages[0].on("console", (msg) => { - const logEntry = `[${msg.type()}] ${msg.text()}`; - consoleLogs.push(logEntry); - server.notification({ - method: "notifications/resources/updated", - params: { uri: "console://logs" }, + if (!screenshot) { + return { + toolResult: { + content: [{ + type: "text", + text: args.selector ? `Element not found: ${args.selector}` : "Screenshot failed", + }], + isError: true, + }, + }; + } + + this.screenshots.set(args.name, screenshot as string); + this.server.notification({ + method: "notifications/resources/list_changed", }); - }); - } - const pages = await browser.pages(); - await pages[0].goto(url); + return { + toolResult: { + content: [ + { + type: "text", + text: `Screenshot '${args.name}' taken at ${width}x${height}`, + } as TextContent, + { + type: "image", + data: screenshot, + mimeType: "image/png", + } as ImageContent, + ], + isError: false, + }, + }; + } + + case "puppeteer_click": + try { + await page.click(args.selector); + return { + toolResult: { + content: [{ + type: "text", + text: `Clicked: ${args.selector}`, + }], + isError: false, + }, + }; + } catch (error) { + return { + toolResult: { + content: [{ + type: "text", + text: `Failed to click ${args.selector}: ${(error as Error).message}`, + }], + isError: true, + }, + }; + } + + case "puppeteer_fill": + try { + await page.waitForSelector(args.selector); + await page.type(args.selector, args.value); + return { + toolResult: { + content: [{ + type: "text", + text: `Filled ${args.selector} with: ${args.value}`, + }], + isError: false, + }, + }; + } catch (error) { + return { + toolResult: { + content: [{ + type: "text", + text: `Failed to fill ${args.selector}: ${(error as Error).message}`, + }], + isError: true, + }, + }; + } + + case "puppeteer_evaluate": + try { + const result = await page.evaluate((script) => { + 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); - return { - content: [{ type: "text", text: `Navigated to ${url}` }], - isError: false, - }; + 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 (error) { + return { + toolResult: { + content: [{ + type: "text", + text: `Script execution failed: ${(error as Error).message}`, + }], + isError: true, + }, + }; + } + + default: + return { + toolResult: { + content: [{ + type: "text", + text: `Unknown tool: ${name}`, + }], + isError: true, + }, + }; + } } - throw new Error(`Unknown tool: ${request.params.name}`); -}); + + async start() { + const transport = new StdioServerTransport(); + await this.server.connect(transport); + } +} async function runServer() { - const transport = new StdioServerTransport(); - await server.connect(transport); + const server = new PuppeteerServer(); + await server.start(); } -runServer().catch(console.error); +runServer().catch(console.error); \ No newline at end of file