@miadi/stateloom-mcp 0.2.3 → 0.2.5

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/dist/erd.d.ts ADDED
@@ -0,0 +1,47 @@
1
+ /**
2
+ * The ERD half of the MCP server (Spec 80).
3
+ *
4
+ * The loom weaves one active document at a time, and the document's type is
5
+ * its extension: `.smdf.json` is a state machine, `.erdf.json` an
6
+ * entity-relationship definition. The tools here act on the active document
7
+ * when it is an ERDF; `create_erd` is the one that also moves the active
8
+ * document, because naming an ERD is how its first version is written.
9
+ *
10
+ * An ERD travels to the live canvas as a whole document (`emitFull`) — the
11
+ * granular PatchOp vocabulary is SMDF's, and an ERD is small enough that the
12
+ * whole of it is a reasonable unit of change.
13
+ *
14
+ * Everything that touches the server's own state comes in through `ErdHost`,
15
+ * so a test can register these tools on a bare McpServer over a temp directory.
16
+ */
17
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
18
+ import { type EntityRelationshipDefinition } from "@miadi/stateloom-protocol";
19
+ export interface ErdHost {
20
+ /** The active document's absolute path. */
21
+ projectFile(): string;
22
+ /** Re-point the active document under set_project_file's guards. Returns the refusal, if any. */
23
+ switchTo(path: string): string | undefined;
24
+ /** Mirror the whole document to the live bridge room. Best-effort. */
25
+ emitFull(def: EntityRelationshipDefinition): void;
26
+ /** The refusal for a path outside the permitted document root, if any. */
27
+ outsideRoot(path: string): string | undefined;
28
+ }
29
+ type ToolResult = {
30
+ content: {
31
+ type: "text";
32
+ text: string;
33
+ }[];
34
+ isError?: boolean;
35
+ };
36
+ export declare function readErd(path: string): EntityRelationshipDefinition | null;
37
+ /** One line for set_project_file / get_project_file when the active document is an ERDF. */
38
+ export declare function erdSummary(path: string): string;
39
+ export declare function erdGetDefinition(host: ErdHost): ToolResult;
40
+ export declare function erdLoadDefinition(host: ErdHost, json: string): ToolResult;
41
+ export declare function erdRender(host: ErdHost, opts: {
42
+ format?: string;
43
+ path?: string;
44
+ stamp?: boolean;
45
+ }): ToolResult;
46
+ export declare function registerErdTools(server: McpServer, host: ErdHost): void;
47
+ export {};
package/dist/erd.js ADDED
@@ -0,0 +1,240 @@
1
+ import { z } from "zod";
2
+ import { existsSync, readFileSync, readdirSync, writeFileSync } from "fs";
3
+ import { dirname, join, resolve } from "path";
4
+ import { addAttribute, addEntity, addRelationship, checkLinks, checkStateOf, emptyErd, ERD_CARDINALITIES, isErdDefinition, isErdfPath, removeAttribute, removeEntity, removeRelationship, renderMermaidEr, updateEntity, summarizeErd, validateErd, } from "@miadi/stateloom-protocol";
5
+ import { stampedOutputPath } from "@miadi/stateloom-cli/render";
6
+ const ok = (text) => ({ content: [{ type: "text", text }] });
7
+ const fail = (text) => ({ content: [{ type: "text", text }], isError: true });
8
+ export function readErd(path) {
9
+ if (!existsSync(path))
10
+ return null;
11
+ try {
12
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
13
+ return isErdDefinition(parsed) ? parsed : null;
14
+ }
15
+ catch {
16
+ return null;
17
+ }
18
+ }
19
+ function writeErd(path, def) {
20
+ writeFileSync(path, JSON.stringify(def, null, 2) + "\n", "utf8");
21
+ }
22
+ /** One line for set_project_file / get_project_file when the active document is an ERDF. */
23
+ export function erdSummary(path) {
24
+ const def = readErd(path);
25
+ if (def) {
26
+ return `existing ERD '${def.settings?.name ?? ""}' (${def.entities.length} entities, ${(def.relationships ?? []).length} relationships)`;
27
+ }
28
+ return existsSync(path)
29
+ ? "file exists but is not a readable ERD definition"
30
+ : "no file yet — create_erd or load_definition will write it";
31
+ }
32
+ const NOT_ERD = (path) => `The active document ${path} is not an ERD. Use create_erd, or set_project_file to a .erdf.json document.`;
33
+ /** Read the active ERD, apply `edit`, persist, mirror, and report. Errors become tool errors. */
34
+ function mutate(host, edit, report) {
35
+ const path = host.projectFile();
36
+ if (!isErdfPath(path))
37
+ return fail(NOT_ERD(path));
38
+ const def = readErd(path);
39
+ if (!def)
40
+ return fail(`No ERD at ${path}. Use create_erd first.`);
41
+ try {
42
+ const next = edit(def);
43
+ writeErd(path, next);
44
+ host.emitFull(next);
45
+ return ok(report(next));
46
+ }
47
+ catch (e) {
48
+ return fail(e instanceof Error ? e.message : String(e));
49
+ }
50
+ }
51
+ // ─── The tools that answer by extension (called from server.ts) ──────
52
+ export function erdGetDefinition(host) {
53
+ const def = readErd(host.projectFile());
54
+ return def ? ok(JSON.stringify(def, null, 2)) : fail(`No ERD at ${host.projectFile()}.`);
55
+ }
56
+ export function erdLoadDefinition(host, json) {
57
+ let parsed;
58
+ try {
59
+ parsed = JSON.parse(json);
60
+ }
61
+ catch (e) {
62
+ return fail(`Invalid JSON: ${e}`);
63
+ }
64
+ if (!isErdDefinition(parsed)) {
65
+ return fail(`The active document ${host.projectFile()} is an ERD, and this JSON is not one (it needs an "entities" array and no "state"). ` +
66
+ `To load a state machine, set_project_file to a .smdf.json document first.`);
67
+ }
68
+ const def = { ...parsed, relationships: parsed.relationships ?? [] };
69
+ writeErd(host.projectFile(), def);
70
+ host.emitFull(def);
71
+ const errors = validateErd(def);
72
+ return ok(`Loaded ERD → ${host.projectFile()}: ${def.settings?.name ?? ""} (${def.entities.length} entities, ${def.relationships.length} relationships)` +
73
+ (errors.length ? `\n${formatErrors(errors)}` : ""));
74
+ }
75
+ export function erdRender(host, opts) {
76
+ const doc = host.projectFile();
77
+ const def = readErd(doc);
78
+ if (!def)
79
+ return fail(`No ERD at ${doc}.`);
80
+ const format = opts.format ?? "mermaid";
81
+ if (format !== "mermaid") {
82
+ return fail(`An ERD renders as mermaid only (asked for ${format}); svg and png are not drawn for an ERD yet.`);
83
+ }
84
+ if (opts.path) {
85
+ const denial = host.outsideRoot(opts.path);
86
+ if (denial)
87
+ return fail(denial);
88
+ }
89
+ // `.erd.mmd`, so a machine and an ERD that share a basename never overwrite each other.
90
+ const out = opts.path
91
+ ? resolve(opts.path)
92
+ : opts.stamp
93
+ ? stampedOutputPath(doc, def.settings?.name, "mermaid", new Date())
94
+ : resolve(doc).replace(/\.erdf\.json$/i, "") + ".erd.mmd";
95
+ const text = renderMermaidEr(def);
96
+ writeFileSync(out, text + "\n", "utf8");
97
+ return {
98
+ content: [
99
+ { type: "text", text: `Rendered ERD '${def.settings?.name ?? ""}' → ${out} (${text.length} bytes)` },
100
+ { type: "text", text },
101
+ ],
102
+ };
103
+ }
104
+ const formatErrors = (errors) => errors.map((e) => `[${e.ruleId}] ${e.message}`).join("\n");
105
+ function readSmdf(path) {
106
+ if (!existsSync(path))
107
+ return null;
108
+ try {
109
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
110
+ const def = parsed.stateMachine ?? parsed.StateMachine ?? parsed;
111
+ return def && typeof def === "object" && def.state ? def : null;
112
+ }
113
+ catch {
114
+ return null;
115
+ }
116
+ }
117
+ // ─── Tool registration ───────────────────────────────────────────────
118
+ export function registerErdTools(server, host) {
119
+ server.tool("create_erd", "Create a new entity-relationship definition (.erdf.json) and make it the active document. An ERD is the sibling of a state machine: it describes the data the machines act on, once, for every machine that touches it. Without `path` it lands next to the active document as <name>.erdf.json. Refuses to replace an ERD that already has entities unless overwrite is true.", {
120
+ namespace: z.string(),
121
+ name: z.string(),
122
+ description: z.string().optional(),
123
+ path: z.string().optional(),
124
+ overwrite: z.boolean().optional(),
125
+ }, async ({ namespace, name, description, path, overwrite }) => {
126
+ const active = host.projectFile();
127
+ const target = resolve(path ?? (isErdfPath(active) ? active : join(dirname(active), `${name}.erdf.json`)));
128
+ if (!isErdfPath(target))
129
+ return fail(`An ERD document ends in .erdf.json (got '${target}').`);
130
+ const existing = readErd(target);
131
+ if (existing && existing.entities.length > 0 && !overwrite) {
132
+ return fail(`${target} already holds ERD '${existing.settings?.name ?? ""}' with ${existing.entities.length} entities. ` +
133
+ `Pass overwrite: true to replace it, or set_project_file to it to keep working on it.`);
134
+ }
135
+ if (target !== active) {
136
+ const denial = host.switchTo(target);
137
+ if (denial)
138
+ return fail(denial);
139
+ }
140
+ const def = emptyErd(namespace, name, description);
141
+ writeErd(target, def);
142
+ host.emitFull(def);
143
+ return ok(`Created ERD '${name}' in namespace '${namespace}' → ${target}. It is now the active document. Add entities next.`);
144
+ });
145
+ server.tool("add_entity", "Add an entity to the active ERD. `weak` marks an entity that exists only through another one (an order line, without its order); Chen notation draws it as a double rectangle.", { name: z.string(), description: z.string().optional(), weak: z.boolean().optional() }, async ({ name, description, weak }) => mutate(host, (def) => addEntity(def, { name, description, weak: weak || undefined }), () => `Added ${weak ? "weak " : ""}entity '${name}'.`));
146
+ server.tool("update_entity", "Change an entity's description or whether it is weak, keeping its attributes and relationships. Omit a field to leave it alone; an empty description or weak:false clears it.", { name: z.string(), description: z.string().optional(), weak: z.boolean().optional() }, async ({ name, description, weak }) => mutate(host, (def) => updateEntity(def, name, { description, weak }), () => `Updated entity '${name}'.`));
147
+ server.tool("add_attribute", "Add an attribute to an entity of the active ERD. `key` is pk or uk; a foreign key is stated by `references` ('Entity' or 'Entity.attribute'), so one attribute can be both. `stateOf` names the state machine(s) whose current state this attribute stores — the link between the two diagrams.", {
148
+ entity: z.string(),
149
+ name: z.string(),
150
+ type: z.string(),
151
+ key: z.enum(["pk", "uk"]).optional(),
152
+ references: z.string().optional(),
153
+ nullable: z.boolean().optional(),
154
+ stateOf: z.union([z.string(), z.array(z.string())]).optional(),
155
+ description: z.string().optional(),
156
+ }, async ({ entity, ...attribute }) => mutate(host, (def) => addAttribute(def, entity, attribute), () => `Added attribute '${entity}.${attribute.name}' (${attribute.type}).`));
157
+ server.tool("add_relationship", "Add a relationship between two entities of the active ERD. Cardinality reads left to right: one `from` has many `to` is 1:N.", {
158
+ from: z.string(),
159
+ to: z.string(),
160
+ cardinality: z.enum(ERD_CARDINALITIES),
161
+ label: z.string().optional(),
162
+ description: z.string().optional(),
163
+ }, async (rel) => mutate(host, (def) => addRelationship(def, rel), () => `Added relationship ${rel.from} ${rel.cardinality} ${rel.to}${rel.label ? ` : ${rel.label}` : ""}.`));
164
+ server.tool("remove_entity", "Remove an entity from the active ERD, with every relationship that touches it. Attributes elsewhere that reference it are left in place; validate_erd names them.", { name: z.string() }, async ({ name }) => mutate(host, (def) => removeEntity(def, name), () => `Removed entity '${name}' and its relationships.`));
165
+ server.tool("remove_attribute", "Remove an attribute from an entity of the active ERD", { entity: z.string(), name: z.string() }, async ({ entity, name }) => mutate(host, (def) => removeAttribute(def, entity, name), () => `Removed attribute '${entity}.${name}'.`));
166
+ server.tool("remove_relationship", "Remove the relationship(s) written from one entity to another in the active ERD — only the one carrying `label` when a label is given.", { from: z.string(), to: z.string(), label: z.string().optional() }, async ({ from, to, label }) => mutate(host, (def) => removeRelationship(def, from, to, label), () => `Removed relationship ${from} → ${to}${label ? ` : ${label}` : ""}.`));
167
+ server.tool("validate_erd", "Validate the active ERD against rules E001–E005 (unique names, relationship ends, cardinality, references) and list what it holds", {}, async () => {
168
+ const path = host.projectFile();
169
+ if (!isErdfPath(path))
170
+ return fail(NOT_ERD(path));
171
+ const def = readErd(path);
172
+ if (!def)
173
+ return fail(`No ERD at ${path}. Use create_erd first.`);
174
+ const errors = validateErd(def);
175
+ const body = summarizeErd(def) || "(empty)";
176
+ return errors.length
177
+ ? fail(`${errors.length} problem(s) in '${def.settings?.name ?? ""}':\n${formatErrors(errors)}\n\n${body}`)
178
+ : ok(`ERD '${def.settings?.name ?? ""}' is valid.\n${body}`);
179
+ });
180
+ server.tool("check_links", "Check the names a state machine uses for its data against an ERD (rules L001–L004): the class of each object a guard reads a field of must be an entity, each <instance>.<field> in a guard must be an attribute of it, and an attribute's stateOf must point at a machine built with that entity. With no arguments: when the active document is an ERD, every .smdf.json beside it is checked; when it is a state machine, pass erd_path.", { erd_path: z.string().optional(), smdf_paths: z.array(z.string()).optional() }, async ({ erd_path, smdf_paths }) => {
181
+ const active = host.projectFile();
182
+ const erdPath = erd_path ? resolve(erd_path) : isErdfPath(active) ? active : undefined;
183
+ if (!erdPath) {
184
+ return fail(`The active document ${active} is a state machine — pass erd_path, the .erdf.json to check it against.`);
185
+ }
186
+ // The single active machine is one of possibly several, so its ERD's other
187
+ // stateOf names are not held against it; any other set is "the machines at hand".
188
+ let machinesAreTheSet = true;
189
+ let smdfPaths;
190
+ if (smdf_paths?.length) {
191
+ smdfPaths = smdf_paths.map((p) => resolve(p));
192
+ }
193
+ else if (!isErdfPath(active)) {
194
+ smdfPaths = [active];
195
+ machinesAreTheSet = false;
196
+ }
197
+ else {
198
+ const dir = dirname(erdPath);
199
+ smdfPaths = readdirSync(dir)
200
+ .filter((f) => f.toLowerCase().endsWith(".smdf.json"))
201
+ .sort()
202
+ .map((f) => join(dir, f));
203
+ if (smdfPaths.length === 0)
204
+ return fail(`No .smdf.json beside ${erdPath} — pass smdf_paths.`);
205
+ }
206
+ for (const p of [erdPath, ...smdfPaths]) {
207
+ const denial = host.outsideRoot(p);
208
+ if (denial)
209
+ return fail(denial);
210
+ }
211
+ const erd = readErd(erdPath);
212
+ if (!erd)
213
+ return fail(`No readable ERD at ${erdPath}.`);
214
+ const lines = [];
215
+ const names = [];
216
+ let problems = 0;
217
+ for (const p of smdfPaths) {
218
+ const smdf = readSmdf(p);
219
+ if (!smdf) {
220
+ lines.push(`${p}: not a readable state machine`);
221
+ problems += 1;
222
+ continue;
223
+ }
224
+ const name = smdf.settings?.name ?? p;
225
+ names.push(name);
226
+ const errors = checkLinks(smdf, erd);
227
+ problems += errors.length;
228
+ lines.push(errors.length ? `${name} (${p}):\n${formatErrors(errors)}` : `${name}: every name resolves`);
229
+ }
230
+ if (machinesAreTheSet) {
231
+ const errors = checkStateOf(erd, names);
232
+ problems += errors.length;
233
+ if (errors.length)
234
+ lines.push(formatErrors(errors));
235
+ }
236
+ const head = `Checked ${smdfPaths.length} machine(s) against ERD '${erd.settings?.name ?? ""}' (${erdPath}): ${problems} problem(s).`;
237
+ const text = `${head}\n${lines.join("\n")}`;
238
+ return problems ? fail(text) : ok(text);
239
+ });
240
+ }
package/dist/server.d.ts CHANGED
@@ -18,9 +18,16 @@
18
18
  * - load_definition: Load a definition from JSON
