Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 41cbd393bd | |||
| 8aa53ed3a8 | |||
| 65ecc8bfa4 | |||
| fcfde9f862 |
@@ -7,30 +7,6 @@ No auth to keep things simple - you either run in a private network or put it be
|
||||
|
||||
> GPT 5.5 generated
|
||||
|
||||
## Tl;dr
|
||||
|
||||
When building the image yourself
|
||||
|
||||
```
|
||||
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" \
|
||||
excali
|
||||
```
|
||||
|
||||
When pulling from GHCR ( you would want to change the `PUBLIC_BASE_URL` )
|
||||
|
||||
```
|
||||
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
|
||||
```
|
||||
|
||||
## Pull from GHCR and run
|
||||
|
||||
Images are published to:
|
||||
@@ -66,19 +42,3 @@ 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`.
|
||||
|
||||
-212
@@ -1,212 +0,0 @@
|
||||
# Styles Guide
|
||||
|
||||
Use this guide when generating or modifying Excalidraw scenes.
|
||||
|
||||
## Author intent (must follow)
|
||||
|
||||
- Excalidraw is for free-flow visual thinking: nodes, arrows, curves, boundaries, and hanging labels.
|
||||
- Treat this as a diagramming task, not a document-writing task.
|
||||
- Prefer visual relationships over long prose.
|
||||
- If content starts becoming paragraph-heavy, split it into smaller nodes and explicit connectors.
|
||||
- Prioritize flow clarity over text density.
|
||||
|
||||
## Core intent
|
||||
|
||||
- Always set `scene.appState.theme` to `"dark"`.
|
||||
- Always use light-style element colors.
|
||||
- Optimize for clarity, technical precision, and fast visual parsing.
|
||||
- Tailor structure to the problem. Do not default to generic flowcharts.
|
||||
- Prefer diagrams that look like working engineering notes, not slides.
|
||||
|
||||
## Theme
|
||||
|
||||
- Use a light canvas and light containers by default.
|
||||
- Keep contrast high enough for comfortable reading.
|
||||
- Use vibrant accent colors with strong readability on light surfaces.
|
||||
- Keep color mapping stable: the same logical component/flow must keep the same color across node, connector, and connector label.
|
||||
- For subtypes within a flow, use close shades of the same family instead of unrelated hues.
|
||||
|
||||
### Default palette
|
||||
|
||||
- Canvas / background: `#f8fafc`
|
||||
- Surface: `#ffffff`
|
||||
- Muted surface: `#f1f5f9`
|
||||
- Text: `#212529`
|
||||
- Muted text: `#343a40`
|
||||
- Green: `#099268`
|
||||
- Pink: `#c2255c`
|
||||
- Red: `#ff5d73`
|
||||
- Purple: `#6741d9`
|
||||
- Blue: `#1971c2`
|
||||
- Teal: `#0c8599`
|
||||
- Border / connector: `#868e96`
|
||||
|
||||
### Extended accent set
|
||||
|
||||
- Mint: `#12b886`
|
||||
- Cyan: `#1098ad`
|
||||
- Indigo: `#364fc7`
|
||||
- Violet: `#5f3dc4`
|
||||
- Magenta: `#a61e4d`
|
||||
- Rose: `#e64980`
|
||||
- Slate dark: `#212529`
|
||||
- Slate mid: `#495057`
|
||||
- Slate light: `#868e96`
|
||||
|
||||
## Layout
|
||||
|
||||
- Keep the layout spacious.
|
||||
- Use consistent alignment and clear grouping.
|
||||
- Maintain obvious reading order (usually left-to-right or top-to-bottom).
|
||||
- Separate major groups with generous whitespace.
|
||||
- Avoid dense clusters and unclear crossings.
|
||||
|
||||
### Spacing defaults
|
||||
|
||||
- Between major groups: `160-240px`
|
||||
- Between related nodes: `72-120px`
|
||||
- Container padding: `48-72px`
|
||||
- Keep connector crossings rare. Re-route instead of stacking lines through central content.
|
||||
|
||||
### Connector label placement
|
||||
|
||||
- Do not place connector labels directly on top of arrow/line strokes.
|
||||
- Offset connector labels from the path by at least `16-24px` on the normal axis.
|
||||
- Prefer labels slightly to the side of the connector midpoint, not centered on the stroke.
|
||||
- If lines still reduce readability, move the label further away.
|
||||
- Connector labels must remain clearly associated with their connector.
|
||||
- Connector endpoints should stop with a visible gap before node/container borders: `8-16px`.
|
||||
- Prefer slight natural curves for connectors (gentle 3-point bends) instead of rigid perfectly straight arrows.
|
||||
- Keep curvature subtle; avoid dramatic arcs unless the route needs explicit detouring.
|
||||
- Use straight connectors only when they are materially clearer than curved ones.
|
||||
|
||||
### Container nesting
|
||||
|
||||
- Default to one container level.
|
||||
- Maximum nesting depth is two.
|
||||
- Use second-level nesting only when needed.
|
||||
- Avoid box-within-box-within-box structures unless explicitly required.
|
||||
|
||||
## Text fit and sizing
|
||||
|
||||
Use explicit sizing so text fits without clipping or cramped boxes.
|
||||
|
||||
### Typography defaults
|
||||
|
||||
- Body/node text: `20px` Excalidraw monospace (`fontFamily: 3`)
|
||||
- Section labels: `24px`
|
||||
- Auxiliary notes: `20px` minimum
|
||||
- Line height multiplier: `1.25`
|
||||
|
||||
### Text length and wrapping
|
||||
|
||||
- Target max line length: `22-28` characters.
|
||||
- If label text exceeds `28` characters, insert line breaks at phrase boundaries.
|
||||
- Keep most labels to `1-2` lines.
|
||||
- Hard cap: `3` lines for standard nodes, `4` lines for large containers.
|
||||
|
||||
### Box size contract
|
||||
|
||||
- Horizontal text padding: `32px` per side (`64px` total)
|
||||
- Vertical text padding: `24px` per side (`48px` total)
|
||||
- Minimum node size: `260x120`
|
||||
- Recommended width bands:
|
||||
- Short labels (`<=18` chars): `260-320px`
|
||||
- Medium labels (`19-40` chars): `340-460px`
|
||||
- Long labels (wrapped): `480-680px`
|
||||
|
||||
### Overflow guardrails
|
||||
|
||||
- Text must remain fully inside its box at `zoom: 1`.
|
||||
- Keep at least `28px` interior clearance from text to borders after render.
|
||||
- If text would overflow: increase width first, then height, then split content into multiple nodes.
|
||||
- No label overlap is allowed.
|
||||
- Bias toward extra whitespace over dense packing.
|
||||
|
||||
## Structural guidance
|
||||
|
||||
Pick the structure that matches the content.
|
||||
|
||||
- Flows/lifecycles: sequence or pipeline layouts
|
||||
- Layered systems: stacked layers with strict boundaries
|
||||
- Ownership/containment: nested containers
|
||||
- Stateful behavior: state-machine style
|
||||
- Dependencies: directional graphs grouped by subsystem
|
||||
- Comparisons/migrations: side-by-side layouts
|
||||
|
||||
Do not force every task into boxes with arrows when a better structure exists.
|
||||
|
||||
## Logical coherence
|
||||
|
||||
- Every element must have a reason to exist.
|
||||
- Group by real system boundaries, not visual symmetry alone.
|
||||
- Make relationships explicit: data flow, control flow, ownership, lifecycle, dependency.
|
||||
- Minimize ambiguous arrows.
|
||||
- If a connection has specific meaning, label it briefly.
|
||||
- Prefer fewer, clearer elements over exhaustive clutter.
|
||||
|
||||
## Language
|
||||
|
||||
- Use terse, technical labels.
|
||||
- Use short phrases, not full sentences.
|
||||
- Assume the reader is a senior engineer.
|
||||
- Prefer concrete nouns/verbs.
|
||||
- Use concrete system terms: API, worker, queue, WAL, cache, AST, token, retry loop, reconciliation pass.
|
||||
|
||||
### Avoid
|
||||
|
||||
- Business speak
|
||||
- Marketing language
|
||||
- Vague labels like `Platform`, `Service Layer`, `System`, `Magic`
|
||||
- Ambiguous shorthand like `edge`, `core`, `backend`, `worker` without qualifiers
|
||||
- Filler phrases like `leverages`, `enables`, `streamlines`, `orchestrates`
|
||||
|
||||
### Naming specificity
|
||||
|
||||
- Prefer concrete component names over abstract layer names.
|
||||
- Label the actual technology/runtime boundary when known.
|
||||
- Example: use `caddy router` or `reverse proxy (caddy :80)` instead of `edge`.
|
||||
- Example: use `bun http api (:3000)` instead of `backend`.
|
||||
|
||||
## Visual style
|
||||
|
||||
- Use subtle emphasis, not decoration.
|
||||
- Reserve accent colors for meaning, not aesthetics alone.
|
||||
- Keep color mapping stable for each logical subsystem/flow.
|
||||
- Use container fills and border weight to show hierarchy.
|
||||
- Keep shapes simple and consistent unless variation materially helps.
|
||||
|
||||
## Creativity rule
|
||||
|
||||
Be creative in structure, not flashy in styling.
|
||||
|
||||
- Adapt composition to the specific problem.
|
||||
- Use framing, grouping, and flow intentionally.
|
||||
- Make the diagram feel specific to the task.
|
||||
|
||||
## Dark mode persistence
|
||||
|
||||
- Treat dark mode as a persisted app-state requirement only.
|
||||
- At create/update time, `scene.appState.theme` must be `"dark"`.
|
||||
- Do not rely on UI defaults or post-hoc toggles.
|
||||
- Theme and element colors are separate controls:
|
||||
- `theme: "dark"` is required.
|
||||
- Keep element colors in a light-style palette.
|
||||
- Do not manually invert colors.
|
||||
- Do not use dark canvas/surface colors for theme compliance.
|
||||
|
||||
## Hard constraints
|
||||
|
||||
- Always set `scene.appState.theme` to `"dark"`.
|
||||
- Always use light-mode element colors.
|
||||
- No manual color inversion.
|
||||
- Default to one container level; max two unless explicitly needed.
|
||||
- No tight text boxes.
|
||||
- No cluttered layouts.
|
||||
- No overlapping labels.
|
||||
- No label text with connector strokes running through glyphs.
|
||||
- No connector endpoint flush against a box border; keep a visible gap.
|
||||
- No inconsistent color mapping for the same logical subsystem/flow.
|
||||
- No decorative noise.
|
||||
- No business/management tone.
|
||||
- No generic one-size-fits-all flowchart when a better structure is appropriate.
|
||||
+6
-78
@@ -1,27 +1,18 @@
|
||||
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 { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { join } from "node:path";
|
||||
import { type ScenePayload } from "../core/shared";
|
||||
import { createDrawingStore, type DrawingStore } from "../server/db";
|
||||
import {
|
||||
DEFAULT_STYLES_GUIDE_PATH,
|
||||
createExcaliMcpHandler,
|
||||
createMcpToolHandlers,
|
||||
resolvePublicBaseUrl,
|
||||
resolveStylesGuidePath,
|
||||
} from "./server";
|
||||
import { createMcpToolHandlers, resolvePublicBaseUrl } from "./server";
|
||||
|
||||
const cleanup: Array<{ dir: string; store?: DrawingStore; client?: Client }> = [];
|
||||
const cleanup: Array<{ dir: string; store: DrawingStore }> = [];
|
||||
|
||||
afterEach(async () => {
|
||||
afterEach(() => {
|
||||
while (cleanup.length > 0) {
|
||||
const item = cleanup.pop();
|
||||
if (item) {
|
||||
await item.client?.close().catch(() => undefined);
|
||||
item.store?.close();
|
||||
item.store.close();
|
||||
rmSync(item.dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
@@ -37,35 +28,6 @@ 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: [
|
||||
@@ -82,40 +44,6 @@ 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();
|
||||
|
||||
+2
-79
@@ -1,6 +1,4 @@
|
||||
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";
|
||||
@@ -37,12 +35,6 @@ 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(/\/+$/, "");
|
||||
}
|
||||
@@ -64,50 +56,6 @@ 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}`;
|
||||
}
|
||||
@@ -209,36 +157,11 @@ export function createMcpToolHandlers(store: DrawingStore, publicBaseUrl: string
|
||||
};
|
||||
}
|
||||
|
||||
export function createExcaliMcpHandler(
|
||||
store: DrawingStore,
|
||||
publicBaseUrl: string,
|
||||
stylesGuide?: StylesGuideResource,
|
||||
) {
|
||||
export function createExcaliMcpHandler(store: DrawingStore, publicBaseUrl: string) {
|
||||
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",
|
||||
{
|
||||
@@ -304,7 +227,7 @@ export function createExcaliMcpHandler(
|
||||
|
||||
export function createMcpServer() {
|
||||
const store = createDrawingStore(process.env.DATABASE_PATH);
|
||||
const handler = createExcaliMcpHandler(store, resolvePublicBaseUrl(), resolveStylesGuidePath());
|
||||
const handler = createExcaliMcpHandler(store, resolvePublicBaseUrl());
|
||||
|
||||
return {
|
||||
port: Number(process.env.MCP_PORT ?? "3001"),
|
||||
|
||||
Reference in New Issue
Block a user