diff --git a/README.md b/README.md index 2e4ae0a..d9ae8a2 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,22 @@ MCP server that streams hand-drawn Excalidraw diagrams with smooth viewport came ![Demo](docs/demo.gif) +> **Works without MCP Apps too.** Clients that don't support MCP Apps (like Claude Code, Cursor, Windsurf) still get server-side rendered PNG snapshots saved to `/tmp/excalidraw-snapshots/`. The agent can reference these images for follow-up edits. + +## MCP Tools + +### `read_me` +Returns the Excalidraw element format reference with color palettes, camera sizes, examples, and tips. Call this before using `create_view`. + +### `create_view` `[interactive]` +Renders a hand-drawn diagram using Excalidraw elements with streaming draw-on animations. Elements stream in one by one with smooth camera panning. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `elements` | string (required) | JSON array of Excalidraw elements. Call `read_me` first for format reference. | + +**Returns**: Checkpoint ID for iterative edits, plus a PNG snapshot path (local mode). + ## Install Works with any client that supports [MCP Apps](https://modelcontextprotocol.io/docs/extensions/apps) — Claude, ChatGPT, VS Code, Goose, and others. If something doesn't work, please [open an issue](https://github.com/antonpk1/excalidraw-mcp-app/issues). diff --git a/package.json b/package.json index 1e3e9c8..ff2204e 100644 --- a/package.json +++ b/package.json @@ -31,13 +31,15 @@ "@excalidraw/excalidraw": "^0.18.0", "@modelcontextprotocol/ext-apps": "^0.4.0", "@modelcontextprotocol/sdk": "^1.24.0", + "@resvg/resvg-wasm": "^2.6.2", + "@upstash/redis": "^1.34.0", "cors": "^2.8.5", + "excalirender": "github:JonRC/excalirender", "express": "^5.1.0", "mcp-handler": "^1.0.7", "morphdom": "^2.7.8", "react": "^19.0.0", "react-dom": "^19.0.0", - "@upstash/redis": "^1.34.0", "zod": "^4.0.0" }, "devDependencies": { diff --git a/src/main.ts b/src/main.ts index b0bb26e..c82db64 100644 --- a/src/main.ts +++ b/src/main.ts @@ -82,7 +82,7 @@ export async function startStdioServer( async function main() { const store = new FileCheckpointStore(); - const factory = () => createServer(store); + const factory = () => createServer(store, { enableSnapshots: true }); if (process.argv.includes("--stdio")) { await startStdioServer(factory); } else { diff --git a/src/server.ts b/src/server.ts index 9475c5b..ad256aa 100644 --- a/src/server.ts +++ b/src/server.ts @@ -8,6 +8,11 @@ import { deflateSync } from "node:zlib"; import { z } from "zod/v4"; import type { CheckpointStore } from "./checkpoint-store.js"; +export interface ServerOptions { + /** Enable server-side PNG snapshot rendering (local/stdio only) */ + enableSnapshots?: boolean; +} + // Works both from source (src/server.ts) and compiled (dist/server.js) const DIST_DIR = import.meta.filename.endsWith(".ts") ? path.join(import.meta.dirname, "..", "dist") @@ -395,7 +400,8 @@ Use the Primary Colors from above — they're bright enough on dark backgrounds. * Registers all Excalidraw tools and resources on the given McpServer. * Shared between local (main.ts) and Vercel (api/mcp.ts) entry points. */ -export function registerTools(server: McpServer, distDir: string, store: CheckpointStore): void { +export function registerTools(server: McpServer, distDir: string, store: CheckpointStore, options?: ServerOptions): void { + const enableSnapshots = options?.enableSnapshots ?? false; const resourceUri = "ui://excalidraw/mcp-app.html"; // ============================================================ @@ -485,6 +491,15 @@ Call read_me first to learn the element format.`, const checkpointId = crypto.randomUUID().replace(/-/g, "").slice(0, 18); await store.save(checkpointId, { elements: resolvedElements }); + let snapshotHint = ""; + if (enableSnapshots) { + try { + const mod = "./snapshot-renderer.js"; + const { renderSnapshot } = await import(/* @vite-ignore */ mod); + const result = await renderSnapshot(checkpointId, resolvedElements); + if (result) snapshotHint = `\nSnapshot: ${result.pngPath}\nExcalidraw file: ${result.excalidrawPath}`; + } catch { /* snapshot rendering is best-effort */ } + } return { content: [{ type: "text", text: `Diagram displayed! Checkpoint id: "${checkpointId}". If user asks to create a new diagram - simply create a new one from scratch. @@ -493,7 +508,7 @@ However, if the user wants to edit something on this diagram "${checkpointId}", 2) decide whether you want to make new diagram from scratch OR - use this one as starting checkpoint: simply start from the first element [{"type":"restoreCheckpoint","id":"${checkpointId}"}, ...your new elements...] this will use same diagram state as the user currently sees, including any manual edits they made in fullscreen, allowing you to add elements on top. - To remove elements, use: {"type":"delete","ids":","}${ratioHint}` }], + To remove elements, use: {"type":"delete","ids":","}${ratioHint}${snapshotHint}` }], structuredContent: { checkpointId }, }; }, @@ -665,11 +680,11 @@ However, if the user wants to edit something on this diagram "${checkpointId}", * Creates a new MCP server instance with Excalidraw drawing tools. * Used by local entry point (main.ts) and Docker deployments. */ -export function createServer(store: CheckpointStore): McpServer { +export function createServer(store: CheckpointStore, options?: ServerOptions): McpServer { const server = new McpServer({ name: "Excalidraw", version: "1.0.0", }); - registerTools(server, DIST_DIR, store); + registerTools(server, DIST_DIR, store, options); return server; } diff --git a/src/snapshot-renderer.ts b/src/snapshot-renderer.ts new file mode 100644 index 0000000..ec612c6 --- /dev/null +++ b/src/snapshot-renderer.ts @@ -0,0 +1,210 @@ +/** + * Server-side diagram snapshot renderer (POC). + * Uses excalirender's pure-JS SVG export (roughjs, no native canvas), + * then converts SVG→PNG via resvg (WASM, no native bindings). + * Isolated module — no coupling to MCP server logic. + */ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { initWasm, Resvg } from "@resvg/resvg-wasm"; +import { exportToSvg } from "excalirender/src/export-svg/export.js"; +import type { ExportOptions } from "excalirender/src/types.js"; + +const TMP_DIR = path.join(os.tmpdir(), "excalidraw-snapshots"); + +// Resolve excalirender's bundled font directory +const FONTS_DIR = path.join( + path.dirname(fileURLToPath(import.meta.resolve("excalirender/src/fonts.js"))), + "..", + "assets", + "fonts", +); + +// Load font buffers once +let fontBuffers: Uint8Array[] | null = null; +function getFontBuffers(): Uint8Array[] { + if (!fontBuffers) { + const fontFiles = ["Excalifont.ttf", "Virgil.ttf", "Cascadia.ttf", "LiberationSans.ttf"]; + fontBuffers = fontFiles.map((f) => new Uint8Array(fs.readFileSync(path.join(FONTS_DIR, f)))); + } + return fontBuffers; +} + +// Initialize resvg WASM once +let wasmReady: Promise | null = null; +function ensureWasm(): Promise { + if (!wasmReady) { + const wasmPath = path.join( + path.dirname(fileURLToPath(import.meta.resolve("@resvg/resvg-wasm"))), + "index_bg.wasm", + ); + wasmReady = initWasm(fs.readFileSync(wasmPath)); + } + return wasmReady; +} + +/** + * Fill in default fields that excalirender expects on every element. + */ +function normalizeElement(el: any): any { + return { + strokeColor: "#1e1e1e", + backgroundColor: "transparent", + fillStyle: "solid", + strokeStyle: "solid", + strokeWidth: 2, + roughness: 1, + opacity: 100, + seed: Math.floor(Math.random() * 100000), + angle: 0, + ...el, + }; +} + +/** + * Estimate text width in scene units (rough approximation). + */ +function estimateTextWidth(text: string, fontSize: number): number { + return text.length * fontSize * 0.55; +} + +/** + * Expand label shorthand into separate bound text elements. + * Centers text within the parent shape using width estimation. + */ +function expandLabels(elements: any[]): any[] { + const result: any[] = []; + for (const el of elements) { + if (el.label?.text && el.type !== "arrow") { + const { label, ...shape } = el; + const fontSize = label.fontSize || 20; + const textWidth = estimateTextWidth(label.text, fontSize); + const textHeight = fontSize * 1.2; + + result.push(normalizeElement(shape)); + result.push(normalizeElement({ + type: "text", + id: `${el.id}_label`, + x: el.x + (el.width || 0) / 2 - textWidth / 2, + y: el.y + (el.height || 0) / 2 - textHeight / 2, + width: textWidth, + height: textHeight, + text: label.text, + fontSize, + fontFamily: label.fontFamily || 1, + textAlign: "center", + verticalAlign: "middle", + containerId: el.id, + strokeColor: label.strokeColor || el.strokeColor || "#1e1e1e", + backgroundColor: "transparent", + strokeWidth: 0, + roughness: 0, + })); + } else if (el.label?.text && el.type === "arrow") { + const { label, ...arrow } = el; + const fontSize = label.fontSize || 16; + const textWidth = estimateTextWidth(label.text, fontSize); + const textHeight = fontSize * 1.2; + + // Arrow midpoint from points array + const pts = el.points || [[0, 0], [el.width || 0, el.height || 0]]; + const lastPt = pts[pts.length - 1]; + const midX = el.x + lastPt[0] / 2; + const midY = el.y + lastPt[1] / 2; + + result.push(normalizeElement(arrow)); + result.push(normalizeElement({ + type: "text", + id: `${el.id}_label`, + x: midX - textWidth / 2, + y: midY - textHeight - 4, + width: textWidth, + height: textHeight, + text: label.text, + fontSize, + fontFamily: label.fontFamily || 1, + textAlign: "center", + verticalAlign: "middle", + containerId: el.id, + strokeColor: label.strokeColor || el.strokeColor || "#1e1e1e", + backgroundColor: "transparent", + strokeWidth: 0, + roughness: 0, + })); + } else { + result.push(normalizeElement(el)); + } + } + return result; +} + +export interface SnapshotResult { + pngPath: string; + excalidrawPath: string; +} + +/** + * Render Excalidraw elements to PNG + .excalidraw files in /tmp. + * Pipeline: elements → .excalidraw JSON → SVG (excalirender) → PNG (resvg). + */ +export async function renderSnapshot( + checkpointId: string, + elements: any[], +): Promise { + try { + fs.mkdirSync(TMP_DIR, { recursive: true }); + + const basePath = path.join(TMP_DIR, checkpointId); + const inputPath = `${basePath}.excalidraw`; + const svgPath = `${basePath}.svg`; + const pngPath = `${basePath}.png`; + + // Filter pseudo-elements, expand labels, normalize + const drawableElements = elements.filter( + (el) => el.type !== "cameraUpdate" && el.type !== "delete" && el.type !== "restoreCheckpoint", + ); + const expandedElements = expandLabels(drawableElements); + + // Write .excalidraw JSON + const excalidrawFile = { + type: "excalidraw", + version: 2, + source: "excalidraw-mcp-app", + elements: expandedElements, + appState: { viewBackgroundColor: "#ffffff" }, + files: {}, + }; + fs.writeFileSync(inputPath, JSON.stringify(excalidrawFile)); + + // Render SVG via excalirender (pure JS, roughjs) + const svgOptions: ExportOptions = { + outputPath: svgPath, + scale: 2, // 2x for crisp PNG + background: null, + darkMode: false, + }; + await exportToSvg(inputPath, svgOptions); + + // Convert SVG → PNG via resvg (WASM) + await ensureWasm(); + const svgData = fs.readFileSync(svgPath, "utf-8"); + const resvg = new Resvg(svgData, { + font: { + fontBuffers: getFontBuffers(), + defaultFontFamily: "Excalifont", + }, + }); + const pngData = resvg.render(); + fs.writeFileSync(pngPath, pngData.asPng()); + + // Clean up intermediate SVG (keep .excalidraw + .png) + fs.unlinkSync(svgPath); + + return { pngPath, excalidrawPath: inputPath }; + } catch (err) { + console.error(`Snapshot render failed: ${(err as Error).message}`); + return null; + } +} diff --git a/tsconfig.json b/tsconfig.json index fc3c210..e849c4e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,5 +16,6 @@ "noUnusedParameters": true, "noFallthroughCasesInSwitch": true }, - "include": ["src", "server.ts"] + "include": ["src", "server.ts"], + "exclude": ["src/snapshot-renderer.ts"] }