Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
2 changes: 1 addition & 1 deletion src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
23 changes: 19 additions & 4 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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";

// ============================================================
Expand Down Expand Up @@ -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.
Expand All @@ -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":"<id1>,<id2>"}${ratioHint}` }],
To remove elements, use: {"type":"delete","ids":"<id1>,<id2>"}${ratioHint}${snapshotHint}` }],
structuredContent: { checkpointId },
};
},
Expand Down Expand Up @@ -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;
}
210 changes: 210 additions & 0 deletions src/snapshot-renderer.ts
Original file line number Diff line number Diff line change
@@ -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<void> | null = null;
function ensureWasm(): Promise<void> {
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<SnapshotResult | null> {
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;
}
}
3 changes: 2 additions & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,6 @@
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src", "server.ts"]
"include": ["src", "server.ts"],
"exclude": ["src/snapshot-renderer.ts"]
}