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
+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"),