import { applyPatch, type Operation } from "fast-json-patch"; import { accessSync, constants, readFileSync, statSync } from "node:fs"; import { extname, isAbsolute } from "node:path"; import { createMcpHandler } from "mcp-handler"; import { z } from "zod"; import { normalizeScene, normalizeTitle } from "../core/scene"; import { type ScenePayload } from "../core/shared"; import { createDrawingStore, type DrawingStore } from "../server/db"; const sceneSchema = z.object({ elements: z.array(z.unknown()), appState: z.record(z.string(), z.unknown()), files: z.record(z.string(), z.unknown()), }); const patchOperationSchema = z.object({ op: z.enum(["add", "remove", "replace", "move", "copy", "test"]), path: z.string(), from: z.string().optional(), value: z.unknown().optional(), }); const idInput = z.object({ id: z.string().min(1) }); const createDrawingInput = z.object({ title: z.string(), scene: sceneSchema }); const replaceDrawingInput = z.object({ id: z.string().min(1), title: z.string().optional(), scene: sceneSchema, }); const patchDrawingInput = z.object({ id: z.string().min(1), title: z.string().optional(), patch: z.array(patchOperationSchema).min(1), }); type ToolResult = { content: Array<{ type: "text"; text: string }>; }; type StylesGuideResource = { path: string; }; export const DEFAULT_STYLES_GUIDE_PATH = "/config/styles-guide.md"; function trimTrailingSlash(url: string): string { return url.replace(/\/+$/, ""); } export function resolvePublicBaseUrl( raw = process.env.PUBLIC_BASE_URL, ): string { const value = raw?.trim(); if (!value) { throw new Error("PUBLIC_BASE_URL is required for the MCP server"); } try { const url = new URL(value); if (url.protocol === "http:" || url.protocol === "https:") { return trimTrailingSlash(url.toString()); } } catch {} throw new Error("PUBLIC_BASE_URL must be an absolute http(s) URL"); } export function resolveStylesGuidePath( raw = DEFAULT_STYLES_GUIDE_PATH, ): StylesGuideResource | undefined { const value = raw.trim(); if (!isAbsolute(value)) { throw new Error("Styles guide path must be an absolute filesystem path"); } let stats; try { stats = statSync(value); } catch (error) { if ( error && typeof error === "object" && "code" in error && error.code === "ENOENT" ) { return undefined; } throw new Error( error instanceof Error ? `Styles guide path could not be checked: ${error.message}` : "Styles guide path could not be checked", ); } const extension = extname(value).toLowerCase(); if (extension !== ".md" && extension !== ".markdown") { throw new Error( "Styles guide path must point to a Markdown file (.md or .markdown)", ); } if (!stats.isFile()) { throw new Error("Styles guide path must point to a regular file"); } try { accessSync(value, constants.R_OK); } catch (error) { throw new Error( error instanceof Error ? `Styles guide path must point to a readable file: ${error.message}` : "Styles guide path must point to a readable file", ); } return { path: value }; } function drawingUrl(baseUrl: string, id: string): string { return `${trimTrailingSlash(baseUrl)}/d/${id}`; } function jsonText(data: unknown): ToolResult { return { content: [ { type: "text", text: JSON.stringify(data, null, 2), }, ], }; } function notFound(id: string): never { throw new Error(`Drawing not found: ${id}`); } function mutationResult( publicBaseUrl: string, drawing: { id: string; title: string; revision: number }, ) { return { id: drawing.id, title: drawing.title, revision: drawing.revision, url: drawingUrl(publicBaseUrl, drawing.id), }; } function updateExistingDrawing( store: DrawingStore, id: string, update: { scene?: ScenePayload; title?: string }, ) { const result = store.updateDrawing(id, update); if (!result.ok) { return notFound(id); } return result.drawing; } function applyScenePatch( scene: ScenePayload, patch: Operation[], ): ScenePayload { try { const result = applyPatch(structuredClone(scene), patch, true, true, true); return normalizeScene(result.newDocument); } catch (error) { throw new Error( error instanceof Error ? `Invalid JSON Patch: ${error.message}` : "Invalid JSON Patch", ); } } export function createMcpToolHandlers( store: DrawingStore, publicBaseUrl: string, ) { return { list_drawings() { return store.listDrawings(); }, get_drawing(input: unknown) { const { id } = idInput.parse(input); const drawing = store.getDrawing(id) ?? notFound(id); return { id: drawing.id, title: drawing.title, createdAt: drawing.createdAt, updatedAt: drawing.updatedAt, revision: drawing.revision, url: drawingUrl(publicBaseUrl, drawing.id), scene: drawing.scene, }; }, create_drawing(input: unknown) { const { title, scene } = createDrawingInput.parse(input); const created = store.createDrawing(normalizeTitle(title)); const updated = updateExistingDrawing(store, created.id, { title, scene: normalizeScene(scene), }); return mutationResult(publicBaseUrl, updated); }, replace_drawing(input: unknown) { const { id, title, scene } = replaceDrawingInput.parse(input); const updated = updateExistingDrawing(store, id, { title, scene: normalizeScene(scene), }); return mutationResult(publicBaseUrl, updated); }, patch_drawing(input: unknown) { const { id, title, patch } = patchDrawingInput.parse(input); const drawing = store.getDrawing(id) ?? notFound(id); const updated = updateExistingDrawing(store, id, { title, scene: applyScenePatch(drawing.scene, patch as Operation[]), }); return mutationResult(publicBaseUrl, updated); }, }; } export function createExcaliMcpHandler( store: DrawingStore, publicBaseUrl: string, stylesGuide?: StylesGuideResource, ) { const tools = createMcpToolHandlers(store, publicBaseUrl); return createMcpHandler( (server) => { if (stylesGuide) { server.registerResource( "styles-guide", "excali://styles-guide", { title: "Styles Guide", description: "User-provided drawing styles guide for scene generation", mimeType: "text/markdown", }, async (uri) => ({ contents: [ { uri: uri.toString(), mimeType: "text/markdown", text: readFileSync(stylesGuide.path, "utf8"), }, ], }), ); } server.registerTool( "list_drawings", { title: "List Drawings", description: "Returns drawing metadata ordered like the app list.", }, async () => jsonText(tools.list_drawings()), ); server.registerTool( "get_drawing", { title: "Get Drawing", description: "Returns drawing metadata, browser URL, and raw ScenePayload.", inputSchema: idInput.shape, }, async (input) => jsonText(tools.get_drawing(input)), ); server.registerTool( "create_drawing", { title: "Create Drawing", description: "Creates a drawing from an explicit Excalidraw ScenePayload.", inputSchema: createDrawingInput.shape, }, async (input) => jsonText(tools.create_drawing(input)), ); server.registerTool( "replace_drawing", { title: "Replace Drawing", description: "Replaces a drawing scene and optionally its title.", inputSchema: replaceDrawingInput.shape, }, async (input) => jsonText(tools.replace_drawing(input)), ); server.registerTool( "patch_drawing", { title: "Patch Drawing", description: "Applies RFC 6902 JSON Patch operations to a drawing scene and optionally updates its title.", inputSchema: patchDrawingInput.shape, }, async (input) => jsonText(tools.patch_drawing(input)), ); }, { serverInfo: { name: "excali", version: "0.1.0", }, }, { basePath: "", disableSse: true, maxDuration: 60, }, ); } export function createMcpServer() { const store = createDrawingStore(process.env.DATABASE_PATH); const handler = createExcaliMcpHandler( store, resolvePublicBaseUrl(), resolveStylesGuidePath(), ); return { port: Number(process.env.MCP_PORT ?? "3001"), hostname: process.env.MCP_HOST ?? "127.0.0.1", fetch(request: Request) { const url = new URL(request.url); if (url.pathname !== "/mcp") { return Response.json( { ok: false, error: "Not found" }, { status: 404 }, ); } return handler(request); }, } satisfies Parameters[0]; } if (import.meta.main) { const server = Bun.serve(createMcpServer()); console.log(`MCP listening on http://${server.hostname}:${server.port}/mcp`); }