@miadi/stateloom-mcp 0.2.2 → 0.2.4

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,239 @@
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, 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", { name: z.string(), description: z.string().optional() }, async ({ name, description }) => mutate(host, (def) => addEntity(def, { name, description }), () => `Added entity '${name}'.`));
146
+ 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.", {
147
+ entity: z.string(),
148
+ name: z.string(),
149
+ type: z.string(),
150
+ key: z.enum(["pk", "uk"]).optional(),
151
+ references: z.string().optional(),
152
+ nullable: z.boolean().optional(),
153
+ stateOf: z.union([z.string(), z.array(z.string())]).optional(),
154
+ description: z.string().optional(),
155
+ }, async ({ entity, ...attribute }) => mutate(host, (def) => addAttribute(def, entity, attribute), () => `Added attribute '${entity}.${attribute.name}' (${attribute.type}).`));
156
+ 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.", {
157
+ from: z.string(),
158
+ to: z.string(),
159
+ cardinality: z.enum(ERD_CARDINALITIES),
160
+ label: z.string().optional(),
161
+ description: z.string().optional(),
162
+ }, async (rel) => mutate(host, (def) => addRelationship(def, rel), () => `Added relationship ${rel.from} ${rel.cardinality} ${rel.to}${rel.label ? ` : ${rel.label}` : ""}.`));
163
+ 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.`));
164
+ 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}'.`));
165
+ 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}` : ""}.`));
166
+ server.tool("validate_erd", "Validate the active ERD against rules E001–E005 (unique names, relationship ends, cardinality, references) and list what it holds", {}, async () => {
167
+ const path = host.projectFile();
168
+ if (!isErdfPath(path))
169
+ return fail(NOT_ERD(path));
170
+ const def = readErd(path);
171
+ if (!def)
172
+ return fail(`No ERD at ${path}. Use create_erd first.`);
173
+ const errors = validateErd(def);
174
+ const body = summarizeErd(def) || "(empty)";
175
+ return errors.length
176
+ ? fail(`${errors.length} problem(s) in '${def.settings?.name ?? ""}':\n${formatErrors(errors)}\n\n${body}`)
177
+ : ok(`ERD '${def.settings?.name ?? ""}' is valid.\n${body}`);
178
+ });
179
+ 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 }) => {
180
+ const active = host.projectFile();
181
+ const erdPath = erd_path ? resolve(erd_path) : isErdfPath(active) ? active : undefined;
182
+ if (!erdPath) {
183
+ return fail(`The active document ${active} is a state machine — pass erd_path, the .erdf.json to check it against.`);
184
+ }
185
+ // The single active machine is one of possibly several, so its ERD's other
186
+ // stateOf names are not held against it; any other set is "the machines at hand".
187
+ let machinesAreTheSet = true;
188
+ let smdfPaths;
189
+ if (smdf_paths?.length) {
190
+ smdfPaths = smdf_paths.map((p) => resolve(p));
191
+ }
192
+ else if (!isErdfPath(active)) {
193
+ smdfPaths = [active];
194
+ machinesAreTheSet = false;
195
+ }
196
+ else {
197
+ const dir = dirname(erdPath);
198
+ smdfPaths = readdirSync(dir)
199
+ .filter((f) => f.toLowerCase().endsWith(".smdf.json"))
200
+ .sort()
201
+ .map((f) => join(dir, f));
202
+ if (smdfPaths.length === 0)
203
+ return fail(`No .smdf.json beside ${erdPath} — pass smdf_paths.`);
204
+ }
205
+ for (const p of [erdPath, ...smdfPaths]) {
206
+ const denial = host.outsideRoot(p);
207
+ if (denial)
208
+ return fail(denial);
209
+ }
210
+ const erd = readErd(erdPath);
211
+ if (!erd)
212
+ return fail(`No readable ERD at ${erdPath}.`);
213
+ const lines = [];
214
+ const names = [];
215
+ let problems = 0;
216
+ for (const p of smdfPaths) {
217
+ const smdf = readSmdf(p);
218
+ if (!smdf) {
219
+ lines.push(`${p}: not a readable state machine`);
220
+ problems += 1;
221
+ continue;
222
+ }
223
+ const name = smdf.settings?.name ?? p;
224
+ names.push(name);
225
+ const errors = checkLinks(smdf, erd);
226
+ problems += errors.length;
227
+ lines.push(errors.length ? `${name} (${p}):\n${formatErrors(errors)}` : `${name}: every name resolves`);
228
+ }
229
+ if (machinesAreTheSet) {
230
+ const errors = checkStateOf(erd, names);
231
+ problems += errors.length;
232
+ if (errors.length)
233
+ lines.push(formatErrors(errors));
234
+ }
235
+ const head = `Checked ${smdfPaths.length} machine(s) against ERD '${erd.settings?.name ?? ""}' (${erdPath}): ${problems} problem(s).`;
236
+ const text = `${head}\n${lines.join("\n")}`;
237
+ return problems ? fail(text) : ok(text);
238
+ });
239
+ }
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, 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, 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
  *
