@miadi/stateloom-mcp 0.1.4 → 0.2.1

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