jui-tools-mcp-server 2.16.0 → 2.18.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/.github/workflows/test.yml +2 -1
- package/README.md +16 -1
- package/dist/index.js +16 -1
- package/dist/tools/infra/infra_check.d.ts +10 -0
- package/dist/tools/infra/infra_check.js +43 -0
- package/dist/tools/infra/infra_common.d.ts +40 -0
- package/dist/tools/infra/infra_common.js +44 -0
- package/dist/tools/infra/infra_cost.d.ts +3 -0
- package/dist/tools/infra/infra_cost.js +181 -0
- package/dist/tools/infra/infra_load_validate.d.ts +8 -0
- package/dist/tools/infra/infra_load_validate.js +39 -0
- package/dist/tools/infra/infra_prices_validate.d.ts +7 -0
- package/dist/tools/infra/infra_prices_validate.js +34 -0
- package/dist/tools/infra/infra_seed.d.ts +27 -0
- package/dist/tools/infra/infra_seed.js +81 -0
- package/dist/tools/infra/infra_validate.d.ts +3 -0
- package/dist/tools/infra/infra_validate.js +74 -0
- package/package.json +1 -1
- package/scripts/record-infra-fixtures.sh +143 -0
- package/src/index.ts +17 -1
- package/src/tools/infra/infra_check.ts +49 -0
- package/src/tools/infra/infra_common.ts +69 -0
- package/src/tools/infra/infra_cost.ts +177 -0
- package/src/tools/infra/infra_load_validate.ts +46 -0
- package/src/tools/infra/infra_prices_validate.ts +41 -0
- package/src/tools/infra/infra_seed.ts +88 -0
- package/src/tools/infra/infra_validate.ts +79 -0
- package/tests/fixtures/infra/RECORDED_FROM +1 -0
- package/tests/fixtures/infra/answers-without-plan.json +9 -0
- package/tests/fixtures/infra/check-infra.json +103 -0
- package/tests/fixtures/infra/cost-infra.day.json +10080 -0
- package/tests/fixtures/infra/cost-infra.month-by-node.json +492 -0
- package/tests/fixtures/infra/cost-infra.month.json +398 -0
- package/tests/fixtures/infra/cost-infra.plan-bad.json +16 -0
- package/tests/fixtures/infra/cost-infra.plan-move-account.json +167 -0
- package/tests/fixtures/infra/cost-infra.plan-move.json +174 -0
- package/tests/fixtures/infra/cost-infra.plan-none.json +16 -0
- package/tests/fixtures/infra/cost-infra.plan-shrink.json +162 -0
- package/tests/fixtures/infra/cost-infra.refused.json +5 -0
- package/tests/fixtures/infra/seed-infra.draft.json +233 -0
- package/tests/fixtures/infra/seed-infra.report.json +46 -0
- package/tests/fixtures/infra/validate-infra-load.errors.json +40 -0
- package/tests/fixtures/infra/validate-infra-load.ok.json +29 -0
- package/tests/fixtures/infra/validate-infra-load.unreadable.json +7 -0
- package/tests/fixtures/infra/validate-infra-prices.ok.json +9 -0
- package/tests/fixtures/infra/validate-infra.errors.json +43 -0
- package/tests/fixtures/infra/validate-infra.ok.json +30 -0
- package/tests/fixtures/infra/validate-infra.plan-both.json +504 -0
- package/tests/fixtures/infra/validate-infra.plan-duplicate.json +539 -0
- package/tests/fixtures/infra/validate-infra.plan-edge-end.json +539 -0
- package/tests/fixtures/infra/validate-infra.plan-half.json +49 -0
- package/tests/fixtures/infra/validate-infra.plan-move.json +506 -0
- package/tests/fixtures/infra/validate-infra.plan-none.json +49 -0
- package/tests/fixtures/infra/validate-infra.plan-order-unknown.json +539 -0
- package/tests/fixtures/infra/validate-infra.plan-remove-missing.json +539 -0
- package/tests/fixtures/infra/validate-infra.plans.json +506 -0
- package/tests/fixtures/infra/validate-infra.unreadable.json +6 -0
- package/tests/tool_count.test.ts +1 -1
- package/tests/tools_infra.test.ts +540 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { ContractError, FIRST, failure, need, problems, runDocJson, text } from "./infra_common.js";
|
|
3
|
+
/**
|
|
4
|
+
* `jsonui-doc validate infra <project> --json`: the room's model (docs/infra/*.json, or jui.config.json's infra.dir)
|
|
5
|
+
* read as jui viz reads it — each file against the schema and the shapes of secrets, then merged, and its flows
|
|
6
|
+
* walked. Exit 0: no error (warnings alone are 0); 1: errors; 2: nothing could be read (reason).
|
|
7
|
+
*
|
|
8
|
+
* Migration plans (jsonui-cli 2.3.20+, docs/infra/migrations/<name>/changes.json + removals.json): the CLI checks every
|
|
9
|
+
* plan with the model, or one with `--migration <name>`, and lists them under "migrations". A plan's error makes the
|
|
10
|
+
* exit 1 while the model's own problems are empty, so a room that has plans gets their summary here — without it the
|
|
11
|
+
* exit would name an error the answer does not show. A room with none answers exactly as before.
|
|
12
|
+
*/
|
|
13
|
+
function migrationSummary(m) {
|
|
14
|
+
need(m, ["name", "dir", "ok", "problems", "steps", "counts"], "validate infra migration");
|
|
15
|
+
return {
|
|
16
|
+
name: m.name,
|
|
17
|
+
dir: m.dir,
|
|
18
|
+
ok: m.ok,
|
|
19
|
+
counts: m.counts,
|
|
20
|
+
problems: problems(m.problems, "validate infra migration"),
|
|
21
|
+
steps: m.steps.length,
|
|
22
|
+
firstSteps: m.steps.slice(0, FIRST).map((s) => {
|
|
23
|
+
need(s, ["index", "kind", "node"], "validate infra migration step");
|
|
24
|
+
return { index: s.index, kind: s.kind, node: s.node,
|
|
25
|
+
...(s.edgesRemoved?.length ? { edgesRemoved: s.edgesRemoved } : {}),
|
|
26
|
+
...(s.edgesAdded?.length ? { edgesAdded: s.edgesAdded } : {}) };
|
|
27
|
+
}),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
function migrations(data, plan) {
|
|
31
|
+
if (plan)
|
|
32
|
+
need(data, ["migrations"], "validate infra --migration");
|
|
33
|
+
const list = data.migrations;
|
|
34
|
+
if (list === undefined)
|
|
35
|
+
return {}; // a room with no plan: the CLI prints no key, and nor does this
|
|
36
|
+
if (!Array.isArray(list))
|
|
37
|
+
throw new ContractError('validate infra: "migrations" is not a list');
|
|
38
|
+
return { migrations: list.map(migrationSummary) };
|
|
39
|
+
}
|
|
40
|
+
export function register(server, config) {
|
|
41
|
+
server.tool("infra_validate", "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. When the room has migration plans (docs/infra/migrations/<name>/changes.json + removals.json), each plan's check too: its problems (migration.remove_missing / duplicate / both / order_unknown / edge_end, and the infra checks on the merged new model), its counts (added, replaced, removed, edges added / removed) and its steps in order; `plan` checks that one plan only. Needs jsonui-cli 2.3.15+ (plans: 2.3.20+).", {
|
|
42
|
+
project_dir: z.string().optional().describe("Project directory with jui.config.json (overrides JUI_PROJECT_DIR env)"),
|
|
43
|
+
plan: z.string().optional().describe("One migration plan's name (its folder under docs/infra/migrations); default: every plan"),
|
|
44
|
+
}, async ({ project_dir, plan }) => {
|
|
45
|
+
try {
|
|
46
|
+
const projectDir = config.resolveProjectDir(project_dir);
|
|
47
|
+
const args = ["validate", "infra", projectDir, "--json"];
|
|
48
|
+
if (plan)
|
|
49
|
+
args.push("--migration", plan);
|
|
50
|
+
const { exitCode, data } = await runDocJson(args, projectDir);
|
|
51
|
+
need(data, ["ok"], "validate infra");
|
|
52
|
+
if (!data.ok && data.reason !== "invalid") {
|
|
53
|
+
need(data, ["reason", "message"], "validate infra");
|
|
54
|
+
return text({ ok: false, exit: exitCode, readable: false, reason: data.reason, message: data.message });
|
|
55
|
+
}
|
|
56
|
+
need(data, ["dir", "files", "problems"], "validate infra");
|
|
57
|
+
return text({
|
|
58
|
+
ok: exitCode === 0,
|
|
59
|
+
exit: exitCode,
|
|
60
|
+
readable: data.ok,
|
|
61
|
+
dir: data.dir,
|
|
62
|
+
files: data.files,
|
|
63
|
+
environments: (data.environments ?? []).map((e) => e.id),
|
|
64
|
+
counts: data.counts ?? null,
|
|
65
|
+
flows: Array.isArray(data.flows) ? data.flows.length : null,
|
|
66
|
+
problems: problems(data.problems, "validate infra"),
|
|
67
|
+
...migrations(data, plan),
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
catch (e) {
|
|
71
|
+
return failure(e);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,143 @@
|
|
|
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
|
+
# Migration plans (jsonui-cli 2.3.20+), in a copy of their own so the room above stays a room without plans: one plan
|
|
69
|
+
# that holds (replace db-prod, add worker-prod and its edge, remove api-stg with its edges, in a written order), and one
|
|
70
|
+
# plan per migration.* code with exactly that error.
|
|
71
|
+
plan() { # plan <room> <name> <changes json> <removals json>
|
|
72
|
+
mkdir -p "$1/docs/infra/migrations/$2"
|
|
73
|
+
printf '%s' "$3" > "$1/docs/infra/migrations/$2/changes.json"
|
|
74
|
+
printf '%s' "$4" > "$1/docs/infra/migrations/$2/removals.json"
|
|
75
|
+
}
|
|
76
|
+
cp -R "${FIX}/aws-three-tier" "${WORK}/plans"
|
|
77
|
+
DB='{"id": "db-prod", "category": "database.relational", "service": "rds.postgres", "group": "private-prod", "ha": "multi-az", "environments": ["production"], "spec": [{"key": "instance_class", "value": "db.r6g.large"}, {"key": "storage_gb", "value": "200"}]}'
|
|
78
|
+
WORKER='{"id": "worker-prod", "category": "compute.container", "service": "ecs.fargate", "group": "private-prod", "count": 1, "environments": ["production"], "spec": [{"key": "cpu", "value": "512"}, {"key": "memory", "value": "1024"}, {"key": "tasks", "value": "1"}]}'
|
|
79
|
+
NONE='{"version": 0, "nodes": [], "groups": [], "edges": []}'
|
|
80
|
+
plan "${WORK}/plans" move "{\"version\": 0, \"order\": [\"db-prod\", \"worker-prod\", \"api-stg\"], \"nodes\": [${DB}, ${WORKER}], \"edges\": [{\"from\": \"api-prod\", \"to\": \"worker-prod\", \"protocol\": \"https\", \"port\": 443}]}" \
|
|
81
|
+
'{"version": 0, "nodes": ["api-stg"], "groups": [], "edges": []}'
|
|
82
|
+
# the account's month needs a load in two environments: staging gets one here (only in this copy)
|
|
83
|
+
python3 - "${WORK}/plans/docs/infra/load" <<'EOF'
|
|
84
|
+
import json, sys
|
|
85
|
+
d = json.load(open(f"{sys.argv[1]}/ordinary.json", encoding="utf-8"))
|
|
86
|
+
d.update(id="staging", label="staging", environment="staging",
|
|
87
|
+
loads=[{"node": "alb-stg", "quantities": {"lcu": 1}}, {"node": "api-stg", "quantities": {"vcpu": 0.25, "memory_gb": 0.5}},
|
|
88
|
+
{"node": "db-stg", "sku": "db.t4g.medium", "quantities": {"storage_gb": 20}}], edges=[])
|
|
89
|
+
json.dump(d, open(f"{sys.argv[1]}/staging.json", "w", encoding="utf-8"), ensure_ascii=False)
|
|
90
|
+
EOF
|
|
91
|
+
cp -R "${FIX}/aws-three-tier" "${WORK}/badplans"
|
|
92
|
+
plan "${WORK}/badplans" remove-missing "{\"version\": 0, \"nodes\": [${WORKER}]}" '{"version": 0, "nodes": ["nope"], "groups": [], "edges": []}'
|
|
93
|
+
plan "${WORK}/badplans" duplicate "{\"version\": 0, \"nodes\": [${WORKER}, ${WORKER}]}" "${NONE}"
|
|
94
|
+
plan "${WORK}/badplans" both "{\"version\": 0, \"nodes\": [${DB}]}" '{"version": 0, "nodes": ["db-prod"], "groups": [], "edges": []}'
|
|
95
|
+
plan "${WORK}/badplans" order-unknown "{\"version\": 0, \"order\": [\"ghost\"], \"nodes\": [${WORKER}]}" "${NONE}"
|
|
96
|
+
plan "${WORK}/badplans" edge-end "{\"version\": 0, \"nodes\": [${WORKER}], \"edges\": [{\"from\": \"worker-prod\", \"to\": \"ghost\", \"protocol\": \"https\", \"port\": 443}]}" "${NONE}"
|
|
97
|
+
doc "${WORK}/plans" validate infra --json > "${OUT}/validate-infra.plans.json"
|
|
98
|
+
doc "${WORK}/plans" validate infra --migration move --json > "${OUT}/validate-infra.plan-move.json"
|
|
99
|
+
doc "${WORK}/plans" validate infra --migration nope --json > "${OUT}/validate-infra.plan-none.json"
|
|
100
|
+
for code in remove-missing duplicate both order-unknown edge-end; do
|
|
101
|
+
doc "${WORK}/badplans" validate infra --migration "${code}" --json > "${OUT}/validate-infra.plan-${code}.json"
|
|
102
|
+
done
|
|
103
|
+
doc "${WORK}/plans" cost infra --migration move --json > "${OUT}/cost-infra.plan-move.json"
|
|
104
|
+
doc "${WORK}/plans" cost infra --migration move --account --scenario sale --json > "${OUT}/cost-infra.plan-move-account.json"
|
|
105
|
+
doc "${WORK}/badplans" cost infra --migration both --json > "${OUT}/cost-infra.plan-bad.json"
|
|
106
|
+
doc "${WORK}/plans" cost infra --migration nope --json > "${OUT}/cost-infra.plan-none.json"
|
|
107
|
+
|
|
108
|
+
# A plan whose biggest change is a fall, and an unchanged node that moves with it: batch-prod (a node of this copy
|
|
109
|
+
# only) comes after api-prod and shares the account's 100 GB internet egress with it (the free amount goes to the
|
|
110
|
+
# first nodes); the plan shrinks the database and removes api-prod, so batch-prod takes more of the free amount.
|
|
111
|
+
cp -R "${FIX}/aws-three-tier" "${WORK}/shrink"
|
|
112
|
+
python3 - "${WORK}/shrink/docs/infra" <<'EOF'
|
|
113
|
+
import json, sys
|
|
114
|
+
infra = sys.argv[1]
|
|
115
|
+
m = json.load(open(f"{infra}/main.json", encoding="utf-8"))
|
|
116
|
+
m["nodes"].append({"id": "batch-prod", "category": "compute.container", "service": "ecs.fargate", "group": "private-prod", "count": 1,
|
|
117
|
+
"environments": ["production"], "spec": [{"key": "cpu", "value": "256"}, {"key": "memory", "value": "512"}, {"key": "tasks", "value": "1"}]})
|
|
118
|
+
json.dump(m, open(f"{infra}/main.json", "w", encoding="utf-8"), ensure_ascii=False)
|
|
119
|
+
d = json.load(open(f"{infra}/load/ordinary.json", encoding="utf-8"))
|
|
120
|
+
for row in d["loads"]:
|
|
121
|
+
if row.get("node") == "api-prod":
|
|
122
|
+
row["quantities"]["egress_gb"] = {"amount": 2.7, "per": "day"}
|
|
123
|
+
d["loads"].append({"node": "batch-prod", "quantities": {"vcpu": 0.25, "memory_gb": 0.5, "egress_gb": {"amount": 2.7, "per": "day"}}})
|
|
124
|
+
json.dump(d, open(f"{infra}/load/ordinary.json", "w", encoding="utf-8"), ensure_ascii=False)
|
|
125
|
+
EOF
|
|
126
|
+
SMALL='{"id": "db-prod", "category": "database.relational", "service": "rds.mysql", "group": "private-prod", "environments": ["production"], "spec": [{"key": "instance_class", "value": "db.t4g.medium"}, {"key": "storage_gb", "value": "200"}]}'
|
|
127
|
+
plan "${WORK}/shrink" shrink "{\"version\": 0, \"nodes\": [${SMALL}]}" '{"version": 0, "nodes": ["api-prod"], "groups": [], "edges": []}'
|
|
128
|
+
doc "${WORK}/shrink" cost infra --migration shrink --json > "${OUT}/cost-infra.plan-shrink.json"
|
|
129
|
+
# a plan folder with nothing in it yet: listed, not ok, its read problem said (an empty migrations folder lists none)
|
|
130
|
+
mkdir -p "${WORK}/badplans/docs/infra/migrations/half"
|
|
131
|
+
doc "${WORK}/badplans" validate infra --migration half --json > "${OUT}/validate-infra.plan-half.json"
|
|
132
|
+
|
|
133
|
+
# The temp folder's path is not part of the contract
|
|
134
|
+
for f in "${OUT}"/*.json; do
|
|
135
|
+
python3 - "$f" "${WORK}" <<'EOF'
|
|
136
|
+
import sys
|
|
137
|
+
p, work = sys.argv[1:]
|
|
138
|
+
s = open(p, encoding="utf-8").read().replace(work, "<ROOT>")
|
|
139
|
+
open(p, "w", encoding="utf-8").write(s)
|
|
140
|
+
EOF
|
|
141
|
+
done
|
|
142
|
+
git -C "${CLI_ROOT}" log -1 --format='%h %s' > "${OUT}/RECORDED_FROM"
|
|
143
|
+
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
|
-
|
|
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,177 @@
|
|
|
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
|
+
* With `plan` (jsonui-cli 2.3.20+, `--migration <name>`): the same engine run on the old model and on the new one (old
|
|
16
|
+
* + changes − removals), and the difference — the totals, per node (what the plan added, replaced, removed or left),
|
|
17
|
+
* per provider, after each step of the plan, and with `account` the account's month (its environments priced
|
|
18
|
+
* together). Nothing is computed here.
|
|
19
|
+
* A plan with an error is not priced: the CLI refuses it (exit 2, reason bad_plan, the plan's problems), as it does a
|
|
20
|
+
* plan name with no folder (no_plan) — both answered by the refusal path below, problems counted.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
function migrationSummary(data: any) {
|
|
24
|
+
need(data, ["migration", "scenario", "old", "new", "diff", "byNode", "byProvider", "steps", "account", "unpricedChanged"],
|
|
25
|
+
"cost infra --migration");
|
|
26
|
+
for (const k of ["old", "new", "diff"]) need(data[k], ["usd"], `cost infra --migration ${k}`);
|
|
27
|
+
for (const k of ["old", "new"]) need(data[k], ["noPrice", "noRule", "loadUndefined"], `cost infra --migration ${k}`);
|
|
28
|
+
for (const n of data.byNode) need(n, ["node", "change", "unpriced", "old", "new", "diff"], "cost infra --migration node");
|
|
29
|
+
for (const s of data.steps) {
|
|
30
|
+
need(s, ["index", "node", "usd", "diff", "moved"], "cost infra --migration step");
|
|
31
|
+
for (const m of s.moved) need(m, ["node", "diff"], "cost infra --migration step moved");
|
|
32
|
+
}
|
|
33
|
+
if (data.account !== null) need(data.account, ["on", "old", "new", "diff"], "cost infra --migration account");
|
|
34
|
+
const changed = data.byNode.filter((n: any) => n.change !== "same" || n.diff !== 0);
|
|
35
|
+
const byChange: Record<string, number> = {};
|
|
36
|
+
for (const n of data.byNode) byChange[n.change] = (byChange[n.change] ?? 0) + 1;
|
|
37
|
+
return {
|
|
38
|
+
migration: data.migration,
|
|
39
|
+
scenario: data.scenario,
|
|
40
|
+
usd: { old: data.old.usd, new: data.new.usd, diff: data.diff.usd },
|
|
41
|
+
// what either model cannot price (no price, no rule, no load): its part is 0 there, so not in the difference
|
|
42
|
+
oldUnpriced: { noPrice: data.old.noPrice, noRule: data.old.noRule, loadUndefined: data.old.loadUndefined },
|
|
43
|
+
newUnpriced: { noPrice: data.new.noPrice, noRule: data.new.noRule, loadUndefined: data.new.loadUndefined },
|
|
44
|
+
// the nodes the plan changes whose month is not all priced: the difference leaves their part out — not a saving
|
|
45
|
+
unpricedChanged: data.unpricedChanged,
|
|
46
|
+
nodes: byChange,
|
|
47
|
+
// the biggest movements first, whichever way
|
|
48
|
+
topChanges: [...changed].sort((a: any, b: any) => Math.abs(b.diff) - Math.abs(a.diff)).slice(0, FIRST),
|
|
49
|
+
changedCount: changed.length,
|
|
50
|
+
byProvider: data.byProvider,
|
|
51
|
+
// a step's diff is the whole month's change at that step, not its node's own: a neighbour can move with it (a
|
|
52
|
+
// shared free amount); moved names what moved, biggest first (a node, or "a>b" for a line's transfer)
|
|
53
|
+
steps: data.steps.map((s: any) => ({ index: s.index, node: s.node, usd: s.usd, diff: s.diff,
|
|
54
|
+
moved: s.moved.slice(0, FIRST), movedCount: s.moved.length })),
|
|
55
|
+
account: data.account,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function monthSummary(m: any) {
|
|
60
|
+
need(m, ["scenario", "month", "usd", "by", "keys", "days", "noPrice", "noRule", "loadUndefined", "overflowHours",
|
|
61
|
+
"warnings", "warningCount"], "cost infra month");
|
|
62
|
+
const totals: Record<string, number> = {};
|
|
63
|
+
for (const d of m.days) {
|
|
64
|
+
need(d, ["date", "usd", "stack"], "cost infra day of the month");
|
|
65
|
+
for (const [k, v] of Object.entries(d.stack)) totals[k] = (totals[k] ?? 0) + (v as number);
|
|
66
|
+
}
|
|
67
|
+
const label = Object.fromEntries(m.keys.map((k: any) => [k.key, k.label]));
|
|
68
|
+
return {
|
|
69
|
+
scenario: m.scenario,
|
|
70
|
+
month: m.month,
|
|
71
|
+
usd: m.usd,
|
|
72
|
+
by: m.by,
|
|
73
|
+
totals: Object.entries(totals).sort((a, b) => b[1] - a[1])
|
|
74
|
+
.map(([key, usd]) => ({ key: key === "__others__" ? "others" : key, label: label[key] ?? key, usd })),
|
|
75
|
+
days: m.days.length,
|
|
76
|
+
dearestDay: m.days.reduce((a: any, b: any) => (b.usd > a.usd ? b : a), m.days[0])?.date ?? null,
|
|
77
|
+
noPrice: m.noPrice,
|
|
78
|
+
noRule: m.noRule,
|
|
79
|
+
loadUndefined: m.loadUndefined,
|
|
80
|
+
overflowHours: m.overflowHours,
|
|
81
|
+
notes: m.warnings.slice(0, FIRST).map((w: any) => (typeof w === "string" ? w : w.message)),
|
|
82
|
+
noteCount: m.warningCount,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function daySummary(day: any) {
|
|
87
|
+
need(day, ["scenario", "date", "usd", "nodes", "others"], "cost infra day");
|
|
88
|
+
return {
|
|
89
|
+
scenario: day.scenario,
|
|
90
|
+
date: day.date,
|
|
91
|
+
usd: day.usd,
|
|
92
|
+
nodes: day.nodes.map((n: any) => {
|
|
93
|
+
need(n, ["node", "usd", "hours"], "cost infra day node");
|
|
94
|
+
const meters: Record<string, { usd: number; amount: number; unit: string; sku: string; layer: string | null }> = {};
|
|
95
|
+
let peak = { hour: 0, usd: -1 };
|
|
96
|
+
n.hours.forEach((h: any, i: number) => {
|
|
97
|
+
if (h.usd > peak.usd) peak = { hour: day.hours?.[i] ?? i, usd: h.usd };
|
|
98
|
+
for (const s of h.segments ?? []) {
|
|
99
|
+
const m = (meters[s.meter] ??= { usd: 0, amount: 0, unit: s.unit, sku: s.sku, layer: s.price?.layer ?? null });
|
|
100
|
+
m.usd += s.usd ?? 0;
|
|
101
|
+
m.amount += s.amount ?? 0;
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
const overflowHours = n.hours.filter((h: any) => h.load && h.load.overflow > 0).length;
|
|
105
|
+
return {
|
|
106
|
+
node: n.node, label: n.label, provider: n.provider, rule: n.rule, note: n.note || undefined, usd: n.usd,
|
|
107
|
+
peakHour: peak.usd >= 0 ? peak.hour : null, meters, overflowHours: overflowHours || undefined,
|
|
108
|
+
};
|
|
109
|
+
}),
|
|
110
|
+
others: { count: day.others.count, usd: (day.others.usd as number[]).reduce((a, b) => a + b, 0) },
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function register(server: McpServer, config: ServerConfig) {
|
|
115
|
+
server.tool(
|
|
116
|
+
"infra_cost",
|
|
117
|
+
"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). With plan (a migration plan's name, docs/infra/migrations/<name>): the month's old, new and difference — per node (added / replaced / removed / same, the biggest changes first), per provider, after each step of the plan (a step's difference is the whole month's change at that step, not its node's own: moved names each node or line's transfer that moved), what is not priced in the old or the new (unpricedChanged: changed nodes whose part the difference leaves out — not a saving), and with account the account's month too (its environments priced together); date and by do not apply. Needs jsonui-cli 2.3.15+ (plan: 2.3.20+).",
|
|
118
|
+
{
|
|
119
|
+
project_dir: z.string().optional().describe("Project directory with jui.config.json (overrides JUI_PROJECT_DIR env)"),
|
|
120
|
+
scenario: z.string().optional().describe("Load scenario id (default: the first)"),
|
|
121
|
+
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("One day of the scenario's month (YYYY-MM-DD)"),
|
|
122
|
+
by: z.enum(["provider", "node"]).optional().describe("Group the month's totals by provider (default) or node"),
|
|
123
|
+
plan: z.string().optional().describe("A migration plan's name: the month's old / new / difference instead"),
|
|
124
|
+
account: z.boolean().optional().describe("With plan: the account's month too — its environments priced together, old / new / difference"),
|
|
125
|
+
},
|
|
126
|
+
async ({ project_dir, scenario, date, by, plan, account }) => {
|
|
127
|
+
try {
|
|
128
|
+
if (!plan && account) return failure(new Error("account applies with plan only"));
|
|
129
|
+
if (plan && (date || by)) return failure(new Error("date and by do not apply with plan: it answers by node and by provider at once, for the month"));
|
|
130
|
+
const projectDir = config.resolveProjectDir(project_dir);
|
|
131
|
+
const args = ["cost", "infra", projectDir, "--json"];
|
|
132
|
+
if (plan) args.push("--migration", plan);
|
|
133
|
+
if (account) args.push("--account");
|
|
134
|
+
if (scenario) args.push("--scenario", scenario);
|
|
135
|
+
if (date) args.push("--date", date);
|
|
136
|
+
if (by) args.push("--by", by);
|
|
137
|
+
const { exitCode, data } = await runDocJson(args, projectDir, 180_000);
|
|
138
|
+
need(data, ["ok"], "cost infra");
|
|
139
|
+
if (!data.ok) {
|
|
140
|
+
need(data, ["reason", "message"], "cost infra");
|
|
141
|
+
return text({ ok: false, exit: exitCode, reason: data.reason, message: data.message,
|
|
142
|
+
...(data.problems ? { problems: problems(data.problems, "cost infra") } : {}) });
|
|
143
|
+
}
|
|
144
|
+
need(data, ["dir", "scenario", "scenarios", "problems", "prices"], "cost infra");
|
|
145
|
+
need(data.problems, ["count", "errors", "first"], "cost infra problems");
|
|
146
|
+
need(data.prices, ["warnings", "warningCount"], "cost infra prices");
|
|
147
|
+
if (plan) {
|
|
148
|
+
// the load problems and price warnings are the old model's reading, as without plan
|
|
149
|
+
return text({ ok: exitCode === 0, exit: exitCode, dir: data.dir, scenarios: data.scenarios,
|
|
150
|
+
...migrationSummary(data), loadProblems: data.problems, priceWarnings: data.prices.warnings,
|
|
151
|
+
priceWarningCount: data.prices.warningCount });
|
|
152
|
+
}
|
|
153
|
+
const out: Record<string, unknown> = {
|
|
154
|
+
ok: exitCode === 0,
|
|
155
|
+
exit: exitCode,
|
|
156
|
+
dir: data.dir,
|
|
157
|
+
scenario: data.scenario,
|
|
158
|
+
scenarios: data.scenarios,
|
|
159
|
+
loadProblems: data.problems,
|
|
160
|
+
priceWarnings: data.prices.warnings,
|
|
161
|
+
priceWarningCount: data.prices.warningCount,
|
|
162
|
+
};
|
|
163
|
+
if (date) {
|
|
164
|
+
need(data, ["day"], "cost infra");
|
|
165
|
+
out.day = daySummary(data.day);
|
|
166
|
+
} else {
|
|
167
|
+
need(data, ["month", "topNodes"], "cost infra");
|
|
168
|
+
out.month = monthSummary(data.month);
|
|
169
|
+
out.topNodes = data.topNodes;
|
|
170
|
+
}
|
|
171
|
+
return text(out);
|
|
172
|
+
} catch (e: any) {
|
|
173
|
+
return failure(e);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
);
|
|
177
|
+
}
|
|
@@ -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
|
+
}
|