@@ -36,11 +43,13 @@ import { z } from "zod";
36
43
  import { execFileSync, spawn } from "child_process";
37
44
  import { writeFileSync, readFileSync, mkdtempSync, rmSync, existsSync, statSync, realpathSync, } from "fs";
38
45
  import { basename, dirname, join, resolve } from "path";
46
+ import { fileURLToPath } from "node:url";
39
47
  import { tmpdir } from "os";
40
48
  import { createBridgeClient } from "@miadi/stateloom-client";
41
- import { envAlias } from "@miadi/stateloom-protocol";
49
+ import { envAlias, isErdfPath, } from "@miadi/stateloom-protocol";
42
50
  import { renderDiagramToFile, defaultOutputPath } from "@miadi/stateloom-cli/render";
43
51
  import { resolveProjectSwitch } from "./projectSwitch.js";
52
+ import { registerErdTools, erdSummary, erdGetDefinition, erdLoadDefinition, erdRender, } from "./erd.js";
44
53
  // STATELOOM_* read first, SMCRAFT_* legacy twin honored — the live MCP
45
54
  // registration bakes SMCRAFT_PROJECT_FILE and must keep working (2026-07-27
46
55
  // rename brief). Resolved once at boot, like the direct reads they replace.
@@ -61,6 +70,9 @@ const canvasLink = (path) => `${CANVAS_URL}/?doc=${encodeURIComponent(path)}`;
61
70
  // .smdf.json document mid-session (the hub keys live rooms by this path).
62
71
  let PROJECT_FILE = resolve(envAlias("PROJECT_FILE") ?? "./statemachine.smdf.json");
63
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;
64
76
  if (!existsSync(PROJECT_FILE))
65
77
  return null;
66
78
  try {
@@ -74,6 +86,11 @@ function readDef() {
74
86
  }
75
87
  }
76
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
+ }
77
94
  writeFileSync(PROJECT_FILE, JSON.stringify({ stateMachine: def }, null, 2), "utf8");
78
95
  }
79
96
  // ─── Optional real-time design bridge (env-gated, best-effort) ───────
@@ -192,6 +209,22 @@ function bridgeEmitFull(def) {
192
209
  /* best-effort */
193
210
  }
