@miadi/stateloom-mcp 0.1.4 → 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.
- package/README.md +57 -2
- package/dist/server.js +623 -332
- 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 (`
|
|
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
|
-
| [`
|
|
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
|
|
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)) {
|
|
@@ -577,364 +580,389 @@ function glyph(direction) {
|
|
|
577
580
|
return { South: "🔥", East: "🌅", North: "❄️", West: "🌊" }[direction] ?? "•";
|
|
578
581
|
}
|
|
579
582
|
// ─── MCP Server ──────────────────────────────────────────────────────
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
})
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
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: [
|
|
635
|
-
|
|
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
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
}
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
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: `
|
|
656
|
-
isError: true,
|
|
644
|
+
content: [{ type: "text", text: `Added state '${name}' under '${parentName}' (${kind ?? "normal"}).` }],
|
|
657
645
|
};
|
|
658
|
-
|
|
659
|
-
|
|
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: `
|
|
662
|
-
isError: true,
|
|
659
|
+
content: [{ type: "text", text: `Added event '${id}'.` }],
|
|
663
660
|
};
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
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: `
|
|
706
|
-
isError: true,
|
|
711
|
+
content: [{ type: "text", text: `Found ${errors.length} validation error(s):\n${lines}` }],
|
|
707
712
|
};
|
|
708
|
-
}
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
}
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
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
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
? `
|
|
779
|
-
:
|
|
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
|
-
: "
|
|
829
|
+
: "file does not exist yet";
|
|
782
830
|
const bridgeNote = BRIDGE_URL
|
|
783
|
-
?
|
|
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
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
894
|
-
|
|
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
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
const
|
|
942
|
+
});
|
|
943
|
+
// Resources
|
|
944
|
+
server.resource("definition", "smcraft://definition", async () => {
|
|
945
|
+
const def = readDef();
|
|
902
946
|
return {
|
|
903
|
-
|
|
947
|
+
contents: [
|
|
904
948
|
{
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
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
|
-
|
|
916
|
-
|
|
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
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
?
|
|
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
|
-
|
|
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
|
-
|
|
953
|
-
|
|
954
|
-
|
|
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