19
19
  * - list_states: List all states in the current definition
20
20
  * - list_events: List all events in the current definition
21
- * - set_project_file: Choose which .smdf.json path is the active document
21
+ * - set_project_file: Choose which document is active (.smdf.json or .erdf.json)
22
22
  * - get_project_file: Report the active document path and bridge status
23
23
  *
24
+ * ERD tools (Spec 80, ./erd.ts) — the active document's type is its extension:
25
+ * - create_erd, add_entity, update_entity, add_attribute, add_relationship
26
+ * - remove_entity, remove_attribute, remove_relationship
27
+ * - validate_erd, check_links
28
+ * get_definition, load_definition and render_diagram answer for an ERD when
29
+ * the active document is one.
30
+ *
24
31
  * Resources:
25
32
  * - smcraft://definition — Current state machine definition
26
33
  *
package/dist/server.js CHANGED
@@ -18,9 +18,16 @@
18
18
  * - load_definition: Load a definition from JSON
19
19
  * - list_states: List all states in the current definition
20
20
  * - list_events: List all events in the current definition
21
- * - set_project_file: Choose which .smdf.json path is the active document
21
+ * - set_project_file: Choose which document is active (.smdf.json or .erdf.json)
22
22
  * - get_project_file: Report the active document path and bridge status
