@miadi/stateloom-mcp 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Guillaume Isabelle (jgwill)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,91 @@
1
+ # @miadi/stateloom-mcp
2
+
3
+ [![npm](https://img.shields.io/npm/v/%40miadi%2Fstateloom-mcp)](https://www.npmjs.com/package/@miadi/stateloom-mcp)
4
+
5
+ MCP server for stateloom — an LLM agent designs a hierarchical state machine through conversation, and the diagram on your screen changes while it talks.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install -g @miadi/stateloom-mcp
11
+ # or let your MCP client fetch it:
12
+ npx -y @miadi/stateloom-mcp
13
+ ```
14
+
15
+ ## What it is
16
+
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
+
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.
20
+
21
+ ## Configure your MCP client
22
+
23
+ ```json
24
+ {
25
+ "mcpServers": {
26
+ "stateloom": {
27
+ "command": "npx",
28
+ "args": ["-y", "@miadi/stateloom-mcp"],
29
+ "env": {
30
+ "STATELOOM_PROJECT_FILE": "/abs/path/to/statemachine.smdf.json",
31
+ "STATELOOM_BRIDGE_URL": "http://127.0.0.1:4599"
32
+ }
33
+ }
34
+ }
35
+ }
36
+ ```
37
+
38
+ | Environment variable | Default | Meaning |
39
+ |---|---|---|
40
+ | `STATELOOM_PROJECT_FILE` | `./statemachine.smdf.json` | The document read and written; also the live room key |
41
+ | `STATELOOM_BRIDGE_URL` | — | Hub URL; without it the server persists to disk only |
42
+ | `STATELOOM_AGENT_NAME` | — | Presence label shown to other people in the room |
43
+ | `STATELOOM_BRIDGE_TOKEN` | — | Handshake token, when the hub requires one |
44
+
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
+
47
+ ## Tools
48
+
49
+ | Tool | What it does |
50
+ |---|---|
51
+ | `create_state_machine` | Start a definition with a namespace and name |
52
+ | `add_state` | Add a state; parent defaults to `Root` |
53
+ | `add_event` | Add an event to an event source |
54
+ | `add_transition` | Add a transition from a state, triggered by an event |
55
+ | `remove_state` | Remove a state |
56
+ | `get_definition` | Return the whole definition as JSON |
57
+ | `load_definition` | Replace the definition from JSON |
58
+ | `list_states` | Every state with its kind and transitions |
59
+ | `list_events` | Every event |
60
+ | `validate_definition` | Run the rule-coded validator |
61
+ | `generate_code` | Emit Python or TypeScript from the definition |
62
+ | `generate_rispec` | Emit a RISE rispec in markdown, folding in the source PDE's intent when the SMDF carries one |
63
+ | `render_diagram` | Draw the machine as `png`, `svg`, `mermaid` or `ascii`, write it beside the document, and hand a `png` back inside the tool result so the agent sees what it designed |
64
+ | `set_project_file` | Re-point the loom at another `.smdf.json` mid-session — disk target and live room both move |
65
+ | `get_project_file` | The active document path, whether it exists, and bridge status |
66
+
67
+ Also exposed: the resource `smcraft://definition` (the current definition), and the prompt `design-state-machine` — a guided conversation for building one from a domain description.
68
+
69
+ ## What a session looks like
70
+
71
+ > **You:** design an order fulfillment workflow
72
+ >
73
+ > The agent calls `create_state_machine`, then `add_event` and `add_state` and `add_transition` a few times, then `validate_definition` to check its work, then `render_diagram` to look at it. Every one of those writes `/abs/path/to/statemachine.smdf.json` and pushes the change to the hub. Your canvas redraws as it goes.
74
+
75
+ Because rooms are keyed by the document's absolute path, `set_project_file` is how one agent moves between machines in a session — point it at another episode's diagram and the next edit lands there instead.
76
+
77
+ ## Part of the stateloom stack
78
+
79
+ | Package | Role |
80
+ |---|---|
81
+ | [`smcraft`](https://www.npmjs.com/package/smcraft) | State machine engine: parser, validator, interpreter, code generators |
82
+ | [`@miadi/stateloom-protocol`](https://www.npmjs.com/package/@miadi/stateloom-protocol) | Patch ops, diff/apply, layout, renderers — zero runtime deps |
83
+ | [`@miadi/stateloom`](https://www.npmjs.com/package/@miadi/stateloom) | socket.io hub holding the live document |
84
+ | [`@miadi/stateloom-client`](https://www.npmjs.com/package/@miadi/stateloom-client) | Framework-agnostic client for the hub |
85
+ | [`@miadi/stateloom-react`](https://www.npmjs.com/package/@miadi/stateloom-react) | React binding over the client |
86
+ | [`@miadi/stateloom-cli`](https://www.npmjs.com/package/@miadi/stateloom-cli) | `smcx` — terminal design surface and renderers |
87
+ | [`@miadi/stateloom-mcp`](https://www.npmjs.com/package/@miadi/stateloom-mcp) | MCP server so LLM agents can design machines — **this package** |
88
+
89
+ ## License
90
+
91
+ MIT © Guillaume Isabelle
package/dist/server.d.ts CHANGED
@@ -13,6 +13,7 @@
13
13
  * - remove_state: Remove a state from the definition
14
14
  * - validate_definition: Validate the current definition
15
15
  * - generate_code: Generate Python or TypeScript code
16
+ * - render_diagram: Draw the machine as an image on disk and return it
16
17
  * - get_definition: Get the current definition as JSON
17
18
  * - load_definition: Load a definition from JSON
18
19
  * - list_states: List all states in the current definition
package/dist/server.js CHANGED
@@ -13,6 +13,7 @@
13
13
  * - remove_state: Remove a state from the definition
14
14
  * - validate_definition: Validate the current definition
15
15
  * - generate_code: Generate Python or TypeScript code
16
+ * - render_diagram: Draw the machine as an image on disk and return it
16
17
  * - get_definition: Get the current definition as JSON
17
18
  * - load_definition: Load a definition from JSON
18
19
  * - list_states: List all states in the current definition
@@ -29,12 +30,13 @@
29
30
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
30
31
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
31
32
  import { z } from "zod";
32
- import { execFileSync } from "child_process";
33
+ import { execFileSync, spawn } from "child_process";
33
34
  import { writeFileSync, readFileSync, mkdtempSync, rmSync, existsSync, statSync } from "fs";
34
35
  import { join, resolve } from "path";
35
36
  import { tmpdir } from "os";
36
37
  import { createBridgeClient } from "@miadi/stateloom-client";
37
38
  import { envAlias } from "@miadi/stateloom-protocol";
39
+ import { renderDiagramToFile, defaultOutputPath } from "@miadi/stateloom-cli/render";
38
40
  import { resolveProjectSwitch } from "./projectSwitch.js";
39
41
  // STATELOOM_* read first, SMCRAFT_* legacy twin honored — the live MCP
40
42
  // registration bakes SMCRAFT_PROJECT_FILE and must keep working (2026-07-27
@@ -576,8 +578,8 @@ function glyph(direction) {
576
578
  }
577
579
  // ─── MCP Server ──────────────────────────────────────────────────────
578
580
  const server = new McpServer({
579
- name: "smcraft-mcp",
580
- version: "0.1.0",
581
+ name: "stateloom-mcp",
582
+ version: "0.1.3",
581
583
  });
582
584
  // Tools
583
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 }) => {
@@ -821,6 +823,95 @@ server.tool("generate_rispec", "Generate a RISE rispec (markdown) from the curre
821
823
  const md = generateRispec(def, intent);
822
824
  return { content: [{ type: "text", text: md }] };
823
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) {
879
+ return {
880
+ content: [
881
+ { type: "text", text: note },
882
+ {
883
+ type: "image",
884
+ data: readFileSync(result.path).toString("base64"),
885
+ mimeType: "image/png",
886
+ },
887
+ ],
888
+ };
889
+ }
890
+ if (chosen === "mermaid" || chosen === "ascii") {
891
+ return {
892
+ content: [
893
+ { type: "text", text: note },
894
+ { type: "text", text: readFileSync(result.path, "utf8") },
895
+ ],
896
+ };
897
+ }
898
+ return { content: [{ type: "text", text: note }] };
899
+ }
900
+ catch (e) {
901
+ const why = e instanceof Error ? e.message : String(e);
902
+ return {
903
+ content: [
904
+ {
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")}).`,
909
+ },
910
+ ],
911
+ isError: true,
912
+ };
913
+ }
914
+ });
824
915
  // Resources
825
916
  server.resource("definition", "smcraft://definition", async () => {
826
917
  const def = readDef();
package/package.json CHANGED
@@ -1,9 +1,20 @@
1
1
  {
2
2
  "name": "@miadi/stateloom-mcp",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "MCP server for miadi-stateloom (formerly smcraft-mcp) — lets LLM agents design state machines through conversation",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/jgwill/smcraft.git",
8
+ "directory": "mcp"
9
+ },
10
+ "homepage": "https://github.com/jgwill/smcraft#readme",
11
+ "bugs": {
12
+ "url": "https://github.com/jgwill/smcraft/issues"
13
+ },
14
+ "author": "Guillaume Isabelle <jgi@jgwill.com>",
5
15
  "type": "module",
6
16
  "main": "dist/server.js",
17
+ "types": "dist/server.d.ts",
7
18
  "bin": {
8
19
  "stateloom-mcp": "dist/server.js",
9
20
  "smcraft-mcp": "dist/server.js"
@@ -13,14 +24,33 @@
13
24
  "start": "node dist/server.js",
14
25
  "test": "node --test dist/tests/*.test.js"
15
26
  },
27
+ "keywords": [
28
+ "state-machine",
29
+ "smdf",
30
+ "mcp",
31
+ "model-context-protocol",
32
+ "agent",
33
+ "llm",
34
+ "stateloom"
35
+ ],
36
+ "license": "MIT",
37
+ "engines": {
38
+ "node": ">=20"
39
+ },
16
40
  "dependencies": {
17
- "@miadi/stateloom-client": "^0.1.0",
18
- "@miadi/stateloom-protocol": "^0.1.1",
41
+ "@miadi/stateloom-cli": "^0.1.2",
42
+ "@miadi/stateloom-client": "^0.1.1",
43
+ "@miadi/stateloom-protocol": "^0.1.3",
19
44
  "@modelcontextprotocol/sdk": "^1.12.1",
20
45
  "zod": "^4.3.6"
21
46
  },
22
47
  "devDependencies": {
23
48
  "@types/node": "^22.19.13",
24
49
  "typescript": "^5.7.0"
25
- }
50
+ },
51
+ "files": [
52
+ "dist/**/*.js",
53
+ "dist/**/*.d.ts",
54
+ "!dist/tests/"
55
+ ]
26
56
  }
@@ -1 +0,0 @@
1
- export {};
@@ -1,41 +0,0 @@
1
- /**
2
- * Path power (HANDOFF v2, Phase 2 deliverable 1): the pure logic behind the
3
- * `set_project_file` MCP tool. An agent may point the loom at any `.json`
4
- * document path; the switch must absolute-resolve the path, report whether a
5
- * file is already there (missing is legitimate — create_state_machine writes
6
- * it next), and refuse non-JSON paths outright.
7
- */
8
- import { test } from "node:test";
9
- import assert from "node:assert/strict";
10
- import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
11
- import { join, resolve } from "node:path";
12
- import { tmpdir } from "node:os";
13
- import { resolveProjectSwitch } from "../projectSwitch.js";
14
- test("projectSwitch: relative path resolves absolute and previous is carried", () => {
15
- const r = resolveProjectSwitch("./some/machine.smdf.json", "/prev/doc.smdf.json");
16
- assert.equal(r.path, resolve("./some/machine.smdf.json"));
17
- assert.equal(r.previous, "/prev/doc.smdf.json");
18
- assert.equal(r.exists, false);
19
- });
20
- test("projectSwitch: reports exists=true for a file already on disk", () => {
21
- const dir = mkdtempSync(join(tmpdir(), "loom-switch-"));
22
- const file = join(dir, "episode.smdf.json");
23
- writeFileSync(file, JSON.stringify({ stateMachine: { settings: {}, events: [], state: { name: "Root" } } }));
24
- try {
25
- const r = resolveProjectSwitch(file, "/prev/doc.smdf.json");
26
- assert.equal(r.path, file);
27
- assert.equal(r.exists, true);
28
- }
29
- finally {
30
- rmSync(dir, { recursive: true, force: true });
31
- }
32
- });
33
- test("projectSwitch: refuses a non-.json path", () => {
34
- assert.throws(() => resolveProjectSwitch("/tmp/machine.yaml", "/prev/doc.smdf.json"), /\.json/);
35
- });
36
- test("projectSwitch: switching to the same path is allowed and reports it", () => {
37
- const r = resolveProjectSwitch("/prev/doc.smdf.json", "/prev/doc.smdf.json");
38
- assert.equal(r.path, "/prev/doc.smdf.json");
39
- assert.equal(r.previous, "/prev/doc.smdf.json");
40
- assert.equal(r.unchanged, true);
41
- });
@@ -1,32 +0,0 @@
1
- /**
2
- * Pure logic behind the `set_project_file` MCP tool (path power — the agent
3
- * chooses which `.smdf.json` document the loom weaves).
4
- */
5
- import { existsSync } from "fs";
6
- import { resolve } from "path";
7
-
8
- export interface SwitchResult {
9
- /** Absolute path of the newly active document. */
10
- path: string;
11
- /** The previously active absolute path. */
12
- previous: string;
13
- /** Whether a file already exists at the new path (missing is fine). */
14
- exists: boolean;
15
- /** True when the new path equals the previous one. */
16
- unchanged: boolean;
17
- }
18
-
19
- export function resolveProjectSwitch(next: string, previous: string): SwitchResult {
20
- const path = resolve(next);
21
- if (!path.endsWith(".json")) {
22
- throw new Error(
23
- `project file must be a .json document (got '${path}') — the loom weaves .smdf.json files`,
24
- );
25
- }
26
- return {
27
- path,
28
- previous,
29
- exists: existsSync(path),
30
- unchanged: path === previous,
31
- };
32
- }