jui-tools-mcp-server 2.16.0 → 2.17.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.
Files changed (44) hide show
  1. package/.github/workflows/test.yml +2 -1
  2. package/README.md +16 -1
  3. package/dist/index.js +16 -1
  4. package/dist/tools/infra/infra_check.d.ts +10 -0
  5. package/dist/tools/infra/infra_check.js +43 -0
  6. package/dist/tools/infra/infra_common.d.ts +40 -0
  7. package/dist/tools/infra/infra_common.js +44 -0
  8. package/dist/tools/infra/infra_cost.d.ts +3 -0
  9. package/dist/tools/infra/infra_cost.js +118 -0
  10. package/dist/tools/infra/infra_load_validate.d.ts +8 -0
  11. package/dist/tools/infra/infra_load_validate.js +39 -0
  12. package/dist/tools/infra/infra_prices_validate.d.ts +7 -0
  13. package/dist/tools/infra/infra_prices_validate.js +34 -0
  14. package/dist/tools/infra/infra_seed.d.ts +27 -0
  15. package/dist/tools/infra/infra_seed.js +81 -0
  16. package/dist/tools/infra/infra_validate.d.ts +8 -0
  17. package/dist/tools/infra/infra_validate.js +37 -0
  18. package/package.json +1 -1
  19. package/scripts/record-infra-fixtures.sh +78 -0
  20. package/src/index.ts +17 -1
  21. package/src/tools/infra/infra_check.ts +49 -0
  22. package/src/tools/infra/infra_common.ts +69 -0
  23. package/src/tools/infra/infra_cost.ts +122 -0
  24. package/src/tools/infra/infra_load_validate.ts +46 -0
  25. package/src/tools/infra/infra_prices_validate.ts +41 -0
  26. package/src/tools/infra/infra_seed.ts +88 -0
  27. package/src/tools/infra/infra_validate.ts +44 -0
  28. package/tests/fixtures/infra/RECORDED_FROM +1 -0
  29. package/tests/fixtures/infra/check-infra.json +101 -0
  30. package/tests/fixtures/infra/cost-infra.day.json +10080 -0
  31. package/tests/fixtures/infra/cost-infra.month-by-node.json +484 -0
  32. package/tests/fixtures/infra/cost-infra.month.json +381 -0
  33. package/tests/fixtures/infra/cost-infra.refused.json +5 -0
  34. package/tests/fixtures/infra/seed-infra.draft.json +233 -0
  35. package/tests/fixtures/infra/seed-infra.report.json +46 -0
  36. package/tests/fixtures/infra/validate-infra-load.errors.json +39 -0
  37. package/tests/fixtures/infra/validate-infra-load.ok.json +29 -0
  38. package/tests/fixtures/infra/validate-infra-load.unreadable.json +6 -0
  39. package/tests/fixtures/infra/validate-infra-prices.ok.json +9 -0
  40. package/tests/fixtures/infra/validate-infra.errors.json +39 -0
  41. package/tests/fixtures/infra/validate-infra.ok.json +30 -0
  42. package/tests/fixtures/infra/validate-infra.unreadable.json +6 -0
  43. package/tests/tool_count.test.ts +1 -1
  44. package/tests/tools_infra.test.ts +332 -0
