Skip to content

Instantly share code, notes, and snippets.

@claudioluciano
Last active January 24, 2026 13:30
Show Gist options
  • Select an option

  • Save claudioluciano/320cd0d5dbce51e85406257b5bcdbf16 to your computer and use it in GitHub Desktop.

Select an option

Save claudioluciano/320cd0d5dbce51e85406257b5bcdbf16 to your computer and use it in GitHub Desktop.
Pencil node to PNG via MCP
#!/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