@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 +47 -0
- package/dist/erd.js +239 -0
- package/dist/server.d.ts +8 -1
- package/dist/server.js +77 -16
- package/package.json +4 -4
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
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
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 =
|
|
803
|
-
?
|
|
804
|
-
:
|
|
805
|
-
?
|
|
806
|
-
:
|
|
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 =
|
|
831
|
-
?
|
|
832
|
-
:
|
|
833
|
-
?
|
|
834
|
-
:
|
|
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.
|
|
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.
|
|
42
|
-
"@miadi/stateloom-client": "^0.1.
|
|
43
|
-
"@miadi/stateloom-protocol": "^0.1.
|
|
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
|
},
|