@@ -0,0 +1,78 @@
1
+ #!/bin/bash
2
+ # Record what the jsonui-doc infra commands print with --json, as the infra tools' test fixtures
3
+ # (tests/fixtures/infra/*.json). The tools summarise those outputs, so the fixtures are the CLI's contract: when the
4
+ # CLI's JSON changes, record again and the tools' tests say what no longer holds.
5
+ #
6
+ # scripts/record-infra-fixtures.sh <jsonui-cli checkout> # e.g. a worktree of integrate/2.3.15
7
+ #
8
+ # Rooms: jsonui-cli's own document_tools/tests/fixtures/infra (aws-three-tier: a model, two load scenarios, a price
9
+ # file; compose-and-cloud: what seed reads), copied to a temp folder; one copy is broken on purpose (a load file that
10
+ # is not JSON, a model file with a schema error) so the error shapes are recorded too. The fetched-price cache is kept
11
+ # out (JUI_VIZ_HOME points nowhere), so the bundled table prices them.
12
+ set -euo pipefail
13
+ CLI_ROOT="$(cd "${1:?usage: $0 <jsonui-cli checkout>}" && pwd)"
14
+ HERE="$(cd "$(dirname "$0")/.." && pwd)"
15
+ OUT="${HERE}/tests/fixtures/infra"
16
+ FIX="${CLI_ROOT}/document_tools/tests/fixtures/infra"
17
+ WORK="$(mktemp -d)"
18
+ trap 'rm -rf "${WORK}"' EXIT
19
+
20
+ export PYTHONPATH="${CLI_ROOT}/document_tools:${CLI_ROOT}/jui_tools:${CLI_ROOT}/test_tools"
21
+ export JUI_VIZ_HOME=/nonexistent
22
+ doc() { (cd "$1" && shift && python3 -m jsonui_doc_cli.cli "$@") || true; }
23
+
24
+ cp -R "${FIX}/aws-three-tier" "${WORK}/room"
25
+ cp -R "${FIX}/aws-three-tier" "${WORK}/broken"
26
+ printf '{' > "${WORK}/broken/docs/infra/load/broken.json"
27
+ printf '{"version": 0, "nodes": [{"id": "x", "category": "nope"}]}' > "${WORK}/broken/docs/infra/bad.json"
28
+ mkdir -p "${WORK}/seed/app"
29
+ cat > "${WORK}/seed/docker-compose.yml" <<'EOF'
30
+ services:
31
+ web:
32
+ image: example/web
33
+ depends_on: [db, cache]
34
+ db:
35
+ image: postgres:16
36
+ cache:
37
+ image: redis:7
38
+ EOF
39
+ cat > "${WORK}/seed/main.tf" <<'EOF'
40
+ provider "aws" { region = "ap-northeast-1" }
41
+ resource "aws_db_instance" "main" { engine = "postgres" }
42
+ resource "aws_lb" "web" { load_balancer_type = "application" }
43
+ resource "aws_s3_bucket" "assets" {}
44
+ resource "aws_glue_job" "etl" {}
45
+ EOF
46
+ printf 'import boto3\nimport stripe\nQUEUE_URL = "SQS_QUEUE_URL"\n' > "${WORK}/seed/app/main.py"
47
+ cat > "${WORK}/room/jui.config.json" <<'EOF'
48
+ {"checks": [{"name": "infra-load", "type": "builtin:infra-load"}, {"name": "infra-code", "type": "builtin:infra-code"}]}
49
+ EOF
50
+
51
+ mkdir -p "${OUT}" "${WORK}/empty"
52
+ doc "${WORK}/empty" validate infra --json > "${OUT}/validate-infra.unreadable.json"
53
+ doc "${WORK}/empty" validate infra-load --json > "${OUT}/validate-infra-load.unreadable.json"
54
+ doc "${WORK}/room" validate infra --json > "${OUT}/validate-infra.ok.json"
55
+ doc "${WORK}/broken" validate infra --json > "${OUT}/validate-infra.errors.json"
56
+ doc "${WORK}/room" validate infra-load --json > "${OUT}/validate-infra-load.ok.json"
57
+ doc "${WORK}/broken" validate infra-load --json > "${OUT}/validate-infra-load.errors.json"
58
+ doc "${WORK}/room" validate infra-prices --json > "${OUT}/validate-infra-prices.ok.json"
59
+ doc "${WORK}/seed" seed infra -o "${WORK}/seed-out" > /dev/null
60
+ cp "${WORK}/seed-out/draft.json" "${OUT}/seed-infra.draft.json"
61
+ cp "${WORK}/seed-out/seed-report.json" "${OUT}/seed-infra.report.json"
62
+ doc "${WORK}/room" cost infra --json > "${OUT}/cost-infra.month.json"
63
+ doc "${WORK}/room" cost infra --by node --scenario sale --json > "${OUT}/cost-infra.month-by-node.json"
64
+ doc "${WORK}/room" cost infra --date 2026-11-03 --json > "${OUT}/cost-infra.day.json"
65
+ doc "${WORK}/room" cost infra --scenario nope --json > "${OUT}/cost-infra.refused.json"
66
+ doc "${WORK}/room" check infra --json 2>/dev/null > "${OUT}/check-infra.json"
67
+
68
+ # The temp folder's path is not part of the contract
69
+ for f in "${OUT}"/*.json; do
70
+ python3 - "$f" "${WORK}" <<'EOF'
71
+ import sys
72
+ p, work = sys.argv[1:]
73
+ s = open(p, encoding="utf-8").read().replace(work, "<ROOT>")
74
+ open(p, "w", encoding="utf-8").write(s)
75
+ EOF
76
+ done
77
+ git -C "${CLI_ROOT}" log -1 --format='%h %s' > "${OUT}/RECORDED_FROM"
78
+ ls -1 "${OUT}"
package/src/index.ts CHANGED
@@ -59,6 +59,12 @@ import { register as registerVizDoctor } from "./tools/viz/viz_doctor.js";
59
59
  import { register as registerVizStart } from "./tools/viz/viz_start.js";
60
60
  import { register as registerVizStop } from "./tools/viz/viz_stop.js";
61
61
  import { register as registerVizOpen } from "./tools/viz/viz_open.js";
62
+ import { register as registerInfraValidate } from "./tools/infra/infra_validate.js";
63
+ import { register as registerInfraLoadValidate } from "./tools/infra/infra_load_validate.js";
64
+ import { register as registerInfraPricesValidate } from "./tools/infra/infra_prices_validate.js";
65
+ import { register as registerInfraSeed } from "./tools/infra/infra_seed.js";
66
+ import { register as registerInfraCheck } from "./tools/infra/infra_check.js";
67
+ import { register as registerInfraCost } from "./tools/infra/infra_cost.js";
62
68
 
63
69
  // --- Group F: jsonui-test CLI ---
64
70
  import { register as registerTestValidate } from "./tools/test/test_validate.js";
@@ -174,7 +180,17 @@ registerVizStart(server);
174
180
  registerVizStop(server);
175
181
  registerVizOpen(server);
176
182
 
177
- log(`Registered 51 tools (9 spec + 7 context + 8 jui + 9 doc + 3 api + 10 test + 5 viz)`);
183
+ // Register Group H: the server room's infra (6 tools) — jsonui-doc's validate / seed / check / cost infra, each
184
+ // summarised from its --json (jsonui-cli 2.3.15+). `jui viz cloud-prices fetch` stays with the CLI (it goes to the
185
+ // network and writes the viz home).
186
+ registerInfraValidate(server, config);
187
+ registerInfraLoadValidate(server, config);
188
+ registerInfraPricesValidate(server, config);
189
+ registerInfraSeed(server, config);
190
+ registerInfraCheck(server, config);
191
+ registerInfraCost(server, config);
192
+
193
+ log(`Registered 57 tools (9 spec + 7 context + 8 jui + 9 doc + 3 api + 10 test + 5 viz + 6 infra)`);
178
194
 
179
195
  async function main() {
180
196
  const transport = new StdioServerTransport();
@@ -0,0 +1,49 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { z } from "zod";
3
+ import { ServerConfig } from "../../config.js";
4
+ import { FIRST, failure, need, runDocJson, text } from "./infra_common.js";
5
+
6
+ /**
7
+ * `jsonui-doc check infra -p <project> --json`: the declared infra checks (jui.config.json → checks:
8
+ * builtin:infra-iac, builtin:infra-code, builtin:infra-load) — the model against the IaC, the code, and the load
9
+ * definitions. This is the one jsonui-doc command that runs project-declared checks; the infra ones read files only.
10
+ * Exit 0: clean (warnings alone are 0); 1: a mismatch (a finding of a check declared with gate: true); 2: an
11
+ * execution error, or no infra check is declared.
12
+ */
13
+ export function register(server: McpServer, config: ServerConfig) {
14
+ server.tool(
15
+ "infra_check",
16
+ "Run the project's declared infra checks with `jsonui-doc check infra --json` (jui.config.json → checks: builtin:infra-iac = the model against Terraform/Compose/CloudFormation/k8s/wrangler, builtin:infra-code = against the code's evidence, SDK imports and env names, builtin:infra-load = against the load definitions). Returns each check's status (ok / mismatch / error), counts, the first 20 results that are not ok (target, status, expected, actual, message), warnings, and its report file. Findings are warnings unless the check sets gate: true. Needs jsonui-cli 2.3.15+.",
17
+ {
18
+ project_dir: z.string().optional().describe("Project directory with jui.config.json (overrides JUI_PROJECT_DIR env)"),
19
+ },
20
+ async ({ project_dir }) => {
21
+ try {
22
+ const projectDir = config.resolveProjectDir(project_dir);
23
+ const { exitCode, data, stderr } = await runDocJson(["check", "infra", "-p", projectDir, "--json"], projectDir, 180_000);
24
+ need(data, ["ok", "exit", "checks"], "check infra");
25
+ if (!data.checks.length) {
26
+ return text({ ok: false, exit: exitCode, checks: [],
27
+ message: stderr.trim() || "no builtin:infra-* check is declared in jui.config.json → checks",
28
+ declared: data.declared ?? [] });
29
+ }
30
+ return text({
31
+ ok: data.ok,
32
+ exit: exitCode,
33
+ checks: data.checks.map((c: any) => {
34
+ need(c, ["name", "type", "status"], "check infra check");
35
+ if (c.status === "error") return { name: c.name, type: c.type, status: "error", error: c.error };
36
+ need(c, ["summary", "results", "warnings", "report"], "check infra check");
37
+ return {
38
+ name: c.name, type: c.type, status: c.status, summary: c.summary,
39
+ results: c.results.slice(0, FIRST), resultCount: c.results.length,
40
+ warnings: c.warnings.slice(0, FIRST), report: c.report,
41
+ };
42
+ }),
43
+ });
44
+ } catch (e: any) {
45
+ return failure(e);
46
+ }
47
+ }
48
+ );
49
+ }
@@ -0,0 +1,69 @@
1
+ import { runCli } from "../../cli_runner.js";
2
+
3
+ /**
4
+ * Shared by the Group H tools: run a `jsonui-doc … --json` infra command and read its one JSON object.
5
+ *
6
+ * The tools summarise that object for an agent (counts, the first problems, the totals), so the keys they read are
7
+ * the CLI's contract. `need` names a key the object lacks instead of summarising nothing — a jsonui-doc older or
8
+ * newer than these tools says so rather than reading as an empty room. The tests feed the CLI's own recorded output
9
+ * (tests/fixtures/infra, scripts/record-infra-fixtures.sh).
10
+ */
11
+
12
+ export interface JsonRun {
13
+ exitCode: number;
14
+ data: any;
15
+ stderr: string;
16
+ }
17
+
18
+ export class ContractError extends Error {}
19
+
20
+ export const FIRST = 20;
21
+
22
+ export async function runDocJson(args: string[], cwd: string, timeout?: number): Promise<JsonRun> {
23
+ const result = await runCli("jsonui-doc", args, { cwd, timeout });
24
+ let data: any;
25
+ try {
26
+ data = JSON.parse(result.stdout);
27
+ } catch {
28
+ throw new ContractError(
29
+ `jsonui-doc ${args.slice(0, 2).join(" ")} printed no JSON (exit ${result.exitCode})` +
30
+ (result.stderr ? `: ${result.stderr.trim().split("\n").slice(-3).join(" / ")}` : "") +
31
+ " — it needs jsonui-cli 2.3.15 or later for --json here"
32
+ );
33
+ }
34
+ return { exitCode: result.exitCode, data, stderr: result.stderr };
35
+ }
36
+
37
+ export function need(obj: any, keys: string[], what: string): void {
38
+ const missing = keys.filter((k) => obj == null || !(k in obj));
39
+ if (missing.length) {
40
+ throw new ContractError(
41
+ `${what}: the JSON has no ${missing.map((k) => `"${k}"`).join(", ")} — a jsonui-doc these tools do not know; ` +
42
+ "update jsonui-cli or this server"
43
+ );
44
+ }
45
+ }
46
+
47
+ /** A problem list as counts (by severity, by code) and the first FIRST, each as written. */
48
+ export function problems(list: any[], what: string, keep: string[] = ["severity", "code", "file", "path", "message"]) {
49
+ if (!Array.isArray(list)) throw new ContractError(`${what}: "problems" is not a list`);
50
+ const bySeverity: Record<string, number> = {};
51
+ const byCode: Record<string, number> = {};
52
+ for (const p of list) {
53
+ need(p, ["severity", "code"], `${what} problem`);
54
+ bySeverity[p.severity] = (bySeverity[p.severity] ?? 0) + 1;
55
+ byCode[p.code] = (byCode[p.code] ?? 0) + 1;
56
+ }
57
+ const first = list.slice(0, FIRST).map((p) =>
58
+ Object.fromEntries(keep.filter((k) => p[k] !== undefined && p[k] !== null).map((k) => [k, p[k]]))
59
+ );
60
+ return { count: list.length, errors: bySeverity.error ?? 0, warnings: bySeverity.warning ?? 0, byCode, first };
61
+ }
62
+
63
+ export function text(value: unknown) {
64
+ return { content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }] };
65
+ }
66
+
67
+ export function failure(e: any) {
68
+ return { content: [{ type: "text" as const, text: `Error: ${e.message}` }] };
69
+ }
@@ -0,0 +1,122 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { z } from "zod";
3
+ import { ServerConfig } from "../../config.js";
4
+ import { FIRST, failure, need, problems, runDocJson, text } from "./infra_common.js";
5
+
6
+ /**
7
+ * `jsonui-doc cost infra <project> --json`: the cost simulator's month (or one day) for a load scenario, the same
8
+ * simulation and shapes as jui viz's コスト. The month's whole reply (every day's stack) and above all a day's (every
9
+ * node's 24 hours with each meter's price trace, ~300 KB) are too long for an agent; this keeps what answers "what
10
+ * does it cost and why": the total, the totals per stack key, the top nodes, what has no price / rule / load, the
11
+ * hours over capacity, the notes, the load problems and the price-book warnings — and for a day, each top node's
12
+ * charge by meter with the price layer it came from. Exit 0: simulated; 1: simulated, a load file has an error (left
13
+ * out); 2: nothing simulated (reason).
14
+ */
15
+
16
+ function monthSummary(m: any) {
17
+ need(m, ["scenario", "month", "usd", "by", "keys", "days", "noPrice", "noRule", "loadUndefined", "overflowHours",
18
+ "warnings", "warningCount"], "cost infra month");
19
+ const totals: Record<string, number> = {};
20
+ for (const d of m.days) {
21
+ need(d, ["date", "usd", "stack"], "cost infra day of the month");
22
+ for (const [k, v] of Object.entries(d.stack)) totals[k] = (totals[k] ?? 0) + (v as number);
23
+ }
24
+ const label = Object.fromEntries(m.keys.map((k: any) => [k.key, k.label]));
25
+ return {
26
+ scenario: m.scenario,
27
+ month: m.month,
28
+ usd: m.usd,
29
+ by: m.by,
30
+ totals: Object.entries(totals).sort((a, b) => b[1] - a[1])
31
+ .map(([key, usd]) => ({ key: key === "__others__" ? "others" : key, label: label[key] ?? key, usd })),
32
+ days: m.days.length,
33
+ dearestDay: m.days.reduce((a: any, b: any) => (b.usd > a.usd ? b : a), m.days[0])?.date ?? null,
34
+ noPrice: m.noPrice,
35
+ noRule: m.noRule,
36
+ loadUndefined: m.loadUndefined,
37
+ overflowHours: m.overflowHours,
38
+ notes: m.warnings.slice(0, FIRST).map((w: any) => (typeof w === "string" ? w : w.message)),
39
+ noteCount: m.warningCount,
40
+ };
41
+ }
42
+
43
+ function daySummary(day: any) {
44
+ need(day, ["scenario", "date", "usd", "nodes", "others"], "cost infra day");
45
+ return {
46
+ scenario: day.scenario,
47
+ date: day.date,
48
+ usd: day.usd,
49
+ nodes: day.nodes.map((n: any) => {
50
+ need(n, ["node", "usd", "hours"], "cost infra day node");
51
+ const meters: Record<string, { usd: number; amount: number; unit: string; sku: string; layer: string | null }> = {};
52
+ let peak = { hour: 0, usd: -1 };
53
+ n.hours.forEach((h: any, i: number) => {
54
+ if (h.usd > peak.usd) peak = { hour: day.hours?.[i] ?? i, usd: h.usd };
55
+ for (const s of h.segments ?? []) {
56
+ const m = (meters[s.meter] ??= { usd: 0, amount: 0, unit: s.unit, sku: s.sku, layer: s.price?.layer ?? null });
57
+ m.usd += s.usd ?? 0;
58
+ m.amount += s.amount ?? 0;
59
+ }
60
+ });
61
+ const overflowHours = n.hours.filter((h: any) => h.load && h.load.overflow > 0).length;
62
+ return {
63
+ node: n.node, label: n.label, provider: n.provider, rule: n.rule, note: n.note || undefined, usd: n.usd,
64
+ peakHour: peak.usd >= 0 ? peak.hour : null, meters, overflowHours: overflowHours || undefined,
65
+ };
66
+ }),
67
+ others: { count: day.others.count, usd: (day.others.usd as number[]).reduce((a, b) => a + b, 0) },
68
+ };
69
+ }
70
+
71
+ export function register(server: McpServer, config: ServerConfig) {
72
+ server.tool(
73
+ "infra_cost",
74
+ "Simulate the server room's cost with `jsonui-doc cost infra --json` (the model, a load scenario under docs/infra/load, prices: the room's own, then fetched, then bundled — USD, on demand). Without date: the month's total, totals by provider (or by node), the top nodes, the dearest day, what has no price / no rule / no load, hours over capacity, notes. With date (YYYY-MM-DD in the scenario's month): that day's top nodes, each with its charge by meter, the price layer, and its peak hour. Also the scenarios there are, the load problems and the price-book warnings (prices.free_dropped: a room's price row dropping a bundled free amount). Needs jsonui-cli 2.3.15+.",
75
+ {
76
+ project_dir: z.string().optional().describe("Project directory with jui.config.json (overrides JUI_PROJECT_DIR env)"),
77
+ scenario: z.string().optional().describe("Load scenario id (default: the first)"),
78
+ date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("One day of the scenario's month (YYYY-MM-DD)"),
79
+ by: z.enum(["provider", "node"]).optional().describe("Group the month's totals by provider (default) or node"),
80
+ },
81
+ async ({ project_dir, scenario, date, by }) => {
82
+ try {
83
+ const projectDir = config.resolveProjectDir(project_dir);
84
+ const args = ["cost", "infra", projectDir, "--json"];
85
+ if (scenario) args.push("--scenario", scenario);
86
+ if (date) args.push("--date", date);
87
+ if (by) args.push("--by", by);
88
+ const { exitCode, data } = await runDocJson(args, projectDir, 180_000);
89
+ need(data, ["ok"], "cost infra");
90
+ if (!data.ok) {
91
+ need(data, ["reason", "message"], "cost infra");
92
+ return text({ ok: false, exit: exitCode, reason: data.reason, message: data.message,
93
+ ...(data.problems ? { problems: problems(data.problems, "cost infra") } : {}) });
94
+ }
95
+ need(data, ["dir", "scenario", "scenarios", "problems", "prices"], "cost infra");
96
+ need(data.problems, ["count", "errors", "first"], "cost infra problems");
97
+ need(data.prices, ["warnings", "warningCount"], "cost infra prices");
98
+ const out: Record<string, unknown> = {
99
+ ok: exitCode === 0,
100
+ exit: exitCode,
101
+ dir: data.dir,
102
+ scenario: data.scenario,
103
+ scenarios: data.scenarios,
104
+ loadProblems: data.problems,
105
+ priceWarnings: data.prices.warnings,
106
+ priceWarningCount: data.prices.warningCount,
107
+ };
108
+ if (date) {
109
+ need(data, ["day"], "cost infra");
110
+ out.day = daySummary(data.day);
111
+ } else {
112
+ need(data, ["month", "topNodes"], "cost infra");
113
+ out.month = monthSummary(data.month);
114
+ out.topNodes = data.topNodes;
115
+ }
116
+ return text(out);
117
+ } catch (e: any) {
118
+ return failure(e);
119
+ }
120
+ }
121
+ );
122
+ }
@@ -0,0 +1,46 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { z } from "zod";
3
+ import { ServerConfig } from "../../config.js";
4
+ import { failure, need, problems, runDocJson, text } from "./infra_common.js";
5
+
6
+ /**
7
+ * `jsonui-doc validate infra-load <project> --json`: the load scenarios the cost simulator reads (<infra.dir>/load,
8
+ * or infra.load_dir) against their schema and against the model. Exit 0: no error; 1: errors; 2: the model or the
9
+ * folder cannot be read (reason).
10
+ */
11
+ export function register(server: McpServer, config: ServerConfig) {
12
+ server.tool(
13
+ "infra_load_validate",
14
+ "Validate the server room's load scenarios (docs/infra/load/*.json) with `jsonui-doc validate infra-load --json`. Returns each scenario (id, environment, month, hours, loads, edges) and the problems by severity and code with the first 20 (file, path, code, message, node, hour, quantity). load.overlap means a node has two rows and only one counts. Needs jsonui-cli 2.3.15+.",
15
+ {
16
+ project_dir: z.string().optional().describe("Project directory with jui.config.json (overrides JUI_PROJECT_DIR env)"),
17
+ },
18
+ async ({ project_dir }) => {
19
+ try {
20
+ const projectDir = config.resolveProjectDir(project_dir);
21
+ const { exitCode, data } = await runDocJson(["validate", "infra-load", projectDir, "--json"], projectDir);
22
+ need(data, ["ok"], "validate infra-load");
23
+ if (!data.ok) {
24
+ need(data, ["reason", "message"], "validate infra-load");
25
+ return text({ ok: false, exit: exitCode, readable: false, reason: data.reason, message: data.message });
26
+ }
27
+ need(data, ["dir", "files", "problems", "scenarios"], "validate infra-load");
28
+ for (const s of data.scenarios) need(s, ["id", "environment", "month", "hours", "loads", "edges"], "validate infra-load scenario");
29
+ return text({
30
+ ok: exitCode === 0,
31
+ exit: exitCode,
32
+ readable: true,
33
+ dir: data.dir,
34
+ files: data.files,
35
+ scenarios: data.scenarios.map((s: any) => ({
36
+ id: s.id, file: s.file, environment: s.environment, month: s.month, hours: s.hours, loads: s.loads, edges: s.edges,
37
+ })),
38
+ problems: problems(data.problems, "validate infra-load",
39
+ ["severity", "code", "file", "path", "message", "node", "hour", "quantity"]),
40
+ });
41
+ } catch (e: any) {
42
+ return failure(e);
43
+ }
44
+ }
45
+ );
46
+ }
@@ -0,0 +1,41 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { z } from "zod";
3
+ import { ServerConfig } from "../../config.js";
4
+ import { failure, need, problems, runDocJson, text } from "./infra_common.js";
5
+
6
+ /**
7
+ * `jsonui-doc validate infra-prices <project> --json`: the room's own price files (<infra.dir>/prices, or
8
+ * infra.prices_dir). Exit 0: no error; 1: errors; 2: the folder cannot be read (reason).
9
+ */
10
+ export function register(server: McpServer, config: ServerConfig) {
11
+ server.tool(
12
+ "infra_prices_validate",
13
+ "Validate the server room's own price files (docs/infra/prices/*.json — only for what the bundled prices lack) with `jsonui-doc validate infra-prices --json`. Returns the files used, how many prices, and the problems by severity and code with the first 20. Needs jsonui-cli 2.3.15+.",
14
+ {
15
+ project_dir: z.string().optional().describe("Project directory with jui.config.json (overrides JUI_PROJECT_DIR env)"),
16
+ },
17
+ async ({ project_dir }) => {
18
+ try {
19
+ const projectDir = config.resolveProjectDir(project_dir);
20
+ const { exitCode, data } = await runDocJson(["validate", "infra-prices", projectDir, "--json"], projectDir);
21
+ need(data, ["ok"], "validate infra-prices");
22
+ if (!data.ok) {
23
+ need(data, ["reason", "message"], "validate infra-prices");
24
+ return text({ ok: false, exit: exitCode, readable: false, reason: data.reason, message: data.message });
25
+ }
26
+ need(data, ["dir", "files", "problems", "count"], "validate infra-prices");
27
+ return text({
28
+ ok: exitCode === 0,
29
+ exit: exitCode,
30
+ readable: true,
31
+ dir: data.dir,
32
+ files: data.files,
33
+ count: data.count,
34
+ problems: problems(data.problems, "validate infra-prices"),
35
+ });
36
+ } catch (e: any) {
37
+ return failure(e);
38
+ }
39
+ }
40
+ );
41
+ }
@@ -0,0 +1,88 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { z } from "zod";
3
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from "fs";
4
+ import { tmpdir } from "os";
5
+ import { join } from "path";
6
+ import { ServerConfig } from "../../config.js";
7
+ import { ContractError, FIRST, failure, need, text } from "./infra_common.js";
8
+ import { runCli } from "../../cli_runner.js";
9
+
10
+ /**
11
+ * `jsonui-doc seed infra <project> -o <dir>`: a draft model from what the project already says (Compose, Terraform /
12
+ * OpenTofu, Kubernetes, CloudFormation / SAM / CDK, wrangler, PaaS files, SDK imports and env names; never state,
13
+ * tfvars or .env values), and its report (what it read, what it could not place). By default the draft is written to
14
+ * a temp folder and read back — nothing in the project changes; write: true writes <infra.dir>/seed (the draft is a
15
+ * starting point, never read as the model). Exit 0: drafted; 2: refused (bad config, the draft fails its own check,
16
+ * or a draft is already there without force).
17
+ */
18
+
19
+ function infraDir(projectDir: string): string {
20
+ try {
21
+ const cfg = JSON.parse(readFileSync(join(projectDir, "jui.config.json"), "utf-8"));
22
+ if (typeof cfg?.infra?.dir === "string") return cfg.infra.dir;
23
+ } catch {
24
+ // no config, or not JSON: the CLI says so itself
25
+ }
26
+ return "docs/infra";
27
+ }
28
+
29
+ function readJson(path: string, what: string): any {
30
+ try {
31
+ return JSON.parse(readFileSync(path, "utf-8"));
32
+ } catch {
33
+ throw new ContractError(`seed infra wrote no readable ${what} at ${path}`);
34
+ }
35
+ }
36
+
37
+ export function summarize(draft: any, report: any) {
38
+ need(draft, ["nodes", "groups", "edges"], "seed infra draft");
39
+ need(report, ["inputs", "unmatched", "skipped", "skippedCount", "notes", "withheld"], "seed infra report");
40
+ return {
41
+ counts: { nodes: draft.nodes.length, groups: draft.groups.length, edges: draft.edges.length,
42
+ flows: Array.isArray(draft.flows) ? draft.flows.length : 0 },
43
+ environments: (draft.environments ?? []).map((e: any) => e.id),
44
+ nodes: draft.nodes.slice(0, 50).map((n: any) => ({
45
+ id: n.id, category: n.category, provider: n.provider, service: n.service, iac: n.iac, environments: n.environments,
46
+ })),
47
+ inputs: report.inputs,
48
+ unmatched: Object.fromEntries(Object.entries(report.unmatched).map(([provider, u]: [string, any]) =>
49
+ [provider, { count: u.count, items: (u.items ?? []).slice(0, FIRST) }])),
50
+ notDrawn: report.notDrawn ?? {},
51
+ skipped: { count: report.skippedCount, first: report.skipped.slice(0, FIRST) },
52
+ notes: report.notes.slice(0, FIRST),
53
+ withheld: report.withheld,
54
+ };
55
+ }
56
+
57
+ export function register(server: McpServer, config: ServerConfig) {
58
+ server.tool(
59
+ "infra_seed",
60
+ "Draft the server room's model from the project's IaC, config files and code with `jsonui-doc seed infra` (Compose, Terraform/OpenTofu, Kubernetes, CloudFormation/SAM/CDK, wrangler, Firebase/PaaS files, SDK imports and env var names — never Terraform state, tfvars or .env values). Returns the draft's counts and nodes (id, category, provider, service, iac), what was read, what could not be placed (unmatched, by provider) or was skipped, and notes. By default nothing in the project is written; write: true writes <infra.dir>/seed/draft.json and seed-report.json (a starting point to copy from — the model is docs/infra/*.json).",
61
+ {
62
+ project_dir: z.string().optional().describe("Project directory with jui.config.json (overrides JUI_PROJECT_DIR env)"),
63
+ write: z.boolean().optional().describe("Write the draft to <infra.dir>/seed in the project (default false: read only)"),
64
+ force: z.boolean().optional().describe("With write: replace a draft already there"),
65
+ },
66
+ async ({ project_dir, write, force }) => {
67
+ const temp = write ? null : mkdtempSync(join(tmpdir(), "jui-mcp-seed-"));
68
+ try {
69
+ const projectDir = config.resolveProjectDir(project_dir);
70
+ const out = temp ?? join(projectDir, infraDir(projectDir), "seed");
71
+ const args = ["seed", "infra", projectDir, "-o", out];
72
+ if (write && force) args.push("--force");
73
+ const result = await runCli("jsonui-doc", args, { cwd: projectDir, timeout: 180_000 });
74
+ if (result.exitCode !== 0 || !existsSync(join(out, "draft.json"))) {
75
+ return text({ ok: false, exit: result.exitCode,
76
+ message: (result.stderr || result.stdout).trim().split("\n").slice(-5).join("\n") });
77
+ }
78
+ const summary = summarize(readJson(join(out, "draft.json"), "draft.json"),
79
+ readJson(join(out, "seed-report.json"), "seed-report.json"));
80
+ return text({ ok: true, exit: 0, written: write ? out : null, ...summary });
81
+ } catch (e: any) {
82
+ return failure(e);
83
+ } finally {
84
+ if (temp) rmSync(temp, { recursive: true, force: true });
85
+ }
86
+ }
87
+ );
88
+ }
@@ -0,0 +1,44 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { z } from "zod";
3
+ import { ServerConfig } from "../../config.js";
4
+ import { failure, need, problems, runDocJson, text } from "./infra_common.js";
5
+
6
+ /**
7
+ * `jsonui-doc validate infra <project> --json`: the room's model (docs/infra/*.json, or jui.config.json's infra.dir)
8
+ * read as jui viz reads it — each file against the schema and the shapes of secrets, then merged, and its flows
9
+ * walked. Exit 0: no error (warnings alone are 0); 1: errors; 2: nothing could be read (reason).
10
+ */
11
+ export function register(server: McpServer, config: ServerConfig) {
12
+ server.tool(
13
+ "infra_validate",
14
+ "Validate the server room's infra model (docs/infra/*.json: environments, groups, nodes, edges, flows) with `jsonui-doc validate infra --json`. Returns whether it could be read (and why not), the files used, the counts, and the problems by severity and code with the first 20 (file, path, code, message). Flow problems (flow.*) name the rule a step most likely missed. Needs jsonui-cli 2.3.15+.",
15
+ {
16
+ project_dir: z.string().optional().describe("Project directory with jui.config.json (overrides JUI_PROJECT_DIR env)"),
17
+ },
18
+ async ({ project_dir }) => {
19
+ try {
20
+ const projectDir = config.resolveProjectDir(project_dir);
21
+ const { exitCode, data } = await runDocJson(["validate", "infra", projectDir, "--json"], projectDir);
22
+ need(data, ["ok"], "validate infra");
23
+ if (!data.ok && data.reason !== "invalid") {
24
+ need(data, ["reason", "message"], "validate infra");
25
+ return text({ ok: false, exit: exitCode, readable: false, reason: data.reason, message: data.message });
26
+ }
27
+ need(data, ["dir", "files", "problems"], "validate infra");
28
+ return text({
29
+ ok: exitCode === 0,
30
+ exit: exitCode,
31
+ readable: data.ok,
32
+ dir: data.dir,
33
+ files: data.files,
34
+ environments: (data.environments ?? []).map((e: any) => e.id),
35
+ counts: data.counts ?? null,
36
+ flows: Array.isArray(data.flows) ? data.flows.length : null,
37
+ problems: problems(data.problems, "validate infra"),
38
+ });
39
+ } catch (e: any) {
40
+ return failure(e);
41
+ }
42
+ }
43
+ );
44
+ }
@@ -0,0 +1 @@
1
+ 7479faaf infra arms: a load with warnings alone is cost infra's 0 (still counted), and a price dropped by two rows warns once