- install.sh: run mosaic-ensure-excalidraw post-install (non-fatal) - runtime-setup.ts: configure excalidraw MCP during wizard setup - bin/mosaic-ensure-excalidraw: install deps + register MCP with Claude - runtime/mcp/EXCALIDRAW.json: MCP server config template - tools/excalidraw/: headless .excalidraw → SVG export server Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
324 lines
11 KiB
JavaScript
324 lines
11 KiB
JavaScript
#!/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('<!DOCTYPE html><html><body></body></html>', {
|
|
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);
|