feat: add mcp scene api

This commit is contained in:
2026-05-31 00:08:51 +00:00
parent 570baf83ab
commit 1b0024d78f
9 changed files with 598 additions and 13 deletions
+119
View File
@@ -0,0 +1,119 @@
import { afterEach, describe, expect, test } from "bun:test";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createDrawingStore, type DrawingStore } from "./db";
import { createMcpToolHandlers, resolvePublicBaseUrl } from "./mcp-server";
import { type ScenePayload } from "./shared";
const cleanup: Array<{ dir: string; store: DrawingStore }> = [];
afterEach(() => {
while (cleanup.length > 0) {
const item = cleanup.pop();
if (item) {
item.store.close();
rmSync(item.dir, { recursive: true, force: true });
}
}
});
function createTempTools() {
const dir = mkdtempSync(join(tmpdir(), "excali-mcp-"));
const store = createDrawingStore(join(dir, "test.sqlite"));
cleanup.push({ dir, store });
return {
store,
tools: createMcpToolHandlers(store, "http://example.test"),
};
}
function testScene(): ScenePayload {
return {
elements: [
{ id: "one", type: "rectangle", x: 10, y: 20 },
{ id: "two", type: "text", text: "hello" },
],
appState: { theme: "dark" },
files: {},
};
}
describe("mcp tool handlers", () => {
test("PUBLIC_BASE_URL is required", () => {
expect(() => resolvePublicBaseUrl("")).toThrow("PUBLIC_BASE_URL is required");
});
test("create_drawing writes an explicit scene to a temp SQLite DB", () => {
const { store, tools } = createTempTools();
const scene = testScene();
const result = tools.create_drawing({ title: "explicit", scene });
const drawing = store.getDrawing(result.id);
expect(result).toEqual({
id: result.id,
title: "explicit",
url: `http://example.test/d/${result.id}`,
});
expect(drawing?.scene).toEqual(scene);
});
test("get_drawing returns raw scene plus URL", () => {
const { tools } = createTempTools();
const scene = testScene();
const result = tools.create_drawing({ title: "read", scene });
const drawing = tools.get_drawing({ id: result.id });
expect(drawing).toMatchObject({
id: result.id,
title: "read",
url: `http://example.test/d/${result.id}`,
scene,
});
});
test("replace_drawing updates the existing drawing", () => {
const { store, tools } = createTempTools();
const result = tools.create_drawing({ title: "replace", scene: testScene() });
const nextScene: ScenePayload = {
elements: [{ id: "manual", type: "rectangle" }],
appState: { theme: "dark" },
files: {},
};
tools.replace_drawing({ id: result.id, title: "replaced", scene: nextScene });
expect(store.listDrawings()).toHaveLength(1);
expect(store.getDrawing(result.id)?.title).toBe("replaced");
expect(store.getDrawing(result.id)?.scene).toEqual(nextScene);
});
test("patch_drawing replaces an element property and removes an element", () => {
const { store, tools } = createTempTools();
const result = tools.create_drawing({ title: "patch", scene: testScene() });
tools.patch_drawing({
id: result.id,
patch: [
{ op: "replace", path: "/elements/0/x", value: 42 },
{ op: "remove", path: "/elements/1" },
],
});
expect(store.getDrawing(result.id)?.scene.elements).toEqual([{ id: "one", type: "rectangle", x: 42, y: 20 }]);
});
test("missing drawing IDs return tool errors", () => {
const { tools } = createTempTools();
expect(() => tools.get_drawing({ id: "missing" })).toThrow("Drawing not found: missing");
expect(() =>
tools.replace_drawing({
id: "missing",
scene: testScene(),
}),
).toThrow("Drawing not found: missing");
});
});
+243
View File
@@ -0,0 +1,243 @@
import { applyPatch, type Operation } from "fast-json-patch";
import { createMcpHandler } from "mcp-handler";
import { z } from "zod";
import { createDrawingStore, type DrawingStore } from "./db";
import { normalizeScene, normalizeTitle } from "./scene";
import { type ScenePayload } from "./shared";
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 }>;
};
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");
}
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 }) {
return {
id: drawing.id,
title: drawing.title,
url: drawingUrl(publicBaseUrl, drawing.id),
};
}
function updateExistingDrawing(
store: DrawingStore,
id: string,
update: { scene?: ScenePayload; title?: string },
) {
const updated = store.updateDrawing(id, update);
return updated ?? notFound(id);
}
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,
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) {
const tools = createMcpToolHandlers(store, publicBaseUrl);
return createMcpHandler(
(server) => {
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());
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<typeof Bun.serve>[0];
}
if (import.meta.main) {
const server = Bun.serve(createMcpServer());
console.log(`MCP listening on http://${server.hostname}:${server.port}/mcp`);
}