@neverprepared/mcp-phantom-diagrams 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +34 -0
- package/.github/workflows/release-please.yml +50 -0
- package/CLAUDE.md +49 -0
- package/README.md +83 -0
- package/dist/cache.d.ts +14 -0
- package/dist/cache.js +44 -0
- package/dist/docker.d.ts +2 -0
- package/dist/docker.js +81 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +169 -0
- package/dist/kroki.d.ts +124 -0
- package/dist/kroki.js +104 -0
- package/docker-compose.yml +37 -0
- package/package.json +32 -0
- package/src/cache.ts +65 -0
- package/src/docker.ts +102 -0
- package/src/index.ts +207 -0
- package/src/kroki.ts +140 -0
- package/test/integration.test.ts +55 -0
- package/test/unit.test.ts +124 -0
- package/tsconfig.json +16 -0
package/dist/kroki.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { KROKI_URL } from "./docker.js";
|
|
2
|
+
export const DIAGRAM_TYPES = {
|
|
3
|
+
// Built-in (no companion container required)
|
|
4
|
+
plantuml: { formats: ["svg", "png", "jpeg"], requiresCompanion: false },
|
|
5
|
+
graphviz: { formats: ["svg", "png", "jpeg"], requiresCompanion: false },
|
|
6
|
+
ditaa: { formats: ["svg", "png"], requiresCompanion: false },
|
|
7
|
+
svgbob: { formats: ["svg", "png"], requiresCompanion: false },
|
|
8
|
+
umlet: { formats: ["svg", "png"], requiresCompanion: false },
|
|
9
|
+
erd: { formats: ["svg", "png"], requiresCompanion: false },
|
|
10
|
+
nomnoml: { formats: ["svg", "png"], requiresCompanion: false },
|
|
11
|
+
structurizr: { formats: ["svg", "png"], requiresCompanion: false },
|
|
12
|
+
bytefield: { formats: ["svg", "png"], requiresCompanion: false },
|
|
13
|
+
c4plantuml: { formats: ["svg", "png"], requiresCompanion: false },
|
|
14
|
+
d2: { formats: ["svg"], requiresCompanion: false },
|
|
15
|
+
dbml: { formats: ["svg", "png"], requiresCompanion: false },
|
|
16
|
+
pikchr: { formats: ["svg"], requiresCompanion: false },
|
|
17
|
+
symbolator: { formats: ["svg", "png"], requiresCompanion: false },
|
|
18
|
+
vega: { formats: ["svg", "png"], requiresCompanion: false },
|
|
19
|
+
vegalite: { formats: ["svg", "png"], requiresCompanion: false },
|
|
20
|
+
wavedrom: { formats: ["svg", "png"], requiresCompanion: false },
|
|
21
|
+
// Companion containers required
|
|
22
|
+
mermaid: { formats: ["svg", "png"], requiresCompanion: true },
|
|
23
|
+
bpmn: { formats: ["svg"], requiresCompanion: true },
|
|
24
|
+
excalidraw: { formats: ["svg", "png"], requiresCompanion: true },
|
|
25
|
+
blockdiag: { formats: ["svg", "png"], requiresCompanion: true },
|
|
26
|
+
seqdiag: { formats: ["svg", "png"], requiresCompanion: true },
|
|
27
|
+
actdiag: { formats: ["svg", "png"], requiresCompanion: true },
|
|
28
|
+
nwdiag: { formats: ["svg", "png"], requiresCompanion: true },
|
|
29
|
+
packetdiag: { formats: ["svg", "png"], requiresCompanion: true },
|
|
30
|
+
rackdiag: { formats: ["svg", "png"], requiresCompanion: true },
|
|
31
|
+
diagramsnet: { formats: ["svg", "png"], requiresCompanion: true },
|
|
32
|
+
};
|
|
33
|
+
const MAX_SOURCE_BYTES = 256 * 1024; // 256 KB
|
|
34
|
+
const MAX_RESPONSE_BYTES = 10 * 1024 * 1024; // 10 MB
|
|
35
|
+
// Allowlist for Kroki-Diagram-Options-* header keys and values to prevent CRLF injection.
|
|
36
|
+
const OPTION_KEY_RE = /^[A-Za-z0-9_-]{1,64}$/;
|
|
37
|
+
const OPTION_VAL_RE = /^[\x20-\x7E]{0,1024}$/;
|
|
38
|
+
export async function convertDiagram(diagramType, source, outputFormat, options, queryOptions) {
|
|
39
|
+
if (Buffer.byteLength(source, "utf8") > MAX_SOURCE_BYTES) {
|
|
40
|
+
throw new Error(`Diagram source exceeds ${MAX_SOURCE_BYTES / 1024} KB limit`);
|
|
41
|
+
}
|
|
42
|
+
const headers = {
|
|
43
|
+
"Content-Type": "text/plain",
|
|
44
|
+
Accept: outputFormat === "svg" ? "image/svg+xml" : `image/${outputFormat}`,
|
|
45
|
+
};
|
|
46
|
+
if (options) {
|
|
47
|
+
for (const [key, value] of Object.entries(options)) {
|
|
48
|
+
if (!OPTION_KEY_RE.test(key)) {
|
|
49
|
+
throw new Error(`Invalid option key: ${JSON.stringify(key)}`);
|
|
50
|
+
}
|
|
51
|
+
if (!OPTION_VAL_RE.test(value)) {
|
|
52
|
+
throw new Error(`Invalid option value for key ${JSON.stringify(key)}`);
|
|
53
|
+
}
|
|
54
|
+
headers[`Kroki-Diagram-Options-${key}`] = value;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const url = new URL(`${KROKI_URL}/${diagramType}/${outputFormat}`);
|
|
58
|
+
if (queryOptions) {
|
|
59
|
+
for (const [key, value] of Object.entries(queryOptions)) {
|
|
60
|
+
if (!OPTION_KEY_RE.test(key)) {
|
|
61
|
+
throw new Error(`Invalid query option key: ${JSON.stringify(key)}`);
|
|
62
|
+
}
|
|
63
|
+
if (!OPTION_VAL_RE.test(value)) {
|
|
64
|
+
throw new Error(`Invalid query option value for key ${JSON.stringify(key)}`);
|
|
65
|
+
}
|
|
66
|
+
url.searchParams.set(key, value);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const res = await fetch(url, {
|
|
70
|
+
method: "POST",
|
|
71
|
+
headers,
|
|
72
|
+
body: source,
|
|
73
|
+
signal: AbortSignal.timeout(30_000),
|
|
74
|
+
});
|
|
75
|
+
if (!res.ok) {
|
|
76
|
+
const body = await res.text().catch(() => "");
|
|
77
|
+
const hint = res.status >= 400 && res.status < 500
|
|
78
|
+
? `Diagram source rejected — likely a syntax error in the ${diagramType} source. `
|
|
79
|
+
: "Kroki server error. ";
|
|
80
|
+
throw new Error(`${hint}HTTP ${res.status}: ${body}`);
|
|
81
|
+
}
|
|
82
|
+
const contentType = res.headers.get("content-type") ?? "";
|
|
83
|
+
const expectedMime = outputFormat === "svg" ? "image/svg+xml" : `image/${outputFormat}`;
|
|
84
|
+
if (!contentType.includes(expectedMime)) {
|
|
85
|
+
throw new Error(`Unexpected content-type from Kroki: "${contentType}" (expected "${expectedMime}")`);
|
|
86
|
+
}
|
|
87
|
+
const contentLength = Number(res.headers.get("content-length"));
|
|
88
|
+
if (Number.isFinite(contentLength) && contentLength > MAX_RESPONSE_BYTES) {
|
|
89
|
+
throw new Error(`Kroki response too large: ${contentLength} bytes (limit ${MAX_RESPONSE_BYTES / 1024 / 1024} MB)`);
|
|
90
|
+
}
|
|
91
|
+
if (outputFormat === "svg") {
|
|
92
|
+
const text = await res.text();
|
|
93
|
+
if (Buffer.byteLength(text, "utf8") > MAX_RESPONSE_BYTES) {
|
|
94
|
+
throw new Error("Kroki SVG response exceeds size limit");
|
|
95
|
+
}
|
|
96
|
+
return { format: "svg", data: text };
|
|
97
|
+
}
|
|
98
|
+
const arrayBuffer = await res.arrayBuffer();
|
|
99
|
+
if (arrayBuffer.byteLength > MAX_RESPONSE_BYTES) {
|
|
100
|
+
throw new Error("Kroki image response exceeds size limit");
|
|
101
|
+
}
|
|
102
|
+
return { format: outputFormat, data: Buffer.from(arrayBuffer) };
|
|
103
|
+
}
|
|
104
|
+
//# sourceMappingURL=kroki.js.map
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
services:
|
|
2
|
+
kroki:
|
|
3
|
+
image: yuzutech/kroki
|
|
4
|
+
ports:
|
|
5
|
+
- "127.0.0.1:18000:8000"
|
|
6
|
+
environment:
|
|
7
|
+
- KROKI_MERMAID_HOST=mermaid
|
|
8
|
+
- KROKI_BPMN_HOST=bpmn
|
|
9
|
+
- KROKI_EXCALIDRAW_HOST=excalidraw
|
|
10
|
+
- KROKI_BLOCKDIAG_HOST=blockdiag
|
|
11
|
+
- KROKI_SEQDIAG_HOST=blockdiag
|
|
12
|
+
- KROKI_ACTDIAG_HOST=blockdiag
|
|
13
|
+
- KROKI_NWDIAG_HOST=blockdiag
|
|
14
|
+
- KROKI_PACKETDIAG_HOST=blockdiag
|
|
15
|
+
- KROKI_RACKDIAG_HOST=blockdiag
|
|
16
|
+
- KROKI_DIAGRAMSNET_HOST=diagramsnet
|
|
17
|
+
depends_on:
|
|
18
|
+
- mermaid
|
|
19
|
+
- bpmn
|
|
20
|
+
- excalidraw
|
|
21
|
+
- blockdiag
|
|
22
|
+
- diagramsnet
|
|
23
|
+
|
|
24
|
+
mermaid:
|
|
25
|
+
image: yuzutech/kroki-mermaid
|
|
26
|
+
|
|
27
|
+
bpmn:
|
|
28
|
+
image: yuzutech/kroki-bpmn
|
|
29
|
+
|
|
30
|
+
excalidraw:
|
|
31
|
+
image: yuzutech/kroki-excalidraw
|
|
32
|
+
|
|
33
|
+
blockdiag:
|
|
34
|
+
image: yuzutech/kroki-blockdiag
|
|
35
|
+
|
|
36
|
+
diagramsnet:
|
|
37
|
+
image: yuzutech/kroki-diagramsnet
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@neverprepared/mcp-phantom-diagrams",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "MCP server that converts diagram markup to images via a local Kroki instance",
|
|
6
|
+
"bin": {
|
|
7
|
+
"mcp-phantom-diagrams": "dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "tsc",
|
|
11
|
+
"postbuild": "chmod +x dist/index.js",
|
|
12
|
+
"prepare": "npm run build",
|
|
13
|
+
"start": "node dist/index.js",
|
|
14
|
+
"dev": "tsc --watch",
|
|
15
|
+
"typecheck": "tsc --noEmit",
|
|
16
|
+
"test": "node --import tsx/esm --test 'test/**/*.test.ts'",
|
|
17
|
+
"test:unit": "node --import tsx/esm --test 'test/unit.test.ts'",
|
|
18
|
+
"test:integration": "node --import tsx/esm --test 'test/integration.test.ts'"
|
|
19
|
+
},
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": ">=20"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
25
|
+
"zod": "^4.0.0"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@types/node": "^20.0.0",
|
|
29
|
+
"tsx": "^4.0.0",
|
|
30
|
+
"typescript": "^5.0.0"
|
|
31
|
+
}
|
|
32
|
+
}
|
package/src/cache.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { createHash } from "crypto";
|
|
2
|
+
import type { ConvertResult } from "./kroki.js";
|
|
3
|
+
|
|
4
|
+
const MAX_ENTRIES = 100;
|
|
5
|
+
const MAX_BYTES = 50 * 1024 * 1024; // 50 MB
|
|
6
|
+
|
|
7
|
+
interface Entry {
|
|
8
|
+
result: ConvertResult;
|
|
9
|
+
bytes: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function resultBytes(result: ConvertResult): number {
|
|
13
|
+
return result.format === "svg"
|
|
14
|
+
? Buffer.byteLength(result.data as string, "utf8")
|
|
15
|
+
: (result.data as Buffer).byteLength;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
class DiagramCache {
|
|
19
|
+
// Map preserves insertion order; delete+reinsert moves entry to "most recent" end.
|
|
20
|
+
private readonly store = new Map<string, Entry>();
|
|
21
|
+
private totalBytes = 0;
|
|
22
|
+
|
|
23
|
+
cacheKey(
|
|
24
|
+
diagramType: string,
|
|
25
|
+
source: string,
|
|
26
|
+
outputFormat: string,
|
|
27
|
+
options?: Record<string, string>,
|
|
28
|
+
queryOptions?: Record<string, string>
|
|
29
|
+
): string {
|
|
30
|
+
return createHash("sha256")
|
|
31
|
+
.update(JSON.stringify([diagramType, source, outputFormat, options ?? {}, queryOptions ?? {}]))
|
|
32
|
+
.digest("hex");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
get(key: string): ConvertResult | undefined {
|
|
36
|
+
const entry = this.store.get(key);
|
|
37
|
+
if (!entry) return undefined;
|
|
38
|
+
this.store.delete(key);
|
|
39
|
+
this.store.set(key, entry);
|
|
40
|
+
return entry.result;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
set(key: string, result: ConvertResult): void {
|
|
44
|
+
const bytes = resultBytes(result);
|
|
45
|
+
|
|
46
|
+
while (
|
|
47
|
+
this.store.size >= MAX_ENTRIES ||
|
|
48
|
+
(this.totalBytes + bytes > MAX_BYTES && this.store.size > 0)
|
|
49
|
+
) {
|
|
50
|
+
const oldest = this.store.keys().next().value;
|
|
51
|
+
if (oldest === undefined) break;
|
|
52
|
+
this.totalBytes -= this.store.get(oldest)!.bytes;
|
|
53
|
+
this.store.delete(oldest);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
this.store.set(key, { result, bytes });
|
|
57
|
+
this.totalBytes += bytes;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
stats(): { entries: number; bytes: number } {
|
|
61
|
+
return { entries: this.store.size, bytes: this.totalBytes };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const diagramCache = new DiagramCache();
|
package/src/docker.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { spawn } from "child_process";
|
|
2
|
+
import { fileURLToPath } from "url";
|
|
3
|
+
import { dirname, join } from "path";
|
|
4
|
+
|
|
5
|
+
export const KROKI_URL = process.env.KROKI_URL ?? "http://localhost:18000";
|
|
6
|
+
const COMPOSE_PROJECT = "kroki-shared";
|
|
7
|
+
const COMPOSE_TIMEOUT_MS = 5 * 60_000; // 5 min — allows for image pulls on first run
|
|
8
|
+
const HEALTH_TIMEOUT_MS = 30_000;
|
|
9
|
+
const HEALTH_POLL_START_MS = 500;
|
|
10
|
+
const HEALTH_POLL_MAX_MS = 2_000;
|
|
11
|
+
|
|
12
|
+
// Resolved at module load so it's portable regardless of cwd.
|
|
13
|
+
// spawnSync/spawn receive it as a single argv element — no shell, no injection risk.
|
|
14
|
+
const composeFile = join(
|
|
15
|
+
dirname(fileURLToPath(import.meta.url)),
|
|
16
|
+
"..",
|
|
17
|
+
"docker-compose.yml"
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
async function isHealthy(): Promise<boolean> {
|
|
21
|
+
try {
|
|
22
|
+
const res = await fetch(`${KROKI_URL}/health`, {
|
|
23
|
+
signal: AbortSignal.timeout(2_000),
|
|
24
|
+
});
|
|
25
|
+
if (!res.ok) return false;
|
|
26
|
+
// Kroki returns {"status":"pass"} — validate to avoid false positives
|
|
27
|
+
// from other services that happen to expose a /health endpoint.
|
|
28
|
+
const body = await res.json() as Record<string, unknown>;
|
|
29
|
+
return body["status"] === "pass";
|
|
30
|
+
} catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function startContainers(): Promise<void> {
|
|
36
|
+
return new Promise((resolve, reject) => {
|
|
37
|
+
const proc = spawn(
|
|
38
|
+
"docker",
|
|
39
|
+
["compose", "-p", COMPOSE_PROJECT, "-f", composeFile, "up", "-d"],
|
|
40
|
+
{ stdio: ["ignore", "pipe", "pipe"] }
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
const timer = setTimeout(() => {
|
|
44
|
+
proc.kill("SIGKILL");
|
|
45
|
+
reject(new Error(
|
|
46
|
+
`docker compose up timed out after ${COMPOSE_TIMEOUT_MS / 60_000} minutes. ` +
|
|
47
|
+
"Images may still be pulling — run 'docker compose -p kroki-shared pull' manually first."
|
|
48
|
+
));
|
|
49
|
+
}, COMPOSE_TIMEOUT_MS);
|
|
50
|
+
|
|
51
|
+
let stderr = "";
|
|
52
|
+
proc.stderr?.on("data", (chunk: Buffer) => { stderr += chunk.toString(); });
|
|
53
|
+
|
|
54
|
+
proc.on("error", (err) => {
|
|
55
|
+
clearTimeout(timer);
|
|
56
|
+
const isNotFound = (err as NodeJS.ErrnoException).code === "ENOENT";
|
|
57
|
+
reject(new Error(
|
|
58
|
+
isNotFound
|
|
59
|
+
? "docker not found on PATH — is Docker installed and running?"
|
|
60
|
+
: `Failed to invoke docker: ${err.message}`
|
|
61
|
+
));
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
proc.on("close", (code) => {
|
|
65
|
+
clearTimeout(timer);
|
|
66
|
+
if (code !== 0) {
|
|
67
|
+
reject(new Error(`docker compose up failed (exit ${code}):\n${stderr}`));
|
|
68
|
+
} else {
|
|
69
|
+
resolve();
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function waitForHealth(): Promise<void> {
|
|
76
|
+
const deadline = Date.now() + HEALTH_TIMEOUT_MS;
|
|
77
|
+
let delay = HEALTH_POLL_START_MS;
|
|
78
|
+
|
|
79
|
+
while (Date.now() < deadline) {
|
|
80
|
+
if (await isHealthy()) return;
|
|
81
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
82
|
+
delay = Math.min(delay * 2, HEALTH_POLL_MAX_MS);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
throw new Error(
|
|
86
|
+
"Kroki did not become healthy within 30s — ensure Docker is running and port 18000 is free. " +
|
|
87
|
+
"On first run, images may still be pulling; wait a moment and try again."
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Single-flight latch: concurrent callers share one start attempt.
|
|
92
|
+
let startingPromise: Promise<void> | null = null;
|
|
93
|
+
|
|
94
|
+
export async function ensureKrokiRunning(): Promise<void> {
|
|
95
|
+
if (await isHealthy()) return;
|
|
96
|
+
if (!startingPromise) {
|
|
97
|
+
startingPromise = startContainers()
|
|
98
|
+
.then(() => waitForHealth())
|
|
99
|
+
.finally(() => { startingPromise = null; });
|
|
100
|
+
}
|
|
101
|
+
return startingPromise;
|
|
102
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { mkdir, writeFile } from "fs/promises";
|
|
3
|
+
import { dirname, resolve } from "path";
|
|
4
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
5
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { ensureKrokiRunning, KROKI_URL } from "./docker.js";
|
|
8
|
+
import { convertDiagram, DIAGRAM_TYPES, type DiagramType, type OutputFormat } from "./kroki.js";
|
|
9
|
+
import { diagramCache } from "./cache.js";
|
|
10
|
+
|
|
11
|
+
const server = new McpServer({
|
|
12
|
+
name: "phantom-diagrams",
|
|
13
|
+
version: "1.0.0",
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const diagramTypeEnum = Object.keys(DIAGRAM_TYPES) as [DiagramType, ...DiagramType[]];
|
|
17
|
+
|
|
18
|
+
server.registerTool(
|
|
19
|
+
"convert_diagram",
|
|
20
|
+
{
|
|
21
|
+
title: "Convert diagram source to image",
|
|
22
|
+
description:
|
|
23
|
+
"Render a diagram from source text using a local Kroki instance. " +
|
|
24
|
+
"SVG output is returned as a text block; PNG and JPEG as base64 image blocks — or written to disk if output_path is provided. " +
|
|
25
|
+
"Results are cached in memory; identical inputs return instantly. " +
|
|
26
|
+
"Call list_diagram_types first to see which types support which formats. " +
|
|
27
|
+
"If Kroki returns an HTTP 4xx error, the diagram source likely has a syntax error — fix it and retry.",
|
|
28
|
+
inputSchema: {
|
|
29
|
+
diagram_type: z
|
|
30
|
+
.enum(diagramTypeEnum)
|
|
31
|
+
.describe("Diagram language/type — e.g. plantuml, mermaid, graphviz. Call list_diagram_types to see all options."),
|
|
32
|
+
source: z
|
|
33
|
+
.string()
|
|
34
|
+
.min(1)
|
|
35
|
+
.max(256 * 1024)
|
|
36
|
+
.describe("Diagram markup source text"),
|
|
37
|
+
output_format: z
|
|
38
|
+
.enum(["svg", "png", "jpeg"])
|
|
39
|
+
.default("svg")
|
|
40
|
+
.describe("Output format. SVG is returned as text; PNG and JPEG as base64-encoded image blocks (or written to disk if output_path is set)."),
|
|
41
|
+
output_path: z
|
|
42
|
+
.string()
|
|
43
|
+
.optional()
|
|
44
|
+
.describe("Absolute or relative path to write the output file to. Parent directories are created automatically. When set, returns the path and file size instead of raw content — preferred for PNG/JPEG to avoid large base64 payloads."),
|
|
45
|
+
options: z
|
|
46
|
+
.record(z.string(), z.string())
|
|
47
|
+
.optional()
|
|
48
|
+
.describe("Diagram-type-specific rendering options sent as Kroki-Diagram-Options-* HTTP headers (see Kroki docs). Keys and values must be printable ASCII."),
|
|
49
|
+
query_options: z
|
|
50
|
+
.record(z.string(), z.string())
|
|
51
|
+
.optional()
|
|
52
|
+
.describe("Diagram-type-specific options passed as URL query parameters (e.g. { theme: 'dark' }). Keys and values must be printable ASCII."),
|
|
53
|
+
},
|
|
54
|
+
annotations: {
|
|
55
|
+
readOnlyHint: false, // can write files when output_path is set
|
|
56
|
+
idempotentHint: true,
|
|
57
|
+
openWorldHint: false,
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
async ({ diagram_type, source, output_format, output_path, options, query_options }) => {
|
|
61
|
+
await ensureKrokiRunning();
|
|
62
|
+
|
|
63
|
+
const typeInfo = DIAGRAM_TYPES[diagram_type as DiagramType];
|
|
64
|
+
if (!typeInfo.formats.includes(output_format as OutputFormat)) {
|
|
65
|
+
return {
|
|
66
|
+
isError: true,
|
|
67
|
+
content: [
|
|
68
|
+
{
|
|
69
|
+
type: "text" as const,
|
|
70
|
+
text: `${diagram_type} does not support ${output_format}. Supported formats: ${typeInfo.formats.join(", ")}`,
|
|
71
|
+
},
|
|
72
|
+
],
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const cacheKey = diagramCache.cacheKey(diagram_type, source, output_format, options, query_options);
|
|
77
|
+
let result = diagramCache.get(cacheKey);
|
|
78
|
+
|
|
79
|
+
if (!result) {
|
|
80
|
+
result = await convertDiagram(
|
|
81
|
+
diagram_type as DiagramType,
|
|
82
|
+
source,
|
|
83
|
+
output_format as OutputFormat,
|
|
84
|
+
options,
|
|
85
|
+
query_options
|
|
86
|
+
);
|
|
87
|
+
diagramCache.set(cacheKey, result);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (output_path) {
|
|
91
|
+
const absPath = resolve(output_path);
|
|
92
|
+
await mkdir(dirname(absPath), { recursive: true });
|
|
93
|
+
const content = result.format === "svg" ? result.data : result.data;
|
|
94
|
+
await writeFile(absPath, content);
|
|
95
|
+
const bytes =
|
|
96
|
+
result.format === "svg"
|
|
97
|
+
? Buffer.byteLength(result.data as string, "utf8")
|
|
98
|
+
: (result.data as Buffer).byteLength;
|
|
99
|
+
return {
|
|
100
|
+
content: [
|
|
101
|
+
{
|
|
102
|
+
type: "text" as const,
|
|
103
|
+
text: `Written to ${absPath} (${(bytes / 1024).toFixed(1)} KB)`,
|
|
104
|
+
},
|
|
105
|
+
],
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (result.format === "svg") {
|
|
110
|
+
return { content: [{ type: "text" as const, text: result.data }] };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
content: [
|
|
115
|
+
{
|
|
116
|
+
type: "image" as const,
|
|
117
|
+
data: result.data.toString("base64"),
|
|
118
|
+
mimeType: `image/${result.format}` as "image/png" | "image/jpeg",
|
|
119
|
+
},
|
|
120
|
+
],
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
server.registerTool(
|
|
126
|
+
"list_diagram_types",
|
|
127
|
+
{
|
|
128
|
+
title: "List supported diagram types",
|
|
129
|
+
description:
|
|
130
|
+
"Returns all diagram types Kroki supports along with their available output formats and whether they require a companion container. " +
|
|
131
|
+
"Call this before convert_diagram if you are unsure which type or format to use.",
|
|
132
|
+
inputSchema: {},
|
|
133
|
+
annotations: {
|
|
134
|
+
readOnlyHint: true,
|
|
135
|
+
idempotentHint: true,
|
|
136
|
+
openWorldHint: false,
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
async () => {
|
|
140
|
+
const rows = Object.entries(DIAGRAM_TYPES).map(([type, info]) => ({
|
|
141
|
+
type,
|
|
142
|
+
formats: info.formats,
|
|
143
|
+
requiresCompanion: info.requiresCompanion,
|
|
144
|
+
}));
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
content: [{ type: "text" as const, text: JSON.stringify(rows, null, 2) }],
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
server.registerTool(
|
|
153
|
+
"get_kroki_status",
|
|
154
|
+
{
|
|
155
|
+
title: "Get Kroki server status",
|
|
156
|
+
description:
|
|
157
|
+
"Returns the health and version of the local Kroki instance, which diagram types require companion containers, and current render cache stats. " +
|
|
158
|
+
"Useful for diagnosing why a diagram type is failing or checking what is running.",
|
|
159
|
+
inputSchema: {},
|
|
160
|
+
annotations: {
|
|
161
|
+
readOnlyHint: true,
|
|
162
|
+
idempotentHint: false,
|
|
163
|
+
openWorldHint: false,
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
async () => {
|
|
167
|
+
let healthy = false;
|
|
168
|
+
let krokiInfo: unknown = null;
|
|
169
|
+
|
|
170
|
+
try {
|
|
171
|
+
const res = await fetch(`${KROKI_URL}/health`, { signal: AbortSignal.timeout(3_000) });
|
|
172
|
+
healthy = res.ok;
|
|
173
|
+
if (res.ok) {
|
|
174
|
+
krokiInfo = await res.json().catch(() => null);
|
|
175
|
+
}
|
|
176
|
+
} catch {
|
|
177
|
+
// healthy stays false
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const companions = Object.entries(DIAGRAM_TYPES)
|
|
181
|
+
.filter(([, info]) => info.requiresCompanion)
|
|
182
|
+
.map(([type]) => type);
|
|
183
|
+
|
|
184
|
+
const status = {
|
|
185
|
+
kroki: {
|
|
186
|
+
url: KROKI_URL,
|
|
187
|
+
healthy,
|
|
188
|
+
...(krokiInfo ? { info: krokiInfo } : {}),
|
|
189
|
+
},
|
|
190
|
+
companions,
|
|
191
|
+
cache: diagramCache.stats(),
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
content: [{ type: "text" as const, text: JSON.stringify(status, null, 2) }],
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
// Warm up Kroki before accepting requests so the first tool call doesn't
|
|
201
|
+
// absorb the container start latency (can be 30s+ on cold pull).
|
|
202
|
+
await ensureKrokiRunning().catch((err: Error) => {
|
|
203
|
+
console.error(`[phantom-diagrams] Kroki warm-up failed: ${err.message}. Will retry on first tool call.`);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
const transport = new StdioServerTransport();
|
|
207
|
+
await server.connect(transport);
|
package/src/kroki.ts
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { KROKI_URL } from "./docker.js";
|
|
2
|
+
|
|
3
|
+
export type OutputFormat = "svg" | "png" | "jpeg";
|
|
4
|
+
|
|
5
|
+
export interface DiagramTypeInfo {
|
|
6
|
+
formats: OutputFormat[];
|
|
7
|
+
requiresCompanion: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const DIAGRAM_TYPES = {
|
|
11
|
+
// Built-in (no companion container required)
|
|
12
|
+
plantuml: { formats: ["svg", "png", "jpeg"] as OutputFormat[], requiresCompanion: false },
|
|
13
|
+
graphviz: { formats: ["svg", "png", "jpeg"] as OutputFormat[], requiresCompanion: false },
|
|
14
|
+
ditaa: { formats: ["svg", "png"] as OutputFormat[], requiresCompanion: false },
|
|
15
|
+
svgbob: { formats: ["svg", "png"] as OutputFormat[], requiresCompanion: false },
|
|
16
|
+
umlet: { formats: ["svg", "png"] as OutputFormat[], requiresCompanion: false },
|
|
17
|
+
erd: { formats: ["svg", "png"] as OutputFormat[], requiresCompanion: false },
|
|
18
|
+
nomnoml: { formats: ["svg", "png"] as OutputFormat[], requiresCompanion: false },
|
|
19
|
+
structurizr: { formats: ["svg", "png"] as OutputFormat[], requiresCompanion: false },
|
|
20
|
+
bytefield: { formats: ["svg", "png"] as OutputFormat[], requiresCompanion: false },
|
|
21
|
+
c4plantuml: { formats: ["svg", "png"] as OutputFormat[], requiresCompanion: false },
|
|
22
|
+
d2: { formats: ["svg"] as OutputFormat[], requiresCompanion: false },
|
|
23
|
+
dbml: { formats: ["svg", "png"] as OutputFormat[], requiresCompanion: false },
|
|
24
|
+
pikchr: { formats: ["svg"] as OutputFormat[], requiresCompanion: false },
|
|
25
|
+
symbolator: { formats: ["svg", "png"] as OutputFormat[], requiresCompanion: false },
|
|
26
|
+
vega: { formats: ["svg", "png"] as OutputFormat[], requiresCompanion: false },
|
|
27
|
+
vegalite: { formats: ["svg", "png"] as OutputFormat[], requiresCompanion: false },
|
|
28
|
+
wavedrom: { formats: ["svg", "png"] as OutputFormat[], requiresCompanion: false },
|
|
29
|
+
|
|
30
|
+
// Companion containers required
|
|
31
|
+
mermaid: { formats: ["svg", "png"] as OutputFormat[], requiresCompanion: true },
|
|
32
|
+
bpmn: { formats: ["svg"] as OutputFormat[], requiresCompanion: true },
|
|
33
|
+
excalidraw: { formats: ["svg", "png"] as OutputFormat[], requiresCompanion: true },
|
|
34
|
+
blockdiag: { formats: ["svg", "png"] as OutputFormat[], requiresCompanion: true },
|
|
35
|
+
seqdiag: { formats: ["svg", "png"] as OutputFormat[], requiresCompanion: true },
|
|
36
|
+
actdiag: { formats: ["svg", "png"] as OutputFormat[], requiresCompanion: true },
|
|
37
|
+
nwdiag: { formats: ["svg", "png"] as OutputFormat[], requiresCompanion: true },
|
|
38
|
+
packetdiag: { formats: ["svg", "png"] as OutputFormat[], requiresCompanion: true },
|
|
39
|
+
rackdiag: { formats: ["svg", "png"] as OutputFormat[], requiresCompanion: true },
|
|
40
|
+
diagramsnet: { formats: ["svg", "png"] as OutputFormat[], requiresCompanion: true },
|
|
41
|
+
} satisfies Record<string, DiagramTypeInfo>;
|
|
42
|
+
|
|
43
|
+
export type DiagramType = keyof typeof DIAGRAM_TYPES;
|
|
44
|
+
|
|
45
|
+
const MAX_SOURCE_BYTES = 256 * 1024; // 256 KB
|
|
46
|
+
const MAX_RESPONSE_BYTES = 10 * 1024 * 1024; // 10 MB
|
|
47
|
+
|
|
48
|
+
// Allowlist for Kroki-Diagram-Options-* header keys and values to prevent CRLF injection.
|
|
49
|
+
const OPTION_KEY_RE = /^[A-Za-z0-9_-]{1,64}$/;
|
|
50
|
+
const OPTION_VAL_RE = /^[\x20-\x7E]{0,1024}$/;
|
|
51
|
+
|
|
52
|
+
export type ConvertResult =
|
|
53
|
+
| { format: "svg"; data: string }
|
|
54
|
+
| { format: "png" | "jpeg"; data: Buffer };
|
|
55
|
+
|
|
56
|
+
export async function convertDiagram(
|
|
57
|
+
diagramType: DiagramType,
|
|
58
|
+
source: string,
|
|
59
|
+
outputFormat: OutputFormat,
|
|
60
|
+
options?: Record<string, string>,
|
|
61
|
+
queryOptions?: Record<string, string>
|
|
62
|
+
): Promise<ConvertResult> {
|
|
63
|
+
if (Buffer.byteLength(source, "utf8") > MAX_SOURCE_BYTES) {
|
|
64
|
+
throw new Error(`Diagram source exceeds ${MAX_SOURCE_BYTES / 1024} KB limit`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const headers: Record<string, string> = {
|
|
68
|
+
"Content-Type": "text/plain",
|
|
69
|
+
Accept: outputFormat === "svg" ? "image/svg+xml" : `image/${outputFormat}`,
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
if (options) {
|
|
73
|
+
for (const [key, value] of Object.entries(options)) {
|
|
74
|
+
if (!OPTION_KEY_RE.test(key)) {
|
|
75
|
+
throw new Error(`Invalid option key: ${JSON.stringify(key)}`);
|
|
76
|
+
}
|
|
77
|
+
if (!OPTION_VAL_RE.test(value)) {
|
|
78
|
+
throw new Error(`Invalid option value for key ${JSON.stringify(key)}`);
|
|
79
|
+
}
|
|
80
|
+
headers[`Kroki-Diagram-Options-${key}`] = value;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const url = new URL(`${KROKI_URL}/${diagramType}/${outputFormat}`);
|
|
85
|
+
if (queryOptions) {
|
|
86
|
+
for (const [key, value] of Object.entries(queryOptions)) {
|
|
87
|
+
if (!OPTION_KEY_RE.test(key)) {
|
|
88
|
+
throw new Error(`Invalid query option key: ${JSON.stringify(key)}`);
|
|
89
|
+
}
|
|
90
|
+
if (!OPTION_VAL_RE.test(value)) {
|
|
91
|
+
throw new Error(`Invalid query option value for key ${JSON.stringify(key)}`);
|
|
92
|
+
}
|
|
93
|
+
url.searchParams.set(key, value);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const res = await fetch(url, {
|
|
98
|
+
method: "POST",
|
|
99
|
+
headers,
|
|
100
|
+
body: source,
|
|
101
|
+
signal: AbortSignal.timeout(30_000),
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
if (!res.ok) {
|
|
105
|
+
const body = await res.text().catch(() => "");
|
|
106
|
+
const hint = res.status >= 400 && res.status < 500
|
|
107
|
+
? `Diagram source rejected — likely a syntax error in the ${diagramType} source. `
|
|
108
|
+
: "Kroki server error. ";
|
|
109
|
+
throw new Error(`${hint}HTTP ${res.status}: ${body}`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const contentType = res.headers.get("content-type") ?? "";
|
|
113
|
+
const expectedMime = outputFormat === "svg" ? "image/svg+xml" : `image/${outputFormat}`;
|
|
114
|
+
if (!contentType.includes(expectedMime)) {
|
|
115
|
+
throw new Error(
|
|
116
|
+
`Unexpected content-type from Kroki: "${contentType}" (expected "${expectedMime}")`
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const contentLength = Number(res.headers.get("content-length"));
|
|
121
|
+
if (Number.isFinite(contentLength) && contentLength > MAX_RESPONSE_BYTES) {
|
|
122
|
+
throw new Error(
|
|
123
|
+
`Kroki response too large: ${contentLength} bytes (limit ${MAX_RESPONSE_BYTES / 1024 / 1024} MB)`
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (outputFormat === "svg") {
|
|
128
|
+
const text = await res.text();
|
|
129
|
+
if (Buffer.byteLength(text, "utf8") > MAX_RESPONSE_BYTES) {
|
|
130
|
+
throw new Error("Kroki SVG response exceeds size limit");
|
|
131
|
+
}
|
|
132
|
+
return { format: "svg", data: text };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const arrayBuffer = await res.arrayBuffer();
|
|
136
|
+
if (arrayBuffer.byteLength > MAX_RESPONSE_BYTES) {
|
|
137
|
+
throw new Error("Kroki image response exceeds size limit");
|
|
138
|
+
}
|
|
139
|
+
return { format: outputFormat, data: Buffer.from(arrayBuffer) };
|
|
140
|
+
}
|