@miadi/stateloom-mcp 0.1.1 → 0.1.2

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/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
@@ -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,6 +1,6 @@
1
1
  {
2
2
  "name": "@miadi/stateloom-mcp",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "MCP server for miadi-stateloom (formerly smcraft-mcp) — lets LLM agents design state machines through conversation",
5
5
  "type": "module",
6
6
  "main": "dist/server.js",
@@ -14,8 +14,9 @@
14
14
  "test": "node --test dist/tests/*.test.js"
15
15
  },
16
16
  "dependencies": {
17
+ "@miadi/stateloom-cli": "^0.1.1",
17
18
  "@miadi/stateloom-client": "^0.1.0",
18
- "@miadi/stateloom-protocol": "^0.1.1",
19
+ "@miadi/stateloom-protocol": "^0.1.2",
19
20
  "@modelcontextprotocol/sdk": "^1.12.1",
20
21
  "zod": "^4.3.6"
21
22
  },
package/src/server.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
@@ -30,12 +31,13 @@
30
31
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
31
32
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
32
33
  import { z } from "zod";
33
- import { execFileSync } from "child_process";
34
+ import { execFileSync, spawn } from "child_process";
34
35
  import { writeFileSync, readFileSync, mkdtempSync, rmSync, existsSync, statSync } from "fs";
35
36
  import { join, resolve } from "path";
36
37
  import { tmpdir } from "os";
37
38
  import { createBridgeClient, type BridgeClient } from "@miadi/stateloom-client";
38
39
  import { envAlias, type PatchOp, type StateMachineDefinition } from "@miadi/stateloom-protocol";
40
+ import { renderDiagramToFile, defaultOutputPath } from "@miadi/stateloom-cli/render";
39
41
  import { resolveProjectSwitch } from "./projectSwitch.js";
40
42
 
41
43
  // STATELOOM_* read first, SMCRAFT_* legacy twin honored — the live MCP
@@ -1024,6 +1026,106 @@ server.tool(
1024
1026
  }
1025
1027
  );
1026
1028
 
1029
+ /**
1030
+ * How large a PNG may be and still ride back inside the tool result. Past this
1031
+ * the agent gets the path alone — a multi-megabyte base64 blob would crowd out
1032
+ * the conversation it was meant to illustrate.
1033
+ */
1034
+ const INLINE_IMAGE_LIMIT = 1_500_000;
1035
+
1036
+ /** Hand a rendered file to the platform viewer. Never fatal — a server has none. */
1037
+ function openOnDesktop(path: string): string {
1038
+ const opener =
1039
+ process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
1040
+ try {
1041
+ const child = spawn(opener, [path], {
1042
+ stdio: "ignore",
1043
+ detached: true,
1044
+ shell: process.platform === "win32",
1045
+ });
1046
+ child.on("error", () => {});
1047
+ child.unref();
1048
+ return `opened with ${opener}`;
1049
+ } catch {
1050
+ return "no desktop opener available";
1051
+ }
1052
+ }
1053
+
1054
+ server.tool(
1055
+ "render_diagram",
1056
+ "Draw the current state machine as a picture. Writes the file next to the project document (or at `path`) and returns its absolute path — plus the image itself for png, so it can be looked at without leaving the conversation. Formats: png (raster, needs librsvg/Inkscape/ImageMagick/Chrome on the host), svg (needs nothing), mermaid, ascii. Pass stamp:true to keep every render instead of overwriting one file.",
1057
+ {
1058
+ format: z.enum(["png", "svg", "mermaid", "ascii"]).optional(),
1059
+ path: z.string().optional(),
1060
+ scale: z.number().optional(),
1061
+ theme: z.enum(["dark", "light"]).optional(),
1062
+ stamp: z.boolean().optional(),
1063
+ open: z.boolean().optional(),
1064
+ },
1065
+ async ({ format, path, scale, theme, stamp, open }) => {
1066
+ const def = readDef();
1067
+ if (!def)
1068
+ return {
1069
+ content: [{ type: "text", text: `No state machine at ${PROJECT_FILE}.` }],
1070
+ isError: true,
1071
+ };
1072
+
1073
+ const chosen = format ?? "png";
1074
+ try {
1075
+ const result = await renderDiagramToFile(def as unknown as StateMachineDefinition, {
1076
+ format: chosen,
1077
+ out: path,
1078
+ doc: PROJECT_FILE,
1079
+ scale,
1080
+ theme,
1081
+ stamp,
1082
+ });
1083
+
1084
+ const size = result.width && result.height ? `, ${result.width}×${result.height}` : "";
1085
+ const how = result.backend ? `, drawn by ${result.backend}` : "";
1086
+ const opened = open ? ` — ${openOnDesktop(result.path)}` : "";
1087
+ const note = `Rendered '${def.settings.name}' → ${result.path} (${result.bytes} bytes${size}${how})${opened}`;
1088
+
1089
+ // png and svg come back as pictures; the text formats are already text.
1090
+ if (chosen === "png" && result.bytes <= INLINE_IMAGE_LIMIT) {
1091
+ return {
1092
+ content: [
1093
+ { type: "text", text: note },
1094
+ {
1095
+ type: "image",
1096
+ data: readFileSync(result.path).toString("base64"),
1097
+ mimeType: "image/png",
1098
+ },
1099
+ ],
1100
+ };
1101
+ }
1102
+ if (chosen === "mermaid" || chosen === "ascii") {
1103
+ return {
1104
+ content: [
1105
+ { type: "text", text: note },
1106
+ { type: "text", text: readFileSync(result.path, "utf8") },
1107
+ ],
1108
+ };
1109
+ }
1110
+ return { content: [{ type: "text", text: note }] };
1111
+ } catch (e) {
1112
+ const why = e instanceof Error ? e.message : String(e);
1113
+ return {
1114
+ content: [
1115
+ {
1116
+ type: "text",
1117
+ text:
1118
+ `render_diagram (${chosen}) failed: ${why}\n` +
1119
+ `An SVG needs no rasterizer — retry with format "svg" ` +
1120
+ `(it would land at ${defaultOutputPath(PROJECT_FILE, "svg")}).`,
1121
+ },
1122
+ ],
1123
+ isError: true,
1124
+ };
1125
+ }
1126
+ }
1127
+ );
1128
+
1027
1129
  // Resources
1028
1130
 
1029
1131
  server.resource("definition", "smcraft://definition", async () => {