Standalone MCP server that streams Excalidraw diagrams as SVG with hand-drawn animations.
server.ts → 3 tools (read_me, create_view, update_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.
Takes checkpointId + elements — edits an existing diagram by applying changes on top of a saved checkpoint. The server loads the base state from the checkpoint, applies deletes, merges new elements, and saves a new checkpoint. The widget handles the checkpointId field during streaming to show the base + new elements progressively.
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