Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3f70e5f1c7 | |||
| dd1fb6af9b | |||
| 7ac355c0d0 | |||
| 95d27c5142 | |||
| b60b921ff8 |
@@ -34,6 +34,4 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
|||||||
# Finder (MacOS) folder config
|
# Finder (MacOS) folder config
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
PLAN.md
|
|
||||||
|
|
||||||
excali-box-data/
|
excali-box-data/
|
||||||
|
|||||||
@@ -4,6 +4,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:80 {
|
:80 {
|
||||||
|
basicauth /* {
|
||||||
|
{$AUTH_USER:admin} {$AUTH_HASH:$2a$14$J3A9qfgJTikjVe.XmDn49OUjOrPiiwAN3.SXbK1tvfef1XzDkIuRm}
|
||||||
|
}
|
||||||
|
|
||||||
encode zstd gzip
|
encode zstd gzip
|
||||||
|
|
||||||
@mcp path /mcp
|
@mcp path /mcp
|
||||||
|
|||||||
+4
-1
@@ -19,14 +19,17 @@ ENV MCP_HOST=127.0.0.1
|
|||||||
ENV MCP_PORT=3001
|
ENV MCP_PORT=3001
|
||||||
ENV DATABASE_PATH=/data/excalidraw.sqlite
|
ENV DATABASE_PATH=/data/excalidraw.sqlite
|
||||||
|
|
||||||
|
RUN mkdir -p /config /data
|
||||||
|
|
||||||
COPY --from=caddy /usr/bin/caddy /usr/bin/caddy
|
COPY --from=caddy /usr/bin/caddy /usr/bin/caddy
|
||||||
COPY --from=build /app/dist/public /srv/public
|
COPY --from=build /app/dist/public /srv/public
|
||||||
COPY --from=build /app/dist/server /app/dist/server
|
COPY --from=build /app/dist/server /app/dist/server
|
||||||
COPY --from=build /app/dist/mcp /app/dist/mcp
|
COPY --from=build /app/dist/mcp /app/dist/mcp
|
||||||
COPY Caddyfile /etc/caddy/Caddyfile
|
COPY Caddyfile /etc/caddy/Caddyfile
|
||||||
|
COPY STYLES_GUIDE.md /config/styles-guide.md
|
||||||
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||||
|
|
||||||
RUN mkdir -p /data && chmod +x /usr/local/bin/docker-entrypoint.sh
|
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||||
|
|
||||||
VOLUME ["/data"]
|
VOLUME ["/data"]
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
Private self-hosted Excalidraw with Bun, Caddy, and SQLite.
|
Private self-hosted Excalidraw with Bun, Caddy, and SQLite.
|
||||||
|
|
||||||
This is bare wrapper around the excalidraw packages that adds autosave and disk-persistence.
|
This is bare wrapper around the excalidraw packages that adds autosave and disk-persistence.
|
||||||
No auth to keep things simple - you either run in a private network or put it behind Caddy auth or similar solutions.
|
|
||||||
|
|
||||||
> GPT 5.5 generated
|
> GPT 5.5 generated
|
||||||
|
|
||||||
@@ -16,7 +15,6 @@ docker run --rm \
|
|||||||
-p 127.0.0.1:3000:80 \
|
-p 127.0.0.1:3000:80 \
|
||||||
-e PUBLIC_BASE_URL=http://localhost:3000 \
|
-e PUBLIC_BASE_URL=http://localhost:3000 \
|
||||||
-v "$PWD/excali-box-data:/data" \
|
-v "$PWD/excali-box-data:/data" \
|
||||||
-v "$PWD/STYLES_GUIDE.md:/config/styles-guide.md:ro" \
|
|
||||||
excali
|
excali
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -27,7 +25,6 @@ docker run --rm \
|
|||||||
-p 127.0.0.1:3000:80 \
|
-p 127.0.0.1:3000:80 \
|
||||||
-e PUBLIC_BASE_URL=http://localhost:3000 \
|
-e PUBLIC_BASE_URL=http://localhost:3000 \
|
||||||
-v "$PWD/excali-box-data:/data" \
|
-v "$PWD/excali-box-data:/data" \
|
||||||
-v "$PWD/STYLES_GUIDE.md:/config/styles-guide.md:ro" \
|
|
||||||
ghcr.io/ruinivist/excalidraw-box:latest
|
ghcr.io/ruinivist/excalidraw-box:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -70,7 +67,8 @@ This will make the data persistent in the `excali-box-data` folder in the curren
|
|||||||
## Optional MCP styles guide resource
|
## Optional MCP styles guide resource
|
||||||
|
|
||||||
The MCP server can optionally expose one extra read-only resource at `excali://styles-guide`. This file is then
|
The MCP server can optionally expose one extra read-only resource at `excali://styles-guide`. This file is then
|
||||||
discoverable as an MCP resource.
|
discoverable as an MCP resource. Container images already include the repo `STYLES_GUIDE.md` at
|
||||||
|
`/config/styles-guide.md`, so the default guide is exposed automatically in Docker/GHCR.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run --rm \
|
docker run --rm \
|
||||||
@@ -81,4 +79,31 @@ docker run --rm \
|
|||||||
ghcr.io/ruinivist/excalidraw-box:latest
|
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`.
|
Bind-mounting a different file to `/config/styles-guide.md` overrides the bundled default for that container.
|
||||||
|
Local non-container runs are unchanged; if you want a styles guide there, you still need a file at `/config/styles-guide.md`.
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
By default, the application is protected by basic authentication using Caddy.
|
||||||
|
The default credentials are:
|
||||||
|
- **Username:** `admin`
|
||||||
|
- **Password:** `password`
|
||||||
|
|
||||||
|
You can customize these by setting the following environment variables when running the Docker container.
|
||||||
|
The password must be hashed using `bcrypt` since Caddy requires hashed passwords for basicauth.
|
||||||
|
|
||||||
|
**Intended flow to set a custom password:**
|
||||||
|
1. Generate a bcrypt hash of your desired password. For example, using a container to run Caddy's hash-password utility:
|
||||||
|
```bash
|
||||||
|
docker run --rm caddy:2-alpine caddy hash-password --plaintext your_secure_password
|
||||||
|
```
|
||||||
|
2. Pass the generated hash and your desired username via environment variables when running the app:
|
||||||
|
```bash
|
||||||
|
docker run --rm \
|
||||||
|
-p 127.0.0.1:3000:80 \
|
||||||
|
-e PUBLIC_BASE_URL=http://localhost:3000 \
|
||||||
|
-e AUTH_USER=myuser \
|
||||||
|
-e AUTH_HASH='\$2a\$14\$exampleHash...' \
|
||||||
|
-v "$PWD/excali-box-data:/data" \
|
||||||
|
ghcr.io/ruinivist/excalidraw-box:latest
|
||||||
|
```
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
|
if [ "${AUTH_HASH:-}" = "\$2a\$14\$J3A9qfgJTikjVe.XmDn49OUjOrPiiwAN3.SXbK1tvfef1XzDkIuRm" ] || [ -z "${AUTH_HASH:-}" ]; then
|
||||||
|
echo "WARNING: Using the default password for basic authentication. It is highly recommended to change it by setting the AUTH_HASH environment variable." >&2
|
||||||
|
fi
|
||||||
|
|
||||||
app_pid=""
|
app_pid=""
|
||||||
mcp_pid=""
|
mcp_pid=""
|
||||||
caddy_pid=""
|
caddy_pid=""
|
||||||
|
|||||||
+4
-76
@@ -1,26 +1,17 @@
|
|||||||
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 { 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 { tmpdir } from "node:os";
|
||||||
import { dirname, join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { type ScenePayload } from "../core/shared";
|
import { type ScenePayload } from "../core/shared";
|
||||||
import { createDrawingStore, type DrawingStore } from "../server/db";
|
import { createDrawingStore, type DrawingStore } from "../server/db";
|
||||||
import {
|
import { createMcpToolHandlers, resolvePublicBaseUrl } from "./server";
|
||||||
DEFAULT_STYLES_GUIDE_PATH,
|
|
||||||
createExcaliMcpHandler,
|
|
||||||
createMcpToolHandlers,
|
|
||||||
resolvePublicBaseUrl,
|
|
||||||
resolveStylesGuidePath,
|
|
||||||
} from "./server";
|
|
||||||
|
|
||||||
const cleanup: Array<{ dir: string; store?: DrawingStore; client?: Client }> = [];
|
const cleanup: Array<{ dir: string; store?: DrawingStore }> = [];
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
while (cleanup.length > 0) {
|
while (cleanup.length > 0) {
|
||||||
const item = cleanup.pop();
|
const item = cleanup.pop();
|
||||||
if (item) {
|
if (item) {
|
||||||
await item.client?.close().catch(() => undefined);
|
|
||||||
item.store?.close();
|
item.store?.close();
|
||||||
rmSync(item.dir, { recursive: true, force: true });
|
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 {
|
function testScene(): ScenePayload {
|
||||||
return {
|
return {
|
||||||
elements: [
|
elements: [
|
||||||
@@ -82,40 +44,6 @@ describe("mcp tool handlers", () => {
|
|||||||
expect(() => resolvePublicBaseUrl("")).toThrow("PUBLIC_BASE_URL is required");
|
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", () => {
|
test("create_drawing writes an explicit scene to a temp SQLite DB", () => {
|
||||||
const { store, tools } = createTempTools();
|
const { store, tools } = createTempTools();
|
||||||
const scene = testScene();
|
const scene = testScene();
|
||||||
|
|||||||
Reference in New Issue
Block a user