feat: add mcp scene api
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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`);
|
||||
}
|
||||
Reference in New Issue
Block a user