23
23
  *
24
+ * ERD tools (Spec 80, ./erd.ts) — the active document's type is its extension:
25
+ * - create_erd, add_entity, update_entity, add_attribute, add_relationship
26
+ * - remove_entity, remove_attribute, remove_relationship
27
+ * - validate_erd, check_links
28
+ * get_definition, load_definition and render_diagram answer for an ERD when
29
+ * the active document is one.
30
+ *
24
31
  * Resources:
25
32
  * - smcraft://definition — Current state machine definition
26
33
  *
@@ -39,9 +46,10 @@ import { basename, dirname, join, resolve } from "path";
39
46
  import { fileURLToPath } from "node:url";
40
47
  import { tmpdir } from "os";
41
48
  import { createBridgeClient } from "@miadi/stateloom-client";
42
- import { envAlias } from "@miadi/stateloom-protocol";
49
+ import { envAlias, isErdfPath, } from "@miadi/stateloom-protocol";
43
50
  import { renderDiagramToFile, defaultOutputPath } from "@miadi/stateloom-cli/render";
44
51
  import { resolveProjectSwitch } from "./projectSwitch.js";
52
+ import { registerErdTools, erdSummary, erdGetDefinition, erdLoadDefinition, erdRender, } from "./erd.js";
45
53
  // STATELOOM_* read first, SMCRAFT_* legacy twin honored — the live MCP
