@miadi/stateloom-mcp 0.1.3 → 0.2.0

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.
Files changed (3) hide show
  1. package/README.md +57 -2
  2. package/dist/server.js +624 -333
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -16,7 +16,7 @@ npx -y @miadi/stateloom-mcp
16
16
 
17
17
  A [Model Context Protocol](https://modelcontextprotocol.io) server over stdio that gives an agent fifteen tools for building a state machine: create it, add states and events and transitions, validate it, generate code or a RISE rispec from it, and draw it. Every mutation persists to a `.smdf.json` file on disk and — when a bridge URL is configured — emits to the live hub, so a human watching the web canvas or a terminal running `smcx watch` sees each edit as the agent makes it.
18
18
 
19
- It is not the state machine engine (`smcraft` runs the machines) and not the hub (`@miadi/stateloom` sequences the live document). This is the agent-facing surface, sitting beside the human-facing ones on the same file.
19
+ It is not the state machine engine (`@miadi/stateloom-engine` runs the machines) and not the hub (`@miadi/stateloom` sequences the live document). This is the agent-facing surface, sitting beside the human-facing ones on the same file.
20
20
 
21
21
  ## Configure your MCP client
22
22
 
@@ -44,6 +44,61 @@ It is not the state machine engine (`smcraft` runs the machines) and not the hub
44
44
 
45
45
  The legacy `SMCRAFT_*` twin of each name is still honored. The package ships two bins — `stateloom-mcp` and, for existing registrations, `smcraft-mcp`.
46
46
 
47
+ ## Remote access (optional, 0.2.0+)
48
+
49
+ By default this server speaks **stdio** and runs as a child process beside one agent,
50
+ with that agent's trust. Setting a port switches it to Streamable HTTP so agents on
51
+ other machines can reach the same loom. Nothing changes unless you set the port.
52
+
53
+ ```bash
54
+ STATELOOM_MCP_HTTP_PORT=4790 \
55
+ STATELOOM_MCP_TOKEN="$(openssl rand -hex 32)" \
56
+ STATELOOM_MCP_ROOT=/srv/machines \
57
+ npx -y @miadi/stateloom-mcp
58
+ # → stateloom-mcp on http://127.0.0.1:4790/mcp
59
+ ```
60
+
61
+ | Environment variable | Default | Meaning |
62
+ |---|---|---|
63
+ | `STATELOOM_MCP_HTTP_PORT` | — | Bind this port and speak HTTP. **Unset means stdio**, unchanged. |
64
+ | `STATELOOM_MCP_HTTP_HOST` | `127.0.0.1` | Interface. Loopback by default — put a TLS terminator in front before widening it. |
65
+ | `STATELOOM_MCP_TOKEN` | — | **Required in HTTP mode.** `Authorization: Bearer <token>`, compared in constant time. |
66
+ | `STATELOOM_MCP_ROOT` | — | Confine the active document to this directory; `set_project_file` refuses to leave it. |
67
+ | `STATELOOM_MCP_LOCK_PROJECT` | — | `1`/`true` refuses `set_project_file` outright. |
68
+
69
+ Endpoints: `POST /mcp` (the MCP endpoint) and `GET /health`.
70
+
71
+ The server **refuses to start** in HTTP mode without a token. A reachable port here is a
72
+ way to read and write files on the host, and a default-open one would be a mistake someone
73
+ else pays for.
74
+
75
+ ### What remote mode is not
76
+
77
+ **It is not multi-tenant.** The active document is process state, so every HTTP client
78
+ shares one board. That is the loom's whole premise when a human and an agent work together,
79
+ and a hazard between strangers — one caller's `set_project_file` moves the document for
80
+ everyone. `STATELOOM_MCP_ROOT` bounds where it can move; `STATELOOM_MCP_LOCK_PROJECT` stops
81
+ it moving at all. Run one server per document, or per team, rather than one for everybody.
82
+
83
+ Each request gets its own server instance and transport (stateless Streamable HTTP), so
84
+ clients may connect, disconnect and reconnect freely.
85
+
86
+ **It does not serve concurrent clients fairly.** `generate_code` shells out to `smcg` and
87
+ `render_diagram` shells out to a rasterizer, both synchronously — they block the event loop
88
+ for as long as the subprocess runs. On stdio that is invisible, because there is one caller
89
+ and it is waiting anyway. Over HTTP, one agent's slow codegen stalls every other request,
90
+ and a subprocess outlasting Node's 5-second keep-alive timeout makes those requests fail
91
+ outright rather than merely arrive late. Until those calls are made asynchronous, treat a
92
+ remote server as serving one working agent at a time, not a pool.
93
+
94
+ **What is confined and what is not.** With `STATELOOM_MCP_ROOT` set, both caller-supplied
95
+ paths — `set_project_file`'s `path` and `render_diagram`'s `path` — are resolved through
96
+ `realpath` and refused if they land outside the root, so a symlink inside the root cannot
97
+ walk out of it. The server also refuses to start if `STATELOOM_PROJECT_FILE` is itself
98
+ outside the root. `generate_code` writes only to a temporary directory. Note that
99
+ `STATELOOM_MCP_ROOT` is read the same way every other setting is, so it constrains
100
+ `set_project_file` on stdio too if you set it there.
101
+
47
102
  ## Tools
48
103
 
49
104
  | Tool | What it does |
@@ -78,7 +133,7 @@ Because rooms are keyed by the document's absolute path, `set_project_file` is h
78
133
 
79
134
  | Package | Role |
80
135
  |---|---|
81
- | [`smcraft`](https://www.npmjs.com/package/smcraft) | State machine engine: parser, validator, interpreter, code generators |
136
+ | [`@miadi/stateloom-engine`](https://www.npmjs.com/package/@miadi/stateloom-engine) | State machine engine: parser, validator, interpreter, code generators |
82
137
  | [`@miadi/stateloom-protocol`](https://www.npmjs.com/package/@miadi/stateloom-protocol) | Patch ops, diff/apply, layout, renderers — zero runtime deps |
83
138
  | [`@miadi/stateloom`](https://www.npmjs.com/package/@miadi/stateloom) | socket.io hub holding the live document |
84
139
  | [`@miadi/stateloom-client`](https://www.npmjs.com/package/@miadi/stateloom-client) | Framework-agnostic client for the hub |
package/dist/server.js CHANGED
@@ -29,10 +29,13 @@
29
29
  */
30
30
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
31
31
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
32
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
33
+ import { createServer as createHttpServer } from "node:http";
34
+ import { timingSafeEqual } from "node:crypto";
32
35
  import { z } from "zod";
33
36
  import { execFileSync, spawn } from "child_process";
34
- import { writeFileSync, readFileSync, mkdtempSync, rmSync, existsSync, statSync } from "fs";
35
- import { join, resolve } from "path";
37
+ import { writeFileSync, readFileSync, mkdtempSync, rmSync, existsSync, statSync, realpathSync, } from "fs";
38
+ import { basename, dirname, join, resolve } from "path";
36
39
  import { tmpdir } from "os";
37
40
  import { createBridgeClient } from "@miadi/stateloom-client";
38
41
  import { envAlias } from "@miadi/stateloom-protocol";
@@ -301,7 +304,7 @@ function generatePythonFallback(def) {
301
304
  const events = collectEventIds(def);
302
305
  lines.push(`"""Generated by SMCraft"""`);
303
306
  lines.push(`from enum import Enum, auto`);
304
- lines.push(`from smcraft.runtime import Context, State, TransitionHelper`);
307
+ lines.push(`from stateloom.runtime import Context, State, TransitionHelper`);
305
308
  lines.push(``);
306
309
  lines.push(`class StateId(Enum):`);
307
310
  for (const s of collectStateNames(def.state)) {
@@ -342,7 +345,7 @@ function generateTypeScriptFallback(def) {
342
345
  const name = def.settings.name ?? "StateMachine";
343
346
  const states = def.state.states ?? [];
344
347
  lines.push(`/** Generated by SMCraft */`);
345
- lines.push(`import { Context, State, TransitionHelper } from "smcraft/runtime";`);
348
+ lines.push(`import { Context, State, TransitionHelper } from "@miadi/stateloom-engine/runtime";`);
346
349
  lines.push(``);
347
350
  lines.push(`export enum StateId {`);
348
351
  for (const s of collectStateNames(def.state)) {
@@ -577,364 +580,389 @@ function glyph(direction) {
577
580
  return { South: "🔥", East: "🌅", North: "❄️", West: "🌊" }[direction] ?? "•";
578
581
  }
579
582
  // ─── MCP Server ──────────────────────────────────────────────────────
580
- const server = new McpServer({
581
- name: "stateloom-mcp",
582
- version: "0.1.3",
583
- });
584
- // Tools
585
- 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 }) => {
586
- const def = createEmpty(namespace, name);
587
- if (asynchronous)
588
- def.settings.asynchronous = true;
589
- writeDef(def);
590
- bridgeEmitFull(def);
591
- return {
592
- content: [
593
- {
594
- type: "text",
595
- text: `Created state machine '${name}' in namespace '${namespace}' → ${PROJECT_FILE}. Add states and events next.`,
596
- },
597
- ],
598
- };
599
- });
600
- server.tool("add_state", "Add a state to the state machine. Parent defaults to Root.", {
601
- name: z.string(),
602
- parent: z.string().optional(),
603
- kind: z.enum(["normal", "final", "history"]).optional(),
604
- description: z.string().optional(),
605
- }, async ({ name, parent, kind, description }) => {
606
- const def = readDef();
607
- if (!def)
608
- return {
609
- content: [{ type: "text", text: `No state machine at ${PROJECT_FILE}. Use create_state_machine first.` }],
610
- isError: true,
611
- };
612
- const parentName = parent ?? "Root";
613
- const parentState = findState(def.state, parentName);
614
- if (!parentState)
615
- return {
616
- content: [{ type: "text", text: `Parent state '${parentName}' not found.` }],
617
- isError: true,
618
- };
619
- if (!parentState.states)
620
- parentState.states = [];
621
- parentState.states.push({ name, kind: kind ?? "normal", description });
622
- writeDef(def);
623
- bridgeEmitPatch([
624
- { op: "state.add", parent: parentName, state: { name, kind: kind ?? "normal", description } },
625
- ]);
626
- return {
627
- content: [{ type: "text", text: `Added state '${name}' under '${parentName}' (${kind ?? "normal"}).` }],
628
- };
629
- });
630
- server.tool("add_event", "Add an event to the state machine", { id: z.string(), description: z.string().optional() }, async ({ id, description }) => {
631
- const def = readDef();
632
- if (!def)
583
+ /**
584
+ * Build a fully-registered server instance.
585
+ *
586
+ * A factory rather than a singleton because one `McpServer` can only be
587
+ * initialized once: a second client sending `initialize` to the same instance
588
+ * is refused, so a shared instance can serve exactly one caller for the life of
589
+ * the process. Stdio still builds exactly one and behaves as it always has;
590
+ * HTTP builds one per request, which is the SDK's stateless pattern and what
591
+ * lets more than one agent reach the same loom.
592
+ *
593
+ * Every tool closes over module-level state (PROJECT_FILE, the bridge client),
594
+ * so instances are interchangeable — building one is registration, not state.
595
+ */
596
+ function buildServer() {
597
+ const server = new McpServer({
598
+ name: "stateloom-mcp",
599
+ version: "0.2.0",
600
+ });
601
+ // Tools
602
+ 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 }) => {
603
+ const def = createEmpty(namespace, name);
604
+ if (asynchronous)
605
+ def.settings.asynchronous = true;
606
+ writeDef(def);
607
+ bridgeEmitFull(def);
633
608
  return {
634
- content: [{ type: "text", text: `No state machine at ${PROJECT_FILE}.` }],
635
- isError: true,
609
+ content: [
610
+ {
611
+ type: "text",
612
+ text: `Created state machine '${name}' in namespace '${namespace}' → ${PROJECT_FILE}. Add states and events next.`,
613
+ },
614
+ ],
636
615
  };
637
- const source = def.events[0];
638
- source.events.push({ id, description });
639
- writeDef(def);
640
- bridgeEmitPatch([{ op: "event.add", sourceIndex: 0, event: { id, description } }]);
641
- return {
642
- content: [{ type: "text", text: `Added event '${id}'.` }],
643
- };
644
- });
645
- server.tool("add_transition", "Add a transition to a state, triggered by an event", {
646
- state: z.string(),
647
- event: z.string(),
648
- nextState: z.string().optional(),
649
- condition: z.string().optional(),
650
- description: z.string().optional(),
651
- }, async ({ state, event, nextState, condition, description }) => {
652
- const def = readDef();
653
- if (!def)
616
+ });
617
+ server.tool("add_state", "Add a state to the state machine. Parent defaults to Root.", {
618
+ name: z.string(),
619
+ parent: z.string().optional(),
620
+ kind: z.enum(["normal", "final", "history"]).optional(),
621
+ description: z.string().optional(),
622
+ }, async ({ name, parent, kind, description }) => {
623
+ const def = readDef();
624
+ if (!def)
625
+ return {
626
+ content: [{ type: "text", text: `No state machine at ${PROJECT_FILE}. Use create_state_machine first.` }],
627
+ isError: true,
628
+ };
629
+ const parentName = parent ?? "Root";
630
+ const parentState = findState(def.state, parentName);
631
+ if (!parentState)
632
+ return {
633
+ content: [{ type: "text", text: `Parent state '${parentName}' not found.` }],
634
+ isError: true,
635
+ };
636
+ if (!parentState.states)
637
+ parentState.states = [];
638
+ parentState.states.push({ name, kind: kind ?? "normal", description });
639
+ writeDef(def);
640
+ bridgeEmitPatch([
641
+ { op: "state.add", parent: parentName, state: { name, kind: kind ?? "normal", description } },
642
+ ]);
654
643
  return {
655
- content: [{ type: "text", text: `No state machine at ${PROJECT_FILE}.` }],
656
- isError: true,
644
+ content: [{ type: "text", text: `Added state '${name}' under '${parentName}' (${kind ?? "normal"}).` }],
657
645
  };
658
- const target = findState(def.state, state);
659
- if (!target)
646
+ });
647
+ server.tool("add_event", "Add an event to the state machine", { id: z.string(), description: z.string().optional() }, async ({ id, description }) => {
648
+ const def = readDef();
649
+ if (!def)
650
+ return {
651
+ content: [{ type: "text", text: `No state machine at ${PROJECT_FILE}.` }],
652
+ isError: true,
653
+ };
654
+ const source = def.events[0];
655
+ source.events.push({ id, description });
656
+ writeDef(def);
657
+ bridgeEmitPatch([{ op: "event.add", sourceIndex: 0, event: { id, description } }]);
660
658
  return {
661
- content: [{ type: "text", text: `State '${state}' not found.` }],
662
- isError: true,
659
+ content: [{ type: "text", text: `Added event '${id}'.` }],
663
660
  };
664
- if (!target.transitions)
665
- target.transitions = [];
666
- target.transitions.push({ event, nextState, condition, description });
667
- writeDef(def);
668
- bridgeEmitPatch([
669
- { op: "transition.add", state, transition: { event, nextState, condition, description } },
670
- ]);
671
- const desc = nextState ? `${state} --[${event}]--> ${nextState}` : `${state} --[${event}]--> (internal)`;
672
- return { content: [{ type: "text", text: `Added transition: ${desc}` }] };
673
- });
674
- server.tool("remove_state", "Remove a state from the definition", { name: z.string() }, async ({ name }) => {
675
- const def = readDef();
676
- if (!def)
677
- return { content: [{ type: "text", text: `No state machine at ${PROJECT_FILE}.` }], isError: true };
678
- if (name === "Root")
679
- return { content: [{ type: "text", text: "Cannot remove Root state." }], isError: true };
680
- def.state = removeState(def.state, name);
681
- writeDef(def);
682
- bridgeEmitPatch([{ op: "state.remove", name }]);
683
- return { content: [{ type: "text", text: `Removed state '${name}'.` }] };
684
- });
685
- server.tool("validate_definition", "Validate the current state machine definition against all rules", {}, async () => {
686
- const def = readDef();
687
- if (!def)
688
- return { content: [{ type: "text", text: `No state machine at ${PROJECT_FILE}.` }], isError: true };
689
- const errors = validate(def);
690
- if (errors.length === 0)
691
- return { content: [{ type: "text", text: "✓ Definition is valid. No errors found." }] };
692
- const lines = errors.map((e) => `[${e.ruleId}] ${e.message}`).join("\n");
693
- return {
694
- content: [{ type: "text", text: `Found ${errors.length} validation error(s):\n${lines}` }],
695
- };
696
- });
697
- server.tool("generate_code", "Generate Python or TypeScript code from the current definition", { language: z.enum(["python", "typescript"]) }, async ({ language }) => {
698
- const def = readDef();
699
- if (!def)
700
- return { content: [{ type: "text", text: `No state machine at ${PROJECT_FILE}.` }], isError: true };
701
- const errors = validate(def);
702
- if (errors.length > 0) {
661
+ });
662
+ server.tool("add_transition", "Add a transition to a state, triggered by an event", {
663
+ state: z.string(),
664
+ event: z.string(),
665
+ nextState: z.string().optional(),
666
+ condition: z.string().optional(),
667
+ description: z.string().optional(),
668
+ }, async ({ state, event, nextState, condition, description }) => {
669
+ const def = readDef();
670
+ if (!def)
671
+ return {
672
+ content: [{ type: "text", text: `No state machine at ${PROJECT_FILE}.` }],
673
+ isError: true,
674
+ };
675
+ const target = findState(def.state, state);
676
+ if (!target)
677
+ return {
678
+ content: [{ type: "text", text: `State '${state}' not found.` }],
679
+ isError: true,
680
+ };
681
+ if (!target.transitions)
682
+ target.transitions = [];
683
+ target.transitions.push({ event, nextState, condition, description });
684
+ writeDef(def);
685
+ bridgeEmitPatch([
686
+ { op: "transition.add", state, transition: { event, nextState, condition, description } },
687
+ ]);
688
+ const desc = nextState ? `${state} --[${event}]--> ${nextState}` : `${state} --[${event}]--> (internal)`;
689
+ return { content: [{ type: "text", text: `Added transition: ${desc}` }] };
690
+ });
691
+ server.tool("remove_state", "Remove a state from the definition", { name: z.string() }, async ({ name }) => {
692
+ const def = readDef();
693
+ if (!def)
694
+ return { content: [{ type: "text", text: `No state machine at ${PROJECT_FILE}.` }], isError: true };
695
+ if (name === "Root")
696
+ return { content: [{ type: "text", text: "Cannot remove Root state." }], isError: true };
697
+ def.state = removeState(def.state, name);
698
+ writeDef(def);
699
+ bridgeEmitPatch([{ op: "state.remove", name }]);
700
+ return { content: [{ type: "text", text: `Removed state '${name}'.` }] };
701
+ });
702
+ server.tool("validate_definition", "Validate the current state machine definition against all rules", {}, async () => {
703
+ const def = readDef();
704
+ if (!def)
705
+ return { content: [{ type: "text", text: `No state machine at ${PROJECT_FILE}.` }], isError: true };
706
+ const errors = validate(def);
707
+ if (errors.length === 0)
708
+ return { content: [{ type: "text", text: "✓ Definition is valid. No errors found." }] };
703
709
  const lines = errors.map((e) => `[${e.ruleId}] ${e.message}`).join("\n");
704
710
  return {
705
- content: [{ type: "text", text: `Fix validation errors first:\n${lines}` }],
706
- isError: true,
711
+ content: [{ type: "text", text: `Found ${errors.length} validation error(s):\n${lines}` }],
707
712
  };
708
- }
709
- const code = generateViaSmcg(def, language);
710
- return { content: [{ type: "text", text: code }] };
711
- });
712
- server.tool("get_definition", "Get the current state machine definition as JSON", {}, async () => {
713
- const def = readDef();
714
- if (!def)
715
- return { content: [{ type: "text", text: `No state machine at ${PROJECT_FILE}.` }], isError: true };
716
- return {
717
- content: [{ type: "text", text: JSON.stringify({ stateMachine: def }, null, 2) }],
718
- };
719
- });
720
- server.tool("load_definition", "Load a state machine definition from JSON", { json: z.string() }, async ({ json }) => {
721
- try {
722
- const parsed = JSON.parse(json);
723
- const def = parsed.stateMachine ?? parsed.StateMachine ?? parsed;
724
- writeDef(def);
725
- bridgeEmitFull(def);
726
- const stateCount = collectStateNames(def.state).length;
727
- const eventCount = collectEventIds(def).length;
713
+ });
714
+ server.tool("generate_code", "Generate Python or TypeScript code from the current definition", { language: z.enum(["python", "typescript"]) }, async ({ language }) => {
715
+ const def = readDef();
716
+ if (!def)
717
+ return { content: [{ type: "text", text: `No state machine at ${PROJECT_FILE}.` }], isError: true };
718
+ const errors = validate(def);
719
+ if (errors.length > 0) {
720
+ const lines = errors.map((e) => `[${e.ruleId}] ${e.message}`).join("\n");
721
+ return {
722
+ content: [{ type: "text", text: `Fix validation errors first:\n${lines}` }],
723
+ isError: true,
724
+ };
725
+ }
726
+ const code = generateViaSmcg(def, language);
727
+ return { content: [{ type: "text", text: code }] };
728
+ });
729
+ server.tool("get_definition", "Get the current state machine definition as JSON", {}, async () => {
730
+ const def = readDef();
731
+ if (!def)
732
+ return { content: [{ type: "text", text: `No state machine at ${PROJECT_FILE}.` }], isError: true };
728
733
  return {
729
- content: [
730
- {
731
- type: "text",
732
- text: `Loaded definition → ${PROJECT_FILE}: ${def.settings.name} (${stateCount} states, ${eventCount} events)`,
733
- },
734
- ],
734
+ content: [{ type: "text", text: JSON.stringify({ stateMachine: def }, null, 2) }],
735
735
  };
736
- }
737
- catch (e) {
738
- return { content: [{ type: "text", text: `Invalid JSON: ${e}` }], isError: true };
739
- }
740
- });
741
- server.tool("list_states", "List all states in the current definition with their kind and transitions", {}, async () => {
742
- const def = readDef();
743
- if (!def)
744
- return { content: [{ type: "text", text: `No state machine at ${PROJECT_FILE}.` }], isError: true };
745
- const lines = [];
746
- const walk = (state, depth) => {
747
- const indent = " ".repeat(depth);
748
- const kind = state.kind ? ` (${state.kind})` : "";
749
- const transCount = state.transitions?.length ?? 0;
750
- lines.push(`${indent}${state.name}${kind} — ${transCount} transition(s)`);
751
- for (const t of state.transitions ?? []) {
752
- lines.push(`${indent} → [${t.event}] → ${t.nextState ?? "(internal)"}`);
736
+ });
737
+ server.tool("load_definition", "Load a state machine definition from JSON", { json: z.string() }, async ({ json }) => {
738
+ try {
739
+ const parsed = JSON.parse(json);
740
+ const def = parsed.stateMachine ?? parsed.StateMachine ?? parsed;
741
+ writeDef(def);
742
+ bridgeEmitFull(def);
743
+ const stateCount = collectStateNames(def.state).length;
744
+ const eventCount = collectEventIds(def).length;
745
+ return {
746
+ content: [
747
+ {
748
+ type: "text",
749
+ text: `Loaded definition → ${PROJECT_FILE}: ${def.settings.name} (${stateCount} states, ${eventCount} events)`,
750
+ },
751
+ ],
752
+ };
753
753
  }
754
- (state.states ?? []).forEach((s) => walk(s, depth + 1));
755
- };
756
- walk(def.state, 0);
757
- return { content: [{ type: "text", text: lines.join("\n") }] };
758
- });
759
- server.tool("list_events", "List all events in the current definition", {}, async () => {
760
- const def = readDef();
761
- if (!def)
762
- return { content: [{ type: "text", text: `No state machine at ${PROJECT_FILE}.` }], isError: true };
763
- const events = def.events.flatMap((src) => src.events.map((e) => `${e.id}${e.description ? ` — ${e.description}` : ""}`));
764
- if (events.length === 0)
765
- return { content: [{ type: "text", text: "No events defined yet." }] };
766
- return { content: [{ type: "text", text: events.join("\n") }] };
767
- });
768
- 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 }) => {
769
- try {
770
- const r = resolveProjectSwitch(path, PROJECT_FILE);
771
- PROJECT_FILE = r.path;
772
- if (!r.unchanged) {
773
- bindBridge(PROJECT_FILE);
774
- joinBridge();
754
+ catch (e) {
755
+ return { content: [{ type: "text", text: `Invalid JSON: ${e}` }], isError: true };
775
756
  }
776
- const def = r.exists ? readDef() : null;
757
+ });
758
+ server.tool("list_states", "List all states in the current definition with their kind and transitions", {}, async () => {
759
+ const def = readDef();
760
+ if (!def)
761
+ return { content: [{ type: "text", text: `No state machine at ${PROJECT_FILE}.` }], isError: true };
762
+ const lines = [];
763
+ const walk = (state, depth) => {
764
+ const indent = " ".repeat(depth);
765
+ const kind = state.kind ? ` (${state.kind})` : "";
766
+ const transCount = state.transitions?.length ?? 0;
767
+ lines.push(`${indent}${state.name}${kind} — ${transCount} transition(s)`);
768
+ for (const t of state.transitions ?? []) {
769
+ lines.push(`${indent} → [${t.event}] → ${t.nextState ?? "(internal)"}`);
770
+ }
771
+ (state.states ?? []).forEach((s) => walk(s, depth + 1));
772
+ };
773
+ walk(def.state, 0);
774
+ return { content: [{ type: "text", text: lines.join("\n") }] };
775
+ });
776
+ server.tool("list_events", "List all events in the current definition", {}, async () => {
777
+ const def = readDef();
778
+ if (!def)
779
+ return { content: [{ type: "text", text: `No state machine at ${PROJECT_FILE}.` }], isError: true };
780
+ const events = def.events.flatMap((src) => src.events.map((e) => `${e.id}${e.description ? ` — ${e.description}` : ""}`));
781
+ if (events.length === 0)
782
+ return { content: [{ type: "text", text: "No events defined yet." }] };
783
+ return { content: [{ type: "text", text: events.join("\n") }] };
784
+ });
785
+ 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 }) => {
786
+ try {
787
+ const r = resolveProjectSwitch(path, PROJECT_FILE);
788
+ const denial = denyProjectSwitch(r.path);
789
+ if (denial)
790
+ return { content: [{ type: "text", text: denial }], isError: true };
791
+ PROJECT_FILE = r.path;
792
+ if (!r.unchanged) {
793
+ bindBridge(PROJECT_FILE);
794
+ joinBridge();
795
+ }
796
+ const def = r.exists ? readDef() : null;
797
+ const summary = def
798
+ ? `existing machine '${def.settings.name}' (${collectStateNames(def.state).length} states, ${collectEventIds(def).length} events)`
799
+ : r.exists
800
+ ? "file exists but is not a readable definition"
801
+ : "no file yet — create_state_machine or load_definition will write it";
802
+ const bridgeNote = BRIDGE_URL
803
+ ? r.unchanged
804
+ ? `bridge unchanged (${bridge?.status ?? "not configured"})`
805
+ : `bridge re-joining room '${r.path}'`
806
+ : "bridge not configured (STATELOOM_BRIDGE_URL unset)";
807
+ return {
808
+ content: [
809
+ {
810
+ type: "text",
811
+ text: `Active document: ${r.path}\nPrevious: ${r.previous}\n${summary}\n${bridgeNote}`,
812
+ },
813
+ ],
814
+ };
815
+ }
816
+ catch (e) {
817
+ return {
818
+ content: [{ type: "text", text: e instanceof Error ? e.message : String(e) }],
819
+ isError: true,
820
+ };
821
+ }
822
+ });
823
+ server.tool("get_project_file", "Report the active .smdf.json document path, whether it exists on disk, and live-bridge status", {}, async () => {
824
+ const def = readDef();
777
825
  const summary = def
778
- ? `existing machine '${def.settings.name}' (${collectStateNames(def.state).length} states, ${collectEventIds(def).length} events)`
779
- : r.exists
826
+ ? `machine '${def.settings.name}' (${collectStateNames(def.state).length} states, ${collectEventIds(def).length} events)`
827
+ : existsSync(PROJECT_FILE)
780
828
  ? "file exists but is not a readable definition"
781
- : "no file yet — create_state_machine or load_definition will write it";
829
+ : "file does not exist yet";
782
830
  const bridgeNote = BRIDGE_URL
783
- ? r.unchanged
784
- ? `bridge unchanged (${bridge?.status ?? "not configured"})`
785
- : `bridge re-joining room '${r.path}'`
831
+ ? `bridge: ${bridge?.status ?? "not initialized"} → ${BRIDGE_URL}`
786
832
  : "bridge not configured (STATELOOM_BRIDGE_URL unset)";
787
833
  return {
788
834
  content: [
789
- {
790
- type: "text",
791
- text: `Active document: ${r.path}\nPrevious: ${r.previous}\n${summary}\n${bridgeNote}`,
792
- },
835
+ { type: "text", text: `Active document: ${PROJECT_FILE}\n${summary}\n${bridgeNote}` },
793
836
  ],
794
837
  };
838
+ });
839
+ 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 }) => {
840
+ const def = readDef();
841
+ if (!def)
842
+ return { content: [{ type: "text", text: `No state machine at ${PROJECT_FILE}.` }], isError: true };
843
+ const md = generateRispec(def, intent);
844
+ return { content: [{ type: "text", text: md }] };
845
+ });
846
+ /**
847
+ * How large a PNG may be and still ride back inside the tool result. Past this
848
+ * the agent gets the path alone — a multi-megabyte base64 blob would crowd out
849
+ * the conversation it was meant to illustrate.
850
+ */
851
+ const INLINE_IMAGE_LIMIT = 1_500_000;
852
+ /** Hand a rendered file to the platform viewer. Never fatal — a server has none. */
853
+ function openOnDesktop(path) {
854
+ const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
855
+ try {
856
+ const child = spawn(opener, [path], {
857
+ stdio: "ignore",
858
+ detached: true,
859
+ shell: process.platform === "win32",
860
+ });
861
+ child.on("error", () => { });
862
+ child.unref();
863
+ return `opened with ${opener}`;
864
+ }
865
+ catch {
866
+ return "no desktop opener available";
867
+ }
795
868
  }
796
- catch (e) {
797
- return {
798
- content: [{ type: "text", text: e instanceof Error ? e.message : String(e) }],
799
- isError: true,
800
- };
801
- }
802
- });
803
- server.tool("get_project_file", "Report the active .smdf.json document path, whether it exists on disk, and live-bridge status", {}, async () => {
804
- const def = readDef();
805
- const summary = def
806
- ? `machine '${def.settings.name}' (${collectStateNames(def.state).length} states, ${collectEventIds(def).length} events)`
807
- : existsSync(PROJECT_FILE)
808
- ? "file exists but is not a readable definition"
809
- : "file does not exist yet";
810
- const bridgeNote = BRIDGE_URL
811
- ? `bridge: ${bridge?.status ?? "not initialized"} → ${BRIDGE_URL}`
812
- : "bridge not configured (STATELOOM_BRIDGE_URL unset)";
813
- return {
814
- content: [
815
- { type: "text", text: `Active document: ${PROJECT_FILE}\n${summary}\n${bridgeNote}` },
816
- ],
817
- };
818
- });
819
- 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 }) => {
820
- const def = readDef();
821
- if (!def)
822
- return { content: [{ type: "text", text: `No state machine at ${PROJECT_FILE}.` }], isError: true };
823
- const md = generateRispec(def, intent);
824
- return { content: [{ type: "text", text: md }] };
825
- });
826
- /**
827
- * How large a PNG may be and still ride back inside the tool result. Past this
828
- * the agent gets the path alone — a multi-megabyte base64 blob would crowd out
829
- * the conversation it was meant to illustrate.
830
- */
831
- const INLINE_IMAGE_LIMIT = 1_500_000;
832
- /** Hand a rendered file to the platform viewer. Never fatal — a server has none. */
833
- function openOnDesktop(path) {
834
- const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
835
- try {
836
- const child = spawn(opener, [path], {
837
- stdio: "ignore",
838
- detached: true,
839
- shell: process.platform === "win32",
840
- });
841
- child.on("error", () => { });
842
- child.unref();
843
- return `opened with ${opener}`;
844
- }
845
- catch {
846
- return "no desktop opener available";
847
- }
848
- }
849
- server.tool("render_diagram", "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.", {
850
- format: z.enum(["png", "svg", "mermaid", "ascii"]).optional(),
851
- path: z.string().optional(),
852
- scale: z.number().optional(),
853
- theme: z.enum(["dark", "light"]).optional(),
854
- stamp: z.boolean().optional(),
855
- open: z.boolean().optional(),
856
- }, async ({ format, path, scale, theme, stamp, open }) => {
857
- const def = readDef();
858
- if (!def)
859
- return {
860
- content: [{ type: "text", text: `No state machine at ${PROJECT_FILE}.` }],
861
- isError: true,
862
- };
863
- const chosen = format ?? "png";
864
- try {
865
- const result = await renderDiagramToFile(def, {
866
- format: chosen,
867
- out: path,
868
- doc: PROJECT_FILE,
869
- scale,
870
- theme,
871
- stamp,
872
- });
873
- const size = result.width && result.height ? `, ${result.width}×${result.height}` : "";
874
- const how = result.backend ? `, drawn by ${result.backend}` : "";
875
- const opened = open ? ` — ${openOnDesktop(result.path)}` : "";
876
- const note = `Rendered '${def.settings.name}' → ${result.path} (${result.bytes} bytes${size}${how})${opened}`;
877
- // png and svg come back as pictures; the text formats are already text.
878
- if (chosen === "png" && result.bytes <= INLINE_IMAGE_LIMIT) {
869
+ server.tool("render_diagram", "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.", {
870
+ format: z.enum(["png", "svg", "mermaid", "ascii"]).optional(),
871
+ path: z.string().optional(),
872
+ scale: z.number().optional(),
873
+ theme: z.enum(["dark", "light"]).optional(),
874
+ stamp: z.boolean().optional(),
875
+ open: z.boolean().optional(),
876
+ }, async ({ format, path, scale, theme, stamp, open }) => {
877
+ const def = readDef();
878
+ if (!def)
879
879
  return {
880
- content: [
881
- { type: "text", text: note },
882
- {
883
- type: "image",
884
- data: readFileSync(result.path).toString("base64"),
885
- mimeType: "image/png",
886
- },
887
- ],
880
+ content: [{ type: "text", text: `No state machine at ${PROJECT_FILE}.` }],
881
+ isError: true,
888
882
  };
883
+ const chosen = format ?? "png";
884
+ if (path) {
885
+ // A render writes state names — free text the caller chose — into a file
886
+ // the caller named. Under a document root that is exactly the write that
887
+ // has to stay inside it.
888
+ const denial = outsideRoot(path);
889
+ if (denial)
890
+ return { content: [{ type: "text", text: denial }], isError: true };
889
891
  }
890
- if (chosen === "mermaid" || chosen === "ascii") {
892
+ try {
893
+ const result = await renderDiagramToFile(def, {
894
+ format: chosen,
895
+ out: path,
896
+ doc: PROJECT_FILE,
897
+ scale,
898
+ theme,
899
+ stamp,
900
+ });
901
+ const size = result.width && result.height ? `, ${result.width}×${result.height}` : "";
902
+ const how = result.backend ? `, drawn by ${result.backend}` : "";
903
+ const opened = open ? ` — ${openOnDesktop(result.path)}` : "";
904
+ const note = `Rendered '${def.settings.name}' → ${result.path} (${result.bytes} bytes${size}${how})${opened}`;
905
+ // png and svg come back as pictures; the text formats are already text.
906
+ if (chosen === "png" && result.bytes <= INLINE_IMAGE_LIMIT) {
907
+ return {
908
+ content: [
909
+ { type: "text", text: note },
910
+ {
911
+ type: "image",
912
+ data: readFileSync(result.path).toString("base64"),
913
+ mimeType: "image/png",
914
+ },
915
+ ],
916
+ };
917
+ }
918
+ if (chosen === "mermaid" || chosen === "ascii") {
919
+ return {
920
+ content: [
921
+ { type: "text", text: note },
922
+ { type: "text", text: readFileSync(result.path, "utf8") },
923
+ ],
924
+ };
925
+ }
926
+ return { content: [{ type: "text", text: note }] };
927
+ }
928
+ catch (e) {
929
+ const why = e instanceof Error ? e.message : String(e);
891
930
  return {
892
931
  content: [
893
- { type: "text", text: note },
894
- { type: "text", text: readFileSync(result.path, "utf8") },
932
+ {
933
+ type: "text",
934
+ text: `render_diagram (${chosen}) failed: ${why}\n` +
935
+ `An SVG needs no rasterizer — retry with format "svg" ` +
936
+ `(it would land at ${defaultOutputPath(PROJECT_FILE, "svg")}).`,
937
+ },
895
938
  ],
939
+ isError: true,
896
940
  };
897
941
  }
898
- return { content: [{ type: "text", text: note }] };
899
- }
900
- catch (e) {
901
- const why = e instanceof Error ? e.message : String(e);
942
+ });
943
+ // Resources
944
+ server.resource("definition", "smcraft://definition", async () => {
945
+ const def = readDef();
902
946
  return {
903
- content: [
947
+ contents: [
904
948
  {
905
- type: "text",
906
- text: `render_diagram (${chosen}) failed: ${why}\n` +
907
- `An SVG needs no rasterizer — retry with format "svg" ` +
908
- `(it would land at ${defaultOutputPath(PROJECT_FILE, "svg")}).`,
949
+ uri: "smcraft://definition",
950
+ mimeType: "application/json",
951
+ text: def
952
+ ? JSON.stringify({ stateMachine: def }, null, 2)
953
+ : `{"error": "No definition at ${PROJECT_FILE}"}`,
909
954
  },
910
955
  ],
911
- isError: true,
912
956
  };
913
- }
914
- });
915
- // Resources
916
- server.resource("definition", "smcraft://definition", async () => {
917
- const def = readDef();
918
- return {
919
- contents: [
957
+ });
958
+ // Prompts
959
+ 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 }) => ({
960
+ messages: [
920
961
  {
921
- uri: "smcraft://definition",
922
- mimeType: "application/json",
923
- text: def
924
- ? JSON.stringify({ stateMachine: def }, null, 2)
925
- : `{"error": "No definition at ${PROJECT_FILE}"}`,
926
- },
927
- ],
928
- };
929
- });
930
- // Prompts
931
- 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 }) => ({
932
- messages: [
933
- {
934
- role: "user",
935
- content: {
936
- type: "text",
937
- text: `I want to design a state machine${name ? ` called "${name}"` : ""}${domain ? ` for the "${domain}" domain` : ""}. Guide me through:
962
+ role: "user",
963
+ content: {
964
+ type: "text",
965
+ text: `I want to design a state machine${name ? ` called "${name}"` : ""}${domain ? ` for the "${domain}" domain` : ""}. Guide me through:
938
966
  1. First, create the state machine with create_state_machine
939
967
  2. Help me identify the key states and add them with add_state
940
968
  3. Define the events that trigger transitions with add_event
@@ -943,15 +971,278 @@ server.prompt("design-state-machine", "Guided conversation to design a state mac
943
971
  6. Generate code with generate_code
944
972
 
945
973
  Start by asking me about the states and events I need.`,
974
+ },
946
975
  },
947
- },
948
- ],
949
- }));
950
- // Start
976
+ ],
977
+ }));
978
+ return server;
979
+ }
980
+ // ─── Transports ──────────────────────────────────────────────────────
981
+ //
982
+ // Stdio is the default and is unchanged: no new environment variable, no new
983
+ // flag, and this server still boots as a child process beside one agent with
984
+ // the parent's trust. Everything below only runs when someone explicitly asks
985
+ // for a port, so an existing registration cannot fall into HTTP by accident.
986
+ //
987
+ // The remote mode is deliberately narrow about what it will do, because the
988
+ // two things stdio got for free — one client, and the caller's own filesystem
989
+ // trust — are exactly what a port gives away:
990
+ //
991
+ // STATELOOM_MCP_HTTP_PORT the port to bind; unset means stdio (default)
992
+ // STATELOOM_MCP_HTTP_HOST interface, default 127.0.0.1 — loopback, not 0.0.0.0
993
+ // STATELOOM_MCP_TOKEN required bearer token; without one, a reachable
994
+ // port is an unauthenticated write primitive on the
995
+ // host, so HTTP mode refuses to start
996
+ // STATELOOM_MCP_ROOT confine every document to this directory
997
+ // STATELOOM_MCP_LOCK_PROJECT refuse set_project_file outright
998
+ //
999
+ // What this mode is NOT: multi-tenant. The active document is process state,
1000
+ // so every HTTP client shares one board — which is the loom's whole premise
1001
+ // for humans and agents together, and a hazard between strangers. Per-session
1002
+ // documents need a server instance per session; that is a real change and is
1003
+ // not pretended at here.
1004
+ const HTTP_PORT = envAlias("MCP_HTTP_PORT");
1005
+ const HTTP_HOST = envAlias("MCP_HTTP_HOST") ?? "127.0.0.1";
1006
+ const HTTP_TOKEN = envAlias("MCP_TOKEN");
1007
+ const DOC_ROOT = (() => {
1008
+ const raw = envAlias("MCP_ROOT");
1009
+ if (!raw)
1010
+ return undefined;
1011
+ // realpath the root too: comparing a real path against a symlinked root
1012
+ // would refuse every legitimate document inside it.
1013
+ try {
1014
+ return realpathSync(resolve(raw));
1015
+ }
1016
+ catch {
1017
+ return resolve(raw);
1018
+ }
1019
+ })();
1020
+ const LOCK_PROJECT = /^(1|true|yes)$/i.test(envAlias("MCP_LOCK_PROJECT") ?? "");
1021
+ /**
1022
+ * Guard for `set_project_file`. Returns a refusal string, or undefined to allow.
1023
+ *
1024
+ * Unset environment means unset behaviour: with neither a root nor a lock this
1025
+ * returns undefined every time, so the stdio path a thousand agents already run
1026
+ * behaves exactly as it did.
1027
+ */
1028
+ function denyProjectSwitch(target) {
1029
+ if (LOCK_PROJECT) {
1030
+ return `set_project_file is locked on this server (STATELOOM_MCP_LOCK_PROJECT). Active document stays ${PROJECT_FILE}.`;
1031
+ }
1032
+ return outsideRoot(target);
1033
+ }
1034
+ /**
1035
+ * Resolve a path the way the filesystem will, not the way the string looks.
1036
+ *
1037
+ * A lexical `startsWith(root + "/")` refuses `..` and prefix siblings, and is
1038
+ * still wrong: `root/link -> /elsewhere` passes the string test and lands
1039
+ * outside. So the deepest part of the path that actually exists is put through
1040
+ * `realpathSync` — which follows every symlink in it — and the not-yet-created
1041
+ * tail is rejoined onto that. A file the caller is about to create inside a
1042
+ * symlinked directory is judged by where the directory really is.
1043
+ */
1044
+ function trueResolve(target) {
1045
+ let existing = resolve(target);
1046
+ const tail = [];
1047
+ while (!existsSync(existing)) {
1048
+ const parent = dirname(existing);
1049
+ if (parent === existing)
1050
+ return resolve(target); // reached the filesystem root
1051
+ tail.unshift(basename(existing));
1052
+ existing = parent;
1053
+ }
1054
+ try {
1055
+ return join(realpathSync(existing), ...tail);
1056
+ }
1057
+ catch {
1058
+ return resolve(target);
1059
+ }
1060
+ }
1061
+ /**
1062
+ * Refusal string when `target` falls outside `STATELOOM_MCP_ROOT`, else undefined.
1063
+ *
1064
+ * Every caller-supplied path goes through here, not just `set_project_file`:
1065
+ * `render_diagram` takes a `path` and writes state names into it, which is a
1066
+ * file write with attacker-chosen content and was the hole a confined server
1067
+ * most needed closed. With no root configured this returns undefined always, so
1068
+ * the stdio path is unaffected.
1069
+ */
1070
+ function outsideRoot(target) {
1071
+ if (!DOC_ROOT)
1072
+ return undefined;
1073
+ const real = trueResolve(target);
1074
+ if (real === DOC_ROOT || real.startsWith(DOC_ROOT + "/"))
1075
+ return undefined;
1076
+ return `Refused: ${target} resolves to ${real}, outside the permitted document root ${DOC_ROOT} (STATELOOM_MCP_ROOT).`;
1077
+ }
1078
+ /** Constant-time bearer check — a fast string compare leaks the token by timing. */
1079
+ function authorized(req) {
1080
+ if (!HTTP_TOKEN)
1081
+ return false;
1082
+ const header = req.headers.authorization ?? "";
1083
+ const offered = header.startsWith("Bearer ") ? header.slice(7) : "";
1084
+ const a = Buffer.from(offered);
1085
+ const b = Buffer.from(HTTP_TOKEN);
1086
+ return a.length === b.length && timingSafeEqual(a, b);
1087
+ }
1088
+ /** Largest body accepted; the biggest legitimate one is a whole definition. */
1089
+ const BODY_LIMIT = 8 * 1024 * 1024;
1090
+ function readBody(req) {
1091
+ return new Promise((ok, fail) => {
1092
+ const chunks = [];
1093
+ let size = 0;
1094
+ let oversize = false;
1095
+ req.on("data", (c) => {
1096
+ size += c.length;
1097
+ // A body cap keeps an unauthenticated probe from being a memory attack;
1098
+ // the largest legitimate payload here is a whole definition.
1099
+ if (size > BODY_LIMIT) {
1100
+ oversize = true;
1101
+ fail(new Error(`request body over ${BODY_LIMIT} bytes`));
1102
+ return;
1103
+ }
1104
+ chunks.push(c);
1105
+ });
1106
+ req.on("end", () => {
1107
+ if (oversize)
1108
+ return;
1109
+ const raw = Buffer.concat(chunks).toString("utf8");
1110
+ if (!raw)
1111
+ return ok(undefined);
1112
+ try {
1113
+ ok(JSON.parse(raw));
1114
+ }
1115
+ catch {
1116
+ fail(new Error("body is not JSON"));
1117
+ }
1118
+ });
1119
+ req.on("error", fail);
1120
+ });
1121
+ }
1122
+ function sendJson(res, status, body) {
1123
+ const text = JSON.stringify(body);
1124
+ res.writeHead(status, { "content-type": "application/json" });
1125
+ res.end(text);
1126
+ }
1127
+ async function startHttp(port) {
1128
+ if (!HTTP_TOKEN) {
1129
+ console.error("Refusing to start HTTP mode without STATELOOM_MCP_TOKEN.\n" +
1130
+ "An open port on this server is an unauthenticated way to read and write\n" +
1131
+ "files on the host. Set a token, or drop the port and use stdio.");
1132
+ process.exit(1);
1133
+ }
1134
+ // A root that does not contain the document it starts on confines nothing:
1135
+ // every write tool would land outside it from the first request. Refuse at
1136
+ // boot, where it is a config error, rather than at the first surprise.
1137
+ const bootDenial = outsideRoot(PROJECT_FILE);
1138
+ if (bootDenial) {
1139
+ console.error(`Refusing to start: STATELOOM_PROJECT_FILE is outside STATELOOM_MCP_ROOT.\n ${bootDenial}`);
1140
+ process.exit(1);
1141
+ }
1142
+ // Stateless, deliberately, and a fresh server+transport per request. The
1143
+ // stateful mode binds a session id to one transport and one server instance,
1144
+ // and an `McpServer` refuses a second `initialize` — so anything shared here
1145
+ // serves exactly one client for the life of the process, which is not what a
1146
+ // port is for. Sessions would only earn that cost if they partitioned state,
1147
+ // and they do not: the active document is process-global either way.
1148
+ const http = createHttpServer((req, res) => {
1149
+ const path = (req.url ?? "/").split("?")[0];
1150
+ if (path === "/health") {
1151
+ // Deliberately says nothing about the document. /health is the one
1152
+ // unauthenticated route, and the active path is host filesystem layout.
1153
+ sendJson(res, 200, { ok: true, transport: "http" });
1154
+ return;
1155
+ }
1156
+ if (path !== "/mcp") {
1157
+ sendJson(res, 404, { error: "not found — the MCP endpoint is /mcp" });
1158
+ return;
1159
+ }
1160
+ if (req.method !== "POST") {
1161
+ // GET on the MCP endpoint opens a server-push stream this server never
1162
+ // has anything to push on: it stays open, pinning a socket and its
1163
+ // buffers, and nothing ever closes it. The documented surface is POST.
1164
+ res.setHeader("allow", "POST");
1165
+ sendJson(res, 405, { error: "method not allowed — POST /mcp" });
1166
+ return;
1167
+ }
1168
+ if (!authorized(req)) {
1169
+ res.setHeader("www-authenticate", 'Bearer realm="stateloom-mcp"');
1170
+ sendJson(res, 401, { error: "unauthorized" });
1171
+ return;
1172
+ }
1173
+ readBody(req)
1174
+ .then(async (body) => {
1175
+ const instance = buildServer();
1176
+ const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
1177
+ // Tear down with the response, not before it: closing the transport
1178
+ // while the reply is still streaming truncates it.
1179
+ res.on("close", () => {
1180
+ void transport.close().catch(() => { });
1181
+ void instance.close().catch(() => { });
1182
+ });
1183
+ await instance.connect(transport);
1184
+ await transport.handleRequest(req, res, body);
1185
+ })
1186
+ .catch((err) => {
1187
+ const message = err instanceof Error ? err.message : String(err);
1188
+ console.error("[stateloom-mcp] request failed:", message);
1189
+ if (res.headersSent)
1190
+ return;
1191
+ const tooBig = message.includes("request body over");
1192
+ sendJson(res, tooBig ? 413 : 400, { error: message });
1193
+ req.destroy();
1194
+ });
1195
+ });
1196
+ await new Promise((ok, fail) => {
1197
+ http.once("error", fail);
1198
+ http.listen(port, HTTP_HOST, () => {
1199
+ http.removeListener("error", fail);
1200
+ ok();
1201
+ });
1202
+ }).catch((err) => {
1203
+ const why = err.code === "EADDRINUSE"
1204
+ ? `port ${port} is already in use on ${HTTP_HOST}`
1205
+ : err.code === "EACCES"
1206
+ ? `not allowed to bind ${HTTP_HOST}:${port}`
1207
+ : (err.message ?? String(err));
1208
+ console.error(`stateloom-mcp could not start: ${why}`);
1209
+ process.exit(1);
1210
+ });
1211
+ console.error(`stateloom-mcp on http://${HTTP_HOST}:${port}/mcp (project file: ${PROJECT_FILE})` +
1212
+ (DOC_ROOT ? `\n documents confined to ${DOC_ROOT}` : "") +
1213
+ (LOCK_PROJECT ? `\n set_project_file locked` : "") +
1214
+ (HTTP_HOST === "0.0.0.0"
1215
+ ? `\n WARNING: bound to every interface — put a TLS terminator in front of this.`
1216
+ : ""));
1217
+ const close = () => {
1218
+ http.close();
1219
+ bridge?.disconnect();
1220
+ process.exit(0);
1221
+ };
1222
+ process.on("SIGINT", close);
1223
+ process.on("SIGTERM", close);
1224
+ }
951
1225
  async function main() {
952
- const transport = new StdioServerTransport();
953
- await server.connect(transport);
954
- console.error(`SMCraft MCP server running on stdio (project file: ${PROJECT_FILE})`);
1226
+ if (HTTP_PORT) {
1227
+ // Whole-string match: parseInt("8080abc") is 8080, which would bind a port
1228
+ // nobody asked for. Zero is rejected too — the OS would pick an ephemeral
1229
+ // port while the banner printed ":0", leaving a server nobody can reach.
1230
+ if (!/^[0-9]{1,5}$/.test(HTTP_PORT.trim())) {
1231
+ console.error(`STATELOOM_MCP_HTTP_PORT is not a port number: ${HTTP_PORT}`);
1232
+ process.exit(1);
1233
+ }
1234
+ const port = Number.parseInt(HTTP_PORT.trim(), 10);
1235
+ if (port < 1 || port > 65535) {
1236
+ console.error(`STATELOOM_MCP_HTTP_PORT out of range (1-65535): ${HTTP_PORT}`);
1237
+ process.exit(1);
1238
+ }
1239
+ await startHttp(port);
1240
+ }
1241
+ else {
1242
+ const transport = new StdioServerTransport();
1243
+ await buildServer().connect(transport);
1244
+ console.error(`stateloom-mcp running on stdio (project file: ${PROJECT_FILE})`);
1245
+ }
955
1246
  joinBridge();
956
1247
  }
957
1248
  main().catch(console.error);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miadi/stateloom-mcp",
3
- "version": "0.1.3",
3
+ "version": "0.2.0",
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",