194
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
+ };
195
228
  function createEmpty(namespace, name) {
196
229
  return {
197
230
  settings: { namespace, name, asynchronous: false },
@@ -598,10 +631,27 @@ function glyph(direction) {
598
631
  * Every tool closes over module-level state (PROJECT_FILE, the bridge client),
599
632
  * so instances are interchangeable — building one is registration, not state.
600
633
  */
634
+ /**
635
+ * The version a client sees in `serverInfo`, read from the manifest that npm
636
+ * actually shipped rather than typed in beside it. The literal here said 0.2.0
637
+ * while the package on the registry was 0.2.2 — two releases during which every
638
+ * agent that asked this server who it was got a confident wrong answer, and no
639
+ * test could catch it because the two numbers had no relationship to compare.
640
+ * Reading the manifest gives them one.
641
+ */
642
+ const PACKAGE_VERSION = (() => {
643
+ try {
644
+ const manifest = resolve(dirname(fileURLToPath(import.meta.url)), "..", "package.json");
645
+ return String(JSON.parse(readFileSync(manifest, "utf8")).version);
646
+ }
647
+ catch {
648
+ return "0.0.0-unknown";
649
+ }
650
+ })();
601
651
  function buildServer() {
602
652
  const server = new McpServer({
603
653
  name: "stateloom-mcp",
604
- version: "0.2.0",
654
+ version: PACKAGE_VERSION,
605
655
  });
606
656
  // Tools
607
657
  server.tool("create_state_machine", "Create a new state machine definition with namespace and name", { namespace: z.string(), name: z.string(), asynchronous: z.boolean().optional() }, async ({ namespace, name, asynchronous }) => {
@@ -731,7 +781,9 @@ function buildServer() {
731
781
  const code = generateViaSmcg(def, language);
732
782
  return { content: [{ type: "text", text: code }] };
733
783
  });
734
- 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);
735
787
  const def = readDef();
736
788
  if (!def)
737
789
  return { content: [{ type: "text", text: `No state machine at ${PROJECT_FILE}.` }], isError: true };
@@ -739,7 +791,9 @@ function buildServer() {
739
791
  content: [{ type: "text", text: JSON.stringify({ stateMachine: def }, null, 2) }],
740
792
  };
741
793
  });
742
- 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);
743
797
  try {
744
798
  const parsed = JSON.parse(json);
745
799
  const def = parsed.stateMachine ?? parsed.StateMachine ?? parsed;
@@ -787,7 +841,7 @@ function buildServer() {
787
841
  return { content: [{ type: "text", text: "No events defined yet." }] };
788
842
  return { content: [{ type: "text", text: events.join("\n") }] };
789
843
  });
790
- 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 }) => {
791
845
  try {
792
846
  const r = resolveProjectSwitch(path, PROJECT_FILE);
793
847
  const denial = denyProjectSwitch(r.path);
@@ -799,11 +853,13 @@ function buildServer() {
799
853
  joinBridge();
800
854
  }
801
855
  const def = r.exists ? readDef() : null;
802
- const summary = def
803
- ? `existing machine '${def.settings.name}' (${collectStateNames(def.state).length} states, ${collectEventIds(def).length} events)`
804
- : r.exists
805
- ? "file exists but is not a readable definition"
806
- : "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";
807
863
  const bridgeNote = BRIDGE_URL
808
864
  ? r.unchanged
809
865
  ? `bridge unchanged (${bridge?.status ?? "not configured"})`
@@ -827,11 +883,13 @@ function buildServer() {
827
883
  });
828
884
  server.tool("get_project_file", "Report the active .smdf.json document path, whether it exists on disk, and live-bridge status", {}, async () => {
829
885
  const def = readDef();
830
- const summary = def
831
- ? `machine '${def.settings.name}' (${collectStateNames(def.state).length} states, ${collectEventIds(def).length} events)`
832
- : existsSync(PROJECT_FILE)
833
- ? "file exists but is not a readable definition"
834
- : "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";
835
893
  const bridgeNote = BRIDGE_URL
836
894
  ? `bridge: ${bridge?.status ?? "not initialized"} → ${BRIDGE_URL}`
837
895
  : "bridge not configured (STATELOOM_BRIDGE_URL unset)";
@@ -879,6 +937,8 @@ function buildServer() {
879
937
  stamp: z.boolean().optional(),
880
938
  open: z.boolean().optional(),
881
939
  }, async ({ format, path, scale, theme, stamp, open }) => {
940
+ if (isErdfPath(PROJECT_FILE))
941
+ return erdRender(erdHost, { format, path, stamp });
882
942
  const def = readDef();
883
943
  if (!def)
884
944
  return {
@@ -945,6 +1005,7 @@ function buildServer() {
945
1005
  };
946
1006
  }
947
1007
  });
1008
+ registerErdTools(server, erdHost);
948
1009
  // Resources
949
1010
  server.resource("definition", "smcraft://definition", async () => {
950
1011
  const def = readDef();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miadi/stateloom-mcp",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
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",
@@ -38,9 +38,9 @@
38
38
  "node": ">=20"
39
39
  },
40
40
  "dependencies": {
41
- "@miadi/stateloom-cli": "^0.1.2",
42
- "@miadi/stateloom-client": "^0.1.1",
43
- "@miadi/stateloom-protocol": "^0.1.3",
41
+ "@miadi/stateloom-cli": "^0.1.3",
42
+ "@miadi/stateloom-client": "^0.1.2",
43
+ "@miadi/stateloom-protocol": "^0.1.6",
44
44
  "@modelcontextprotocol/sdk": "^1.12.1",
45
45
  "zod": "^4.3.6"
46
46
  },