46
54
  // registration bakes SMCRAFT_PROJECT_FILE and must keep working (2026-07-27
47
55
  // rename brief). Resolved once at boot, like the direct reads they replace.
@@ -62,6 +70,9 @@ const canvasLink = (path) => `${CANVAS_URL}/?doc=${encodeURIComponent(path)}`;
62
70
  // .smdf.json document mid-session (the hub keys live rooms by this path).
63
71
  let PROJECT_FILE = resolve(envAlias("PROJECT_FILE") ?? "./statemachine.smdf.json");
64
72
  function readDef() {
73
+ // An ERD is not a machine; the state-machine tools see no definition there.
74
+ if (isErdfPath(PROJECT_FILE))
75
+ return null;
65
76
  if (!existsSync(PROJECT_FILE))
66
77
  return null;
67
78
  try {
@@ -75,6 +86,11 @@ function readDef() {
75
86
  }
76
87
  }
77
88
  function writeDef(def) {
89
+ // Never write a machine over an ERD: create_state_machine and load_definition
90
+ // write without reading first, so this is the one place that can refuse.
91
+ if (isErdfPath(PROJECT_FILE)) {
92
+ throw new Error(`The active document ${PROJECT_FILE} is an ERD. Use the ERD tools on it, or set_project_file to a .smdf.json document for a state machine.`);
93
+ }
78
94
  writeFileSync(PROJECT_FILE, JSON.stringify({ stateMachine: def }, null, 2), "utf8");
79
95
  }
80
96
  // ─── Optional real-time design bridge (env-gated, best-effort) ───────
@@ -193,6 +209,22 @@ function bridgeEmitFull(def) {
193
209
  /* best-effort */
194
210
  }
195
211
  }
