feat: add optional styles guide resource

This commit is contained in:
2026-05-31 14:36:51 +00:00
parent 25bf171f6b
commit b4a540214b
3 changed files with 173 additions and 8 deletions
+16
View File
@@ -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`.
+78 -6
View File
@@ -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();
+79 -2
View File
@@ -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"),