#!/usr/bin/env node /** * Excalidraw MCP stdio server * Provides headless .excalidraw → SVG export via @excalidraw/excalidraw. * Optional: diagram generation via EXCALIDRAW_GEN_PATH (excalidraw_gen.py). */ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { z } from "zod/v3"; import { readFileSync, writeFileSync, existsSync } from 'fs'; import { resolve } from 'path'; import { spawnSync } from 'child_process'; import { JSDOM } from 'jsdom'; // --------------------------------------------------------------------------- // 1. DOM environment — must be established BEFORE importing excalidraw // --------------------------------------------------------------------------- const dom = new JSDOM('', { url: 'http://localhost/', pretendToBeVisual: true, }); const { window } = dom; // Helper: define a global, overriding read-only getters (e.g. navigator in Node v22) function defineGlobal(key, value) { if (value === undefined) return; try { Object.defineProperty(global, key, { value, writable: true, configurable: true, }); } catch { // Already defined and non-configurable — skip } } // Core DOM globals defineGlobal('window', window); defineGlobal('document', window.document); defineGlobal('navigator', window.navigator); defineGlobal('location', window.location); defineGlobal('history', window.history); defineGlobal('screen', window.screen); // Element / event interfaces for (const key of [ 'Node', 'Element', 'HTMLElement', 'SVGElement', 'SVGSVGElement', 'HTMLCanvasElement', 'HTMLImageElement', 'Image', 'Event', 'CustomEvent', 'MouseEvent', 'PointerEvent', 'KeyboardEvent', 'TouchEvent', 'WheelEvent', 'InputEvent', 'MutationObserver', 'ResizeObserver', 'IntersectionObserver', 'XMLHttpRequest', 'XMLSerializer', 'DOMParser', 'Range', 'getComputedStyle', 'matchMedia', ]) { defineGlobal(key, window[key]); } // Animation frame stubs (jsdom doesn't implement them) global.requestAnimationFrame = (fn) => setTimeout(() => fn(Date.now()), 0); global.cancelAnimationFrame = (id) => clearTimeout(id); // CSS Font Loading API stub — jsdom doesn't implement FontFace class FontFaceStub { constructor(family, source, _descriptors) { this.family = family; this.source = source; this.status = 'loaded'; this.loaded = Promise.resolve(this); } load() { return Promise.resolve(this); } } defineGlobal('FontFace', FontFaceStub); // FontFaceSet stub for document.fonts const fontFaceSet = { add: () => {}, delete: () => {}, has: () => false, clear: () => {}, load: () => Promise.resolve([]), check: () => true, ready: Promise.resolve(), status: 'loaded', forEach: () => {}, [Symbol.iterator]: function*() {}, }; Object.defineProperty(window.document, 'fonts', { value: fontFaceSet, writable: true, configurable: true, }); // Canvas stub — excalidraw's exportToSvg doesn't need real canvas rendering, // but the class must exist for isinstance checks. if (!global.HTMLCanvasElement) { defineGlobal('HTMLCanvasElement', window.HTMLCanvasElement ?? class HTMLCanvasElement {}); } // Device pixel ratio global.devicePixelRatio = 1; // --------------------------------------------------------------------------- // 1b. Stub canvas getContext — excalidraw calls this at module init time. // jsdom throws "Not implemented" by default; we return a no-op 2D stub. // --------------------------------------------------------------------------- const _canvasCtx = { canvas: { width: 800, height: 600 }, fillRect: () => {}, clearRect: () => {}, strokeRect: () => {}, getImageData: (x, y, w, h) => ({ data: new Uint8ClampedArray(w * h * 4), width: w, height: h }), putImageData: () => {}, createImageData: () => ({ data: new Uint8ClampedArray(0) }), setTransform: () => {}, resetTransform: () => {}, transform: () => {}, drawImage: () => {}, save: () => {}, restore: () => {}, scale: () => {}, rotate: () => {}, translate: () => {}, beginPath: () => {}, closePath: () => {}, moveTo: () => {}, lineTo: () => {}, bezierCurveTo: () => {}, quadraticCurveTo: () => {}, arc: () => {}, arcTo: () => {}, ellipse: () => {}, rect: () => {}, fill: () => {}, stroke: () => {}, clip: () => {}, fillText: () => {}, strokeText: () => {}, measureText: (t) => ({ width: t.length * 8, actualBoundingBoxAscent: 12, actualBoundingBoxDescent: 3, fontBoundingBoxAscent: 14, fontBoundingBoxDescent: 4 }), createLinearGradient: () => ({ addColorStop: () => {} }), createRadialGradient: () => ({ addColorStop: () => {} }), createPattern: () => null, setLineDash: () => {}, getLineDash: () => [], isPointInPath: () => false, isPointInStroke: () => false, getContextAttributes: () => ({ alpha: true, desynchronized: false }), font: '10px sans-serif', fillStyle: '#000', strokeStyle: '#000', lineWidth: 1, lineCap: 'butt', lineJoin: 'miter', textAlign: 'start', textBaseline: 'alphabetic', globalAlpha: 1, globalCompositeOperation: 'source-over', shadowOffsetX: 0, shadowOffsetY: 0, shadowBlur: 0, shadowColor: 'transparent', miterLimit: 10, lineDashOffset: 0, filter: 'none', imageSmoothingEnabled: true, }; // Patch before excalidraw import so module-level canvas calls get the stub if (window.HTMLCanvasElement) { window.HTMLCanvasElement.prototype.getContext = function (type) { if (type === '2d') return _canvasCtx; return null; }; } // --------------------------------------------------------------------------- // 2. Load excalidraw (dynamic import so globals are set first) // --------------------------------------------------------------------------- let exportToSvg; try { const excalidraw = await import('@excalidraw/excalidraw'); exportToSvg = excalidraw.exportToSvg; if (!exportToSvg) throw new Error('exportToSvg not found in package exports'); } catch (err) { process.stderr.write(`FATAL: Failed to load @excalidraw/excalidraw: ${err.message}\n`); process.exit(1); } // --------------------------------------------------------------------------- // 3. SVG export helper // --------------------------------------------------------------------------- async function renderToSvg(elements, appState, files) { const svgEl = await exportToSvg({ elements: elements ?? [], appState: { exportWithDarkMode: false, exportBackground: true, viewBackgroundColor: '#ffffff', ...appState, }, files: files ?? {}, }); const serializer = new window.XMLSerializer(); return serializer.serializeToString(svgEl); } // --------------------------------------------------------------------------- // 4. Gen subprocess helper (optional — requires EXCALIDRAW_GEN_PATH) // --------------------------------------------------------------------------- function requireGenPath() { const p = process.env.EXCALIDRAW_GEN_PATH; if (!p) { return null; } return p; } function spawnGen(args) { const genPath = requireGenPath(); if (!genPath) { return { ok: false, text: 'EXCALIDRAW_GEN_PATH is not set. Set it to the path of excalidraw_gen.py to use diagram generation.', }; } const result = spawnSync('python3', [genPath, ...args], { encoding: 'utf8' }); if (result.error) return { ok: false, text: `spawn error: ${result.error.message}` }; if (result.status !== 0) return { ok: false, text: result.stderr || 'subprocess failed' }; return { ok: true, text: result.stdout.trim() }; } // --------------------------------------------------------------------------- // 5. MCP Server // --------------------------------------------------------------------------- const server = new McpServer({ name: "excalidraw", version: "1.0.0", }); // --- Tool: excalidraw_to_svg --- server.tool( "excalidraw_to_svg", "Convert Excalidraw elements JSON to SVG string", { elements: z.string().describe("JSON string of Excalidraw elements array"), app_state: z.string().optional().describe("JSON string of appState overrides"), }, async ({ elements, app_state }) => { let parsed; try { parsed = JSON.parse(elements); } catch (err) { throw new Error(`Invalid elements JSON: ${err.message}`); } const appState = app_state ? JSON.parse(app_state) : {}; const svg = await renderToSvg(parsed, appState, {}); return { content: [{ type: "text", text: svg }] }; } ); // --- Tool: excalidraw_file_to_svg --- server.tool( "excalidraw_file_to_svg", "Convert an .excalidraw file to SVG (writes .svg alongside the input file)", { file_path: z.string().describe("Absolute or relative path to .excalidraw file"), }, async ({ file_path }) => { const absPath = resolve(file_path); if (!existsSync(absPath)) { throw new Error(`File not found: ${absPath}`); } const raw = JSON.parse(readFileSync(absPath, 'utf8')); const svg = await renderToSvg(raw.elements, raw.appState, raw.files); const outPath = absPath.replace(/\.excalidraw$/, '.svg'); writeFileSync(outPath, svg, 'utf8'); return { content: [{ type: "text", text: `SVG written to: ${outPath}\n\n${svg}` }], }; } ); // --- Tool: list_diagrams --- server.tool( "list_diagrams", "List available diagram templates from the DIAGRAMS registry (requires EXCALIDRAW_GEN_PATH)", {}, async () => { const res = spawnGen(['--list']); return { content: [{ type: "text", text: res.text }] }; } ); // --- Tool: generate_diagram --- server.tool( "generate_diagram", "Generate an .excalidraw file from a named diagram template (requires EXCALIDRAW_GEN_PATH)", { name: z.string().describe("Diagram template name (from list_diagrams)"), output_path: z.string().optional().describe("Output path for the .excalidraw file"), }, async ({ name, output_path }) => { const args = [name]; if (output_path) args.push('--output', output_path); const res = spawnGen(args); if (!res.ok) throw new Error(res.text); return { content: [{ type: "text", text: res.text }] }; } ); // --- Tool: generate_and_export --- server.tool( "generate_and_export", "Generate an .excalidraw file and immediately export it to SVG (requires EXCALIDRAW_GEN_PATH)", { name: z.string().describe("Diagram template name (from list_diagrams)"), output_path: z.string().optional().describe("Output path for the .excalidraw file (SVG written alongside)"), }, async ({ name, output_path }) => { const genArgs = [name]; if (output_path) genArgs.push('--output', output_path); const genRes = spawnGen(genArgs); if (!genRes.ok) throw new Error(genRes.text); const excalidrawPath = genRes.text; if (!existsSync(excalidrawPath)) { throw new Error(`Generated file not found: ${excalidrawPath}`); } const raw = JSON.parse(readFileSync(excalidrawPath, 'utf8')); const svg = await renderToSvg(raw.elements, raw.appState, raw.files); const svgPath = excalidrawPath.replace(/\.excalidraw$/, '.svg'); writeFileSync(svgPath, svg, 'utf8'); return { content: [{ type: "text", text: `Generated: ${excalidrawPath}\nExported SVG: ${svgPath}` }], }; } ); // --- Start --- const transport = new StdioServerTransport(); await server.connect(transport);