@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
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [main]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
ci:
|
|
11
|
+
name: Build & Test
|
|
12
|
+
runs-on: ubuntu-latest
|
|
13
|
+
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v4
|
|
16
|
+
|
|
17
|
+
- uses: actions/setup-node@v4
|
|
18
|
+
with:
|
|
19
|
+
node-version: 20
|
|
20
|
+
cache: npm
|
|
21
|
+
|
|
22
|
+
- name: Install dependencies
|
|
23
|
+
run: npm ci
|
|
24
|
+
|
|
25
|
+
- name: Type check
|
|
26
|
+
run: npm run typecheck
|
|
27
|
+
|
|
28
|
+
- name: Test
|
|
29
|
+
run: npm run test:unit
|
|
30
|
+
# Integration tests require Docker + running Kroki — skipped in CI.
|
|
31
|
+
# Run locally with: npm run test:integration
|
|
32
|
+
|
|
33
|
+
- name: Build
|
|
34
|
+
run: npm run build
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
name: Release Please
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
workflow_dispatch:
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
contents: write
|
|
10
|
+
pull-requests: write
|
|
11
|
+
packages: write
|
|
12
|
+
|
|
13
|
+
jobs:
|
|
14
|
+
release-please:
|
|
15
|
+
runs-on: ubuntu-latest
|
|
16
|
+
if: github.event_name == 'push'
|
|
17
|
+
outputs:
|
|
18
|
+
release_created: ${{ steps.release.outputs.release_created }}
|
|
19
|
+
steps:
|
|
20
|
+
- uses: googleapis/release-please-action@v4
|
|
21
|
+
id: release
|
|
22
|
+
with:
|
|
23
|
+
release-type: node
|
|
24
|
+
|
|
25
|
+
publish:
|
|
26
|
+
runs-on: ubuntu-latest
|
|
27
|
+
needs: release-please
|
|
28
|
+
if: |
|
|
29
|
+
always() &&
|
|
30
|
+
(needs.release-please.outputs.release_created == 'true' || github.event_name == 'workflow_dispatch')
|
|
31
|
+
|
|
32
|
+
steps:
|
|
33
|
+
- uses: actions/checkout@v4
|
|
34
|
+
|
|
35
|
+
- uses: actions/setup-node@v4
|
|
36
|
+
with:
|
|
37
|
+
node-version: 20
|
|
38
|
+
cache: npm
|
|
39
|
+
registry-url: https://registry.npmjs.org/
|
|
40
|
+
|
|
41
|
+
- name: Install dependencies
|
|
42
|
+
run: npm ci
|
|
43
|
+
|
|
44
|
+
- name: Build
|
|
45
|
+
run: npm run build
|
|
46
|
+
|
|
47
|
+
- name: Publish to npm
|
|
48
|
+
run: npm publish --access public
|
|
49
|
+
env:
|
|
50
|
+
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
package/CLAUDE.md
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# CLAUDE.md
|
|
2
|
+
|
|
3
|
+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
4
|
+
|
|
5
|
+
## Commands
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install # install dependencies
|
|
9
|
+
npm run build # compile TypeScript → dist/
|
|
10
|
+
npm start # run the MCP server (stdio transport)
|
|
11
|
+
npm run dev # watch mode — recompiles on change
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
No tests are configured yet. Verify manually via `npx @modelcontextprotocol/inspector node dist/index.js`.
|
|
15
|
+
|
|
16
|
+
## Architecture
|
|
17
|
+
|
|
18
|
+
This is a TypeScript MCP server (stdio transport) that wraps [Kroki](https://kroki.io/) — a unified diagram-rendering gateway. Kroki runs as a local Docker stack; the server auto-starts it on first tool call if it isn't already up.
|
|
19
|
+
|
|
20
|
+
### File map
|
|
21
|
+
|
|
22
|
+
| File | Role |
|
|
23
|
+
|---|---|
|
|
24
|
+
| `src/index.ts` | MCP server entry: registers tools, connects `StdioServerTransport` |
|
|
25
|
+
| `src/docker.ts` | `ensureKrokiRunning()` — health-checks Kroki and runs `docker compose up -d` if needed |
|
|
26
|
+
| `src/kroki.ts` | `convertDiagram()` HTTP client + `DIAGRAM_TYPES` static map |
|
|
27
|
+
| `docker-compose.yml` | Kroki + companion containers; project name `kroki-shared` (shared across profiles) |
|
|
28
|
+
|
|
29
|
+
### MCP tools exposed
|
|
30
|
+
|
|
31
|
+
- **`convert_diagram`** — POST diagram source text to Kroki, return SVG string or base64 PNG/JPEG
|
|
32
|
+
- **`list_diagram_types`** — return static map of supported types and their output formats
|
|
33
|
+
|
|
34
|
+
### Container startup flow
|
|
35
|
+
|
|
36
|
+
1. `GET localhost:8000/health` → already up, done
|
|
37
|
+
2. Else: `docker compose -p kroki-shared -f <abs-path>/docker-compose.yml up -d`
|
|
38
|
+
3. Poll health with exponential backoff (max 30 s)
|
|
39
|
+
|
|
40
|
+
Port is configurable via `KROKI_URL` env var (default `http://localhost:8000`).
|
|
41
|
+
|
|
42
|
+
### Output format handling
|
|
43
|
+
|
|
44
|
+
- `svg` → returned as a `text` content block (plain SVG string)
|
|
45
|
+
- `png` / `jpeg` → returned as an `image` content block (base64-encoded)
|
|
46
|
+
|
|
47
|
+
### Zod version
|
|
48
|
+
|
|
49
|
+
The project uses Zod v4 (bundled with `@modelcontextprotocol/sdk` 1.29+). Use `z.record(z.string(), z.string())` — Zod v4 requires both key and value type arguments.
|
package/README.md
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# mcp-phantom-diagrams
|
|
2
|
+
|
|
3
|
+
An MCP server that converts diagram markup text into images using a locally-managed [Kroki](https://kroki.io/) Docker stack. Supports 28+ diagram types (PlantUML, Mermaid, Graphviz, and more) with SVG, PNG, and JPEG output. Starts the Kroki containers automatically on first use — no manual Docker setup required.
|
|
4
|
+
|
|
5
|
+
## Tools
|
|
6
|
+
|
|
7
|
+
| Tool | Description |
|
|
8
|
+
|---|---|
|
|
9
|
+
| `convert_diagram` | Render diagram markup to SVG, PNG, or JPEG |
|
|
10
|
+
| `list_diagram_types` | List all supported diagram types and their available formats |
|
|
11
|
+
| `get_kroki_status` | Check Kroki health, version, companion container status, and cache stats |
|
|
12
|
+
|
|
13
|
+
### `convert_diagram` parameters
|
|
14
|
+
|
|
15
|
+
| Parameter | Type | Default | Description |
|
|
16
|
+
|---|---|---|---|
|
|
17
|
+
| `diagram_type` | string | — | Diagram language (e.g. `plantuml`, `mermaid`, `graphviz`) |
|
|
18
|
+
| `source` | string | — | Diagram markup source text |
|
|
19
|
+
| `output_format` | `svg` \| `png` \| `jpeg` | `svg` | Output format |
|
|
20
|
+
| `output_path` | string | — | Write output to this path instead of returning inline content |
|
|
21
|
+
| `options` | object | — | Diagram-type-specific options sent as `Kroki-Diagram-Options-*` headers |
|
|
22
|
+
| `query_options` | object | — | Options passed as URL query parameters (e.g. `{ "theme": "dark" }`) |
|
|
23
|
+
|
|
24
|
+
SVG is returned as a text block. PNG/JPEG are returned as base64 image blocks, or written to disk if `output_path` is set (recommended for large images to avoid token overhead).
|
|
25
|
+
|
|
26
|
+
## Supported diagram types
|
|
27
|
+
|
|
28
|
+
**Built-in** (no companion container required): `plantuml`, `graphviz`, `ditaa`, `svgbob`, `umlet`, `erd`, `nomnoml`, `structurizr`, `bytefield`, `c4plantuml`, `d2`, `dbml`, `pikchr`, `symbolator`, `vega`, `vegalite`, `wavedrom`
|
|
29
|
+
|
|
30
|
+
**Companion containers** (started automatically): `mermaid`, `bpmn`, `excalidraw`, `blockdiag`, `seqdiag`, `actdiag`, `nwdiag`, `packetdiag`, `rackdiag`, `diagramsnet`
|
|
31
|
+
|
|
32
|
+
## Requirements
|
|
33
|
+
|
|
34
|
+
- Node.js 20+
|
|
35
|
+
- Docker with Compose V2 (`docker compose` command)
|
|
36
|
+
|
|
37
|
+
## Installation
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
npm install
|
|
41
|
+
npm run build
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Claude Code configuration
|
|
45
|
+
|
|
46
|
+
Add to your `~/.claude/claude.json` (or project-level `.claude/claude.json`):
|
|
47
|
+
|
|
48
|
+
```json
|
|
49
|
+
{
|
|
50
|
+
"mcpServers": {
|
|
51
|
+
"phantom-diagrams": {
|
|
52
|
+
"command": "node",
|
|
53
|
+
"args": ["/absolute/path/to/mcp-phantom-diagrams/dist/index.js"]
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
On first start, the server pulls and starts the Kroki Docker images automatically (shared across all Claude profiles via the `kroki-shared` compose project). Subsequent starts are instant if containers are already running.
|
|
60
|
+
|
|
61
|
+
## Development
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
npm run dev # watch mode — recompiles on change
|
|
65
|
+
npm run test:unit # unit tests (no Docker required)
|
|
66
|
+
npm run test:integration # round-trip tests (requires Docker)
|
|
67
|
+
SKIP_INTEGRATION=1 npm test # unit tests only, e.g. in CI
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Architecture
|
|
71
|
+
|
|
72
|
+
```
|
|
73
|
+
src/
|
|
74
|
+
index.ts — MCP server, tool registration, startup warm-up
|
|
75
|
+
docker.ts — container health-check and docker compose up (async, single-flight)
|
|
76
|
+
kroki.ts — HTTP client for Kroki API, DIAGRAM_TYPES map
|
|
77
|
+
cache.ts — SHA-256 keyed LRU cache (100 entries / 50 MB)
|
|
78
|
+
docker-compose.yml — kroki + companion containers (project: kroki-shared)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
The server warms up Kroki before accepting connections so the first tool call is never delayed by container start time. If warm-up fails (Docker not running), it logs a warning and retries on the first tool call.
|
|
82
|
+
|
|
83
|
+
Rendered results are cached in memory for the lifetime of the server process, keyed on `(diagram_type, source, output_format, options, query_options)`.
|
package/dist/cache.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { ConvertResult } from "./kroki.js";
|
|
2
|
+
declare class DiagramCache {
|
|
3
|
+
private readonly store;
|
|
4
|
+
private totalBytes;
|
|
5
|
+
cacheKey(diagramType: string, source: string, outputFormat: string, options?: Record<string, string>, queryOptions?: Record<string, string>): string;
|
|
6
|
+
get(key: string): ConvertResult | undefined;
|
|
7
|
+
set(key: string, result: ConvertResult): void;
|
|
8
|
+
stats(): {
|
|
9
|
+
entries: number;
|
|
10
|
+
bytes: number;
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
export declare const diagramCache: DiagramCache;
|
|
14
|
+
export {};
|
package/dist/cache.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { createHash } from "crypto";
|
|
2
|
+
const MAX_ENTRIES = 100;
|
|
3
|
+
const MAX_BYTES = 50 * 1024 * 1024; // 50 MB
|
|
4
|
+
function resultBytes(result) {
|
|
5
|
+
return result.format === "svg"
|
|
6
|
+
? Buffer.byteLength(result.data, "utf8")
|
|
7
|
+
: result.data.byteLength;
|
|
8
|
+
}
|
|
9
|
+
class DiagramCache {
|
|
10
|
+
// Map preserves insertion order; delete+reinsert moves entry to "most recent" end.
|
|
11
|
+
store = new Map();
|
|
12
|
+
totalBytes = 0;
|
|
13
|
+
cacheKey(diagramType, source, outputFormat, options, queryOptions) {
|
|
14
|
+
return createHash("sha256")
|
|
15
|
+
.update(JSON.stringify([diagramType, source, outputFormat, options ?? {}, queryOptions ?? {}]))
|
|
16
|
+
.digest("hex");
|
|
17
|
+
}
|
|
18
|
+
get(key) {
|
|
19
|
+
const entry = this.store.get(key);
|
|
20
|
+
if (!entry)
|
|
21
|
+
return undefined;
|
|
22
|
+
this.store.delete(key);
|
|
23
|
+
this.store.set(key, entry);
|
|
24
|
+
return entry.result;
|
|
25
|
+
}
|
|
26
|
+
set(key, result) {
|
|
27
|
+
const bytes = resultBytes(result);
|
|
28
|
+
while (this.store.size >= MAX_ENTRIES ||
|
|
29
|
+
(this.totalBytes + bytes > MAX_BYTES && this.store.size > 0)) {
|
|
30
|
+
const oldest = this.store.keys().next().value;
|
|
31
|
+
if (oldest === undefined)
|
|
32
|
+
break;
|
|
33
|
+
this.totalBytes -= this.store.get(oldest).bytes;
|
|
34
|
+
this.store.delete(oldest);
|
|
35
|
+
}
|
|
36
|
+
this.store.set(key, { result, bytes });
|
|
37
|
+
this.totalBytes += bytes;
|
|
38
|
+
}
|
|
39
|
+
stats() {
|
|
40
|
+
return { entries: this.store.size, bytes: this.totalBytes };
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
export const diagramCache = new DiagramCache();
|
|
44
|
+
//# sourceMappingURL=cache.js.map
|
package/dist/docker.d.ts
ADDED
package/dist/docker.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { spawn } from "child_process";
|
|
2
|
+
import { fileURLToPath } from "url";
|
|
3
|
+
import { dirname, join } from "path";
|
|
4
|
+
export const KROKI_URL = process.env.KROKI_URL ?? "http://localhost:18000";
|
|
5
|
+
const COMPOSE_PROJECT = "kroki-shared";
|
|
6
|
+
const COMPOSE_TIMEOUT_MS = 5 * 60_000; // 5 min — allows for image pulls on first run
|
|
7
|
+
const HEALTH_TIMEOUT_MS = 30_000;
|
|
8
|
+
const HEALTH_POLL_START_MS = 500;
|
|
9
|
+
const HEALTH_POLL_MAX_MS = 2_000;
|
|
10
|
+
// Resolved at module load so it's portable regardless of cwd.
|
|
11
|
+
// spawnSync/spawn receive it as a single argv element — no shell, no injection risk.
|
|
12
|
+
const composeFile = join(dirname(fileURLToPath(import.meta.url)), "..", "docker-compose.yml");
|
|
13
|
+
async function isHealthy() {
|
|
14
|
+
try {
|
|
15
|
+
const res = await fetch(`${KROKI_URL}/health`, {
|
|
16
|
+
signal: AbortSignal.timeout(2_000),
|
|
17
|
+
});
|
|
18
|
+
if (!res.ok)
|
|
19
|
+
return false;
|
|
20
|
+
// Kroki returns {"status":"pass"} — validate to avoid false positives
|
|
21
|
+
// from other services that happen to expose a /health endpoint.
|
|
22
|
+
const body = await res.json();
|
|
23
|
+
return body["status"] === "pass";
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function startContainers() {
|
|
30
|
+
return new Promise((resolve, reject) => {
|
|
31
|
+
const proc = spawn("docker", ["compose", "-p", COMPOSE_PROJECT, "-f", composeFile, "up", "-d"], { stdio: ["ignore", "pipe", "pipe"] });
|
|
32
|
+
const timer = setTimeout(() => {
|
|
33
|
+
proc.kill("SIGKILL");
|
|
34
|
+
reject(new Error(`docker compose up timed out after ${COMPOSE_TIMEOUT_MS / 60_000} minutes. ` +
|
|
35
|
+
"Images may still be pulling — run 'docker compose -p kroki-shared pull' manually first."));
|
|
36
|
+
}, COMPOSE_TIMEOUT_MS);
|
|
37
|
+
let stderr = "";
|
|
38
|
+
proc.stderr?.on("data", (chunk) => { stderr += chunk.toString(); });
|
|
39
|
+
proc.on("error", (err) => {
|
|
40
|
+
clearTimeout(timer);
|
|
41
|
+
const isNotFound = err.code === "ENOENT";
|
|
42
|
+
reject(new Error(isNotFound
|
|
43
|
+
? "docker not found on PATH — is Docker installed and running?"
|
|
44
|
+
: `Failed to invoke docker: ${err.message}`));
|
|
45
|
+
});
|
|
46
|
+
proc.on("close", (code) => {
|
|
47
|
+
clearTimeout(timer);
|
|
48
|
+
if (code !== 0) {
|
|
49
|
+
reject(new Error(`docker compose up failed (exit ${code}):\n${stderr}`));
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
resolve();
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
async function waitForHealth() {
|
|
58
|
+
const deadline = Date.now() + HEALTH_TIMEOUT_MS;
|
|
59
|
+
let delay = HEALTH_POLL_START_MS;
|
|
60
|
+
while (Date.now() < deadline) {
|
|
61
|
+
if (await isHealthy())
|
|
62
|
+
return;
|
|
63
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
64
|
+
delay = Math.min(delay * 2, HEALTH_POLL_MAX_MS);
|
|
65
|
+
}
|
|
66
|
+
throw new Error("Kroki did not become healthy within 30s — ensure Docker is running and port 18000 is free. " +
|
|
67
|
+
"On first run, images may still be pulling; wait a moment and try again.");
|
|
68
|
+
}
|
|
69
|
+
// Single-flight latch: concurrent callers share one start attempt.
|
|
70
|
+
let startingPromise = null;
|
|
71
|
+
export async function ensureKrokiRunning() {
|
|
72
|
+
if (await isHealthy())
|
|
73
|
+
return;
|
|
74
|
+
if (!startingPromise) {
|
|
75
|
+
startingPromise = startContainers()
|
|
76
|
+
.then(() => waitForHealth())
|
|
77
|
+
.finally(() => { startingPromise = null; });
|
|
78
|
+
}
|
|
79
|
+
return startingPromise;
|
|
80
|
+
}
|
|
81
|
+
//# sourceMappingURL=docker.js.map
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
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 } from "./kroki.js";
|
|
9
|
+
import { diagramCache } from "./cache.js";
|
|
10
|
+
const server = new McpServer({
|
|
11
|
+
name: "phantom-diagrams",
|
|
12
|
+
version: "1.0.0",
|
|
13
|
+
});
|
|
14
|
+
const diagramTypeEnum = Object.keys(DIAGRAM_TYPES);
|
|
15
|
+
server.registerTool("convert_diagram", {
|
|
16
|
+
title: "Convert diagram source to image",
|
|
17
|
+
description: "Render a diagram from source text using a local Kroki instance. " +
|
|
18
|
+
"SVG output is returned as a text block; PNG and JPEG as base64 image blocks — or written to disk if output_path is provided. " +
|
|
19
|
+
"Results are cached in memory; identical inputs return instantly. " +
|
|
20
|
+
"Call list_diagram_types first to see which types support which formats. " +
|
|
21
|
+
"If Kroki returns an HTTP 4xx error, the diagram source likely has a syntax error — fix it and retry.",
|
|
22
|
+
inputSchema: {
|
|
23
|
+
diagram_type: z
|
|
24
|
+
.enum(diagramTypeEnum)
|
|
25
|
+
.describe("Diagram language/type — e.g. plantuml, mermaid, graphviz. Call list_diagram_types to see all options."),
|
|
26
|
+
source: z
|
|
27
|
+
.string()
|
|
28
|
+
.min(1)
|
|
29
|
+
.max(256 * 1024)
|
|
30
|
+
.describe("Diagram markup source text"),
|
|
31
|
+
output_format: z
|
|
32
|
+
.enum(["svg", "png", "jpeg"])
|
|
33
|
+
.default("svg")
|
|
34
|
+
.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)."),
|
|
35
|
+
output_path: z
|
|
36
|
+
.string()
|
|
37
|
+
.optional()
|
|
38
|
+
.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."),
|
|
39
|
+
options: z
|
|
40
|
+
.record(z.string(), z.string())
|
|
41
|
+
.optional()
|
|
42
|
+
.describe("Diagram-type-specific rendering options sent as Kroki-Diagram-Options-* HTTP headers (see Kroki docs). Keys and values must be printable ASCII."),
|
|
43
|
+
query_options: z
|
|
44
|
+
.record(z.string(), z.string())
|
|
45
|
+
.optional()
|
|
46
|
+
.describe("Diagram-type-specific options passed as URL query parameters (e.g. { theme: 'dark' }). Keys and values must be printable ASCII."),
|
|
47
|
+
},
|
|
48
|
+
annotations: {
|
|
49
|
+
readOnlyHint: false, // can write files when output_path is set
|
|
50
|
+
idempotentHint: true,
|
|
51
|
+
openWorldHint: false,
|
|
52
|
+
},
|
|
53
|
+
}, async ({ diagram_type, source, output_format, output_path, options, query_options }) => {
|
|
54
|
+
await ensureKrokiRunning();
|
|
55
|
+
const typeInfo = DIAGRAM_TYPES[diagram_type];
|
|
56
|
+
if (!typeInfo.formats.includes(output_format)) {
|
|
57
|
+
return {
|
|
58
|
+
isError: true,
|
|
59
|
+
content: [
|
|
60
|
+
{
|
|
61
|
+
type: "text",
|
|
62
|
+
text: `${diagram_type} does not support ${output_format}. Supported formats: ${typeInfo.formats.join(", ")}`,
|
|
63
|
+
},
|
|
64
|
+
],
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
const cacheKey = diagramCache.cacheKey(diagram_type, source, output_format, options, query_options);
|
|
68
|
+
let result = diagramCache.get(cacheKey);
|
|
69
|
+
if (!result) {
|
|
70
|
+
result = await convertDiagram(diagram_type, source, output_format, options, query_options);
|
|
71
|
+
diagramCache.set(cacheKey, result);
|
|
72
|
+
}
|
|
73
|
+
if (output_path) {
|
|
74
|
+
const absPath = resolve(output_path);
|
|
75
|
+
await mkdir(dirname(absPath), { recursive: true });
|
|
76
|
+
const content = result.format === "svg" ? result.data : result.data;
|
|
77
|
+
await writeFile(absPath, content);
|
|
78
|
+
const bytes = result.format === "svg"
|
|
79
|
+
? Buffer.byteLength(result.data, "utf8")
|
|
80
|
+
: result.data.byteLength;
|
|
81
|
+
return {
|
|
82
|
+
content: [
|
|
83
|
+
{
|
|
84
|
+
type: "text",
|
|
85
|
+
text: `Written to ${absPath} (${(bytes / 1024).toFixed(1)} KB)`,
|
|
86
|
+
},
|
|
87
|
+
],
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
if (result.format === "svg") {
|
|
91
|
+
return { content: [{ type: "text", text: result.data }] };
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
content: [
|
|
95
|
+
{
|
|
96
|
+
type: "image",
|
|
97
|
+
data: result.data.toString("base64"),
|
|
98
|
+
mimeType: `image/${result.format}`,
|
|
99
|
+
},
|
|
100
|
+
],
|
|
101
|
+
};
|
|
102
|
+
});
|
|
103
|
+
server.registerTool("list_diagram_types", {
|
|
104
|
+
title: "List supported diagram types",
|
|
105
|
+
description: "Returns all diagram types Kroki supports along with their available output formats and whether they require a companion container. " +
|
|
106
|
+
"Call this before convert_diagram if you are unsure which type or format to use.",
|
|
107
|
+
inputSchema: {},
|
|
108
|
+
annotations: {
|
|
109
|
+
readOnlyHint: true,
|
|
110
|
+
idempotentHint: true,
|
|
111
|
+
openWorldHint: false,
|
|
112
|
+
},
|
|
113
|
+
}, async () => {
|
|
114
|
+
const rows = Object.entries(DIAGRAM_TYPES).map(([type, info]) => ({
|
|
115
|
+
type,
|
|
116
|
+
formats: info.formats,
|
|
117
|
+
requiresCompanion: info.requiresCompanion,
|
|
118
|
+
}));
|
|
119
|
+
return {
|
|
120
|
+
content: [{ type: "text", text: JSON.stringify(rows, null, 2) }],
|
|
121
|
+
};
|
|
122
|
+
});
|
|
123
|
+
server.registerTool("get_kroki_status", {
|
|
124
|
+
title: "Get Kroki server status",
|
|
125
|
+
description: "Returns the health and version of the local Kroki instance, which diagram types require companion containers, and current render cache stats. " +
|
|
126
|
+
"Useful for diagnosing why a diagram type is failing or checking what is running.",
|
|
127
|
+
inputSchema: {},
|
|
128
|
+
annotations: {
|
|
129
|
+
readOnlyHint: true,
|
|
130
|
+
idempotentHint: false,
|
|
131
|
+
openWorldHint: false,
|
|
132
|
+
},
|
|
133
|
+
}, async () => {
|
|
134
|
+
let healthy = false;
|
|
135
|
+
let krokiInfo = null;
|
|
136
|
+
try {
|
|
137
|
+
const res = await fetch(`${KROKI_URL}/health`, { signal: AbortSignal.timeout(3_000) });
|
|
138
|
+
healthy = res.ok;
|
|
139
|
+
if (res.ok) {
|
|
140
|
+
krokiInfo = await res.json().catch(() => null);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
// healthy stays false
|
|
145
|
+
}
|
|
146
|
+
const companions = Object.entries(DIAGRAM_TYPES)
|
|
147
|
+
.filter(([, info]) => info.requiresCompanion)
|
|
148
|
+
.map(([type]) => type);
|
|
149
|
+
const status = {
|
|
150
|
+
kroki: {
|
|
151
|
+
url: KROKI_URL,
|
|
152
|
+
healthy,
|
|
153
|
+
...(krokiInfo ? { info: krokiInfo } : {}),
|
|
154
|
+
},
|
|
155
|
+
companions,
|
|
156
|
+
cache: diagramCache.stats(),
|
|
157
|
+
};
|
|
158
|
+
return {
|
|
159
|
+
content: [{ type: "text", text: JSON.stringify(status, null, 2) }],
|
|
160
|
+
};
|
|
161
|
+
});
|
|
162
|
+
// Warm up Kroki before accepting requests so the first tool call doesn't
|
|
163
|
+
// absorb the container start latency (can be 30s+ on cold pull).
|
|
164
|
+
await ensureKrokiRunning().catch((err) => {
|
|
165
|
+
console.error(`[phantom-diagrams] Kroki warm-up failed: ${err.message}. Will retry on first tool call.`);
|
|
166
|
+
});
|
|
167
|
+
const transport = new StdioServerTransport();
|
|
168
|
+
await server.connect(transport);
|
|
169
|
+
//# sourceMappingURL=index.js.map
|
package/dist/kroki.d.ts
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
export type OutputFormat = "svg" | "png" | "jpeg";
|
|
2
|
+
export interface DiagramTypeInfo {
|
|
3
|
+
formats: OutputFormat[];
|
|
4
|
+
requiresCompanion: boolean;
|
|
5
|
+
}
|
|
6
|
+
export declare const DIAGRAM_TYPES: {
|
|
7
|
+
plantuml: {
|
|
8
|
+
formats: OutputFormat[];
|
|
9
|
+
requiresCompanion: false;
|
|
10
|
+
};
|
|
11
|
+
graphviz: {
|
|
12
|
+
formats: OutputFormat[];
|
|
13
|
+
requiresCompanion: false;
|
|
14
|
+
};
|
|
15
|
+
ditaa: {
|
|
16
|
+
formats: OutputFormat[];
|
|
17
|
+
requiresCompanion: false;
|
|
18
|
+
};
|
|
19
|
+
svgbob: {
|
|
20
|
+
formats: OutputFormat[];
|
|
21
|
+
requiresCompanion: false;
|
|
22
|
+
};
|
|
23
|
+
umlet: {
|
|
24
|
+
formats: OutputFormat[];
|
|
25
|
+
requiresCompanion: false;
|
|
26
|
+
};
|
|
27
|
+
erd: {
|
|
28
|
+
formats: OutputFormat[];
|
|
29
|
+
requiresCompanion: false;
|
|
30
|
+
};
|
|
31
|
+
nomnoml: {
|
|
32
|
+
formats: OutputFormat[];
|
|
33
|
+
requiresCompanion: false;
|
|
34
|
+
};
|
|
35
|
+
structurizr: {
|
|
36
|
+
formats: OutputFormat[];
|
|
37
|
+
requiresCompanion: false;
|
|
38
|
+
};
|
|
39
|
+
bytefield: {
|
|
40
|
+
formats: OutputFormat[];
|
|
41
|
+
requiresCompanion: false;
|
|
42
|
+
};
|
|
43
|
+
c4plantuml: {
|
|
44
|
+
formats: OutputFormat[];
|
|
45
|
+
requiresCompanion: false;
|
|
46
|
+
};
|
|
47
|
+
d2: {
|
|
48
|
+
formats: OutputFormat[];
|
|
49
|
+
requiresCompanion: false;
|
|
50
|
+
};
|
|
51
|
+
dbml: {
|
|
52
|
+
formats: OutputFormat[];
|
|
53
|
+
requiresCompanion: false;
|
|
54
|
+
};
|
|
55
|
+
pikchr: {
|
|
56
|
+
formats: OutputFormat[];
|
|
57
|
+
requiresCompanion: false;
|
|
58
|
+
};
|
|
59
|
+
symbolator: {
|
|
60
|
+
formats: OutputFormat[];
|
|
61
|
+
requiresCompanion: false;
|
|
62
|
+
};
|
|
63
|
+
vega: {
|
|
64
|
+
formats: OutputFormat[];
|
|
65
|
+
requiresCompanion: false;
|
|
66
|
+
};
|
|
67
|
+
vegalite: {
|
|
68
|
+
formats: OutputFormat[];
|
|
69
|
+
requiresCompanion: false;
|
|
70
|
+
};
|
|
71
|
+
wavedrom: {
|
|
72
|
+
formats: OutputFormat[];
|
|
73
|
+
requiresCompanion: false;
|
|
74
|
+
};
|
|
75
|
+
mermaid: {
|
|
76
|
+
formats: OutputFormat[];
|
|
77
|
+
requiresCompanion: true;
|
|
78
|
+
};
|
|
79
|
+
bpmn: {
|
|
80
|
+
formats: OutputFormat[];
|
|
81
|
+
requiresCompanion: true;
|
|
82
|
+
};
|
|
83
|
+
excalidraw: {
|
|
84
|
+
formats: OutputFormat[];
|
|
85
|
+
requiresCompanion: true;
|
|
86
|
+
};
|
|
87
|
+
blockdiag: {
|
|
88
|
+
formats: OutputFormat[];
|
|
89
|
+
requiresCompanion: true;
|
|
90
|
+
};
|
|
91
|
+
seqdiag: {
|
|
92
|
+
formats: OutputFormat[];
|
|
93
|
+
requiresCompanion: true;
|
|
94
|
+
};
|
|
95
|
+
actdiag: {
|
|
96
|
+
formats: OutputFormat[];
|
|
97
|
+
requiresCompanion: true;
|
|
98
|
+
};
|
|
99
|
+
nwdiag: {
|
|
100
|
+
formats: OutputFormat[];
|
|
101
|
+
requiresCompanion: true;
|
|
102
|
+
};
|
|
103
|
+
packetdiag: {
|
|
104
|
+
formats: OutputFormat[];
|
|
105
|
+
requiresCompanion: true;
|
|
106
|
+
};
|
|
107
|
+
rackdiag: {
|
|
108
|
+
formats: OutputFormat[];
|
|
109
|
+
requiresCompanion: true;
|
|
110
|
+
};
|
|
111
|
+
diagramsnet: {
|
|
112
|
+
formats: OutputFormat[];
|
|
113
|
+
requiresCompanion: true;
|
|
114
|
+
};
|
|
115
|
+
};
|
|
116
|
+
export type DiagramType = keyof typeof DIAGRAM_TYPES;
|
|
117
|
+
export type ConvertResult = {
|
|
118
|
+
format: "svg";
|
|
119
|
+
data: string;
|
|
120
|
+
} | {
|
|
121
|
+
format: "png" | "jpeg";
|
|
122
|
+
data: Buffer;
|
|
123
|
+
};
|
|
124
|
+
export declare function convertDiagram(diagramType: DiagramType, source: string, outputFormat: OutputFormat, options?: Record<string, string>, queryOptions?: Record<string, string>): Promise<ConvertResult>;
|