# Excalidraw MCP App Server Standalone MCP server that streams Excalidraw diagrams as SVG with hand-drawn animations. ## Architecture ``` server.ts → 2 tools (read_me, create_view) + resource + cheat sheet main.ts → HTTP (Streamable) + stdio transports src/mcp-app.tsx → ExcalidrawAppCore (widget logic) + ExcalidrawApp (useApp wrapper) src/mcp-entry.tsx → Production entry point: createRoot + ExcalidrawApp src/global.css → Animations (stroke draw-on, fade-in) + auto-resize src/dev.tsx → Dev entry point: mock app + sample elements + control panel src/dev-mock.ts → Mock MCP App with event simulation (sendToolInput, streamElements, etc.) index-dev.html → Dev HTML entry (served by vite dev server) vite.config.dev.ts → Dev-only vite config (resolves from node_modules, no esm.sh externals) ``` ## Tools ### `read_me` (text tool, no UI) Returns a cheat sheet with element format, color palettes, coordinate tips, and examples. The model should call this before `create_view`. ### `create_view` (UI tool) Takes `elements` — a JSON string of standard Excalidraw elements. The widget parses partial JSON during streaming and renders via `exportToSvg` + morphdom diffing. No Excalidraw React canvas component — pure SVG rendering. **Screenshot as model context:** After final render, the SVG is captured as a 512px-max PNG and sent via `app.updateModelContext()` so the model can see the diagram and iterate on user feedback. ## Key Design Decisions ### Standard Excalidraw JSON — no extensions The input is standard Excalidraw element JSON. No `label` on containers, no `start`/`end` on arrows. These are Excalidraw's internal "skeleton" API (`convertToExcalidrawElements`) — not the standard format. **Why:** Standard format means any `.excalidraw` file's elements array works as input. **Trade-off:** Labels require separate text elements with manually computed centered coordinates. The cheat sheet teaches the formula: `x = shape.x + (shape.width - text.width) / 2`. ### No `convertToExcalidrawElements` We tried Excalidraw's skeleton API. Problems: 1. Needs font metrics at conversion time (canvas `measureText`) 2. Non-standard format 3. Added complexity for marginal benefit ### SVG-only rendering (no Excalidraw React canvas) The widget uses `exportToSvg` for ALL rendering — no `` React component. **Why:** - Eliminates blink on final render (no component swap from SVG preview to canvas) - Loads Virgil hand-drawn font from the start (no `skipInliningFonts`) - morphdom works on SVG DOM — smooth diffing between streaming updates ### Auto-sizing The container has no fixed height. SVG gets `width: 100%` + `height: auto` with the `width` attribute removed. The SVG's `viewBox` preserves aspect ratio, so height scales proportionally to content. ### CSP: `esm.sh` allowed Excalidraw loads the Virgil font from `esm.sh` at runtime. The resource's `_meta.ui.csp.resourceDomains` includes `https://esm.sh`. ### `prefersBorder: true` Set on the resource content's `_meta.ui` so the host renders a border/background around the widget. ### Fullscreen mode Supports `app.requestDisplayMode({ mode: "fullscreen" })`. Button appears on hover (top-right), hidden in fullscreen (host provides exit UI). Escape key exits fullscreen. ## Checkpoint System Two-tier storage for diagram state persistence: ### Architecture 1. **Server-side store** (primary): `CheckpointStore` interface with 3 implementations: - `FileCheckpointStore` — local dev, writes JSON to `$TMPDIR/excalidraw-mcp-checkpoints/` - `MemoryCheckpointStore` — Vercel fallback (in-memory Map, lost on cold start) - `RedisCheckpointStore` — Vercel with Upstash KV (persistent, 30-day TTL) - Factory: `createVercelStore()` picks Redis if env vars exist, else Memory 2. **localStorage** (widget-side cache): Fast local cache keyed by `excalidraw:` for persisting user edits across page reloads within the same session. ### Flow - `create_view` resolves `restoreCheckpoint` references server-side, saves fully resolved state, returns `checkpointId` - Widget reads checkpoints via `read_checkpoint` server tool (private, app-only visibility) - User edits in fullscreen sync back to server via `save_checkpoint` server tool (debounced) - `cameraUpdate` elements are stored as part of checkpoint data (not a separate viewport field) ### Key Design Decisions - Server resolves checkpoints so the model never needs to re-send full element arrays - `containerId` filtering ensures bound text elements are deleted with their containers - Camera aspect ratio check nudges model toward 4:3 ratios - `checkpointId` uses `crypto.randomUUID()` truncated to 18 chars (collision-resistant, URL-safe) ## Build ```bash npm install npm run build ``` Build pipeline: `tsc --noEmit` → `vite build` (singlefile HTML) → `tsc -p tsconfig.server.json` → `bun build` (server + index). ## Running ```bash # HTTP (Streamable) — default, stateless per-request npm run serve # or: bun --watch main.ts # Starts on http://localhost:3001/mcp # stdio — for Claude Desktop node dist/index.js --stdio # Dev mode (watch + serve) — full MCP flow npm run dev # Dev mode (standalone UI) — no MCP server needed npm run dev:ui # Opens http://localhost:5173/index-dev.html with mock app + sample diagram ``` ## Claude Desktop config ```json { "excalidraw": { "command": "node", "args": ["/dist/index.js", "--stdio"] } } ``` ## Rendering Pipeline ### Streaming (`ontoolinputpartial`) 1. `parsePartialElements` tries `JSON.parse`, falls back to closing array after last `}` 2. `excludeIncompleteLastItem` drops the last element (may be incomplete) 3. Only re-renders when element **count** changes (not on every partial update) 4. Seeds are **randomized** per render — hand-drawn style animates naturally 5. `exportToSvg` generates SVG → **morphdom** diffs against existing DOM 6. morphdom preserves existing elements (no re-animation), only new elements trigger CSS animations ### Final render (`ontoolinput`) 1. Parses complete JSON, renders with **original seeds** (stable final look) 2. Same `exportToSvg` + morphdom path — seamless transition, no blink 3. Sends PNG screenshot to model context (debounced 1.5s) ### CSS Animations (3 layers) - **Shapes** (`g, rect, circle, ellipse, text, image`): opacity fade-in 0.5s - **Lines** (`path, line, polyline, polygon`): stroke-dashoffset draw-on effect 0.6s - **Existing elements**: smooth `transition` on fill/stroke/opacity changes ### Key Libraries - **morphdom**: DOM diffing for SVG — preserves existing nodes, only new nodes get animations - **exportToSvg**: Excalidraw's SVG export (with fonts inlined by default) ## Cheat Sheet: Progressive Element Ordering The `server.ts` cheat sheet instructs the model to emit elements progressively: - BAD: all rectangles → all texts → all arrows (blank boxes stream, then labels appear late) - GOOD: background shapes first, then per node: shape → label → arrows → next node - This way each node appears complete with its label during streaming ## Debugging ### Dev workflow 1. Edit source files 2. `npm run build` (or `npm run dev` for watch mode) 3. Restart the server process (module cache means hot reload doesn't pick up `server.ts` changes for tool definitions) 4. In Claude Desktop: restart the MCP server connection ### Widget logging — NEVER use console.log Use the SDK logger — it routes through the host to the log file: ```typescript app.sendLog({ level: "info", logger: "Excalidraw", data: "my message" }); ``` **Log file**: `~/Library/Logs/Claude/claude.ai-web.log` ```bash # Fullscreen transition logs (logger: "FS") grep "FS" ~/Library/Logs/Claude/claude.ai-web.log | tail -40 # General widget logs (logger: "Excalidraw") grep "Excalidraw" ~/Library/Logs/Claude/claude.ai-web.log | tail -20 # Clear logs before repro for clean output > ~/Library/Logs/Claude/claude.ai-web.log ``` ### Widget debugging - The widget runs in an iframe - Check that `exportToSvg` isn't throwing (catches are silent) - morphdom issues: compare old vs new SVG structure in Elements panel ### Common issues - **No diagram appears:** Check that `ontoolinputpartial` is firing — the `elements` field might be nested differently (`params.arguments.elements` vs `params.elements`) - **All elements re-animate on each update:** morphdom not working — check that SVG structure is similar enough for diffing (different root SVG attributes can cause full replacement) - **Font is default (not hand-drawn):** `skipInliningFonts` was set to `true` — must be removed/false - **Elements in wrong positions during animation:** Don't use CSS `transform: scale()` on SVG child elements — conflicts with Excalidraw's own transform attributes. Use opacity-only animations. ## Gotchas - `ExcalidrawElement` type is at `@excalidraw/excalidraw/element/types`, not re-exported from main - `ExcalidrawImperativeAPI` type is at `@excalidraw/excalidraw/types` - Excalidraw's `containerId` on text elements does NOT auto-position text — that only works via `convertToExcalidrawElements` skeleton API - The `.SVGLayer` div is not used for rendering but takes layout space — safe to `display: none` - morphdom is essential — without it, replacing innerHTML re-triggers all animations on every update - `ReactDOM.render()` per update remounts the tree and kills animations — use `createRoot()` once + `useState` if adding React components