@miadi/stateloom-mcp 0.1.2 → 0.1.3

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/src/server.ts DELETED
@@ -1,1182 +0,0 @@
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
- * - render_diagram: Draw the machine as an image on disk and return it
17
- * - get_definition: Get the current definition as JSON
18
- * - load_definition: Load a definition from JSON
19
- * - list_states: List all states in the current definition
20
- * - list_events: List all events in the current definition
21
- * - set_project_file: Choose which .smdf.json path is the active document
22
- * - get_project_file: Report the active document path and bridge status
23
- *
24
- * Resources:
25
- * - smcraft://definition — Current state machine definition
26
- *
27
- * Prompts:
28
- * - design-state-machine — Guided state machine design conversation
29
- */
30
-
31
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
32
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
33
- import { z } from "zod";
34
- import { execFileSync, spawn } from "child_process";
35
- import { writeFileSync, readFileSync, mkdtempSync, rmSync, existsSync, statSync } from "fs";
36
- import { join, resolve } from "path";
37
- import { tmpdir } from "os";
38
- import { createBridgeClient, type BridgeClient } from "@miadi/stateloom-client";
39
- import { envAlias, type PatchOp, type StateMachineDefinition } from "@miadi/stateloom-protocol";
40
- import { renderDiagramToFile, defaultOutputPath } from "@miadi/stateloom-cli/render";
41
- import { resolveProjectSwitch } from "./projectSwitch.js";
42
-
43
- // STATELOOM_* read first, SMCRAFT_* legacy twin honored — the live MCP
44
- // registration bakes SMCRAFT_PROJECT_FILE and must keep working (2026-07-27
45
- // rename brief). Resolved once at boot, like the direct reads they replace.
46
- const BRIDGE_URL = envAlias("BRIDGE_URL");
47
- const AGENT_NAME = envAlias("AGENT_NAME");
48
- const BRIDGE_TOKEN = envAlias("BRIDGE_TOKEN");
49
-
50
- // ─── In-memory state machine definition ──────────────────────────────
51
-
52
- interface StateDef {
53
- name: string;
54
- kind?: "normal" | "final" | "history";
55
- description?: string;
56
- transitions?: TransitionDef[];
57
- states?: StateDef[];
58
- onEntry?: string;
59
- onExit?: string;
60
- }
61
-
62
- interface TransitionDef {
63
- event: string;
64
- nextState?: string;
65
- condition?: string;
66
- description?: string;
67
- }
68
-
69
- interface EventDef {
70
- id: string;
71
- description?: string;
72
- }
73
-
74
- interface Definition {
75
- settings: {
76
- namespace: string;
77
- name: string;
78
- asynchronous: boolean;
79
- _source?: {
80
- kind?: string;
81
- pdeId?: string;
82
- pdeFolder?: string;
83
- originalPrompt?: string;
84
- engine?: string;
85
- };
86
- };
87
- events: { name: string; events: EventDef[] }[];
88
- state: StateDef;
89
- }
90
-
91
- // ─── File-backed shared store ────────────────────────────────────────
92
- // Both web/ and mcp/ resolve the same path from STATELOOM_PROJECT_FILE
93
- // (legacy twin SMCRAFT_PROJECT_FILE still honored).
94
- // Web reads/writes via /api/file; mcp reads/writes here. fs.watch on
95
- // the web side broadcasts disk changes back to the canvas.
96
-
97
- // Mutable: `set_project_file` lets the agent re-point the loom at another
98
- // .smdf.json document mid-session (the hub keys live rooms by this path).
99
- let PROJECT_FILE = resolve(
100
- envAlias("PROJECT_FILE") ?? "./statemachine.smdf.json"
101
- );
102
-
103
- function readDef(): Definition | null {
104
- if (!existsSync(PROJECT_FILE)) return null;
105
- try {
106
- const raw = readFileSync(PROJECT_FILE, "utf8");
107
- const parsed = JSON.parse(raw);
108
- return parsed.stateMachine ?? parsed.StateMachine ?? parsed;
109
- } catch (e) {
110
- console.error(`[smcraft-mcp] Failed to read ${PROJECT_FILE}:`, e);
111
- return null;
112
- }
113
- }
114
-
115
- function writeDef(def: Definition): void {
116
- writeFileSync(PROJECT_FILE, JSON.stringify({ stateMachine: def }, null, 2), "utf8");
117
- }
118
-
119
- // ─── Optional real-time design bridge (env-gated, best-effort) ───────
120
- // When STATELOOM_BRIDGE_URL (or legacy SMCRAFT_BRIDGE_URL) is set, each
121
- // mutation is mirrored to the bridge
122
- // hub so a live web canvas updates as the agent edits. Fully backward-
123
- // compatible: the file on disk remains the durable truth, every bridge
124
- // call is wrapped so any failure degrades to a no-op, and standalone
125
- // operation (no bridge URL) is untouched.
126
-
127
- let bridge: BridgeClient | undefined;
128
- if (!BRIDGE_URL) {
129
- console.error(
130
- "[smcraft-mcp] STATELOOM_BRIDGE_URL is not set — edits persist to disk but " +
131
- "are NOT broadcast; a live canvas will not update. Set STATELOOM_BRIDGE_URL " +
132
- "(legacy twin: SMCRAFT_BRIDGE_URL; e.g. http://127.0.0.1:4599) on this MCP " +
133
- "process to go live."
134
- );
135
- }
136
-
137
- /** (Re)create the bridge client bound to `docId`, dropping any previous one. */
138
- function bindBridge(docId: string): void {
139
- if (!BRIDGE_URL) return;
140
- try {
141
- bridge?.disconnect();
142
- } catch {
143
- /* best-effort */
144
- }
145
- try {
146
- bridge = createBridgeClient({
147
- url: BRIDGE_URL,
148
- role: "agent",
149
- docId,
150
- name: AGENT_NAME ?? "mcp-agent",
151
- token: BRIDGE_TOKEN,
152
- });
153
- } catch (e) {
154
- console.error("[smcraft-mcp] bridge init failed (continuing standalone):", e);
155
- bridge = undefined;
156
- }
157
- }
158
-
159
- /** Best-effort, non-blocking join. Failure never blocks tool handling. */
160
- function joinBridge(): void {
161
- if (!bridge) return;
162
- bridge
163
- .join()
164
- .then(() => {
165
- console.error(
166
- `[smcraft-mcp] bridge connected (${BRIDGE_URL}) docId=${PROJECT_FILE}`
167
- );
168
- })
169
- .catch((e) => {
170
- console.error("[smcraft-mcp] bridge join failed (continuing standalone):", e?.message ?? e);
171
- });
172
- }
173
-
174
- bindBridge(PROJECT_FILE);
175
-
176
- if (BRIDGE_URL) {
177
- // Best-effort teardown on process exit.
178
- let bridgeClosed = false;
179
- const closeBridge = (): void => {
180
- if (bridgeClosed) return;
181
- bridgeClosed = true;
182
- try {
183
- bridge?.disconnect();
184
- } catch {
185
- /* best-effort */
186
- }
187
- };
188
- process.on("exit", closeBridge);
189
- process.on("SIGINT", () => {
190
- closeBridge();
191
- process.exit(0);
192
- });
193
- process.on("SIGTERM", () => {
194
- closeBridge();
195
- process.exit(0);
196
- });
197
- }
198
-
199
- // Emit precise granular ops (never diffs) AFTER writeDef. The mtime is
200
- // stamped from the file we just wrote so the hub can dedup its own
201
- // file-watch echo of this same change.
202
- let droppedEmitWarned = false;
203
- function warnDroppedEmit(kind: string): void {
204
- if (droppedEmitWarned) return;
205
- droppedEmitWarned = true;
206
- console.error(
207
- `[smcraft-mcp] dropped ${kind} emit — bridge ` +
208
- (bridge ? `status=${bridge.status}` : "not configured (STATELOOM_BRIDGE_URL unset)") +
209
- "; disk write succeeded, live canvas did not update. (warned once)"
210
- );
211
- }
212
-
213
- function bridgeEmitPatch(ops: PatchOp[]): void {
214
- if (!bridge || bridge.status !== "connected") {
215
- warnDroppedEmit("patch");
216
- return;
217
- }
218
- try {
219
- const mtime = statSync(PROJECT_FILE).mtimeMs;
220
- bridge.emitPatch(ops, mtime);
221
- } catch {
222
- /* best-effort */
223
- }
224
- }
225
-
226
- function bridgeEmitFull(def: Definition): void {
227
- if (!bridge || bridge.status !== "connected") {
228
- warnDroppedEmit("full-def");
229
- return;
230
- }
231
- try {
232
- const mtime = statSync(PROJECT_FILE).mtimeMs;
233
- // Local Definition/StateDef differ slightly from the protocol's
234
- // StateMachineDefinition; the payload is serialized as-is over the wire.
235
- bridge.emitFull(def as unknown as StateMachineDefinition, mtime);
236
- } catch {
237
- /* best-effort */
238
- }
239
- }
240
-
241
- function createEmpty(namespace: string, name: string): Definition {
242
- return {
243
- settings: { namespace, name, asynchronous: false },
244
- events: [{ name: "Internal", events: [] }],
245
- state: { name: "Root", states: [] },
246
- };
247
- }
248
-
249
- function findState(root: StateDef, name: string): StateDef | null {
250
- if (root.name === name) return root;
251
- for (const child of root.states ?? []) {
252
- const found = findState(child, name);
253
- if (found) return found;
254
- }
255
- return null;
256
- }
257
-
258
- function removeState(root: StateDef, name: string): StateDef {
259
- return {
260
- ...root,
261
- states: root.states
262
- ?.filter((s) => s.name !== name)
263
- .map((s) => removeState(s, name)),
264
- };
265
- }
266
-
267
- function collectStateNames(root: StateDef): string[] {
268
- const names = [root.name];
269
- for (const child of root.states ?? []) {
270
- names.push(...collectStateNames(child));
271
- }
272
- return names;
273
- }
274
-
275
- function collectEventIds(def: Definition): string[] {
276
- return def.events.flatMap((src) => src.events.map((e) => e.id));
277
- }
278
-
279
- interface ValidationError {
280
- ruleId: string;
281
- message: string;
282
- }
283
-
284
- function validate(def: Definition): ValidationError[] {
285
- const errors: ValidationError[] = [];
286
- const stateNames = collectStateNames(def.state);
287
- const eventIds = collectEventIds(def);
288
-
289
- if (eventIds.length === 0) {
290
- errors.push({ ruleId: "V001", message: "No events defined" });
291
- }
292
-
293
- const seen = new Set<string>();
294
- for (const name of stateNames) {
295
- if (seen.has(name))
296
- errors.push({
297
- ruleId: "V002",
298
- message: `Duplicate state name: ${name}`,
299
- });
300
- seen.add(name);
301
- }
302
-
303
- const checkTransitions = (state: StateDef) => {
304
- for (const t of state.transitions ?? []) {
305
- if (!eventIds.includes(t.event))
306
- errors.push({
307
- ruleId: "V003",
308
- message: `State '${state.name}' references unknown event '${t.event}'`,
309
- });
310
- if (t.nextState && !stateNames.includes(t.nextState))
311
- errors.push({
312
- ruleId: "V004",
313
- message: `State '${state.name}' targets unknown state '${t.nextState}'`,
314
- });
315
- }
316
- (state.states ?? []).forEach(checkTransitions);
317
- };
318
- checkTransitions(def.state);
319
-
320
- if (!def.state.states || def.state.states.length === 0)
321
- errors.push({
322
- ruleId: "V005",
323
- message: "Root must have at least one child state",
324
- });
325
-
326
- return errors;
327
- }
328
-
329
- function generateViaSmcg(def: Definition, language: string): string {
330
- const tmpDir = mkdtempSync(join(tmpdir(), "smcraft-"));
331
- // Sanitize the input filename — settings.name is agent-controlled free text.
332
- const safeName = String(def.settings.name ?? "machine").replace(/[^A-Za-z0-9_.-]/g, "_");
333
- const inputFile = join(tmpDir, `${safeName}.smdf.json`);
334
- const outputDir = join(tmpDir, "output");
335
-
336
- try {
337
- // Write definition as SMDF JSON
338
- const smdf = { settings: def.settings, events: def.events, state: def.state };
339
- writeFileSync(inputFile, JSON.stringify(smdf, null, 2));
340
-
341
- // Call real smcg CLI — execFileSync (no shell) so a crafted name cannot
342
- // inject shell commands.
343
- execFileSync("smcg", [inputFile, "-l", language, "-o", outputDir], {
344
- encoding: "utf-8",
345
- timeout: 30000,
346
- });
347
-
348
- // Read generated output
349
- const ext = language === "python" ? "py" : "ts";
350
- const name = def.settings.name ?? "machine";
351
- const outputFile = join(outputDir, `${name}_fsm.${ext}`);
352
- return readFileSync(outputFile, "utf-8");
353
- } catch (e: any) {
354
- // Fallback to lightweight inline generation if smcg fails
355
- return language === "python"
356
- ? generatePythonFallback(def)
357
- : generateTypeScriptFallback(def);
358
- } finally {
359
- try { rmSync(tmpDir, { recursive: true, force: true }); } catch {}
360
- }
361
- }
362
-
363
- // Fallback lightweight generators (used when smcg CLI unavailable)
364
- function generatePythonFallback(def: Definition): string {
365
- const lines: string[] = [];
366
- const name = def.settings.name ?? "StateMachine";
367
- const states = def.state.states ?? [];
368
- const events = collectEventIds(def);
369
-
370
- lines.push(`"""Generated by SMCraft"""`);
371
- lines.push(`from enum import Enum, auto`);
372
- lines.push(`from smcraft.runtime import Context, State, TransitionHelper`);
373
- lines.push(``);
374
- lines.push(`class StateId(Enum):`);
375
- for (const s of collectStateNames(def.state)) {
376
- lines.push(` ${s} = auto()`);
377
- }
378
- lines.push(``);
379
-
380
- for (const s of states) {
381
- lines.push(`class State${s.name}(State):`);
382
- lines.push(` """State: ${s.name}"""`);
383
- if (s.onEntry)
384
- lines.push(
385
- ` def on_entry(self, context):\n ${s.onEntry}`
386
- );
387
- if (s.onExit)
388
- lines.push(
389
- ` def on_exit(self, context):\n ${s.onExit}`
390
- );
391
- for (const t of s.transitions ?? []) {
392
- lines.push(` def on_${t.event.toLowerCase()}(self, context):`);
393
- if (t.condition) lines.push(` if not (${t.condition}): return`);
394
- if (t.nextState)
395
- lines.push(
396
- ` TransitionHelper.do_transition(context, context.state_${t.nextState.toLowerCase()})`
397
- );
398
- else lines.push(` pass # internal transition`);
399
- }
400
- lines.push(``);
401
- }
402
-
403
- lines.push(`class ${name}Context(Context):`);
404
- lines.push(` """Context for ${name}"""`);
405
- lines.push(` def __init__(self):`);
406
- lines.push(` super().__init__()`);
407
- for (const s of states) {
408
- lines.push(
409
- ` self.state_${s.name.toLowerCase()} = State${s.name}()`
410
- );
411
- }
412
- lines.push(
413
- ` self.state_initial = self.state_${(states[0]?.name ?? "Root").toLowerCase()}`
414
- );
415
- lines.push(``);
416
-
417
- return lines.join("\n");
418
- }
419
-
420
- function generateTypeScriptFallback(def: Definition): string {
421
- const lines: string[] = [];
422
- const name = def.settings.name ?? "StateMachine";
423
- const states = def.state.states ?? [];
424
-
425
- lines.push(`/** Generated by SMCraft */`);
426
- lines.push(`import { Context, State, TransitionHelper } from "smcraft/runtime";`);
427
- lines.push(``);
428
- lines.push(`export enum StateId {`);
429
- for (const s of collectStateNames(def.state)) {
430
- lines.push(` ${s} = "${s}",`);
431
- }
432
- lines.push(`}`);
433
- lines.push(``);
434
-
435
- for (const s of states) {
436
- lines.push(`export class State${s.name} extends State {`);
437
- for (const t of s.transitions ?? []) {
438
- lines.push(` on${t.event}(context: ${name}Context): void {`);
439
- if (t.condition) lines.push(` if (!(${t.condition})) return;`);
440
- if (t.nextState)
441
- lines.push(
442
- ` TransitionHelper.doTransition(context, (context as any).state${t.nextState});`
443
- );
444
- lines.push(` }`);
445
- }
446
- lines.push(`}`);
447
- lines.push(``);
448
- }
449
-
450
- lines.push(`export class ${name}Context extends Context {`);
451
- for (const s of states) {
452
- lines.push(` state${s.name} = new State${s.name}();`);
453
- }
454
- if (states.length > 0)
455
- lines.push(` stateInitial = this.state${states[0].name};`);
456
- lines.push(`}`);
457
-
458
- return lines.join("\n");
459
- }
460
-
461
- // ─── RISE rispec generator ───────────────────────────────────────────
462
- // Reverse-Engineering → Intent → Specifications → Exportation.
463
- // Reads the SMDF (and optionally its source PDE) and emits a markdown
464
- // rispec following the workspace's spec conventions:
465
- // Title → Tagline → Desired Outcome → Structural Tension →
466
- // States/Events/Transitions → Action Steps → Exportation.
467
-
468
- interface PdeSource {
469
- prompt?: string;
470
- primary?: { action?: string; target?: string; urgency?: string; confidence?: number };
471
- secondary?: Array<{ action?: string; target?: string; confidence?: number }>;
472
- directions?: {
473
- east?: Array<{ text: string; confidence?: number }>;
474
- south?: Array<{ text: string; confidence?: number }>;
475
- west?: Array<{ text: string; confidence?: number }>;
476
- north?: Array<{ text: string; confidence?: number }>;
477
- };
478
- actionStack?: Array<{ text: string; direction?: string; completed?: boolean }>;
479
- ambiguities?: Array<{ text: string; suggestion?: string }>;
480
- }
481
-
482
- function loadPde(def: Definition): PdeSource | null {
483
- const folder = def.settings._source?.pdeFolder;
484
- const id = def.settings._source?.pdeId;
485
- if (!folder || !id) return null;
486
- const root = resolve(PROJECT_FILE, "..");
487
- const candidates = [
488
- resolve(root, folder, `pde-${id}.json`),
489
- resolve(folder, `pde-${id}.json`),
490
- ];
491
- for (const path of candidates) {
492
- if (!existsSync(path)) continue;
493
- try {
494
- const data = JSON.parse(readFileSync(path, "utf8"));
495
- return {
496
- prompt: data.prompt,
497
- primary: data.result?.primary,
498
- secondary: data.result?.secondary,
499
- directions: data.result?.directions,
500
- actionStack: data.result?.actionStack,
501
- ambiguities: data.result?.ambiguities,
502
- };
503
- } catch {
504
- /* try next */
505
- }
506
- }
507
- return null;
508
- }
509
-
510
- function generateRispec(def: Definition, intent?: string): string {
511
- const pde = loadPde(def);
512
- const name = def.settings.name;
513
- const ns = def.settings.namespace;
514
- const lines: string[] = [];
515
- const states = def.state.states ?? [];
516
-
517
- const tagline =
518
- intent ??
519
- pde?.primary?.target ??
520
- def.settings._source?.originalPrompt ??
521
- `${name} state machine`;
522
-
523
- lines.push(`# ${name} — RISE rispec`);
524
- lines.push("");
525
- lines.push(`> ${tagline}`);
526
- lines.push("");
527
- lines.push(`**Namespace:** \`${ns}\` · **Async:** ${def.settings.asynchronous}`);
528
- if (def.settings._source?.pdeId) {
529
- lines.push(`**Source PDE:** \`${def.settings._source.pdeId}\``);
530
- }
531
- lines.push("");
532
- lines.push("---");
533
- lines.push("");
534
-
535
- // Reverse Engineering
536
- lines.push("## R — Reverse Engineering");
537
- lines.push("");
538
- lines.push(
539
- `${name} is modeled as a state machine with ${collectStateNames(def.state).length - 1} non-root states across ${depth(def.state)} level(s) of composition. ` +
540
- `Events: ${collectEventIds(def).length}. Transitions: ${countTransitions(def.state)}.`
541
- );
542
- lines.push("");
543
- if (pde?.prompt) {
544
- lines.push(`Originating prompt:`);
545
- lines.push("");
546
- lines.push(`> ${pde.prompt}`);
547
- lines.push("");
548
- }
549
-
550
- // Intent
551
- lines.push("## I — Intent");
552
- lines.push("");
553
- lines.push("**Desired Outcome**");
554
- lines.push("");
555
- lines.push(intent ?? pde?.primary?.target ?? "_(no intent supplied — pass `intent` arg or set _source.originalPrompt)_");
556
- lines.push("");
557
- if (pde?.directions?.east?.length) {
558
- lines.push("**Vision (🌅 East)**");
559
- lines.push("");
560
- for (const v of pde.directions.east) lines.push(`- ${v.text}`);
561
- lines.push("");
562
- }
563
- lines.push("**Structural Tension**");
564
- lines.push("");
565
- lines.push(`- *Current:* ${currentReality(def, pde)}`);
566
- lines.push(`- *Desired:* ${desiredOutcome(def, pde, intent)}`);
567
- lines.push("");
568
-
569
- // Specifications
570
- lines.push("## S — Specifications");
571
- lines.push("");
572
- lines.push("### States");
573
- lines.push("");
574
- for (const s of states) {
575
- renderStateSpec(s, lines, 0);
576
- }
577
- lines.push("");
578
-
579
- lines.push("### Events");
580
- lines.push("");
581
- for (const src of def.events) {
582
- if (!src.events?.length) continue;
583
- lines.push(`**${src.name}**`);
584
- for (const e of src.events) {
585
- lines.push(`- \`${e.id}\`${e.description ? ` — ${e.description}` : ""}`);
586
- }
587
- lines.push("");
588
- }
589
-
590
- lines.push("### Transitions (flat)");
591
- lines.push("");
592
- for (const t of collectTransitions(def.state)) {
593
- const arrow = t.next ? `→ ${t.next}` : "→ (internal)";
594
- lines.push(`- \`${t.from}\` --[${t.event}]-- ${arrow}${t.condition ? ` *(when: ${t.condition})*` : ""}`);
595
- }
596
- lines.push("");
597
-
598
- // Action Steps (medicine-wheel walk if South/East/North/West present)
599
- const mw = states.filter((s) => ["East", "South", "West", "North"].includes(s.name));
600
- if (mw.length || pde?.actionStack?.length) {
601
- lines.push("### Action Steps");
602
- lines.push("");
603
- if (mw.length) {
604
- const order = ["South", "East", "North", "West"];
605
- for (const dirName of order) {
606
- const dir = states.find((s) => s.name === dirName);
607
- if (!dir) continue;
608
- lines.push(`**${glyph(dirName)} ${dirName}** — ${dir.description ?? ""}`);
609
- for (const c of dir.states ?? []) {
610
- lines.push(`1. ${c.description ?? c.name}`);
611
- }
612
- lines.push("");
613
- }
614
- } else if (pde?.actionStack?.length) {
615
- for (const a of pde.actionStack) {
616
- lines.push(`- *(${a.direction ?? "?"})* ${a.text}`);
617
- }
618
- lines.push("");
619
- }
620
- }
621
-
622
- // Ambiguities surfaced from PDE
623
- if (pde?.ambiguities?.length) {
624
- lines.push("### Open Questions");
625
- lines.push("");
626
- for (const a of pde.ambiguities) {
627
- lines.push(`- ${a.text}${a.suggestion ? ` \n → *${a.suggestion}*` : ""}`);
628
- }
629
- lines.push("");
630
- }
631
-
632
- // Exportation
633
- lines.push("## E — Exportation");
634
- lines.push("");
635
- lines.push("- Code: `generate_code` (python | typescript) — runtime stubs from this SMDF");
636
- lines.push(`- Visual: open this file in the stateloom web designer with \`STATELOOM_PROJECT_FILE=${PROJECT_FILE}\``);
637
- lines.push("- SMDF: `get_definition` returns the canonical JSON");
638
- lines.push("");
639
-
640
- return lines.join("\n");
641
- }
642
-
643
- function depth(s: StateDef, d = 0): number {
644
- if (!s.states?.length) return d;
645
- return Math.max(...s.states.map((c) => depth(c, d + 1)));
646
- }
647
-
648
- function countTransitions(s: StateDef): number {
649
- let n = s.transitions?.length ?? 0;
650
- for (const c of s.states ?? []) n += countTransitions(c);
651
- return n;
652
- }
653
-
654
- function collectTransitions(
655
- s: StateDef
656
- ): Array<{ from: string; event: string; next?: string; condition?: string }> {
657
- const out: Array<{ from: string; event: string; next?: string; condition?: string }> = [];
658
- for (const t of s.transitions ?? []) {
659
- out.push({ from: s.name, event: t.event, next: t.nextState, condition: t.condition });
660
- }
661
- for (const c of s.states ?? []) out.push(...collectTransitions(c));
662
- return out;
663
- }
664
-
665
- function renderStateSpec(s: StateDef, lines: string[], depthLevel: number): void {
666
- const indent = " ".repeat(depthLevel);
667
- const kind = s.kind && s.kind !== "normal" ? ` *(${s.kind})*` : "";
668
- lines.push(`${indent}- **${s.name}**${kind}${s.description ? ` — ${s.description}` : ""}`);
669
- for (const t of s.transitions ?? []) {
670
- lines.push(`${indent} - on \`${t.event}\` ${t.nextState ? `→ \`${t.nextState}\`` : "(internal)"}`);
671
- }
672
- for (const c of s.states ?? []) renderStateSpec(c, lines, depthLevel + 1);
673
- }
674
-
675
- function currentReality(def: Definition, _pde: PdeSource | null): string {
676
- const hasFinal = collectAllStates(def.state).some((s) => s.kind === "final");
677
- return hasFinal
678
- ? `${def.settings.name} is designed but not implemented; entry state defined, terminal state reachable in principle.`
679
- : `${def.settings.name} is partially modeled; no terminal state declared yet.`;
680
- }
681
-
682
- function collectAllStates(s: StateDef): StateDef[] {
683
- const out = [s];
684
- for (const c of s.states ?? []) out.push(...collectAllStates(c));
685
- return out;
686
- }
687
-
688
- function desiredOutcome(def: Definition, pde: PdeSource | null, intent?: string): string {
689
- if (intent) return intent;
690
- if (pde?.primary?.target) return pde.primary.target;
691
- return `${def.settings.name} runs the modeled flow end-to-end and reaches its terminal state.`;
692
- }
693
-
694
- function glyph(direction: string): string {
695
- return { South: "🔥", East: "🌅", North: "❄️", West: "🌊" }[direction] ?? "•";
696
- }
697
-
698
- // ─── MCP Server ──────────────────────────────────────────────────────
699
-
700
- const server = new McpServer({
701
- name: "smcraft-mcp",
702
- version: "0.1.0",
703
- });
704
-
705
- // Tools
706
-
707
- server.tool(
708
- "create_state_machine",
709
- "Create a new state machine definition with namespace and name",
710
- { namespace: z.string(), name: z.string(), asynchronous: z.boolean().optional() },
711
- async ({ namespace, name, asynchronous }) => {
712
- const def = createEmpty(namespace, name);
713
- if (asynchronous) def.settings.asynchronous = true;
714
- writeDef(def);
715
- bridgeEmitFull(def);
716
- return {
717
- content: [
718
- {
719
- type: "text",
720
- text: `Created state machine '${name}' in namespace '${namespace}' → ${PROJECT_FILE}. Add states and events next.`,
721
- },
722
- ],
723
- };
724
- }
725
- );
726
-
727
- server.tool(
728
- "add_state",
729
- "Add a state to the state machine. Parent defaults to Root.",
730
- {
731
- name: z.string(),
732
- parent: z.string().optional(),
733
- kind: z.enum(["normal", "final", "history"]).optional(),
734
- description: z.string().optional(),
735
- },
736
- async ({ name, parent, kind, description }) => {
737
- const def = readDef();
738
- if (!def)
739
- return {
740
- content: [{ type: "text", text: `No state machine at ${PROJECT_FILE}. Use create_state_machine first.` }],
741
- isError: true,
742
- };
743
- const parentName = parent ?? "Root";
744
- const parentState = findState(def.state, parentName);
745
- if (!parentState)
746
- return {
747
- content: [{ type: "text", text: `Parent state '${parentName}' not found.` }],
748
- isError: true,
749
- };
750
- if (!parentState.states) parentState.states = [];
751
- parentState.states.push({ name, kind: kind ?? "normal", description });
752
- writeDef(def);
753
- bridgeEmitPatch([
754
- { op: "state.add", parent: parentName, state: { name, kind: kind ?? "normal", description } },
755
- ]);
756
- return {
757
- content: [{ type: "text", text: `Added state '${name}' under '${parentName}' (${kind ?? "normal"}).` }],
758
- };
759
- }
760
- );
761
-
762
- server.tool(
763
- "add_event",
764
- "Add an event to the state machine",
765
- { id: z.string(), description: z.string().optional() },
766
- async ({ id, description }) => {
767
- const def = readDef();
768
- if (!def)
769
- return {
770
- content: [{ type: "text", text: `No state machine at ${PROJECT_FILE}.` }],
771
- isError: true,
772
- };
773
- const source = def.events[0];
774
- source.events.push({ id, description });
775
- writeDef(def);
776
- bridgeEmitPatch([{ op: "event.add", sourceIndex: 0, event: { id, description } }]);
777
- return {
778
- content: [{ type: "text", text: `Added event '${id}'.` }],
779
- };
780
- }
781
- );
782
-
783
- server.tool(
784
- "add_transition",
785
- "Add a transition to a state, triggered by an event",
786
- {
787
- state: z.string(),
788
- event: z.string(),
789
- nextState: z.string().optional(),
790
- condition: z.string().optional(),
791
- description: z.string().optional(),
792
- },
793
- async ({ state, event, nextState, condition, description }) => {
794
- const def = readDef();
795
- if (!def)
796
- return {
797
- content: [{ type: "text", text: `No state machine at ${PROJECT_FILE}.` }],
798
- isError: true,
799
- };
800
- const target = findState(def.state, state);
801
- if (!target)
802
- return {
803
- content: [{ type: "text", text: `State '${state}' not found.` }],
804
- isError: true,
805
- };
806
- if (!target.transitions) target.transitions = [];
807
- target.transitions.push({ event, nextState, condition, description });
808
- writeDef(def);
809
- bridgeEmitPatch([
810
- { op: "transition.add", state, transition: { event, nextState, condition, description } },
811
- ]);
812
- const desc = nextState ? `${state} --[${event}]--> ${nextState}` : `${state} --[${event}]--> (internal)`;
813
- return { content: [{ type: "text", text: `Added transition: ${desc}` }] };
814
- }
815
- );
816
-
817
- server.tool(
818
- "remove_state",
819
- "Remove a state from the definition",
820
- { name: z.string() },
821
- async ({ name }) => {
822
- const def = readDef();
823
- if (!def)
824
- return { content: [{ type: "text", text: `No state machine at ${PROJECT_FILE}.` }], isError: true };
825
- if (name === "Root")
826
- return { content: [{ type: "text", text: "Cannot remove Root state." }], isError: true };
827
- def.state = removeState(def.state, name);
828
- writeDef(def);
829
- bridgeEmitPatch([{ op: "state.remove", name }]);
830
- return { content: [{ type: "text", text: `Removed state '${name}'.` }] };
831
- }
832
- );
833
-
834
- server.tool(
835
- "validate_definition",
836
- "Validate the current state machine definition against all rules",
837
- {},
838
- async () => {
839
- const def = readDef();
840
- if (!def)
841
- return { content: [{ type: "text", text: `No state machine at ${PROJECT_FILE}.` }], isError: true };
842
- const errors = validate(def);
843
- if (errors.length === 0)
844
- return { content: [{ type: "text", text: "✓ Definition is valid. No errors found." }] };
845
- const lines = errors.map((e) => `[${e.ruleId}] ${e.message}`).join("\n");
846
- return {
847
- content: [{ type: "text", text: `Found ${errors.length} validation error(s):\n${lines}` }],
848
- };
849
- }
850
- );
851
-
852
- server.tool(
853
- "generate_code",
854
- "Generate Python or TypeScript code from the current definition",
855
- { language: z.enum(["python", "typescript"]) },
856
- async ({ language }) => {
857
- const def = readDef();
858
- if (!def)
859
- return { content: [{ type: "text", text: `No state machine at ${PROJECT_FILE}.` }], isError: true };
860
- const errors = validate(def);
861
- if (errors.length > 0) {
862
- const lines = errors.map((e) => `[${e.ruleId}] ${e.message}`).join("\n");
863
- return {
864
- content: [{ type: "text", text: `Fix validation errors first:\n${lines}` }],
865
- isError: true,
866
- };
867
- }
868
- const code = generateViaSmcg(def, language);
869
- return { content: [{ type: "text", text: code }] };
870
- }
871
- );
872
-
873
- server.tool(
874
- "get_definition",
875
- "Get the current state machine definition as JSON",
876
- {},
877
- async () => {
878
- const def = readDef();
879
- if (!def)
880
- return { content: [{ type: "text", text: `No state machine at ${PROJECT_FILE}.` }], isError: true };
881
- return {
882
- content: [{ type: "text", text: JSON.stringify({ stateMachine: def }, null, 2) }],
883
- };
884
- }
885
- );
886
-
887
- server.tool(
888
- "load_definition",
889
- "Load a state machine definition from JSON",
890
- { json: z.string() },
891
- async ({ json }) => {
892
- try {
893
- const parsed = JSON.parse(json);
894
- const def: Definition = parsed.stateMachine ?? parsed.StateMachine ?? parsed;
895
- writeDef(def);
896
- bridgeEmitFull(def);
897
- const stateCount = collectStateNames(def.state).length;
898
- const eventCount = collectEventIds(def).length;
899
- return {
900
- content: [
901
- {
902
- type: "text",
903
- text: `Loaded definition → ${PROJECT_FILE}: ${def.settings.name} (${stateCount} states, ${eventCount} events)`,
904
- },
905
- ],
906
- };
907
- } catch (e) {
908
- return { content: [{ type: "text", text: `Invalid JSON: ${e}` }], isError: true };
909
- }
910
- }
911
- );
912
-
913
- server.tool(
914
- "list_states",
915
- "List all states in the current definition with their kind and transitions",
916
- {},
917
- async () => {
918
- const def = readDef();
919
- if (!def)
920
- return { content: [{ type: "text", text: `No state machine at ${PROJECT_FILE}.` }], isError: true };
921
- const lines: string[] = [];
922
- const walk = (state: StateDef, depth: number) => {
923
- const indent = " ".repeat(depth);
924
- const kind = state.kind ? ` (${state.kind})` : "";
925
- const transCount = state.transitions?.length ?? 0;
926
- lines.push(`${indent}${state.name}${kind} — ${transCount} transition(s)`);
927
- for (const t of state.transitions ?? []) {
928
- lines.push(`${indent} → [${t.event}] → ${t.nextState ?? "(internal)"}`);
929
- }
930
- (state.states ?? []).forEach((s) => walk(s, depth + 1));
931
- };
932
- walk(def.state, 0);
933
- return { content: [{ type: "text", text: lines.join("\n") }] };
934
- }
935
- );
936
-
937
- server.tool(
938
- "list_events",
939
- "List all events in the current definition",
940
- {},
941
- async () => {
942
- const def = readDef();
943
- if (!def)
944
- return { content: [{ type: "text", text: `No state machine at ${PROJECT_FILE}.` }], isError: true };
945
- const events = def.events.flatMap((src) =>
946
- src.events.map((e) => `${e.id}${e.description ? ` — ${e.description}` : ""}`)
947
- );
948
- if (events.length === 0)
949
- return { content: [{ type: "text", text: "No events defined yet." }] };
950
- return { content: [{ type: "text", text: events.join("\n") }] };
951
- }
952
- );
953
-
954
- server.tool(
955
- "set_project_file",
956
- "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.",
957
- { path: z.string() },
958
- async ({ path }) => {
959
- try {
960
- const r = resolveProjectSwitch(path, PROJECT_FILE);
961
- PROJECT_FILE = r.path;
962
- if (!r.unchanged) {
963
- bindBridge(PROJECT_FILE);
964
- joinBridge();
965
- }
966
- const def = r.exists ? readDef() : null;
967
- const summary = def
968
- ? `existing machine '${def.settings.name}' (${collectStateNames(def.state).length} states, ${collectEventIds(def).length} events)`
969
- : r.exists
970
- ? "file exists but is not a readable definition"
971
- : "no file yet — create_state_machine or load_definition will write it";
972
- const bridgeNote = BRIDGE_URL
973
- ? r.unchanged
974
- ? `bridge unchanged (${bridge?.status ?? "not configured"})`
975
- : `bridge re-joining room '${r.path}'`
976
- : "bridge not configured (STATELOOM_BRIDGE_URL unset)";
977
- return {
978
- content: [
979
- {
980
- type: "text",
981
- text: `Active document: ${r.path}\nPrevious: ${r.previous}\n${summary}\n${bridgeNote}`,
982
- },
983
- ],
984
- };
985
- } catch (e) {
986
- return {
987
- content: [{ type: "text", text: e instanceof Error ? e.message : String(e) }],
988
- isError: true,
989
- };
990
- }
991
- }
992
- );
993
-
994
- server.tool(
995
- "get_project_file",
996
- "Report the active .smdf.json document path, whether it exists on disk, and live-bridge status",
997
- {},
998
- async () => {
999
- const def = readDef();
1000
- const summary = def
1001
- ? `machine '${def.settings.name}' (${collectStateNames(def.state).length} states, ${collectEventIds(def).length} events)`
1002
- : existsSync(PROJECT_FILE)
1003
- ? "file exists but is not a readable definition"
1004
- : "file does not exist yet";
1005
- const bridgeNote = BRIDGE_URL
1006
- ? `bridge: ${bridge?.status ?? "not initialized"} → ${BRIDGE_URL}`
1007
- : "bridge not configured (STATELOOM_BRIDGE_URL unset)";
1008
- return {
1009
- content: [
1010
- { type: "text", text: `Active document: ${PROJECT_FILE}\n${summary}\n${bridgeNote}` },
1011
- ],
1012
- };
1013
- }
1014
- );
1015
-
1016
- server.tool(
1017
- "generate_rispec",
1018
- "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.",
1019
- { intent: z.string().optional() },
1020
- async ({ intent }) => {
1021
- const def = readDef();
1022
- if (!def)
1023
- return { content: [{ type: "text", text: `No state machine at ${PROJECT_FILE}.` }], isError: true };
1024
- const md = generateRispec(def, intent);
1025
- return { content: [{ type: "text", text: md }] };
1026
- }
1027
- );
1028
-
1029
- /**
1030
- * How large a PNG may be and still ride back inside the tool result. Past this
1031
- * the agent gets the path alone — a multi-megabyte base64 blob would crowd out
1032
- * the conversation it was meant to illustrate.
1033
- */
1034
- const INLINE_IMAGE_LIMIT = 1_500_000;
1035
-
1036
- /** Hand a rendered file to the platform viewer. Never fatal — a server has none. */
1037
- function openOnDesktop(path: string): string {
1038
- const opener =
1039
- process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
1040
- try {
1041
- const child = spawn(opener, [path], {
1042
- stdio: "ignore",
1043
- detached: true,
1044
- shell: process.platform === "win32",
1045
- });
1046
- child.on("error", () => {});
1047
- child.unref();
1048
- return `opened with ${opener}`;
1049
- } catch {
1050
- return "no desktop opener available";
1051
- }
1052
- }
1053
-
1054
- server.tool(
1055
- "render_diagram",
1056
- "Draw the current state machine as a picture. Writes the file next to the project document (or at `path`) and returns its absolute path — plus the image itself for png, so it can be looked at without leaving the conversation. Formats: png (raster, needs librsvg/Inkscape/ImageMagick/Chrome on the host), svg (needs nothing), mermaid, ascii. Pass stamp:true to keep every render instead of overwriting one file.",
1057
- {
1058
- format: z.enum(["png", "svg", "mermaid", "ascii"]).optional(),
1059
- path: z.string().optional(),
1060
- scale: z.number().optional(),
1061
- theme: z.enum(["dark", "light"]).optional(),
1062
- stamp: z.boolean().optional(),
1063
- open: z.boolean().optional(),
1064
- },
1065
- async ({ format, path, scale, theme, stamp, open }) => {
1066
- const def = readDef();
1067
- if (!def)
1068
- return {
1069
- content: [{ type: "text", text: `No state machine at ${PROJECT_FILE}.` }],
1070
- isError: true,
1071
- };
1072
-
1073
- const chosen = format ?? "png";
1074
- try {
1075
- const result = await renderDiagramToFile(def as unknown as StateMachineDefinition, {
1076
- format: chosen,
1077
- out: path,
1078
- doc: PROJECT_FILE,
1079
- scale,
1080
- theme,
1081
- stamp,
1082
- });
1083
-
1084
- const size = result.width && result.height ? `, ${result.width}×${result.height}` : "";
1085
- const how = result.backend ? `, drawn by ${result.backend}` : "";
1086
- const opened = open ? ` — ${openOnDesktop(result.path)}` : "";
1087
- const note = `Rendered '${def.settings.name}' → ${result.path} (${result.bytes} bytes${size}${how})${opened}`;
1088
-
1089
- // png and svg come back as pictures; the text formats are already text.
1090
- if (chosen === "png" && result.bytes <= INLINE_IMAGE_LIMIT) {
1091
- return {
1092
- content: [
1093
- { type: "text", text: note },
1094
- {
1095
- type: "image",
1096
- data: readFileSync(result.path).toString("base64"),
1097
- mimeType: "image/png",
1098
- },
1099
- ],
1100
- };
1101
- }
1102
- if (chosen === "mermaid" || chosen === "ascii") {
1103
- return {
1104
- content: [
1105
- { type: "text", text: note },
1106
- { type: "text", text: readFileSync(result.path, "utf8") },
1107
- ],
1108
- };
1109
- }
1110
- return { content: [{ type: "text", text: note }] };
1111
- } catch (e) {
1112
- const why = e instanceof Error ? e.message : String(e);
1113
- return {
1114
- content: [
1115
- {
1116
- type: "text",
1117
- text:
1118
- `render_diagram (${chosen}) failed: ${why}\n` +
1119
- `An SVG needs no rasterizer — retry with format "svg" ` +
1120
- `(it would land at ${defaultOutputPath(PROJECT_FILE, "svg")}).`,
1121
- },
1122
- ],
1123
- isError: true,
1124
- };
1125
- }
1126
- }
1127
- );
1128
-
1129
- // Resources
1130
-
1131
- server.resource("definition", "smcraft://definition", async () => {
1132
- const def = readDef();
1133
- return {
1134
- contents: [
1135
- {
1136
- uri: "smcraft://definition",
1137
- mimeType: "application/json",
1138
- text: def
1139
- ? JSON.stringify({ stateMachine: def }, null, 2)
1140
- : `{"error": "No definition at ${PROJECT_FILE}"}`,
1141
- },
1142
- ],
1143
- };
1144
- });
1145
-
1146
- // Prompts
1147
-
1148
- server.prompt(
1149
- "design-state-machine",
1150
- "Guided conversation to design a state machine step by step",
1151
- { domain: z.string().optional(), name: z.string().optional() },
1152
- ({ domain, name }) => ({
1153
- messages: [
1154
- {
1155
- role: "user",
1156
- content: {
1157
- type: "text",
1158
- text: `I want to design a state machine${name ? ` called "${name}"` : ""}${domain ? ` for the "${domain}" domain` : ""}. Guide me through:
1159
- 1. First, create the state machine with create_state_machine
1160
- 2. Help me identify the key states and add them with add_state
1161
- 3. Define the events that trigger transitions with add_event
1162
- 4. Wire up transitions between states with add_transition
1163
- 5. Validate the design with validate_definition
1164
- 6. Generate code with generate_code
1165
-
1166
- Start by asking me about the states and events I need.`,
1167
- },
1168
- },
1169
- ],
1170
- })
1171
- );
1172
-
1173
- // Start
1174
-
1175
- async function main() {
1176
- const transport = new StdioServerTransport();
1177
- await server.connect(transport);
1178
- console.error(`SMCraft MCP server running on stdio (project file: ${PROJECT_FILE})`);
1179
- joinBridge();
1180
- }
1181
-
1182
- main().catch(console.error);