Standalone MCP server that streams Excalidraw diagrams as SVG with hand-drawn animations.
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)
Returns a cheat sheet with element format, color palettes, coordinate tips, and examples. The model should call this before create_view.
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.
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.
We tried Excalidraw's skeleton API. Problems:
- Needs font metrics at conversion time (canvas
measureText) - Non-standard format
- Added complexity for marginal benefit
The widget uses exportToSvg for ALL rendering — no <Excalidraw> 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
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.
Excalidraw loads the Virgil font from esm.sh at runtime. The resource's _meta.ui.csp.resourceDomains includes https://esm.sh.
Set on the resource content's _meta.ui so the host renders a border/background around the widget.
Supports app.requestDisplayMode({ mode: "fullscreen" }). Button appears on hover (top-right), hidden in fullscreen (host provides exit UI). Escape key exits fullscreen.
Two-tier storage for diagram state persistence:
-
Server-side store (primary):
CheckpointStoreinterface 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
-
localStorage (widget-side cache): Fast local cache keyed by
excalidraw:<checkpointId>for persisting user edits across page reloads within the same session.
create_viewresolvesrestoreCheckpointreferences server-side, saves fully resolved state, returnscheckpointId- Widget reads checkpoints via
read_checkpointserver tool (private, app-only visibility) - User edits in fullscreen sync back to server via
save_checkpointserver tool (debounced) cameraUpdateelements are stored as part of checkpoint data (not a separate viewport field)
- Server resolves checkpoints so the model never needs to re-send full element arrays
containerIdfiltering ensures bound text elements are deleted with their containers- Camera aspect ratio check nudges model toward 4:3 ratios
checkpointIdusescrypto.randomUUID()truncated to 18 chars (collision-resistant, URL-safe)
npm install
npm run buildBuild pipeline: tsc --noEmit → vite build (singlefile HTML) → tsc -p tsconfig.server.json → bun build (server + index).
# 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{
"excalidraw": {
"command": "node",
"args": ["<path>/dist/index.js", "--stdio"]
}
}parsePartialElementstriesJSON.parse, falls back to closing array after last}excludeIncompleteLastItemdrops the last element (may be incomplete)- Only re-renders when element count changes (not on every partial update)
- Seeds are randomized per render — hand-drawn style animates naturally
exportToSvggenerates SVG → morphdom diffs against existing DOM- morphdom preserves existing elements (no re-animation), only new elements trigger CSS animations
- Parses complete JSON, renders with original seeds (stable final look)
- Same
exportToSvg+ morphdom path — seamless transition, no blink - Sends PNG screenshot to model context (debounced 1.5s)
- 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
transitionon fill/stroke/opacity changes
- morphdom: DOM diffing for SVG — preserves existing nodes, only new nodes get animations
- exportToSvg: Excalidraw's SVG export (with fonts inlined by default)
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
- Edit source files
npm run build(ornpm run devfor watch mode)- Restart the server process (module cache means hot reload doesn't pick up
server.tschanges for tool definitions) - In Claude Desktop: restart the MCP server connection
Use the SDK logger — it routes through the host to the log file:
app.sendLog({ level: "info", logger: "Excalidraw", data: "my message" });Log file: ~/Library/Logs/Claude/claude.ai-web.log
# 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- The widget runs in an iframe
- Check that
exportToSvgisn't throwing (catches are silent) - morphdom issues: compare old vs new SVG structure in Elements panel
- No diagram appears: Check that
ontoolinputpartialis firing — theelementsfield might be nested differently (params.arguments.elementsvsparams.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):
skipInliningFontswas set totrue— 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.
ExcalidrawElementtype is at@excalidraw/excalidraw/element/types, not re-exported from mainExcalidrawImperativeAPItype is at@excalidraw/excalidraw/types- Excalidraw's
containerIdon text elements does NOT auto-position text — that only works viaconvertToExcalidrawElementsskeleton API - The
.SVGLayerdiv is not used for rendering but takes layout space — safe todisplay: 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 — usecreateRoot()once +useStateif adding React components