Last active
January 24, 2026 13:30
-
-
Save claudioluciano/320cd0d5dbce51e85406257b5bcdbf16 to your computer and use it in GitHub Desktop.
Pencil node to PNG via MCP
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bun | |
| /** | |
| * Pencil Screenshot Exporter | |
| * Exports frames from Pencil .pen files as PNG images via the MCP server. | |
| * | |
| * Usage: | |
| * bun pencil-export.ts <node_id> -f <file.pen> [-o output.png] | |
| * bun pencil-export.ts --list -f <file.pen> | |
| * | |
| * Requirements: | |
| * - Pencil app must be running | |
| */ | |
| import { spawn } from "bun"; | |
| import { parseArgs } from "util"; | |
| const { values, positionals } = parseArgs({ | |
| args: Bun.argv.slice(2), | |
| options: { | |
| output: { type: "string", short: "o", default: "export.png" }, | |
| file: { type: "string", short: "f" }, | |
| port: { type: "string", short: "p", default: "55617" }, | |
| list: { type: "boolean", short: "l", default: false }, | |
| help: { type: "boolean", short: "h", default: false }, | |
| debug: { type: "boolean", short: "d", default: false }, | |
| }, | |
| allowPositionals: true, | |
| }); | |
| const MCP_SERVER = "/Applications/Pencil.app/Contents/Resources/app.asar.unpacked/out/mcp-server-darwin-arm64"; | |
| interface MCPResponse { | |
| jsonrpc: string; | |
| id: number; | |
| result?: { | |
| content?: Array<{ | |
| type: string; | |
| text?: string; | |
| data?: string; | |
| mimeType?: string; | |
| }>; | |
| tools?: Array<{ name: string; description: string }>; | |
| }; | |
| error?: { | |
| code: number; | |
| message: string; | |
| }; | |
| } | |
| class PencilMCP { | |
| private proc: ReturnType<typeof spawn> | null = null; | |
| private requestId = 0; | |
| private pendingRequests = new Map<number, { resolve: (r: MCPResponse) => void; reject: (e: Error) => void }>(); | |
| private debug: boolean; | |
| private buffer = ""; | |
| constructor(debug = false) { | |
| this.debug = debug; | |
| } | |
| async start(port: number): Promise<void> { | |
| this.proc = spawn([MCP_SERVER, "--ws-port", port.toString()], { | |
| stdin: "pipe", | |
| stdout: "pipe", | |
| stderr: "pipe", | |
| }); | |
| // Handle stderr | |
| (async () => { | |
| const decoder = new TextDecoder(); | |
| for await (const chunk of this.proc!.stderr) { | |
| if (this.debug) { | |
| console.error("STDERR:", decoder.decode(chunk).trim()); | |
| } | |
| } | |
| })(); | |
| // Handle stdout | |
| (async () => { | |
| const decoder = new TextDecoder(); | |
| for await (const chunk of this.proc!.stdout) { | |
| this.buffer += decoder.decode(chunk); | |
| this.processBuffer(); | |
| } | |
| })(); | |
| // Wait for connection | |
| await Bun.sleep(1500); | |
| } | |
| private processBuffer() { | |
| const lines = this.buffer.split("\n"); | |
| this.buffer = lines.pop() || ""; | |
| for (const line of lines) { | |
| if (!line.trim()) continue; | |
| try { | |
| const response = JSON.parse(line) as MCPResponse; | |
| if (this.debug) { | |
| const preview = line.length > 200 ? line.slice(0, 200) + "..." : line; | |
| console.log("RECEIVED:", preview); | |
| } | |
| if (response.id !== undefined && this.pendingRequests.has(response.id)) { | |
| const pending = this.pendingRequests.get(response.id)!; | |
| this.pendingRequests.delete(response.id); | |
| pending.resolve(response); | |
| } | |
| } catch { | |
| if (this.debug) { | |
| console.log("Non-JSON line:", line.slice(0, 100)); | |
| } | |
| } | |
| } | |
| } | |
| async send(method: string, params?: unknown): Promise<MCPResponse> { | |
| const id = ++this.requestId; | |
| const request: Record<string, unknown> = { | |
| jsonrpc: "2.0", | |
| id, | |
| method, | |
| }; | |
| if (params !== undefined) { | |
| request.params = params; | |
| } | |
| const requestStr = JSON.stringify(request); | |
| if (this.debug) { | |
| console.log("SENDING:", requestStr.slice(0, 200)); | |
| } | |
| return new Promise((resolve, reject) => { | |
| this.pendingRequests.set(id, { resolve, reject }); | |
| this.proc!.stdin.write(requestStr + "\n"); | |
| setTimeout(() => { | |
| if (this.pendingRequests.has(id)) { | |
| this.pendingRequests.delete(id); | |
| reject(new Error("Request timeout")); | |
| } | |
| }, 30000); | |
| }); | |
| } | |
| async initialize(): Promise<MCPResponse> { | |
| return this.send("initialize", { | |
| protocolVersion: "2024-11-05", | |
| capabilities: {}, | |
| clientInfo: { name: "pencil-export", version: "1.0.0" }, | |
| }); | |
| } | |
| async callTool(name: string, args: Record<string, unknown>): Promise<MCPResponse> { | |
| return this.send("tools/call", { name, arguments: args }); | |
| } | |
| async listTools(): Promise<MCPResponse> { | |
| return this.send("tools/list"); | |
| } | |
| stop() { | |
| this.proc?.kill(); | |
| } | |
| } | |
| async function exportScreenshot( | |
| nodeId: string, | |
| outputPath: string, | |
| filePath: string, | |
| port: number, | |
| debug: boolean | |
| ): Promise<boolean> { | |
| const mcp = new PencilMCP(debug); | |
| try { | |
| console.log("Starting MCP server..."); | |
| await mcp.start(port); | |
| console.log("Initializing..."); | |
| await mcp.initialize(); | |
| console.log(`Requesting screenshot for node: ${nodeId}`); | |
| const result = await mcp.callTool("get_screenshot", { | |
| nodeId, | |
| filePath, | |
| }); | |
| if (result.error) { | |
| console.error(`Error: ${result.error.message}`); | |
| return false; | |
| } | |
| const content = result.result?.content || []; | |
| for (const item of content) { | |
| if (item.type === "image" && item.data) { | |
| const buffer = Uint8Array.from(atob(item.data), (c) => c.charCodeAt(0)); | |
| await Bun.write(outputPath, buffer); | |
| console.log(`✓ Saved screenshot to: ${outputPath}`); | |
| return true; | |
| } | |
| } | |
| console.log("No image data in response"); | |
| if (debug) { | |
| console.log("Response:", JSON.stringify(result, null, 2).slice(0, 1000)); | |
| } | |
| return false; | |
| } catch (e) { | |
| console.error(`Error: ${e}`); | |
| return false; | |
| } finally { | |
| mcp.stop(); | |
| } | |
| } | |
| async function listNodes(filePath: string, port: number, debug: boolean) { | |
| const mcp = new PencilMCP(debug); | |
| try { | |
| console.log("Starting MCP server..."); | |
| await mcp.start(port); | |
| console.log("Initializing..."); | |
| await mcp.initialize(); | |
| console.log(`Fetching nodes from: ${filePath}`); | |
| const result = await mcp.callTool("batch_get", { filePath }); | |
| if (result.error) { | |
| console.error(`Error: ${result.error.message}`); | |
| return; | |
| } | |
| const content = result.result?.content || []; | |
| for (const item of content) { | |
| if (item.type === "text" && item.text) { | |
| console.log(item.text); | |
| } | |
| } | |
| } catch (e) { | |
| console.error(`Error: ${e}`); | |
| } finally { | |
| mcp.stop(); | |
| } | |
| } | |
| function printHelp() { | |
| console.log(` | |
| Pencil Screenshot Exporter | |
| Exports frames from Pencil .pen files as PNG images. | |
| Usage: | |
| bun pencil-export.ts <node_id> -f <file.pen> [-o output.png] | |
| bun pencil-export.ts --list -f <file.pen> | |
| Options: | |
| -o, --output <path> Output PNG path (default: export.png) | |
| -f, --file <path> Path to .pen file (required, use absolute path) | |
| -p, --port <port> Pencil WebSocket port (default: 55617) | |
| -l, --list List available nodes in the file | |
| -d, --debug Show debug output | |
| -h, --help Show this help | |
| Examples: | |
| bun pencil-export.ts 4FQG9 -f /path/to/design.pen -o logo.png | |
| bun pencil-export.ts --list -f /path/to/design.pen | |
| Requirements: | |
| - Pencil app must be running | |
| `); | |
| } | |
| // Main | |
| if (values.help) { | |
| printHelp(); | |
| process.exit(0); | |
| } | |
| if (!values.file) { | |
| console.error("Error: --file (-f) is required. Use absolute path to the .pen file."); | |
| process.exit(1); | |
| } | |
| // Convert to absolute path if needed | |
| const filePath = values.file.startsWith("/") | |
| ? values.file | |
| : `${process.cwd()}/${values.file}`; | |
| const port = parseInt(values.port!, 10); | |
| const debug = values.debug!; | |
| if (values.list) { | |
| await listNodes(filePath, port, debug); | |
| } else if (positionals.length > 0) { | |
| const success = await exportScreenshot( | |
| positionals[0], | |
| values.output!, | |
| filePath, | |
| port, | |
| debug | |
| ); | |
| process.exit(success ? 0 : 1); | |
| } else { | |
| printHelp(); | |
| process.exit(1); | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment