@endiagram/mcp 0.2.38 → 0.3.1

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/README.md CHANGED
@@ -104,6 +104,37 @@ waiter do: deliver needs: meal yields: served customer
104
104
 
105
105
  Learn more at [endiagram.com](https://endiagram.com).
106
106
 
107
+ ## Telemetry
108
+
109
+ `@endiagram/mcp` generates a random install ID on first run, stored at
110
+ `~/.endiagram/install-id` (mode `0600`). It is sent with every request as
111
+ the `X-Endiagram-Install-Id` HTTP header so we can correlate requests
112
+ from the same install for debugging issues that the per-IP signal alone
113
+ cannot track (mobile networks, VPNs, CGNAT all collapse or churn IPs).
114
+
115
+ **No source code, no file paths, no environment variables, and no PII
116
+ are sent.** The install ID is a random opaque UUIDv4 generated locally.
117
+
118
+ A first-run notice prints to **stderr** (never stdout — stdout is the
119
+ MCP JSON-RPC channel) with the disclosure and the opt-out instructions.
120
+ The notice fires once per install and never again.
121
+
122
+ ### Opting out
123
+
124
+ Any of these three methods disables the install ID:
125
+
126
+ 1. Set `ENDIAGRAM_TELEMETRY=off` as an environment variable (also
127
+ accepts `0`, `false`, `no`).
128
+ 2. Create a file at `~/.endiagram/telemetry` containing the word `off`.
129
+ 3. Delete `~/.endiagram/install-id`. (A new one is generated on next
130
+ run unless option 1 or 2 is also set.)
131
+
132
+ When any of these is active, the `X-Endiagram-Install-Id` header is not
133
+ sent at all — the server falls back to its per-IP HMAC `cid` for
134
+ correlation, which works fine for short-term per-session tracing.
135
+
136
+ Full privacy policy: [endiagram.com/privacy](https://endiagram.com/privacy)
137
+
107
138
  ## License
108
139
 
109
140
  MIT
package/dist/index.js CHANGED
@@ -2,12 +2,95 @@
2
2
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
4
  import { z } from "zod";
5
- import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
5
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, chmodSync } from "node:fs";
6
6
  import { join, dirname, resolve } from "node:path";
7
7
  import { fileURLToPath } from "node:url";
8
+ import { homedir } from "node:os";
9
+ import { randomUUID } from "node:crypto";
8
10
  const __dirname = dirname(fileURLToPath(import.meta.url));
9
11
  const toolsConfig = JSON.parse(readFileSync(join(__dirname, "../tools.json"), "utf-8"));
12
+ const pkg = JSON.parse(readFileSync(join(__dirname, "../package.json"), "utf-8"));
10
13
  const EN_API_URL = process.env.EN_API_URL ?? "https://api.endiagram.com";
14
+ // ─────────────────────────────────────────────────────────────────────
15
+ // Install ID — persistent, opt-out by env var or file
16
+ //
17
+ // On first run, generate a UUIDv4 and store it at ~/.endiagram/install-id
18
+ // (chmod 0600). Sent on every request as the X-Endiagram-Install-Id header
19
+ // so the server can correlate requests from the same install for debugging
20
+ // per-installation issues that the per-IP cid HMAC can't track (mobile
21
+ // rotation, CGNAT, VPN).
22
+ //
23
+ // No source code, no file paths, no environment variables, no PII are
24
+ // sent. The install ID is a random opaque UUID generated locally.
25
+ //
26
+ // Three opt-out mechanisms (any one disables the header):
27
+ // 1. ENDIAGRAM_TELEMETRY=off (env var, also accepts 0/false/no)
28
+ // 2. ~/.endiagram/telemetry (file containing "off")
29
+ // 3. delete ~/.endiagram/install-id (regenerated unless 1 or 2 set)
30
+ // ─────────────────────────────────────────────────────────────────────
31
+ const UUIDV4_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
32
+ function resolveInstallId() {
33
+ // Tier 1: env var opt-out
34
+ const envFlag = process.env.ENDIAGRAM_TELEMETRY?.trim().toLowerCase();
35
+ if (envFlag && /^(off|0|false|no)$/.test(envFlag)) {
36
+ return null;
37
+ }
38
+ const stateDir = join(homedir(), ".endiagram");
39
+ const telemetryFile = join(stateDir, "telemetry");
40
+ const installFile = join(stateDir, "install-id");
41
+ // Tier 2: disk flag opt-out
42
+ if (existsSync(telemetryFile)) {
43
+ try {
44
+ const flag = readFileSync(telemetryFile, "utf-8").trim().toLowerCase();
45
+ if (/^(off|0|false|no)$/.test(flag))
46
+ return null;
47
+ }
48
+ catch {
49
+ // unreadable telemetry file — fall through, prefer enabled state
50
+ }
51
+ }
52
+ // Read existing install-id
53
+ if (existsSync(installFile)) {
54
+ try {
55
+ const existing = readFileSync(installFile, "utf-8").trim().toLowerCase();
56
+ if (UUIDV4_REGEX.test(existing))
57
+ return existing;
58
+ // malformed — fall through to regenerate
59
+ }
60
+ catch {
61
+ // unreadable — fall through to regenerate
62
+ }
63
+ }
64
+ // First run (or corrupted file): generate, persist, print notice
65
+ const fresh = randomUUID();
66
+ try {
67
+ mkdirSync(stateDir, { recursive: true });
68
+ writeFileSync(installFile, fresh, { encoding: "utf-8" });
69
+ // Restrict to owner rw only — install ID is effectively a stable
70
+ // pseudonymous identifier; treat it like a low-sensitivity secret.
71
+ try {
72
+ chmodSync(installFile, 0o600);
73
+ }
74
+ catch {
75
+ // Some filesystems don't support POSIX perms — best effort.
76
+ }
77
+ // First-run disclosure to STDERR (never stdout — stdout is the
78
+ // MCP JSON-RPC channel and any bytes there corrupt Claude Desktop).
79
+ process.stderr.write(`[endiagram] First run: generated install ID at ${installFile}\n` +
80
+ `[endiagram] This ID is sent with requests so we can correlate per\n` +
81
+ ` installation for debugging. No source code, file paths,\n` +
82
+ ` env vars, or PII are sent.\n` +
83
+ ` Opt out: ENDIAGRAM_TELEMETRY=off (or delete the file)\n` +
84
+ ` Privacy: https://endiagram.com/privacy\n`);
85
+ }
86
+ catch {
87
+ // Could not persist (read-only home dir, etc.) — return the
88
+ // generated id anyway so the current process still gets correlation
89
+ // within its own lifetime. Next run will try again.
90
+ }
91
+ return fresh;
92
+ }
93
+ const INSTALL_ID = resolveInstallId();
11
94
  /**
12
95
  * Resolve the `source` parameter: if it looks like a file path (.en, .txt,
13
96
  * or starts with / or ~), read the file and return its contents.
@@ -30,9 +113,16 @@ function resolveSource(source) {
30
113
  }
31
114
  async function callApi(toolName, args) {
32
115
  try {
116
+ const headers = {
117
+ "Content-Type": "application/json",
118
+ "User-Agent": `endiagram-mcp/${pkg.version} node/${process.version.replace(/^v/, "")}`,
119
+ };
120
+ if (INSTALL_ID) {
121
+ headers["X-Endiagram-Install-Id"] = INSTALL_ID;
122
+ }
33
123
  const response = await fetch(`${EN_API_URL}/mcp`, {
34
124
  method: "POST",
35
- headers: { "Content-Type": "application/json" },
125
+ headers,
36
126
  body: JSON.stringify({
37
127
  jsonrpc: "2.0",
38
128
  id: Date.now(),
@@ -56,12 +146,17 @@ async function callApi(toolName, args) {
56
146
  const content = result?.content?.[0];
57
147
  const text = content?.text ?? body;
58
148
  const isError = result?.isError ?? false;
59
- // Check for SVG in second content block (render tool)
60
- const svgContent = result?.content?.[1];
61
- const svg = svgContent?.text?.startsWith("<svg")
62
- ? svgContent.text
149
+ // Check for rendered content in second content block (render tool).
150
+ // SVG: text block whose text starts with "<svg".
151
+ // PNG: image block with base64 data + mimeType "image/png".
152
+ const renderContent = result?.content?.[1];
153
+ const svg = renderContent?.text?.startsWith("<svg")
154
+ ? renderContent.text
155
+ : undefined;
156
+ const pngBase64 = renderContent?.type === "image" && renderContent?.mimeType === "image/png"
157
+ ? renderContent.data
63
158
  : undefined;
64
- return { text, isError, svg, data: undefined };
159
+ return { text, isError, svg, pngBase64, data: undefined };
65
160
  }
66
161
  catch {
67
162
  return { text: body, isError: false };
@@ -76,7 +171,7 @@ async function callApi(toolName, args) {
76
171
  }
77
172
  }
78
173
  const EN_INSTRUCTIONS = toolsConfig.instructions;
79
- const server = new McpServer({ name: "endiagram", version: "0.2.0" }, { instructions: EN_INSTRUCTIONS });
174
+ const server = new McpServer({ name: "endiagram", version: pkg.version }, { instructions: EN_INSTRUCTIONS });
80
175
  for (const tool of toolsConfig.tools) {
81
176
  const schemaProps = {};
82
177
  for (const param of tool.parameters) {
@@ -98,33 +193,47 @@ for (const tool of toolsConfig.tools) {
98
193
  const msg = e instanceof Error ? e.message : String(e);
99
194
  return { content: [{ type: "text", text: `Failed to read source file: ${msg}` }], isError: true };
100
195
  }
101
- // Special handling for render (save SVG to file)
196
+ // Special handling for render save SVG or PNG to file.
197
+ // The rendered image is for the user's eyes (audience:user), never
198
+ // injected into the model's context. We save to disk and return the
199
+ // file path as a text content block.
102
200
  if (tool.name === "render") {
103
201
  const result = await callApi("render", args);
104
- if (result.isError || !result.svg) {
202
+ const isPng = !!result.pngBase64;
203
+ const hasContent = isPng || !!result.svg;
204
+ if (result.isError || !hasContent) {
105
205
  return {
106
206
  content: [{ type: "text", text: result.text }],
107
207
  isError: result.isError,
108
208
  };
109
209
  }
110
210
  const outputPath = args.output;
211
+ const ext = isPng ? ".png" : ".svg";
111
212
  let filePath;
112
213
  if (outputPath) {
113
- const dir = dirname(outputPath);
214
+ // If the user gave a path, respect it but fix the extension.
215
+ const base = outputPath.replace(/\.(svg|png)$/i, "");
216
+ filePath = base + ext;
217
+ const dir = dirname(filePath);
114
218
  if (!existsSync(dir))
115
219
  mkdirSync(dir, { recursive: true });
116
- filePath = outputPath;
117
220
  }
118
221
  else {
119
222
  const outDir = join(process.cwd(), ".endiagram");
120
223
  if (!existsSync(outDir))
121
224
  mkdirSync(outDir, { recursive: true });
122
- filePath = join(outDir, `en-${Date.now()}.svg`);
225
+ filePath = join(outDir, `en-${Date.now()}${ext}`);
226
+ }
227
+ if (isPng) {
228
+ writeFileSync(filePath, Buffer.from(result.pngBase64, "base64"));
229
+ }
230
+ else {
231
+ writeFileSync(filePath, result.svg);
123
232
  }
124
- writeFileSync(filePath, result.svg);
233
+ const label = isPng ? "PNG" : "SVG";
125
234
  return {
126
235
  content: [
127
- { type: "text", text: `SVG saved: ${filePath}` },
236
+ { type: "text", text: `${label} saved: ${filePath}` },
128
237
  ],
129
238
  isError: false,
130
239
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@endiagram/mcp",
3
- "version": "0.2.38",
3
+ "version": "0.3.1",
4
4
  "description": "MCP server for EN Diagram — deterministic structural analysis backed by named theorems",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
package/tools.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
- "instructions": "EN Diagram is a structural verification engine for concurrent systems. No AI inside.\n\nTo describe a system, write one statement per line:\n actor do: action needs: input1, input2 yields: output1, output2 at: context\n\nEvery statement requires: actor, do:, needs:, and yields:. Shared names between yields and needs create connections automatically. Multi-word names work fine. Use commas to separate multiple inputs or outputs. at: scopes the action to a context (service, team, region, domain, layerwhatever boundary fits the system).\n\nMultiple actors can share the same action: actor1, actor2 do: action needs: X yields: Y. Two separate statements with the same action, needs, yields, and location are automatically merged into one shared action.\n\nSix tools answer six questions from concurrency theory. Each answer includes who (actors) and where (locations) alongside the structural findings.\n\n1. structure — what is this system?\n2. invariant — what's always true?\n3. live — can it deadlock? can entities overflow?\n4. reachable — can X reach Y?\n5. equivalent — are two systems the same?\n6. compose — how do parts combine?\n\nEvery tool output contains node names, values, and structural properties that can be fed directly into other tools. Read the output. Find the surprising node, the unexpected value, the structural anomaly. Then pick the tool that investigates THAT specific finding.\n\nDo not stop at one tool call. The first call reveals the structure — the real insight comes from the second, third, fourth call that digs into what the first one found.\n\nSpeak in the user's domain, not in graph theory. Only call render when the user explicitly asks to visualize.",
2
+ "instructions": "EN Diagram is a structural verification engine for concurrent systems. No AI inside.\n\n## Language syntax\n\nOne statement per line:\n actor do: action needs: input1, input2 yields: output1, output2 at: context\n\nEvery statement requires: actor, do:, needs:, and yields:. at: scopes the action to a context (service, team, region, domain, layer whatever boundary fits the system).\n\n**Names can contain spaces.** Unquoted identifiers can span multiple words, so `duo initiator do: create DuoSession needs: session factory yields: active session` is valid. Commas separate list items; whitespace inside a name is preserved. Wrap a name in double quotes only when it must contain a comma, colon, or `#` character.\n\n**Shared names create connections.** When one action's `yields:` name matches another action's `needs:` name, the two actions connect automatically. No explicit wiring string equality is the link.\n\n**Multiple actors on one action.** Comma-separate subjects to express that several actors perform the same action together:\n actor1, actor2 do: action needs: X yields: Y\nThis is a single action performed jointly, not two separate actions. Two independent statements with the same action name, needs, yields, AND at: location are automatically merged into one shared action.\n\n**Comments.** Lines starting with `#` are ignored. Blank lines are ignored.\n\n## Tools\n\nSix tools answer six questions from concurrency theory. Each answer includes who (actors) and where (locations) alongside the structural findings.\n\n1. structure — what is this system?\n2. invariant — what's always true?\n3. live — can it deadlock? can entities overflow?\n4. reachable — can X reach Y?\n5. equivalent — are two systems the same?\n6. compose — how do parts combine?\n\nEvery tool output contains node names, values, and structural properties that can be fed directly into other tools. Read the output. Find the surprising node, the unexpected value, the structural anomaly. Then pick the tool that investigates THAT specific finding.\n\nDo not stop at one tool call. The first call reveals the structure — the real insight comes from the second, third, fourth call that digs into what the first one found.\n\nSpeak in the user's domain, not in graph theory. Only call render when the user explicitly asks to visualize.",
3
3
  "tools": [
4
4
  {
5
5
  "name": "structure",
6
- "description": "What is this system? Complete structural overview: shape (topology), stages with roles, bridge nodes, cycles, parallelism, critical path, dominator tree, min-cuts, subsystems, interface nodes. Includes actors (who does what, workload entropy) and locations (where work happens, boundary crossings). Optional: pass node for per-node centrality, detect_findings for structural pattern detection.",
6
+ "description": "What is this system? Complete structural overview: shape (topology), stages with roles, bridge nodes, cycles, parallelism, critical path, dominator tree, min-cuts, subsystems, interface nodes. Includes actors (who does what, workload entropy) and locations (where work happens, boundary crossings). Optional: pass node for per-node centrality, detect_findings for structural pattern detection. See the server instructions for EN language syntax (spaces in names, # comments, shared actions).",
7
7
  "parameters": [
8
8
  {"name": "source", "type": "string", "description": "EN source code, or path to .en/.txt file", "required": true},
9
9
  {"name": "node", "type": "string", "description": "Node name for centrality query", "required": false},
@@ -12,7 +12,7 @@
12
12
  },
13
13
  {
14
14
  "name": "invariant",
15
- "description": "What's always true? conservationLaws are weighted entity sums constant across all executions. sustainableCycles are action sequences that return the system to its starting state (T-invariants). depletableSets are entity groups where simultaneous depletion is irreversible. behavioral.deficiency 0 means structure fully determines dynamics. behavioral.isReversible and behavioral.hasUniqueEquilibrium describe convergence properties.",
15
+ "description": "What's always true? conservationLaws are weighted entity sums constant across all executions. sustainableCycles are action sequences that return the system to its starting state (T-invariants). depletableSets are entity groups where simultaneous depletion is irreversible. behavioral.deficiency 0 means structure fully determines dynamics. behavioral.isReversible and behavioral.hasUniqueEquilibrium describe convergence properties. See the server instructions for EN language syntax.",
16
16
  "parameters": [
17
17
  {"name": "source", "type": "string", "description": "EN source code, or path to .en/.txt file", "required": true},
18
18
  {"name": "rules", "type": "string", "description": "Structural rules to check, one per line", "required": false}
@@ -20,14 +20,14 @@
20
20
  },
21
21
  {
22
22
  "name": "live",
23
- "description": "Can it deadlock? Can entities overflow? isStructurallyLive means every siphon contains a trap — no structural deadlock possible. uncoveredSiphons are entity groups that can drain permanently, with the actors and locations affected. isStructurallyBounded means no entity can accumulate without limit. unboundedCycles are action sequences that could cause overflow.",
23
+ "description": "Can it deadlock? Can entities overflow? isStructurallyLive means every siphon contains a trap — no structural deadlock possible. uncoveredSiphons are entity groups that can drain permanently, with the actors and locations affected. isStructurallyBounded means no entity can accumulate without limit. unboundedCycles are action sequences that could cause overflow. See the server instructions for EN language syntax.",
24
24
  "parameters": [
25
25
  {"name": "source", "type": "string", "description": "EN source code, or path to .en/.txt file", "required": true}
26
26
  ]
27
27
  },
28
28
  {
29
29
  "name": "reachable",
30
- "description": "Can X reach Y? Follows directed data flow first; falls back to undirected. Path shows each step with actor and location. locationCrossings counts boundary transitions. defense_nodes checks if guards cover all paths. coverage.fullCoverage false means unguarded routes exist.",
30
+ "description": "Can X reach Y? Follows directed data flow first; falls back to undirected. Path shows each step with actor and location. locationCrossings counts boundary transitions. defense_nodes checks if guards cover all paths. coverage.fullCoverage false means unguarded routes exist. See the server instructions for EN language syntax.",
31
31
  "parameters": [
32
32
  {"name": "source", "type": "string", "description": "EN source code", "required": true},
33
33
  {"name": "from", "type": "string", "description": "Starting node name", "required": true},
@@ -37,7 +37,7 @@
37
37
  },
38
38
  {
39
39
  "name": "equivalent",
40
- "description": "Are two systems the same? Compare mode (source_a + source_b): shows structural differences, edit distance, and spectral equivalence — isCospectral true means identical structure despite different names. Evolve mode (source + patch): dry-run a change, shows diff plus new/lost bridge nodes. Prefix action name with - in patch to remove it.",
40
+ "description": "Are two systems the same? Compare mode (source_a + source_b): shows structural differences, edit distance, and spectral equivalence — isCospectral true means identical structure despite different names. Evolve mode (source + patch): dry-run a change, shows diff plus new/lost bridge nodes. Prefix action name with - in patch to remove it. See the server instructions for EN language syntax.",
41
41
  "parameters": [
42
42
  {"name": "source_a", "type": "string", "description": "EN source code or path to .en/.txt file for the first system", "required": false},
43
43
  {"name": "source_b", "type": "string", "description": "EN source code or path to .en/.txt file for the second system", "required": false},
@@ -47,7 +47,7 @@
47
47
  },
48
48
  {
49
49
  "name": "compose",
50
- "description": "How do parts combine? Merge mode (source_a + source_b + links): merge two systems by linking shared entities. Extract mode (source + subsystem): extract a subsystem as standalone EN with boundary inputs/outputs, actors, and locations.",
50
+ "description": "How do parts combine? Merge mode (source_a + source_b + links): merge two systems by linking shared entities. Extract mode (source + subsystem): extract a subsystem as standalone EN with boundary inputs/outputs, actors, and locations. See the server instructions for EN language syntax.",
51
51
  "parameters": [
52
52
  {"name": "source_a", "type": "string", "description": "EN source code or path to .en/.txt file for the first system", "required": false},
53
53
  {"name": "source_b", "type": "string", "description": "EN source code or path to .en/.txt file for the second system", "required": false},
@@ -58,15 +58,18 @@
58
58
  },
59
59
  {
60
60
  "name": "render",
61
- "description": "SVG diagram. Only call when user explicitly asks to visualize.",
61
+ "description": "SVG or PNG diagram. Only call when user explicitly asks to visualize. The rendered image is delivered to the user, not injected into the model's context. See the server instructions for EN language syntax.",
62
62
  "parameters": [
63
63
  {"name": "source", "type": "string", "description": "EN source code, or path to .en/.txt file", "required": true},
64
- {"name": "theme", "type": "string", "description": "Color theme: dark or light", "required": false},
64
+ {"name": "theme", "type": "string", "description": "Color theme: named preset (Blueprint, Swiss, etc.) or dark/light", "required": false},
65
+ {"name": "isDark", "type": "string", "description": "true or false. Selects the dark or light variant of a named preset. If omitted, defaults to dark unless theme=light.", "required": false},
66
+ {"name": "type", "type": "string", "description": "Output format: svg (default) or png. PNG is rasterized server-side via Batik.", "required": false},
65
67
  {"name": "quality", "type": "string", "description": "Output quality: small, mid, or max", "required": false},
66
68
  {"name": "view", "type": "string", "description": "Group by: actors (partition by actor) or locations (partition by location). Default auto-detects topology.", "required": false},
67
- {"name": "structure_layers", "type": "string", "description": "Bitmask for structure overlays. Bits: 1=subsystems, 2=pipelines, 4=cycles, 8=forks, 16=joins, 32=hubs. Default 63 (all on). Pass 0 to hide all.", "required": false},
69
+ {"name": "structure_layers", "type": "string", "description": "Bitmask for structure overlays. Bits: 1=subsystems, 2=pipelines, 4=cycles, 8=forks, 16=joins, 32=hubs, 64=deadlock, 128=overflow. Default 255 (all on). Pass 0 to hide all.", "required": false},
68
70
  {"name": "color", "type": "string", "description": "Seed color hex (#RRGGBB) to generate a custom theme. Overrides theme parameter. One color generates the entire palette.", "required": false},
69
- {"name": "output", "type": "string", "description": "File path to save the SVG", "required": false}
71
+ {"name": "direction", "type": "string", "description": "Layout direction: LR (left-to-right) or TB (top-to-bottom). Default auto-detects from condensation DAG aspect ratio.", "required": false},
72
+ {"name": "output", "type": "string", "description": "File path to save the rendered image", "required": false}
70
73
  ]
71
74
  }
72
75
  ]