From b4a540214b0f89d096cb1905af44981fbc1b3903 Mon Sep 17 00:00:00 2001 From: ruinivist Date: Sun, 31 May 2026 14:36:51 +0000 Subject: [PATCH] feat: add optional styles guide resource --- README.md | 16 ++++++++ src/mcp/server.test.ts | 84 +++++++++++++++++++++++++++++++++++++++--- src/mcp/server.ts | 81 +++++++++++++++++++++++++++++++++++++++- 3 files changed, 173 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index b56b7a3..09892ec 100644 --- a/README.md +++ b/README.md @@ -42,3 +42,19 @@ docker run --rm -p 127.0.0.1:3000:80 -e PUBLIC_BASE_URL=http://localhost:3000 -v ``` This will make the data persistent in the `excali-box-data` folder in the current directory, with Caddy serving the browser build on `localhost:3000`. `PUBLIC_BASE_URL` is required so MCP tools can return drawing URLs with the public browser origin. You would want it to be the same url you use to access the app in the browser - in case you reverse proxy or use a tailscale alias etc. + +## Optional MCP styles guide resource + +The MCP server can optionally expose one extra read-only resource at `excali://styles-guide`. This file is then +discoverable as an MCP resource. + +```bash +docker run --rm \ + -p 127.0.0.1:3000:80 \ + -e PUBLIC_BASE_URL=http://localhost:3000 \ + -v "$PWD/excali-box-data:/data" \ + -v "$PWD/STYLES_GUIDE.md:/config/styles-guide.md:ro" \ + ghcr.io/ruinivist/excalidraw-box:latest +``` + +Repo-root `STYLES_GUIDE.md` which is what I wrote for myself is not used at all unless you explicitly mount it to `/config/styles-guide.md`. diff --git a/src/mcp/server.test.ts b/src/mcp/server.test.ts index 7fcb07c..3a2d884 100644 --- a/src/mcp/server.test.ts +++ b/src/mcp/server.test.ts @@ -1,18 +1,27 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import { afterEach, describe, expect, test } from "bun:test"; -import { mkdtempSync, rmSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { type ScenePayload } from "../core/shared"; import { createDrawingStore, type DrawingStore } from "../server/db"; -import { createMcpToolHandlers, resolvePublicBaseUrl } from "./server"; +import { + DEFAULT_STYLES_GUIDE_PATH, + createExcaliMcpHandler, + createMcpToolHandlers, + resolvePublicBaseUrl, + resolveStylesGuidePath, +} from "./server"; -const cleanup: Array<{ dir: string; store: DrawingStore }> = []; +const cleanup: Array<{ dir: string; store?: DrawingStore; client?: Client }> = []; -afterEach(() => { +afterEach(async () => { while (cleanup.length > 0) { const item = cleanup.pop(); if (item) { - item.store.close(); + await item.client?.close().catch(() => undefined); + item.store?.close(); rmSync(item.dir, { recursive: true, force: true }); } } @@ -28,6 +37,35 @@ function createTempTools() { }; } +function createTempStylesGuidePath(content?: string) { + const dir = mkdtempSync(join(tmpdir(), "excali-mcp-")); + const path = join(dir, DEFAULT_STYLES_GUIDE_PATH.slice(1)); + mkdirSync(dirname(path), { recursive: true }); + if (content !== undefined) { + writeFileSync(path, content); + } + cleanup.push({ dir }); + return path; +} + +async function createTempClient(stylesGuidePath?: string) { + const dir = mkdtempSync(join(tmpdir(), "excali-mcp-")); + const store = createDrawingStore(join(dir, "test.sqlite")); + const handler = createExcaliMcpHandler( + store, + "http://example.test", + stylesGuidePath ? resolveStylesGuidePath(stylesGuidePath) : undefined, + ); + const transport = new StreamableHTTPClientTransport(new URL("http://localhost/mcp"), { + fetch: async (input, init) => handler(new Request(input, init)), + }); + const client = new Client({ name: "excali-test", version: "1.0.0" }); + + await client.connect(transport); + cleanup.push({ dir, store, client }); + return { client, store }; +} + function testScene(): ScenePayload { return { elements: [ @@ -44,6 +82,40 @@ describe("mcp tool handlers", () => { expect(() => resolvePublicBaseUrl("")).toThrow("PUBLIC_BASE_URL is required"); }); + test("missing fixed styles-guide path exposes no styles-guide resource", async () => { + const stylesGuidePath = createTempStylesGuidePath(); + expect(resolveStylesGuidePath(stylesGuidePath)).toBeUndefined(); + + const { client } = await createTempClient(stylesGuidePath); + + expect(client.getServerCapabilities()?.resources).toBeUndefined(); + }); + + test("valid fixed styles-guide path registers the styles-guide resource", async () => { + const contents = "# Scene style\n\nKeep labels terse.\n"; + const stylesGuidePath = createTempStylesGuidePath(contents); + const resolved = resolveStylesGuidePath(stylesGuidePath); + const { client } = await createTempClient(stylesGuidePath); + const resources = await client.listResources(); + const readResult = await client.readResource({ uri: "excali://styles-guide" }); + + expect(resolved).toEqual({ path: stylesGuidePath }); + expect(resources.resources).toContainEqual({ + name: "styles-guide", + uri: "excali://styles-guide", + title: "Styles Guide", + description: "User-provided drawing styles guide for scene generation", + mimeType: "text/markdown", + }); + expect(readResult.contents).toEqual([ + { + uri: "excali://styles-guide", + mimeType: "text/markdown", + text: contents, + }, + ]); + }); + test("create_drawing writes an explicit scene to a temp SQLite DB", () => { const { store, tools } = createTempTools(); const scene = testScene(); diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 3c44768..54f3175 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -1,4 +1,6 @@ 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"; @@ -35,6 +37,12 @@ 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(/\/+$/, ""); } @@ -56,6 +64,50 @@ export function resolvePublicBaseUrl(raw = process.env.PUBLIC_BASE_URL): string 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}`; } @@ -157,11 +209,36 @@ export function createMcpToolHandlers(store: DrawingStore, publicBaseUrl: string }; } -export function createExcaliMcpHandler(store: DrawingStore, publicBaseUrl: string) { +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", { @@ -227,7 +304,7 @@ export function createExcaliMcpHandler(store: DrawingStore, publicBaseUrl: strin export function createMcpServer() { const store = createDrawingStore(process.env.DATABASE_PATH); - const handler = createExcaliMcpHandler(store, resolvePublicBaseUrl()); + const handler = createExcaliMcpHandler(store, resolvePublicBaseUrl(), resolveStylesGuidePath()); return { port: Number(process.env.MCP_PORT ?? "3001"),