@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 +1 -0
- package/dist/server.js +92 -1
- package/package.json +3 -2
- package/src/server.ts +103 -1
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.
|
|
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.
|
|
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 () => {
|