@miadi/stateloom-mcp 0.1.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/dist/server.js ADDED
@@ -0,0 +1,866 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * SMCraft MCP Server
4
+ *
5
+ * Provides tools for LLM agents to design, validate, and generate
6
+ * state machines through conversation.
7
+ *
8
+ * Tools:
9
+ * - create_state_machine: Create a new state machine definition
10
+ * - add_state: Add a state to the definition
11
+ * - add_event: Add an event to the definition
12
+ * - add_transition: Add a transition to a state
13
+ * - remove_state: Remove a state from the definition
14
+ * - validate_definition: Validate the current definition
15
+ * - generate_code: Generate Python or TypeScript code
16
+ * - get_definition: Get the current definition as JSON
17
+ * - load_definition: Load a definition from JSON
18
+ * - list_states: List all states in the current definition
19
+ * - list_events: List all events in the current definition
20
+ * - set_project_file: Choose which .smdf.json path is the active document
21
+ * - get_project_file: Report the active document path and bridge status
22
+ *
23
+ * Resources:
24
+ * - smcraft://definition — Current state machine definition
25
+ *
26
+ * Prompts:
27
+ * - design-state-machine — Guided state machine design conversation
28
+ */
29
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
30
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
31
+ import { z } from "zod";
32
+ import { execFileSync } from "child_process";
33
+ import { writeFileSync, readFileSync, mkdtempSync, rmSync, existsSync, statSync } from "fs";
34
+ import { join, resolve } from "path";
35
+ import { tmpdir } from "os";
36
+ import { createBridgeClient } from "@miadi/stateloom-client";
37
+ import { envAlias } from "@miadi/stateloom-protocol";
38
+ import { resolveProjectSwitch } from "./projectSwitch.js";
39
+ // STATELOOM_* read first, SMCRAFT_* legacy twin honored — the live MCP
40
+ // registration bakes SMCRAFT_PROJECT_FILE and must keep working (2026-07-27
41
+ // rename brief). Resolved once at boot, like the direct reads they replace.
42
+ const BRIDGE_URL = envAlias("BRIDGE_URL");
43
+ const AGENT_NAME = envAlias("AGENT_NAME");
44
+ const BRIDGE_TOKEN = envAlias("BRIDGE_TOKEN");
45
+ // ─── File-backed shared store ────────────────────────────────────────
46
+ // Both web/ and mcp/ resolve the same path from STATELOOM_PROJECT_FILE
47
+ // (legacy twin SMCRAFT_PROJECT_FILE still honored).
48
+ // Web reads/writes via /api/file; mcp reads/writes here. fs.watch on
49
+ // the web side broadcasts disk changes back to the canvas.
50
+ // Mutable: `set_project_file` lets the agent re-point the loom at another
51
+ // .smdf.json document mid-session (the hub keys live rooms by this path).
52
+ let PROJECT_FILE = resolve(envAlias("PROJECT_FILE") ?? "./statemachine.smdf.json");
53
+ function readDef() {
54
+ if (!existsSync(PROJECT_FILE))
55
+ return null;
56
+ try {
57
+ const raw = readFileSync(PROJECT_FILE, "utf8");
58
+ const parsed = JSON.parse(raw);
59
+ return parsed.stateMachine ?? parsed.StateMachine ?? parsed;
60
+ }
61
+ catch (e) {
62
+ console.error(`[smcraft-mcp] Failed to read ${PROJECT_FILE}:`, e);
63
+ return null;
64
+ }
65
+ }
66
+ function writeDef(def) {
67
+ writeFileSync(PROJECT_FILE, JSON.stringify({ stateMachine: def }, null, 2), "utf8");
68
+ }
69
+ // ─── Optional real-time design bridge (env-gated, best-effort) ───────
70
+ // When STATELOOM_BRIDGE_URL (or legacy SMCRAFT_BRIDGE_URL) is set, each
71
+ // mutation is mirrored to the bridge
72
+ // hub so a live web canvas updates as the agent edits. Fully backward-
73
+ // compatible: the file on disk remains the durable truth, every bridge
74
+ // call is wrapped so any failure degrades to a no-op, and standalone
75
+ // operation (no bridge URL) is untouched.
76
+ let bridge;
77
+ if (!BRIDGE_URL) {
78
+ console.error("[smcraft-mcp] STATELOOM_BRIDGE_URL is not set — edits persist to disk but " +
79
+ "are NOT broadcast; a live canvas will not update. Set STATELOOM_BRIDGE_URL " +
80
+ "(legacy twin: SMCRAFT_BRIDGE_URL; e.g. http://127.0.0.1:4599) on this MCP " +
81
+ "process to go live.");
82
+ }
83
+ /** (Re)create the bridge client bound to `docId`, dropping any previous one. */
84
+ function bindBridge(docId) {
85
+ if (!BRIDGE_URL)
86
+ return;
87
+ try {
88
+ bridge?.disconnect();
89
+ }
90
+ catch {
91
+ /* best-effort */
92
+ }
93
+ try {
94
+ bridge = createBridgeClient({
95
+ url: BRIDGE_URL,
96
+ role: "agent",
97
+ docId,
98
+ name: AGENT_NAME ?? "mcp-agent",
99
+ token: BRIDGE_TOKEN,
100
+ });
101
+ }
102
+ catch (e) {
103
+ console.error("[smcraft-mcp] bridge init failed (continuing standalone):", e);
104
+ bridge = undefined;
105
+ }
106
+ }
107
+ /** Best-effort, non-blocking join. Failure never blocks tool handling. */
108
+ function joinBridge() {
109
+ if (!bridge)
110
+ return;
111
+ bridge
112
+ .join()
113
+ .then(() => {
114
+ console.error(`[smcraft-mcp] bridge connected (${BRIDGE_URL}) docId=${PROJECT_FILE}`);
115
+ })
116
+ .catch((e) => {
117
+ console.error("[smcraft-mcp] bridge join failed (continuing standalone):", e?.message ?? e);
118
+ });
119
+ }
120
+ bindBridge(PROJECT_FILE);
121
+ if (BRIDGE_URL) {
122
+ // Best-effort teardown on process exit.
123
+ let bridgeClosed = false;
124
+ const closeBridge = () => {
125
+ if (bridgeClosed)
126
+ return;
127
+ bridgeClosed = true;
128
+ try {
129
+ bridge?.disconnect();
130
+ }
131
+ catch {
132
+ /* best-effort */
133
+ }
134
+ };
135
+ process.on("exit", closeBridge);
136
+ process.on("SIGINT", () => {
137
+ closeBridge();
138
+ process.exit(0);
139
+ });
140
+ process.on("SIGTERM", () => {
141
+ closeBridge();
142
+ process.exit(0);
143
+ });
144
+ }
145
+ // Emit precise granular ops (never diffs) AFTER writeDef. The mtime is
146
+ // stamped from the file we just wrote so the hub can dedup its own
147
+ // file-watch echo of this same change.
148
+ let droppedEmitWarned = false;
149
+ function warnDroppedEmit(kind) {
150
+ if (droppedEmitWarned)
151
+ return;
152
+ droppedEmitWarned = true;
153
+ console.error(`[smcraft-mcp] dropped ${kind} emit — bridge ` +
154
+ (bridge ? `status=${bridge.status}` : "not configured (STATELOOM_BRIDGE_URL unset)") +
155
+ "; disk write succeeded, live canvas did not update. (warned once)");
156
+ }
157
+ function bridgeEmitPatch(ops) {
158
+ if (!bridge || bridge.status !== "connected") {
159
+ warnDroppedEmit("patch");
160
+ return;
161
+ }
162
+ try {
163
+ const mtime = statSync(PROJECT_FILE).mtimeMs;
164
+ bridge.emitPatch(ops, mtime);
165
+ }
166
+ catch {
167
+ /* best-effort */
168
+ }
169
+ }
170
+ function bridgeEmitFull(def) {
171
+ if (!bridge || bridge.status !== "connected") {
172
+ warnDroppedEmit("full-def");
173
+ return;
174
+ }
175
+ try {
176
+ const mtime = statSync(PROJECT_FILE).mtimeMs;
177
+ // Local Definition/StateDef differ slightly from the protocol's
178
+ // StateMachineDefinition; the payload is serialized as-is over the wire.
179
+ bridge.emitFull(def, mtime);
180
+ }
181
+ catch {
182
+ /* best-effort */
183
+ }
184
+ }
185
+ function createEmpty(namespace, name) {
186
+ return {
187
+ settings: { namespace, name, asynchronous: false },
188
+ events: [{ name: "Internal", events: [] }],
189
+ state: { name: "Root", states: [] },
190
+ };
191
+ }
192
+ function findState(root, name) {
193
+ if (root.name === name)
194
+ return root;
195
+ for (const child of root.states ?? []) {
196
+ const found = findState(child, name);
197
+ if (found)
198
+ return found;
199
+ }
200
+ return null;
201
+ }
202
+ function removeState(root, name) {
203
+ return {
204
+ ...root,
205
+ states: root.states
206
+ ?.filter((s) => s.name !== name)
207
+ .map((s) => removeState(s, name)),
208
+ };
209
+ }
210
+ function collectStateNames(root) {
211
+ const names = [root.name];
212
+ for (const child of root.states ?? []) {
213
+ names.push(...collectStateNames(child));
214
+ }
215
+ return names;
216
+ }
217
+ function collectEventIds(def) {
218
+ return def.events.flatMap((src) => src.events.map((e) => e.id));
219
+ }
220
+ function validate(def) {
221
+ const errors = [];
222
+ const stateNames = collectStateNames(def.state);
223
+ const eventIds = collectEventIds(def);
224
+ if (eventIds.length === 0) {
225
+ errors.push({ ruleId: "V001", message: "No events defined" });
226
+ }
227
+ const seen = new Set();
228
+ for (const name of stateNames) {
229
+ if (seen.has(name))
230
+ errors.push({
231
+ ruleId: "V002",
232
+ message: `Duplicate state name: ${name}`,
233
+ });
234
+ seen.add(name);
235
+ }
236
+ const checkTransitions = (state) => {
237
+ for (const t of state.transitions ?? []) {
238
+ if (!eventIds.includes(t.event))
239
+ errors.push({
240
+ ruleId: "V003",
241
+ message: `State '${state.name}' references unknown event '${t.event}'`,
242
+ });
243
+ if (t.nextState && !stateNames.includes(t.nextState))
244
+ errors.push({
245
+ ruleId: "V004",
246
+ message: `State '${state.name}' targets unknown state '${t.nextState}'`,
247
+ });
248
+ }
249
+ (state.states ?? []).forEach(checkTransitions);
250
+ };
251
+ checkTransitions(def.state);
252
+ if (!def.state.states || def.state.states.length === 0)
253
+ errors.push({
254
+ ruleId: "V005",
255
+ message: "Root must have at least one child state",
256
+ });
257
+ return errors;
258
+ }
259
+ function generateViaSmcg(def, language) {
260
+ const tmpDir = mkdtempSync(join(tmpdir(), "smcraft-"));
261
+ // Sanitize the input filename — settings.name is agent-controlled free text.
262
+ const safeName = String(def.settings.name ?? "machine").replace(/[^A-Za-z0-9_.-]/g, "_");
263
+ const inputFile = join(tmpDir, `${safeName}.smdf.json`);
264
+ const outputDir = join(tmpDir, "output");
265
+ try {
266
+ // Write definition as SMDF JSON
267
+ const smdf = { settings: def.settings, events: def.events, state: def.state };
268
+ writeFileSync(inputFile, JSON.stringify(smdf, null, 2));
269
+ // Call real smcg CLI — execFileSync (no shell) so a crafted name cannot
270
+ // inject shell commands.
271
+ execFileSync("smcg", [inputFile, "-l", language, "-o", outputDir], {
272
+ encoding: "utf-8",
273
+ timeout: 30000,
274
+ });
275
+ // Read generated output
276
+ const ext = language === "python" ? "py" : "ts";
277
+ const name = def.settings.name ?? "machine";
278
+ const outputFile = join(outputDir, `${name}_fsm.${ext}`);
279
+ return readFileSync(outputFile, "utf-8");
280
+ }
281
+ catch (e) {
282
+ // Fallback to lightweight inline generation if smcg fails
283
+ return language === "python"
284
+ ? generatePythonFallback(def)
285
+ : generateTypeScriptFallback(def);
286
+ }
287
+ finally {
288
+ try {
289
+ rmSync(tmpDir, { recursive: true, force: true });
290
+ }
291
+ catch { }
292
+ }
293
+ }
294
+ // Fallback lightweight generators (used when smcg CLI unavailable)
295
+ function generatePythonFallback(def) {
296
+ const lines = [];
297
+ const name = def.settings.name ?? "StateMachine";
298
+ const states = def.state.states ?? [];
299
+ const events = collectEventIds(def);
300
+ lines.push(`"""Generated by SMCraft"""`);
301
+ lines.push(`from enum import Enum, auto`);
302
+ lines.push(`from smcraft.runtime import Context, State, TransitionHelper`);
303
+ lines.push(``);
304
+ lines.push(`class StateId(Enum):`);
305
+ for (const s of collectStateNames(def.state)) {
306
+ lines.push(` ${s} = auto()`);
307
+ }
308
+ lines.push(``);
309
+ for (const s of states) {
310
+ lines.push(`class State${s.name}(State):`);
311
+ lines.push(` """State: ${s.name}"""`);
312
+ if (s.onEntry)
313
+ lines.push(` def on_entry(self, context):\n ${s.onEntry}`);
314
+ if (s.onExit)
315
+ lines.push(` def on_exit(self, context):\n ${s.onExit}`);
316
+ for (const t of s.transitions ?? []) {
317
+ lines.push(` def on_${t.event.toLowerCase()}(self, context):`);
318
+ if (t.condition)
319
+ lines.push(` if not (${t.condition}): return`);
320
+ if (t.nextState)
321
+ lines.push(` TransitionHelper.do_transition(context, context.state_${t.nextState.toLowerCase()})`);
322
+ else
323
+ lines.push(` pass # internal transition`);
324
+ }
325
+ lines.push(``);
326
+ }
327
+ lines.push(`class ${name}Context(Context):`);
328
+ lines.push(` """Context for ${name}"""`);
329
+ lines.push(` def __init__(self):`);
330
+ lines.push(` super().__init__()`);
331
+ for (const s of states) {
332
+ lines.push(` self.state_${s.name.toLowerCase()} = State${s.name}()`);
333
+ }
334
+ lines.push(` self.state_initial = self.state_${(states[0]?.name ?? "Root").toLowerCase()}`);
335
+ lines.push(``);
336
+ return lines.join("\n");
337
+ }
338
+ function generateTypeScriptFallback(def) {
339
+ const lines = [];
340
+ const name = def.settings.name ?? "StateMachine";
341
+ const states = def.state.states ?? [];
342
+ lines.push(`/** Generated by SMCraft */`);
343
+ lines.push(`import { Context, State, TransitionHelper } from "smcraft/runtime";`);
344
+ lines.push(``);
345
+ lines.push(`export enum StateId {`);
346
+ for (const s of collectStateNames(def.state)) {
347
+ lines.push(` ${s} = "${s}",`);
348
+ }
349
+ lines.push(`}`);
350
+ lines.push(``);
351
+ for (const s of states) {
352
+ lines.push(`export class State${s.name} extends State {`);
353
+ for (const t of s.transitions ?? []) {
354
+ lines.push(` on${t.event}(context: ${name}Context): void {`);
355
+ if (t.condition)
356
+ lines.push(` if (!(${t.condition})) return;`);
357
+ if (t.nextState)
358
+ lines.push(` TransitionHelper.doTransition(context, (context as any).state${t.nextState});`);
359
+ lines.push(` }`);
360
+ }
361
+ lines.push(`}`);
362
+ lines.push(``);
363
+ }
364
+ lines.push(`export class ${name}Context extends Context {`);
365
+ for (const s of states) {
366
+ lines.push(` state${s.name} = new State${s.name}();`);
367
+ }
368
+ if (states.length > 0)
369
+ lines.push(` stateInitial = this.state${states[0].name};`);
370
+ lines.push(`}`);
371
+ return lines.join("\n");
372
+ }
373
+ function loadPde(def) {
374
+ const folder = def.settings._source?.pdeFolder;
375
+ const id = def.settings._source?.pdeId;
376
+ if (!folder || !id)
377
+ return null;
378
+ const root = resolve(PROJECT_FILE, "..");
379
+ const candidates = [
380
+ resolve(root, folder, `pde-${id}.json`),
381
+ resolve(folder, `pde-${id}.json`),
382
+ ];
383
+ for (const path of candidates) {
384
+ if (!existsSync(path))
385
+ continue;
386
+ try {
387
+ const data = JSON.parse(readFileSync(path, "utf8"));
388
+ return {
389
+ prompt: data.prompt,
390
+ primary: data.result?.primary,
391
+ secondary: data.result?.secondary,
392
+ directions: data.result?.directions,
393
+ actionStack: data.result?.actionStack,
394
+ ambiguities: data.result?.ambiguities,
395
+ };
396
+ }
397
+ catch {
398
+ /* try next */
399
+ }
400
+ }
401
+ return null;
402
+ }
403
+ function generateRispec(def, intent) {
404
+ const pde = loadPde(def);
405
+ const name = def.settings.name;
406
+ const ns = def.settings.namespace;
407
+ const lines = [];
408
+ const states = def.state.states ?? [];
409
+ const tagline = intent ??
410
+ pde?.primary?.target ??
411
+ def.settings._source?.originalPrompt ??
412
+ `${name} state machine`;
413
+ lines.push(`# ${name} — RISE rispec`);
414
+ lines.push("");
415
+ lines.push(`> ${tagline}`);
416
+ lines.push("");
417
+ lines.push(`**Namespace:** \`${ns}\` · **Async:** ${def.settings.asynchronous}`);
418
+ if (def.settings._source?.pdeId) {
419
+ lines.push(`**Source PDE:** \`${def.settings._source.pdeId}\``);
420
+ }
421
+ lines.push("");
422
+ lines.push("---");
423
+ lines.push("");
424
+ // Reverse Engineering
425
+ lines.push("## R — Reverse Engineering");
426
+ lines.push("");
427
+ lines.push(`${name} is modeled as a state machine with ${collectStateNames(def.state).length - 1} non-root states across ${depth(def.state)} level(s) of composition. ` +
428
+ `Events: ${collectEventIds(def).length}. Transitions: ${countTransitions(def.state)}.`);
429
+ lines.push("");
430
+ if (pde?.prompt) {
431
+ lines.push(`Originating prompt:`);
432
+ lines.push("");
433
+ lines.push(`> ${pde.prompt}`);
434
+ lines.push("");
435
+ }
436
+ // Intent
437
+ lines.push("## I — Intent");
438
+ lines.push("");
439
+ lines.push("**Desired Outcome**");
440
+ lines.push("");
441
+ lines.push(intent ?? pde?.primary?.target ?? "_(no intent supplied — pass `intent` arg or set _source.originalPrompt)_");
442
+ lines.push("");
443
+ if (pde?.directions?.east?.length) {
444
+ lines.push("**Vision (🌅 East)**");
445
+ lines.push("");
446
+ for (const v of pde.directions.east)
447
+ lines.push(`- ${v.text}`);
448
+ lines.push("");
449
+ }
450
+ lines.push("**Structural Tension**");
451
+ lines.push("");
452
+ lines.push(`- *Current:* ${currentReality(def, pde)}`);
453
+ lines.push(`- *Desired:* ${desiredOutcome(def, pde, intent)}`);
454
+ lines.push("");
455
+ // Specifications
456
+ lines.push("## S — Specifications");
457
+ lines.push("");
458
+ lines.push("### States");
459
+ lines.push("");
460
+ for (const s of states) {
461
+ renderStateSpec(s, lines, 0);
462
+ }
463
+ lines.push("");
464
+ lines.push("### Events");
465
+ lines.push("");
466
+ for (const src of def.events) {
467
+ if (!src.events?.length)
468
+ continue;
469
+ lines.push(`**${src.name}**`);
470
+ for (const e of src.events) {
471
+ lines.push(`- \`${e.id}\`${e.description ? ` — ${e.description}` : ""}`);
472
+ }
473
+ lines.push("");
474
+ }
475
+ lines.push("### Transitions (flat)");
476
+ lines.push("");
477
+ for (const t of collectTransitions(def.state)) {
478
+ const arrow = t.next ? `→ ${t.next}` : "→ (internal)";
479
+ lines.push(`- \`${t.from}\` --[${t.event}]-- ${arrow}${t.condition ? ` *(when: ${t.condition})*` : ""}`);
480
+ }
481
+ lines.push("");
482
+ // Action Steps (medicine-wheel walk if South/East/North/West present)
483
+ const mw = states.filter((s) => ["East", "South", "West", "North"].includes(s.name));
484
+ if (mw.length || pde?.actionStack?.length) {
485
+ lines.push("### Action Steps");
486
+ lines.push("");
487
+ if (mw.length) {
488
+ const order = ["South", "East", "North", "West"];
489
+ for (const dirName of order) {
490
+ const dir = states.find((s) => s.name === dirName);
491
+ if (!dir)
492
+ continue;
493
+ lines.push(`**${glyph(dirName)} ${dirName}** — ${dir.description ?? ""}`);
494
+ for (const c of dir.states ?? []) {
495
+ lines.push(`1. ${c.description ?? c.name}`);
496
+ }
497
+ lines.push("");
498
+ }
499
+ }
500
+ else if (pde?.actionStack?.length) {
501
+ for (const a of pde.actionStack) {
502
+ lines.push(`- *(${a.direction ?? "?"})* ${a.text}`);
503
+ }
504
+ lines.push("");
505
+ }
506
+ }
507
+ // Ambiguities surfaced from PDE
508
+ if (pde?.ambiguities?.length) {
509
+ lines.push("### Open Questions");
510
+ lines.push("");
511
+ for (const a of pde.ambiguities) {
512
+ lines.push(`- ${a.text}${a.suggestion ? ` \n → *${a.suggestion}*` : ""}`);
513
+ }
514
+ lines.push("");
515
+ }
516
+ // Exportation
517
+ lines.push("## E — Exportation");
518
+ lines.push("");
519
+ lines.push("- Code: `generate_code` (python | typescript) — runtime stubs from this SMDF");
520
+ lines.push(`- Visual: open this file in the stateloom web designer with \`STATELOOM_PROJECT_FILE=${PROJECT_FILE}\``);
521
+ lines.push("- SMDF: `get_definition` returns the canonical JSON");
522
+ lines.push("");
523
+ return lines.join("\n");
524
+ }
525
+ function depth(s, d = 0) {
526
+ if (!s.states?.length)
527
+ return d;
528
+ return Math.max(...s.states.map((c) => depth(c, d + 1)));
529
+ }
530
+ function countTransitions(s) {
531
+ let n = s.transitions?.length ?? 0;
532
+ for (const c of s.states ?? [])
533
+ n += countTransitions(c);
534
+ return n;
535
+ }
536
+ function collectTransitions(s) {
537
+ const out = [];
538
+ for (const t of s.transitions ?? []) {
539
+ out.push({ from: s.name, event: t.event, next: t.nextState, condition: t.condition });
540
+ }
541
+ for (const c of s.states ?? [])
542
+ out.push(...collectTransitions(c));
543
+ return out;
544
+ }
545
+ function renderStateSpec(s, lines, depthLevel) {
546
+ const indent = " ".repeat(depthLevel);
547
+ const kind = s.kind && s.kind !== "normal" ? ` *(${s.kind})*` : "";
548
+ lines.push(`${indent}- **${s.name}**${kind}${s.description ? ` — ${s.description}` : ""}`);
549
+ for (const t of s.transitions ?? []) {
550
+ lines.push(`${indent} - on \`${t.event}\` ${t.nextState ? `→ \`${t.nextState}\`` : "(internal)"}`);
551
+ }
552
+ for (const c of s.states ?? [])
553
+ renderStateSpec(c, lines, depthLevel + 1);
554
+ }
555
+ function currentReality(def, _pde) {
556
+ const hasFinal = collectAllStates(def.state).some((s) => s.kind === "final");
557
+ return hasFinal
558
+ ? `${def.settings.name} is designed but not implemented; entry state defined, terminal state reachable in principle.`
559
+ : `${def.settings.name} is partially modeled; no terminal state declared yet.`;
560
+ }
561
+ function collectAllStates(s) {
562
+ const out = [s];
563
+ for (const c of s.states ?? [])
564
+ out.push(...collectAllStates(c));
565
+ return out;
566
+ }
567
+ function desiredOutcome(def, pde, intent) {
568
+ if (intent)
569
+ return intent;
570
+ if (pde?.primary?.target)
571
+ return pde.primary.target;
572
+ return `${def.settings.name} runs the modeled flow end-to-end and reaches its terminal state.`;
573
+ }
574
+ function glyph(direction) {
575
+ return { South: "🔥", East: "🌅", North: "❄️", West: "🌊" }[direction] ?? "•";
576
+ }
577
+ // ─── MCP Server ──────────────────────────────────────────────────────
578
+ const server = new McpServer({
579
+ name: "smcraft-mcp",
580
+ version: "0.1.0",
581
+ });
582
+ // Tools
583
+ 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 }) => {
584
+ const def = createEmpty(namespace, name);
585
+ if (asynchronous)
586
+ def.settings.asynchronous = true;
587
+ writeDef(def);
588
+ bridgeEmitFull(def);
589
+ return {
590
+ content: [
591
+ {
592
+ type: "text",
593
+ text: `Created state machine '${name}' in namespace '${namespace}' → ${PROJECT_FILE}. Add states and events next.`,
594
+ },
595
+ ],
596
+ };
597
+ });
598
+ server.tool("add_state", "Add a state to the state machine. Parent defaults to Root.", {
599
+ name: z.string(),
600
+ parent: z.string().optional(),
601
+ kind: z.enum(["normal", "final", "history"]).optional(),
602
+ description: z.string().optional(),
603
+ }, async ({ name, parent, kind, description }) => {
604
+ const def = readDef();
605
+ if (!def)
606
+ return {
607
+ content: [{ type: "text", text: `No state machine at ${PROJECT_FILE}. Use create_state_machine first.` }],
608
+ isError: true,
609
+ };
610
+ const parentName = parent ?? "Root";
611
+ const parentState = findState(def.state, parentName);
612
+ if (!parentState)
613
+ return {
614
+ content: [{ type: "text", text: `Parent state '${parentName}' not found.` }],
615
+ isError: true,
616
+ };
617
+ if (!parentState.states)
618
+ parentState.states = [];
619
+ parentState.states.push({ name, kind: kind ?? "normal", description });
620
+ writeDef(def);
621
+ bridgeEmitPatch([
622
+ { op: "state.add", parent: parentName, state: { name, kind: kind ?? "normal", description } },
623
+ ]);
624
+ return {
625
+ content: [{ type: "text", text: `Added state '${name}' under '${parentName}' (${kind ?? "normal"}).` }],
626
+ };
627
+ });
628
+ server.tool("add_event", "Add an event to the state machine", { id: z.string(), description: z.string().optional() }, async ({ id, description }) => {
629
+ const def = readDef();
630
+ if (!def)
631
+ return {
632
+ content: [{ type: "text", text: `No state machine at ${PROJECT_FILE}.` }],
633
+ isError: true,
634
+ };
635
+ const source = def.events[0];
636
+ source.events.push({ id, description });
637
+ writeDef(def);
638
+ bridgeEmitPatch([{ op: "event.add", sourceIndex: 0, event: { id, description } }]);
639
+ return {
640
+ content: [{ type: "text", text: `Added event '${id}'.` }],
641
+ };
642
+ });
643
+ server.tool("add_transition", "Add a transition to a state, triggered by an event", {
644
+ state: z.string(),
645
+ event: z.string(),
646
+ nextState: z.string().optional(),
647
+ condition: z.string().optional(),
648
+ description: z.string().optional(),
649
+ }, async ({ state, event, nextState, condition, description }) => {
650
+ const def = readDef();
651
+ if (!def)
652
+ return {
653
+ content: [{ type: "text", text: `No state machine at ${PROJECT_FILE}.` }],
654
+ isError: true,
655
+ };
656
+ const target = findState(def.state, state);
657
+ if (!target)
658
+ return {
659
+ content: [{ type: "text", text: `State '${state}' not found.` }],
660
+ isError: true,
661
+ };
662
+ if (!target.transitions)
663
+ target.transitions = [];
664
+ target.transitions.push({ event, nextState, condition, description });
665
+ writeDef(def);
666
+ bridgeEmitPatch([
667
+ { op: "transition.add", state, transition: { event, nextState, condition, description } },
668
+ ]);
669
+ const desc = nextState ? `${state} --[${event}]--> ${nextState}` : `${state} --[${event}]--> (internal)`;
670
+ return { content: [{ type: "text", text: `Added transition: ${desc}` }] };
671
+ });
672
+ server.tool("remove_state", "Remove a state from the definition", { name: z.string() }, async ({ name }) => {
673
+ const def = readDef();
674
+ if (!def)
675
+ return { content: [{ type: "text", text: `No state machine at ${PROJECT_FILE}.` }], isError: true };
676
+ if (name === "Root")
677
+ return { content: [{ type: "text", text: "Cannot remove Root state." }], isError: true };
678
+ def.state = removeState(def.state, name);
679
+ writeDef(def);
680
+ bridgeEmitPatch([{ op: "state.remove", name }]);
681
+ return { content: [{ type: "text", text: `Removed state '${name}'.` }] };
682
+ });
683
+ server.tool("validate_definition", "Validate the current state machine definition against all rules", {}, async () => {
684
+ const def = readDef();
685
+ if (!def)
686
+ return { content: [{ type: "text", text: `No state machine at ${PROJECT_FILE}.` }], isError: true };
687
+ const errors = validate(def);
688
+ if (errors.length === 0)
689
+ return { content: [{ type: "text", text: "✓ Definition is valid. No errors found." }] };
690
+ const lines = errors.map((e) => `[${e.ruleId}] ${e.message}`).join("\n");
691
+ return {
692
+ content: [{ type: "text", text: `Found ${errors.length} validation error(s):\n${lines}` }],
693
+ };
694
+ });
695
+ server.tool("generate_code", "Generate Python or TypeScript code from the current definition", { language: z.enum(["python", "typescript"]) }, async ({ language }) => {
696
+ const def = readDef();
697
+ if (!def)
698
+ return { content: [{ type: "text", text: `No state machine at ${PROJECT_FILE}.` }], isError: true };
699
+ const errors = validate(def);
700
+ if (errors.length > 0) {
701
+ const lines = errors.map((e) => `[${e.ruleId}] ${e.message}`).join("\n");
702
+ return {
703
+ content: [{ type: "text", text: `Fix validation errors first:\n${lines}` }],
704
+ isError: true,
705
+ };
706
+ }
707
+ const code = generateViaSmcg(def, language);
708
+ return { content: [{ type: "text", text: code }] };
709
+ });
710
+ server.tool("get_definition", "Get the current state machine definition as JSON", {}, async () => {
711
+ const def = readDef();
712
+ if (!def)
713
+ return { content: [{ type: "text", text: `No state machine at ${PROJECT_FILE}.` }], isError: true };
714
+ return {
715
+ content: [{ type: "text", text: JSON.stringify({ stateMachine: def }, null, 2) }],
716
+ };
717
+ });
718
+ server.tool("load_definition", "Load a state machine definition from JSON", { json: z.string() }, async ({ json }) => {
719
+ try {
720
+ const parsed = JSON.parse(json);
721
+ const def = parsed.stateMachine ?? parsed.StateMachine ?? parsed;
722
+ writeDef(def);
723
+ bridgeEmitFull(def);
724
+ const stateCount = collectStateNames(def.state).length;
725
+ const eventCount = collectEventIds(def).length;
726
+ return {
727
+ content: [
728
+ {
729
+ type: "text",
730
+ text: `Loaded definition → ${PROJECT_FILE}: ${def.settings.name} (${stateCount} states, ${eventCount} events)`,
731
+ },
732
+ ],
733
+ };
734
+ }
735
+ catch (e) {
736
+ return { content: [{ type: "text", text: `Invalid JSON: ${e}` }], isError: true };
737
+ }
738
+ });
739
+ server.tool("list_states", "List all states in the current definition with their kind and transitions", {}, async () => {
740
+ const def = readDef();
741
+ if (!def)
742
+ return { content: [{ type: "text", text: `No state machine at ${PROJECT_FILE}.` }], isError: true };
743
+ const lines = [];
744
+ const walk = (state, depth) => {
745
+ const indent = " ".repeat(depth);
746
+ const kind = state.kind ? ` (${state.kind})` : "";
747
+ const transCount = state.transitions?.length ?? 0;
748
+ lines.push(`${indent}${state.name}${kind} — ${transCount} transition(s)`);
749
+ for (const t of state.transitions ?? []) {
750
+ lines.push(`${indent} → [${t.event}] → ${t.nextState ?? "(internal)"}`);
751
+ }
752
+ (state.states ?? []).forEach((s) => walk(s, depth + 1));
753
+ };
754
+ walk(def.state, 0);
755
+ return { content: [{ type: "text", text: lines.join("\n") }] };
756
+ });
757
+ server.tool("list_events", "List all events in the current definition", {}, async () => {
758
+ const def = readDef();
759
+ if (!def)
760
+ return { content: [{ type: "text", text: `No state machine at ${PROJECT_FILE}.` }], isError: true };
761
+ const events = def.events.flatMap((src) => src.events.map((e) => `${e.id}${e.description ? ` — ${e.description}` : ""}`));
762
+ if (events.length === 0)
763
+ return { content: [{ type: "text", text: "No events defined yet." }] };
764
+ return { content: [{ type: "text", text: events.join("\n") }] };
765
+ });
766
+ server.tool("set_project_file", "Choose which .smdf.json path is the active document the loom weaves. Disk persistence AND the live bridge room (the hub keys rooms by this path) both re-point to it. A missing file is legitimate — create_state_machine or load_definition writes it next. Path must end in .json; convention is *.smdf.json.", { path: z.string() }, async ({ path }) => {
767
+ try {
768
+ const r = resolveProjectSwitch(path, PROJECT_FILE);
769
+ PROJECT_FILE = r.path;
770
+ if (!r.unchanged) {
771
+ bindBridge(PROJECT_FILE);
772
+ joinBridge();
773
+ }
774
+ const def = r.exists ? readDef() : null;
775
+ const summary = def
776
+ ? `existing machine '${def.settings.name}' (${collectStateNames(def.state).length} states, ${collectEventIds(def).length} events)`
777
+ : r.exists
778
+ ? "file exists but is not a readable definition"
779
+ : "no file yet — create_state_machine or load_definition will write it";
780
+ const bridgeNote = BRIDGE_URL
781
+ ? r.unchanged
782
+ ? `bridge unchanged (${bridge?.status ?? "not configured"})`
783
+ : `bridge re-joining room '${r.path}'`
784
+ : "bridge not configured (STATELOOM_BRIDGE_URL unset)";
785
+ return {
786
+ content: [
787
+ {
788
+ type: "text",
789
+ text: `Active document: ${r.path}\nPrevious: ${r.previous}\n${summary}\n${bridgeNote}`,
790
+ },
791
+ ],
792
+ };
793
+ }
794
+ catch (e) {
795
+ return {
796
+ content: [{ type: "text", text: e instanceof Error ? e.message : String(e) }],
797
+ isError: true,
798
+ };
799
+ }
800
+ });
801
+ server.tool("get_project_file", "Report the active .smdf.json document path, whether it exists on disk, and live-bridge status", {}, async () => {
802
+ const def = readDef();
803
+ const summary = def
804
+ ? `machine '${def.settings.name}' (${collectStateNames(def.state).length} states, ${collectEventIds(def).length} events)`
805
+ : existsSync(PROJECT_FILE)
806
+ ? "file exists but is not a readable definition"
807
+ : "file does not exist yet";
808
+ const bridgeNote = BRIDGE_URL
809
+ ? `bridge: ${bridge?.status ?? "not initialized"} → ${BRIDGE_URL}`
810
+ : "bridge not configured (STATELOOM_BRIDGE_URL unset)";
811
+ return {
812
+ content: [
813
+ { type: "text", text: `Active document: ${PROJECT_FILE}\n${summary}\n${bridgeNote}` },
814
+ ],
815
+ };
816
+ });
817
+ server.tool("generate_rispec", "Generate a RISE rispec (markdown) from the current state machine. If the SMDF was sourced from a PDE (settings._source.pdeId/pdeFolder), the PDE's intent, directions, and ambiguities are folded in. Pass `intent` to override the desired outcome.", { intent: z.string().optional() }, async ({ intent }) => {
818
+ const def = readDef();
819
+ if (!def)
820
+ return { content: [{ type: "text", text: `No state machine at ${PROJECT_FILE}.` }], isError: true };
821
+ const md = generateRispec(def, intent);
822
+ return { content: [{ type: "text", text: md }] };
823
+ });
824
+ // Resources
825
+ server.resource("definition", "smcraft://definition", async () => {
826
+ const def = readDef();
827
+ return {
828
+ contents: [
829
+ {
830
+ uri: "smcraft://definition",
831
+ mimeType: "application/json",
832
+ text: def
833
+ ? JSON.stringify({ stateMachine: def }, null, 2)
834
+ : `{"error": "No definition at ${PROJECT_FILE}"}`,
835
+ },
836
+ ],
837
+ };
838
+ });
839
+ // Prompts
840
+ server.prompt("design-state-machine", "Guided conversation to design a state machine step by step", { domain: z.string().optional(), name: z.string().optional() }, ({ domain, name }) => ({
841
+ messages: [
842
+ {
843
+ role: "user",
844
+ content: {
845
+ type: "text",
846
+ text: `I want to design a state machine${name ? ` called "${name}"` : ""}${domain ? ` for the "${domain}" domain` : ""}. Guide me through:
847
+ 1. First, create the state machine with create_state_machine
848
+ 2. Help me identify the key states and add them with add_state
849
+ 3. Define the events that trigger transitions with add_event
850
+ 4. Wire up transitions between states with add_transition
851
+ 5. Validate the design with validate_definition
852
+ 6. Generate code with generate_code
853
+
854
+ Start by asking me about the states and events I need.`,
855
+ },
856
+ },
857
+ ],
858
+ }));
859
+ // Start
860
+ async function main() {
861
+ const transport = new StdioServerTransport();
862
+ await server.connect(transport);
863
+ console.error(`SMCraft MCP server running on stdio (project file: ${PROJECT_FILE})`);
864
+ joinBridge();
865
+ }
866
+ main().catch(console.error);