212
+ // What ./erd.ts needs of this server's state. Called at tool time, after the
213
+ // guards further down this file are initialised.
214
+ const erdHost = {
215
+ projectFile: () => PROJECT_FILE,
216
+ switchTo: (path) => {
217
+ const denial = denyProjectSwitch(path);
218
+ if (denial)
219
+ return denial;
220
+ PROJECT_FILE = path;
221
+ bindBridge(PROJECT_FILE);
222
+ joinBridge();
223
+ return undefined;
224
+ },
225
+ emitFull: (def) => bridgeEmitFull(def),
226
+ outsideRoot: (path) => outsideRoot(path),
227
+ };
196
228
  function createEmpty(namespace, name) {
197
229
  return {
198
230
  settings: { namespace, name, asynchronous: false },
@@ -749,7 +781,9 @@ function buildServer() {
749
781
  const code = generateViaSmcg(def, language);
750
782
  return { content: [{ type: "text", text: code }] };
751
783
  });
752
- server.tool("get_definition", "Get the current state machine definition as JSON", {}, async () => {
784
+ server.tool("get_definition", "Get the current definition as JSON — the state machine, or the ERD when the active document is a .erdf.json", {}, async () => {
785
+ if (isErdfPath(PROJECT_FILE))
786
+ return erdGetDefinition(erdHost);
753
787
  const def = readDef();
754
788
  if (!def)
755
789
  return { content: [{ type: "text", text: `No state machine at ${PROJECT_FILE}.` }], isError: true };
@@ -757,7 +791,9 @@ function buildServer() {
757
791
  content: [{ type: "text", text: JSON.stringify({ stateMachine: def }, null, 2) }],
758
792
  };
759
793
  });
760
- server.tool("load_definition", "Load a state machine definition from JSON", { json: z.string() }, async ({ json }) => {
794
+ server.tool("load_definition", "Load a definition from JSON into the active document — a state machine, or an ERD when the active document is a .erdf.json", { json: z.string() }, async ({ json }) => {
795
+ if (isErdfPath(PROJECT_FILE))
796
+ return erdLoadDefinition(erdHost, json);
761
797
  try {
762
798
  const parsed = JSON.parse(json);
763
799
  const def = parsed.stateMachine ?? parsed.StateMachine ?? parsed;
@@ -805,7 +841,7 @@ function buildServer() {
805
841
  return { content: [{ type: "text", text: "No events defined yet." }] };
806
842
  return { content: [{ type: "text", text: events.join("\n") }] };
807
843
  });
808
- server.tool("set_project_file", "Choose the active document the loom weaves — two modalities. STANDALONE: any .smdf.json path. RELATIONAL: a chronicle address `miadi-chronicle://<episode>/<diagram>` resolving over $MIADI_CHRONICLE_ROOT to <episode-dir>/diagrams/<diagram>.smdf.json — an ambiguous episode number is a hard error naming the candidates. Disk persistence AND the live bridge room both re-point. A missing file is legitimate — naming it is how an episode's first diagram is created.", { path: z.string() }, async ({ path }) => {
844
+ server.tool("set_project_file", "Choose the active document the loom weaves — two modalities. STANDALONE: any .smdf.json path (a state machine) or .erdf.json path (an ERD — the ERD tools act on it). RELATIONAL: a chronicle address `miadi-chronicle://<episode>/<diagram>` resolving over $MIADI_CHRONICLE_ROOT to <episode-dir>/diagrams/<diagram>.smdf.json — an ambiguous episode number is a hard error naming the candidates. Disk persistence AND the live bridge room both re-point. A missing file is legitimate — naming it is how an episode's first diagram is created.", { path: z.string() }, async ({ path }) => {
809
845
  try {
810
846
  const r = resolveProjectSwitch(path, PROJECT_FILE);
811
847
  const denial = denyProjectSwitch(r.path);
@@ -817,11 +853,13 @@ function buildServer() {
817
853
  joinBridge();
818
854
  }
819
855
  const def = r.exists ? readDef() : null;
820
- const summary = def
821
- ? `existing machine '${def.settings.name}' (${collectStateNames(def.state).length} states, ${collectEventIds(def).length} events)`
822
- : r.exists
823
- ? "file exists but is not a readable definition"
824
- : "no file yet — create_state_machine or load_definition will write it";
856
+ const summary = isErdfPath(PROJECT_FILE)
857
+ ? erdSummary(PROJECT_FILE)
858
+ : def
859
+ ? `existing machine '${def.settings.name}' (${collectStateNames(def.state).length} states, ${collectEventIds(def).length} events)`
860
+ : r.exists
861
+ ? "file exists but is not a readable definition"
862
+ : "no file yet — create_state_machine or load_definition will write it";
825
863
  const bridgeNote = BRIDGE_URL
826
864
  ? r.unchanged
827
865
  ? `bridge unchanged (${bridge?.status ?? "not configured"})`
@@ -845,11 +883,13 @@ function buildServer() {
845
883
  });
846
884
  server.tool("get_project_file", "Report the active .smdf.json document path, whether it exists on disk, and live-bridge status", {}, async () => {
847
885
  const def = readDef();
848
- const summary = def
849
- ? `machine '${def.settings.name}' (${collectStateNames(def.state).length} states, ${collectEventIds(def).length} events)`
850
- : existsSync(PROJECT_FILE)
851
- ? "file exists but is not a readable definition"
852
- : "file does not exist yet";
886
+ const summary = isErdfPath(PROJECT_FILE)
887
+ ? erdSummary(PROJECT_FILE)
888
+ : def
889
+ ? `machine '${def.settings.name}' (${collectStateNames(def.state).length} states, ${collectEventIds(def).length} events)`
890
+ : existsSync(PROJECT_FILE)
891
+ ? "file exists but is not a readable definition"
892
+ : "file does not exist yet";
853
893
  const bridgeNote = BRIDGE_URL
854
894
  ? `bridge: ${bridge?.status ?? "not initialized"} → ${BRIDGE_URL}`
855
895
  : "bridge not configured (STATELOOM_BRIDGE_URL unset)";
@@ -897,6 +937,8 @@ function buildServer() {
897
937
  stamp: z.boolean().optional(),
898
938
  open: z.boolean().optional(),
899
939
  }, async ({ format, path, scale, theme, stamp, open }) => {
940
+ if (isErdfPath(PROJECT_FILE))
941
+ return erdRender(erdHost, { format, path, stamp });
900
942
  const def = readDef();
901
943
  if (!def)
902
944
  return {
@@ -963,6 +1005,7 @@ function buildServer() {
963
1005
  };
964
1006
  }
965
1007
  });
1008
+ registerErdTools(server, erdHost);
966
1009
  // Resources
967
1010
  server.resource("definition", "smcraft://definition", async () => {
968
1011
  const def = readDef();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miadi/stateloom-mcp",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
4
  "description": "MCP server for miadi-stateloom (formerly smcraft-mcp) — lets LLM agents design state machines through conversation",
5
5
  "repository": {
6
6
  "type": "git",
@@ -40,7 +40,7 @@
40
40
  "dependencies": {
41
41
  "@miadi/stateloom-cli": "^0.1.3",
42
42
  "@miadi/stateloom-client": "^0.1.2",
43
- "@miadi/stateloom-protocol": "^0.1.5",
43
+ "@miadi/stateloom-protocol": "^0.1.7",
44
44
  "@modelcontextprotocol/sdk": "^1.12.1",
45
45
  "zod": "^4.3.6"
46
46
  },