From 8c960eee9d13c81227ed0f192b6be21e2fb23d51 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Wed, 4 Mar 2026 17:59:21 -0600 Subject: [PATCH] feat: integrate excalidraw MCP into bootstrap and runtime setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- bin/mosaic-ensure-excalidraw | 119 +++++++++ install.sh | 6 + runtime/mcp/EXCALIDRAW.json | 7 + src/stages/runtime-setup.ts | 19 ++ tools/excalidraw/.gitignore | 1 + tools/excalidraw/launch.sh | 5 + tools/excalidraw/loader.mjs | 76 ++++++ tools/excalidraw/package.json | 11 + tools/excalidraw/server.mjs | 323 +++++++++++++++++++++++ tools/excalidraw/stubs/laser-pointer.mjs | 7 + 10 files changed, 574 insertions(+) create mode 100755 bin/mosaic-ensure-excalidraw create mode 100644 runtime/mcp/EXCALIDRAW.json create mode 100644 tools/excalidraw/.gitignore create mode 100755 tools/excalidraw/launch.sh create mode 100644 tools/excalidraw/loader.mjs create mode 100644 tools/excalidraw/package.json create mode 100644 tools/excalidraw/server.mjs create mode 100644 tools/excalidraw/stubs/laser-pointer.mjs diff --git a/bin/mosaic-ensure-excalidraw b/bin/mosaic-ensure-excalidraw new file mode 100755 index 0000000..9c3d7d8 --- /dev/null +++ b/bin/mosaic-ensure-excalidraw @@ -0,0 +1,119 @@ +#!/usr/bin/env bash +set -euo pipefail + +MOSAIC_HOME="${MOSAIC_HOME:-$HOME/.config/mosaic}" +TOOLS_DIR="$MOSAIC_HOME/tools/excalidraw" +MODE="apply" +SCOPE="user" + +err() { echo "[mosaic-excalidraw] ERROR: $*" >&2; } +log() { echo "[mosaic-excalidraw] $*"; } + +while [[ $# -gt 0 ]]; do + case "$1" in + --check) MODE="check"; shift ;; + --scope) + if [[ $# -lt 2 ]]; then + err "--scope requires a value: user|local" + exit 2 + fi + SCOPE="$2" + shift 2 + ;; + *) + err "Unknown argument: $1" + exit 2 + ;; + esac +done + +require_binary() { + local name="$1" + if ! command -v "$name" >/dev/null 2>&1; then + err "Required binary missing: $name" + return 1 + fi +} + +check_software() { + require_binary node + require_binary npm +} + +check_tool_dir() { + [[ -d "$TOOLS_DIR" ]] || { err "Tool dir not found: $TOOLS_DIR"; return 1; } + [[ -f "$TOOLS_DIR/package.json" ]] || { err "package.json not found in $TOOLS_DIR"; return 1; } + [[ -f "$TOOLS_DIR/launch.sh" ]] || { err "launch.sh not found in $TOOLS_DIR"; return 1; } +} + +check_npm_deps() { + [[ -d "$TOOLS_DIR/node_modules/@modelcontextprotocol" ]] || return 1 + [[ -d "$TOOLS_DIR/node_modules/@excalidraw" ]] || return 1 + [[ -d "$TOOLS_DIR/node_modules/jsdom" ]] || return 1 +} + +install_npm_deps() { + if check_npm_deps; then + return 0 + fi + log "Installing npm deps in $TOOLS_DIR..." + (cd "$TOOLS_DIR" && npm install --silent) || { + err "npm install failed in $TOOLS_DIR" + return 1 + } +} + +check_claude_config() { + python3 - <<'PY' +import json +from pathlib import Path + +p = Path.home() / ".claude.json" +if not p.exists(): + raise SystemExit(1) +try: + data = json.loads(p.read_text(encoding="utf-8")) +except Exception: + raise SystemExit(1) +mcp = data.get("mcpServers") +if not isinstance(mcp, dict): + raise SystemExit(1) +entry = mcp.get("excalidraw") +if not isinstance(entry, dict): + raise SystemExit(1) +cmd = entry.get("command", "") +if not cmd.endswith("launch.sh"): + raise SystemExit(1) +PY +} + +apply_claude_config() { + require_binary claude + local launch_sh="$TOOLS_DIR/launch.sh" + claude mcp add --scope user excalidraw -- "$launch_sh" +} + +# ── Check mode ──────────────────────────────────────────────────────────────── + +if [[ "$MODE" == "check" ]]; then + check_software + check_tool_dir + if ! check_npm_deps; then + err "npm deps not installed in $TOOLS_DIR (run without --check to install)" + exit 1 + fi + if ! check_claude_config; then + err "excalidraw not registered in ~/.claude.json" + exit 1 + fi + log "excalidraw MCP is configured and available" + exit 0 +fi + +# ── Apply mode ──────────────────────────────────────────────────────────────── + +check_software +check_tool_dir +install_npm_deps +apply_claude_config +log "excalidraw MCP configured (scope: $SCOPE)" diff --git a/install.sh b/install.sh index 03843d2..aae05be 100755 --- a/install.sh +++ b/install.sh @@ -177,6 +177,12 @@ else fi fi +if "$TARGET_DIR/bin/mosaic-ensure-excalidraw" >/dev/null 2>&1; then + ok "excalidraw MCP configured" +else + warn "excalidraw MCP setup failed (non-fatal) — run 'mosaic-ensure-excalidraw' to retry" +fi + if [[ "${MOSAIC_SKIP_SKILLS_SYNC:-0}" == "1" ]]; then ok "Skills sync skipped (MOSAIC_SKIP_SKILLS_SYNC=1)" else diff --git a/runtime/mcp/EXCALIDRAW.json b/runtime/mcp/EXCALIDRAW.json new file mode 100644 index 0000000..c3a4e09 --- /dev/null +++ b/runtime/mcp/EXCALIDRAW.json @@ -0,0 +1,7 @@ +{ + "name": "excalidraw", + "launch": "${MOSAIC_TOOLS}/excalidraw/launch.sh", + "enabled": true, + "required": false, + "description": "Headless .excalidraw → SVG export and diagram generation via @excalidraw/excalidraw" +} diff --git a/src/stages/runtime-setup.ts b/src/stages/runtime-setup.ts index b096080..5e279e2 100644 --- a/src/stages/runtime-setup.ts +++ b/src/stages/runtime-setup.ts @@ -1,3 +1,7 @@ +import { spawnSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { homedir } from 'node:os'; import type { WizardPrompter } from '../prompter/interface.js'; import type { WizardState, RuntimeName } from '../types.js'; import { detectRuntime, type RuntimeInfo } from '../runtime/detector.js'; @@ -66,5 +70,20 @@ export async function runtimeSetupStage( `MCP setup failed: ${err instanceof Error ? err.message : String(err)}. Run 'mosaic seq fix' later.`, ); } + + // Configure excalidraw MCP (non-fatal — optional tool) + const mosaicHome = process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic'); + const ensureExcalidraw = join(mosaicHome, 'bin', 'mosaic-ensure-excalidraw'); + if (existsSync(ensureExcalidraw)) { + const spin3 = p.spinner(); + spin3.update('Configuring excalidraw MCP...'); + const res = spawnSync(ensureExcalidraw, [], { encoding: 'utf8' }); + if (res.status === 0) { + spin3.stop('excalidraw MCP configured'); + } else { + spin3.stop('excalidraw MCP setup failed (non-fatal)'); + p.warn("Run 'mosaic-ensure-excalidraw' manually if needed."); + } + } } } diff --git a/tools/excalidraw/.gitignore b/tools/excalidraw/.gitignore new file mode 100644 index 0000000..c2658d7 --- /dev/null +++ b/tools/excalidraw/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/tools/excalidraw/launch.sh b/tools/excalidraw/launch.sh new file mode 100755 index 0000000..95fc2cd --- /dev/null +++ b/tools/excalidraw/launch.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +# Launcher for Excalidraw MCP stdio server. +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +exec node --loader "$SCRIPT_DIR/loader.mjs" "$SCRIPT_DIR/server.mjs" diff --git a/tools/excalidraw/loader.mjs b/tools/excalidraw/loader.mjs new file mode 100644 index 0000000..8380805 --- /dev/null +++ b/tools/excalidraw/loader.mjs @@ -0,0 +1,76 @@ +/** + * Custom ESM loader to fix missing .js extensions in @excalidraw/excalidraw deps. + * + * Problems patched: + * 1. excalidraw imports 'roughjs/bin/rough' (and other roughjs/* paths) without .js + * 2. roughjs/* files import sibling modules as './canvas' (relative, no .js) + * 3. JSON files need { type: 'json' } import attribute in Node.js v22+ + * + * Usage: node --loader ./loader.mjs server.mjs [args...] + */ + +import { fileURLToPath, pathToFileURL } from 'url'; +import { dirname, resolve as pathResolve } from 'path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +// Modules that have incompatible ESM format — redirect to local stubs +const STUBS = { + '@excalidraw/laser-pointer': pathToFileURL(pathResolve(__dirname, 'stubs/laser-pointer.mjs')).href, +}; + +export async function resolve(specifier, context, nextResolve) { + // 0. Module stubs (incompatible ESM format packages) + if (STUBS[specifier]) { + return { url: STUBS[specifier], shortCircuit: true }; + } + + // 1. Bare roughjs/* specifiers without .js extension + if (/^roughjs\/bin\/[a-z-]+$/.test(specifier)) { + return nextResolve(`${specifier}.js`, context); + } + + // 2. Relative imports without extension (e.g. './canvas' from roughjs/bin/rough.js) + // These come in as relative paths that resolve to extensionless file URLs. + if (specifier.startsWith('./') || specifier.startsWith('../')) { + // Try resolving first; if it fails with a missing-extension error, add .js + try { + return await nextResolve(specifier, context); + } catch (err) { + if (err.code === 'ERR_MODULE_NOT_FOUND') { + // Try appending .js + try { + return await nextResolve(`${specifier}.js`, context); + } catch { + // Fall through to original error + } + } + throw err; + } + } + + // 3. JSON imports need type: 'json' attribute + if (specifier.endsWith('.json')) { + const resolved = await nextResolve(specifier, context); + if (!resolved.importAttributes?.type) { + return { + ...resolved, + importAttributes: { ...resolved.importAttributes, type: 'json' }, + }; + } + return resolved; + } + + return nextResolve(specifier, context); +} + +export async function load(url, context, nextLoad) { + // Ensure JSON files are loaded with json format + if (url.endsWith('.json')) { + return nextLoad(url, { + ...context, + importAttributes: { ...context.importAttributes, type: 'json' }, + }); + } + return nextLoad(url, context); +} diff --git a/tools/excalidraw/package.json b/tools/excalidraw/package.json new file mode 100644 index 0000000..2ba104a --- /dev/null +++ b/tools/excalidraw/package.json @@ -0,0 +1,11 @@ +{ + "name": "excalidraw-mcp", + "version": "1.0.0", + "type": "module", + "private": true, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.12.0", + "@excalidraw/excalidraw": "^0.18.0", + "jsdom": "^25.0.1" + } +} diff --git a/tools/excalidraw/server.mjs b/tools/excalidraw/server.mjs new file mode 100644 index 0000000..00c1f4e --- /dev/null +++ b/tools/excalidraw/server.mjs @@ -0,0 +1,323 @@ +#!/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); diff --git a/tools/excalidraw/stubs/laser-pointer.mjs b/tools/excalidraw/stubs/laser-pointer.mjs new file mode 100644 index 0000000..bebb791 --- /dev/null +++ b/tools/excalidraw/stubs/laser-pointer.mjs @@ -0,0 +1,7 @@ +/** + * Stub for @excalidraw/laser-pointer + * The real package uses a Parcel bundle format that Node.js ESM can't consume. + * For headless SVG export, the laser pointer feature is not needed. + */ +export class LaserPointer {} +export default { LaserPointer };