feat: add optional styles guide resource
This commit is contained in:
+78
-6
@@ -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
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user