stack-guardian 0.1.0__py3-none-any.whl
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.
- stack_guardian/__init__.py +13 -0
- stack_guardian/__main__.py +6 -0
- stack_guardian/checks.d/10_core.py +174 -0
- stack_guardian/cli.py +295 -0
- stack_guardian/collect.py +206 -0
- stack_guardian/core.py +264 -0
- stack_guardian/finding.py +491 -0
- stack_guardian/fixes.py +287 -0
- stack_guardian/mcp_server.py +340 -0
- stack_guardian/multinode.py +152 -0
- stack_guardian/parsers.py +658 -0
- stack_guardian/serve.py +198 -0
- stack_guardian/state.py +266 -0
- stack_guardian-0.1.0.dist-info/METADATA +164 -0
- stack_guardian-0.1.0.dist-info/RECORD +19 -0
- stack_guardian-0.1.0.dist-info/WHEEL +5 -0
- stack_guardian-0.1.0.dist-info/entry_points.txt +4 -0
- stack_guardian-0.1.0.dist-info/licenses/LICENSE +21 -0
- stack_guardian-0.1.0.dist-info/top_level.txt +1 -0
stack_guardian/fixes.py
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"""stack_guardian.fixes -- typed, audited fixes (the finding "fix" field).
|
|
2
|
+
|
|
3
|
+
A finding may carry fix={"action": <id>, "args": {...}}. Only three typed actions exist; there is never a
|
|
4
|
+
raw shell string. Nothing changes without apply=True: a snapshot is taken first, the action runs through an
|
|
5
|
+
injected dependency, and one JSON audit line is written after."""
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import json
|
|
9
|
+
import re
|
|
10
|
+
import sys
|
|
11
|
+
import time
|
|
12
|
+
|
|
13
|
+
ACTIONS = ("unload_model", "restart_lane", "set_env")
|
|
14
|
+
_ENV_NAME = re.compile(r"^[A-Z_][A-Z0-9_]{0,63}$")
|
|
15
|
+
MAX_ENV_VALUE = 4096
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _str_arg(args, key):
|
|
19
|
+
"""Return the stripped string value of args[key], or "" when it is not a usable string."""
|
|
20
|
+
val = args.get(key)
|
|
21
|
+
return val.strip() if isinstance(val, str) else ""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _declared_lane(config):
|
|
25
|
+
"""Lanes table from config, or an empty dict when absent/unusable."""
|
|
26
|
+
if not isinstance(config, dict):
|
|
27
|
+
return {}
|
|
28
|
+
lanes = config.get("lanes") or {}
|
|
29
|
+
return lanes if isinstance(lanes, dict) else {}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def validate_fix(fix, config):
|
|
33
|
+
"""Return "" when fix is a valid typed fix, else the refusal reason."""
|
|
34
|
+
if not isinstance(fix, dict) or not isinstance(fix.get("action"), str) or not isinstance(fix.get("args"), dict):
|
|
35
|
+
return "fix must be {'action': str, 'args': dict}"
|
|
36
|
+
action = fix["action"]
|
|
37
|
+
args = fix["args"]
|
|
38
|
+
if action not in ACTIONS:
|
|
39
|
+
return "unknown action: %s" % action
|
|
40
|
+
|
|
41
|
+
if action == "unload_model":
|
|
42
|
+
for key in ("node", "url", "model"):
|
|
43
|
+
if not _str_arg(args, key):
|
|
44
|
+
return "unload_model needs a non-empty %s" % key
|
|
45
|
+
url = _str_arg(args, "url")
|
|
46
|
+
if not (url.startswith("http://") or url.startswith("https://")):
|
|
47
|
+
return "unload_model url must be http(s)"
|
|
48
|
+
return ""
|
|
49
|
+
|
|
50
|
+
if action == "restart_lane":
|
|
51
|
+
lane = args.get("lane")
|
|
52
|
+
if not isinstance(lane, str) or lane not in _declared_lane(config):
|
|
53
|
+
return "restart_lane: lane is not declared in config lanes"
|
|
54
|
+
cmd = _declared_lane(config)[lane]
|
|
55
|
+
if (not isinstance(cmd, list) or not cmd
|
|
56
|
+
or any(not isinstance(part, str) or not part.strip() for part in cmd)):
|
|
57
|
+
return "restart_lane: the declared command must be a non-empty list of strings"
|
|
58
|
+
return ""
|
|
59
|
+
|
|
60
|
+
name = args.get("name")
|
|
61
|
+
if not isinstance(name, str) or not _ENV_NAME.fullmatch(name):
|
|
62
|
+
return "set_env: name must match [A-Z_][A-Z0-9_]*"
|
|
63
|
+
value = args.get("value")
|
|
64
|
+
if (not isinstance(value, str) or len(value) > MAX_ENV_VALUE
|
|
65
|
+
or "\n" in value or "\r" in value):
|
|
66
|
+
return "set_env: value must be a single-line string of at most %d chars" % MAX_ENV_VALUE
|
|
67
|
+
return ""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def plan_fix(fix, config):
|
|
71
|
+
"""Return the typed plan for a validated fix; raise ValueError with the refusal reason otherwise."""
|
|
72
|
+
reason = validate_fix(fix, config)
|
|
73
|
+
if reason:
|
|
74
|
+
raise ValueError(reason)
|
|
75
|
+
action = fix["action"]
|
|
76
|
+
args = fix["args"]
|
|
77
|
+
|
|
78
|
+
if action == "unload_model":
|
|
79
|
+
url = _str_arg(args, "url").rstrip("/")
|
|
80
|
+
return {"action": action,
|
|
81
|
+
"describe": "unload %s on %s" % (_str_arg(args, "model"), _str_arg(args, "node")),
|
|
82
|
+
"argv": None,
|
|
83
|
+
"http": {"method": "POST", "url": url + "/api/generate",
|
|
84
|
+
"body": {"model": _str_arg(args, "model"), "keep_alive": 0}},
|
|
85
|
+
"env": None}
|
|
86
|
+
|
|
87
|
+
if action == "restart_lane":
|
|
88
|
+
lane = args["lane"]
|
|
89
|
+
return {"action": action,
|
|
90
|
+
"describe": "restart lane %s" % lane,
|
|
91
|
+
"argv": list(_declared_lane(config)[lane]),
|
|
92
|
+
"http": None,
|
|
93
|
+
"env": None}
|
|
94
|
+
|
|
95
|
+
return {"action": action,
|
|
96
|
+
"describe": "set %s" % args["name"],
|
|
97
|
+
"argv": None,
|
|
98
|
+
"http": None,
|
|
99
|
+
"env": {args["name"]: args["value"]}}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _audit_args(fix):
|
|
103
|
+
"""Args as they appear in the audit line: a set_env value is never written, only its length."""
|
|
104
|
+
args = fix.get("args") if isinstance(fix, dict) else None
|
|
105
|
+
if not isinstance(args, dict):
|
|
106
|
+
return args
|
|
107
|
+
if fix.get("action") == "set_env":
|
|
108
|
+
value = args.get("value")
|
|
109
|
+
return {"name": args.get("name"), "value_len": len(value) if isinstance(value, str) else 0}
|
|
110
|
+
return args
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def audit_line(ts, fix, ok, detail, snapshot_ref):
|
|
114
|
+
"""One single-line JSON audit record for an applied fix."""
|
|
115
|
+
action = fix.get("action") if isinstance(fix, dict) else None
|
|
116
|
+
return json.dumps({"ts": ts, "action": action, "args": _audit_args(fix),
|
|
117
|
+
"ok": bool(ok), "detail": detail, "snapshot": snapshot_ref}, sort_keys=True)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def apply_fix(fix, config, deps, apply=False):
|
|
121
|
+
"""Validate, then plan (default) or apply a fix through injected dependencies."""
|
|
122
|
+
reason = validate_fix(fix, config)
|
|
123
|
+
if reason:
|
|
124
|
+
return {"ok": False, "applied": False, "error": reason}
|
|
125
|
+
plan = plan_fix(fix, config)
|
|
126
|
+
if not apply:
|
|
127
|
+
return {"ok": True, "applied": False, "plan": plan}
|
|
128
|
+
|
|
129
|
+
ref = deps["save_snapshot"](deps["snapshot"]())
|
|
130
|
+
if plan["http"] is not None:
|
|
131
|
+
req = plan["http"]
|
|
132
|
+
status = deps["http"](req["method"], req["url"], req["body"])
|
|
133
|
+
ok = status == 200
|
|
134
|
+
detail = "http %s" % status
|
|
135
|
+
elif plan["argv"] is not None:
|
|
136
|
+
out = deps["run"](plan["argv"])
|
|
137
|
+
rc, err = out if isinstance(out, tuple) else (out, "")
|
|
138
|
+
ok = rc == 0
|
|
139
|
+
detail = "rc %s" % rc + ("; stderr: %s" % err if err else "")
|
|
140
|
+
else:
|
|
141
|
+
name, value = fix["args"]["name"], fix["args"]["value"]
|
|
142
|
+
ok = bool(deps["set_env"](name, value))
|
|
143
|
+
detail = "set" if ok else "not set"
|
|
144
|
+
|
|
145
|
+
deps["audit"](audit_line(deps["now"](), fix, ok, detail, ref))
|
|
146
|
+
return {"ok": ok, "applied": True, "plan": plan, "detail": detail, "snapshot": ref}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _selftest():
|
|
150
|
+
cfg = {"lanes": {"cpu_worker": ["service", "restart", "cpu_worker"], "bad": [], "worse": "restart it"}}
|
|
151
|
+
um = {"action": "unload_model", "args": {"node": "gpu-box", "url": "http://100.64.0.10:11434/", "model": "qwen3-coder:30b"}}
|
|
152
|
+
rl = {"action": "restart_lane", "args": {"lane": "cpu_worker"}}
|
|
153
|
+
se = {"action": "set_env", "args": {"name": "OLLAMA_KEEP_ALIVE", "value": "10m"}}
|
|
154
|
+
|
|
155
|
+
def fake(status=200, rc=0, env_ok=True):
|
|
156
|
+
log = {"calls": [], "audit": []}
|
|
157
|
+
deps = {"snapshot": lambda: (log["calls"].append("snapshot"), {"s": 1})[1],
|
|
158
|
+
"save_snapshot": lambda obj: (log["calls"].append("save"), "SNAP1")[1],
|
|
159
|
+
"http": lambda m, u, b: (log["calls"].append(("http", m, u, b)), status)[1],
|
|
160
|
+
"run": lambda argv: (log["calls"].append(("run", list(argv))), rc)[1],
|
|
161
|
+
"set_env": lambda n, v: (log["calls"].append(("env", n, v)), env_ok)[1],
|
|
162
|
+
"audit": lambda line: log["audit"].append(line), "now": lambda: "2026-10-04T16:40:00"}
|
|
163
|
+
return deps, log
|
|
164
|
+
|
|
165
|
+
def c_actions():
|
|
166
|
+
return ACTIONS == ("unload_model", "restart_lane", "set_env")
|
|
167
|
+
|
|
168
|
+
def c_validate_ok():
|
|
169
|
+
return validate_fix(um, cfg) == "" and validate_fix(rl, cfg) == "" and validate_fix(se, cfg) == ""
|
|
170
|
+
|
|
171
|
+
def c_refuses_shape_and_unknown():
|
|
172
|
+
return (validate_fix("rm -rf", cfg) != "" and validate_fix({"action": "shell", "args": {"cmd": "x"}}, cfg) == "unknown action: shell"
|
|
173
|
+
and validate_fix({"action": "set_env"}, cfg) != "")
|
|
174
|
+
|
|
175
|
+
def c_refuses_undeclared_lane():
|
|
176
|
+
return validate_fix({"action": "restart_lane", "args": {"lane": "nope"}}, cfg) == "restart_lane: lane is not declared in config lanes"
|
|
177
|
+
|
|
178
|
+
def c_refuses_bad_lane_command():
|
|
179
|
+
return (validate_fix({"action": "restart_lane", "args": {"lane": "bad"}}, cfg) != ""
|
|
180
|
+
and validate_fix({"action": "restart_lane", "args": {"lane": "worse"}}, cfg) != "")
|
|
181
|
+
|
|
182
|
+
def c_refuses_bad_env():
|
|
183
|
+
bad = [{"name": "lower", "value": "x"}, {"name": "A", "value": "two\nlines"}, {"name": "A", "value": "x" * 4097}, {"name": "A", "value": 5}]
|
|
184
|
+
return all(validate_fix({"action": "set_env", "args": a}, cfg) != "" for a in bad)
|
|
185
|
+
|
|
186
|
+
def c_refuses_non_http_url():
|
|
187
|
+
return validate_fix({"action": "unload_model", "args": dict(um["args"], url="file:///etc")}, cfg) != ""
|
|
188
|
+
|
|
189
|
+
def c_plan_unload():
|
|
190
|
+
p = plan_fix(um, cfg)
|
|
191
|
+
return p["http"] == {"method": "POST", "url": "http://100.64.0.10:11434/api/generate", "body": {"model": "qwen3-coder:30b", "keep_alive": 0}} and p["argv"] is None
|
|
192
|
+
|
|
193
|
+
def c_plan_lane_copies_argv():
|
|
194
|
+
p = plan_fix(rl, cfg)
|
|
195
|
+
p["argv"].append("x")
|
|
196
|
+
return cfg["lanes"]["cpu_worker"] == ["service", "restart", "cpu_worker"]
|
|
197
|
+
|
|
198
|
+
def c_dry_run_calls_nothing():
|
|
199
|
+
deps, log = fake()
|
|
200
|
+
r = apply_fix(um, cfg, deps)
|
|
201
|
+
return r["ok"] and r["applied"] is False and log["calls"] == [] and log["audit"] == []
|
|
202
|
+
|
|
203
|
+
def c_invalid_calls_nothing():
|
|
204
|
+
deps, log = fake()
|
|
205
|
+
r = apply_fix({"action": "shell", "args": {}}, cfg, deps, apply=True)
|
|
206
|
+
return r["ok"] is False and r["applied"] is False and log["calls"] == [] and log["audit"] == []
|
|
207
|
+
|
|
208
|
+
def c_apply_snapshot_first_then_one_audit():
|
|
209
|
+
deps, log = fake()
|
|
210
|
+
r = apply_fix(rl, cfg, deps, apply=True)
|
|
211
|
+
line = json.loads(log["audit"][0])
|
|
212
|
+
return (r["ok"] and r["applied"] and log["calls"][:2] == ["snapshot", "save"] and log["calls"][2] == ("run", ["service", "restart", "cpu_worker"])
|
|
213
|
+
and len(log["audit"]) == 1 and line == {"ts": "2026-10-04T16:40:00", "action": "restart_lane", "args": {"lane": "cpu_worker"},
|
|
214
|
+
"ok": True, "detail": "rc 0", "snapshot": "SNAP1"})
|
|
215
|
+
|
|
216
|
+
def c_apply_http_failure_is_not_ok():
|
|
217
|
+
deps, log = fake(status=500)
|
|
218
|
+
r = apply_fix(um, cfg, deps, apply=True)
|
|
219
|
+
return r["ok"] is False and r["detail"] == "http 500" and json.loads(log["audit"][0])["ok"] is False
|
|
220
|
+
|
|
221
|
+
def c_set_env_audit_never_has_value():
|
|
222
|
+
deps, log = fake()
|
|
223
|
+
r = apply_fix({"action": "set_env", "args": {"name": "TOKEN_X", "value": "s3cret-value-123"}}, cfg, deps, apply=True)
|
|
224
|
+
return r["ok"] and "s3cret" not in log["audit"][0] and json.loads(log["audit"][0])["args"] == {"name": "TOKEN_X", "value_len": 16}
|
|
225
|
+
|
|
226
|
+
def c_audit_line_single_line():
|
|
227
|
+
return "\n" not in audit_line("t", se, True, "set", "R")
|
|
228
|
+
|
|
229
|
+
checks = [
|
|
230
|
+
("actions", c_actions), ("validate_ok", c_validate_ok), ("refuses_shape_and_unknown", c_refuses_shape_and_unknown),
|
|
231
|
+
("refuses_undeclared_lane", c_refuses_undeclared_lane), ("refuses_bad_lane_command", c_refuses_bad_lane_command),
|
|
232
|
+
("refuses_bad_env", c_refuses_bad_env), ("refuses_non_http_url", c_refuses_non_http_url), ("plan_unload", c_plan_unload),
|
|
233
|
+
("plan_lane_copies_argv", c_plan_lane_copies_argv), ("dry_run_calls_nothing", c_dry_run_calls_nothing),
|
|
234
|
+
("invalid_calls_nothing", c_invalid_calls_nothing), ("apply_snapshot_first_then_one_audit", c_apply_snapshot_first_then_one_audit),
|
|
235
|
+
("apply_http_failure_is_not_ok", c_apply_http_failure_is_not_ok), ("set_env_audit_never_has_value", c_set_env_audit_never_has_value),
|
|
236
|
+
("audit_line_single_line", c_audit_line_single_line),
|
|
237
|
+
]
|
|
238
|
+
passed = 0
|
|
239
|
+
for name, fn in checks:
|
|
240
|
+
try:
|
|
241
|
+
ok = bool(fn())
|
|
242
|
+
except Exception as exc: # noqa: BLE001
|
|
243
|
+
ok = False
|
|
244
|
+
print(" FAIL %s -- raised %s: %s" % (name, type(exc).__name__, exc))
|
|
245
|
+
else:
|
|
246
|
+
print((" ok " if ok else " FAIL ") + name)
|
|
247
|
+
passed += 1 if ok else 0
|
|
248
|
+
total = len(checks)
|
|
249
|
+
print("fixes selftest: %d checks, %d passed, %d failed" % (total, passed, total - passed))
|
|
250
|
+
return 0 if total and passed == total else 1
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def main(argv=None):
|
|
254
|
+
ap = argparse.ArgumentParser(prog="stack-guardian-fix", description="Plan (default) or apply one typed fix")
|
|
255
|
+
ap.add_argument("--fix-json", help="file holding {action, args}")
|
|
256
|
+
ap.add_argument("--config", help="file holding {lanes: {name: [argv...]}}")
|
|
257
|
+
ap.add_argument("--selftest", action="store_true")
|
|
258
|
+
a = ap.parse_args(argv)
|
|
259
|
+
if a.selftest:
|
|
260
|
+
return _selftest()
|
|
261
|
+
if not a.fix_json:
|
|
262
|
+
ap.print_usage(sys.stderr)
|
|
263
|
+
return 3
|
|
264
|
+
try:
|
|
265
|
+
with open(a.fix_json, "r", encoding="utf-8") as f:
|
|
266
|
+
fix = json.load(f)
|
|
267
|
+
cfg = {}
|
|
268
|
+
if a.config:
|
|
269
|
+
with open(a.config, "r", encoding="utf-8") as f:
|
|
270
|
+
cfg = json.load(f)
|
|
271
|
+
except (OSError, ValueError) as exc:
|
|
272
|
+
print("fixes: error: %s" % exc, file=sys.stderr)
|
|
273
|
+
return 3
|
|
274
|
+
why = validate_fix(fix, cfg)
|
|
275
|
+
if why:
|
|
276
|
+
print(json.dumps({"ok": False, "error": why}))
|
|
277
|
+
return 1
|
|
278
|
+
plan = plan_fix(fix, cfg)
|
|
279
|
+
print(json.dumps({"ok": True, "applied": False, "describe": plan["describe"],
|
|
280
|
+
"generated_at": time.strftime("%Y-%m-%dT%H:%M:%S")}))
|
|
281
|
+
return 0
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
if __name__ == "__main__" and "--selftest" in sys.argv:
|
|
285
|
+
sys.exit(_selftest())
|
|
286
|
+
if __name__ == "__main__":
|
|
287
|
+
sys.exit(main())
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
"""Read-only MCP stdio server for the stack_guardian package.
|
|
2
|
+
|
|
3
|
+
Speaks JSON-RPC 2.0, one JSON object per line on stdin/stdout, and exposes three
|
|
4
|
+
read-only tools: stack_health, stack_snapshot and explain. Everything above
|
|
5
|
+
default_tools() is pure and package-free; default_tools() is the only place that
|
|
6
|
+
imports the rest of stack_guardian.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
import time
|
|
13
|
+
|
|
14
|
+
PROTOCOL = "2024-11-05"
|
|
15
|
+
SERVER_NAME = "stack-guardian"
|
|
16
|
+
VERSION = "0.1.0.dev0"
|
|
17
|
+
_NODE = {"type": "object", "properties": {"node": {"type": "string", "description": "one node name; omit for all"}}}
|
|
18
|
+
TOOL_SPECS = [
|
|
19
|
+
{"name": "stack_health", "description": "Graded health findings (FAIL/WARN/INFO/OK) for the AI stack's nodes, with what to try.",
|
|
20
|
+
"inputSchema": _NODE},
|
|
21
|
+
{"name": "stack_snapshot", "description": "The raw snapshot the checks read: Ollama endpoints, loaded models, GPUs, tailnet.",
|
|
22
|
+
"inputSchema": _NODE},
|
|
23
|
+
{"name": "explain", "description": "What a finding code means, what to try, and what evidence would settle it.",
|
|
24
|
+
"inputSchema": {"type": "object", "properties": {"code": {"type": "string"}}, "required": ["code"]}},
|
|
25
|
+
]
|
|
26
|
+
EXPLAIN = {
|
|
27
|
+
"node.unreachable": {"summary": "The collector got nothing from the node: it timed out or raised.",
|
|
28
|
+
"try": ["check the machine is on and reachable", "raise the collection timeout if it is just slow"],
|
|
29
|
+
"evidence_needed": ["the node's error in stack_snapshot"]},
|
|
30
|
+
"ollama.unreachable": {"summary": "No Ollama endpoint on the node answered.",
|
|
31
|
+
"try": ["check the Ollama service is running on that node", "check the URL and port in the nodes file"],
|
|
32
|
+
"evidence_needed": ["the endpoint's /api/version from the node itself"]},
|
|
33
|
+
"model.not_loaded": {"summary": "A model the stack expects to be resident is not loaded.",
|
|
34
|
+
"try": ["load it with a short generate", "check whether another job evicted it"],
|
|
35
|
+
"evidence_needed": ["/api/ps on that endpoint"]},
|
|
36
|
+
"vram.unattributed": {"summary": "GPU memory is in use by something that is not a known model process.",
|
|
37
|
+
"try": ["list GPU processes with nvidia-smi", "close the program holding the memory"],
|
|
38
|
+
"evidence_needed": ["nvidia-smi --query-compute-apps output"]},
|
|
39
|
+
"gpu.unavailable": {"summary": "nvidia-smi is missing or failed, so the GPUs could not be read.",
|
|
40
|
+
"try": ["run nvidia-smi on that node", "install or repair the NVIDIA driver"],
|
|
41
|
+
"evidence_needed": ["nvidia-smi output on that node"]},
|
|
42
|
+
"tailnet.unavailable": {"summary": "tailscale is missing or tailscaled is down, so peer state is unknown (not offline).",
|
|
43
|
+
"try": ["run tailscale status", "start tailscaled"],
|
|
44
|
+
"evidence_needed": ["tailscale status --json on this machine"]},
|
|
45
|
+
"tailnet.peer_offline": {"summary": "The tailnet reports the peer offline.",
|
|
46
|
+
"try": ["check the machine is on and tailscale is up", "tailscale ping the peer"],
|
|
47
|
+
"evidence_needed": ["tailscale status --json on both ends"]},
|
|
48
|
+
"tailnet.relayed": {"summary": "The peer is reachable only through a DERP relay, not directly.",
|
|
49
|
+
"try": ["check firewall / UDP 41641", "tailscale netcheck on both ends"],
|
|
50
|
+
"evidence_needed": ["tailscale ping output showing via DERP or direct"]},
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def explain(code):
|
|
55
|
+
"""Return the explanation for a finding code, as fresh (copied) containers."""
|
|
56
|
+
known = EXPLAIN.get(code)
|
|
57
|
+
if known is None:
|
|
58
|
+
return {"summary": "unknown code %r" % code,
|
|
59
|
+
"try": ["run stack_health to list the current codes"],
|
|
60
|
+
"evidence_needed": []}
|
|
61
|
+
return {"summary": known["summary"],
|
|
62
|
+
"try": list(known["try"]),
|
|
63
|
+
"evidence_needed": list(known["evidence_needed"])}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def filter_node(findings, node):
|
|
67
|
+
"""Keep the findings for one node, or all of them when node is falsy."""
|
|
68
|
+
if not node:
|
|
69
|
+
return list(findings)
|
|
70
|
+
return [f for f in findings if f.get("node") == node]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _text_result(text, is_error):
|
|
74
|
+
return {"content": [{"type": "text", "text": text}], "isError": is_error}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def handle(msg, tools):
|
|
78
|
+
"""Answer one JSON-RPC message; return None when there is nothing to say."""
|
|
79
|
+
if not isinstance(msg, dict) or "method" not in msg:
|
|
80
|
+
if isinstance(msg, dict) and "id" in msg:
|
|
81
|
+
return {"jsonrpc": "2.0", "id": msg["id"],
|
|
82
|
+
"error": {"code": -32600, "message": "invalid request"}}
|
|
83
|
+
return None
|
|
84
|
+
if "id" not in msg:
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
method = msg["method"]
|
|
88
|
+
rid = msg["id"]
|
|
89
|
+
if method == "initialize":
|
|
90
|
+
result = {"protocolVersion": PROTOCOL,
|
|
91
|
+
"capabilities": {"tools": {}},
|
|
92
|
+
"serverInfo": {"name": SERVER_NAME, "version": VERSION}}
|
|
93
|
+
elif method == "tools/list":
|
|
94
|
+
result = {"tools": TOOL_SPECS}
|
|
95
|
+
elif method == "ping":
|
|
96
|
+
result = {}
|
|
97
|
+
elif method == "tools/call":
|
|
98
|
+
params = msg.get("params") or {}
|
|
99
|
+
args = (params.get("arguments") or {}) if isinstance(params, dict) else None
|
|
100
|
+
if not isinstance(args, dict):
|
|
101
|
+
return {"jsonrpc": "2.0", "id": rid,
|
|
102
|
+
"error": {"code": -32602, "message": "invalid params: params and arguments must be objects"}}
|
|
103
|
+
name = params.get("name")
|
|
104
|
+
if name not in tools:
|
|
105
|
+
result = _text_result("unknown tool: %s" % name, True)
|
|
106
|
+
else:
|
|
107
|
+
try:
|
|
108
|
+
out = tools[name](args)
|
|
109
|
+
except Exception as exc: # noqa: BLE001 -- reported to the caller as a tool error
|
|
110
|
+
result = _text_result("%s: %s" % (type(exc).__name__, exc), True)
|
|
111
|
+
else:
|
|
112
|
+
result = _text_result(json.dumps(out, indent=1, default=str), False)
|
|
113
|
+
else:
|
|
114
|
+
return {"jsonrpc": "2.0", "id": rid,
|
|
115
|
+
"error": {"code": -32601, "message": "method not found: %s" % method}}
|
|
116
|
+
return {"jsonrpc": "2.0", "id": rid, "result": result}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def serve(lines, write, tools):
|
|
120
|
+
"""Read lines, write one JSON reply per answerable line; return the reply count."""
|
|
121
|
+
count = 0
|
|
122
|
+
for line in lines:
|
|
123
|
+
line = line.strip()
|
|
124
|
+
if not line:
|
|
125
|
+
continue
|
|
126
|
+
try:
|
|
127
|
+
msg = json.loads(line)
|
|
128
|
+
except ValueError:
|
|
129
|
+
reply = {"jsonrpc": "2.0", "id": None, "error": {"code": -32700, "message": "parse error"}}
|
|
130
|
+
else:
|
|
131
|
+
reply = handle(msg, tools)
|
|
132
|
+
if reply is not None:
|
|
133
|
+
write(json.dumps(reply))
|
|
134
|
+
count += 1
|
|
135
|
+
return count
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
LEVELS = ("FAIL", "WARN", "INFO", "OK")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def health_body(findings, node):
|
|
142
|
+
"""The stack_health reply: counts per level and a status, so a clean run is never an empty-looking result."""
|
|
143
|
+
counts = {lvl: 0 for lvl in LEVELS}
|
|
144
|
+
for f in findings:
|
|
145
|
+
lvl = str(f.get("level") or "").upper()
|
|
146
|
+
if lvl in counts:
|
|
147
|
+
counts[lvl] += 1
|
|
148
|
+
status = "fail" if counts["FAIL"] else ("warn" if counts["WARN"] else "ok")
|
|
149
|
+
return {"node": node or "all", "status": status, "counts": counts, "findings": list(findings)}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def make_tools(health_fn, snapshot_fn):
|
|
153
|
+
"""Bind the pure protocol code to two data callables plus explain."""
|
|
154
|
+
def stack_health(a):
|
|
155
|
+
node = a.get("node") or ""
|
|
156
|
+
return health_body(filter_node(health_fn(), node), node)
|
|
157
|
+
|
|
158
|
+
def stack_snapshot(a):
|
|
159
|
+
s = snapshot_fn()
|
|
160
|
+
node = a.get("node") or ""
|
|
161
|
+
if not node:
|
|
162
|
+
return s
|
|
163
|
+
nodes = s.get("nodes") or {}
|
|
164
|
+
if node not in nodes:
|
|
165
|
+
return {"error": "no node %r; known: %s" % (node, ", ".join(sorted(nodes)))}
|
|
166
|
+
return {"taken_at": s.get("taken_at"), "nodes": {node: nodes[node]}}
|
|
167
|
+
|
|
168
|
+
def explain_tool(a):
|
|
169
|
+
return explain(str(a.get("code") or ""))
|
|
170
|
+
|
|
171
|
+
return {"stack_health": stack_health, "stack_snapshot": stack_snapshot, "explain": explain_tool}
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def default_tools(nodes_path, ctx_path=None, state_path=None, ttl_s=10.0, clock=time.monotonic):
|
|
175
|
+
"""Bind the real collector and doctor. A snapshot is reused for ttl_s seconds; stack_health grades against the
|
|
176
|
+
saved state without recording a sample, so repeated calls never turn LIKELY into VERIFIED and never write state."""
|
|
177
|
+
from . import cli as sg_cli
|
|
178
|
+
from . import collect as sg_collect
|
|
179
|
+
|
|
180
|
+
def read(path):
|
|
181
|
+
with open(path, encoding="utf-8") as fh:
|
|
182
|
+
return json.load(fh)
|
|
183
|
+
|
|
184
|
+
cache = {"at": None, "snapshot": None}
|
|
185
|
+
|
|
186
|
+
def snapshot():
|
|
187
|
+
now = clock()
|
|
188
|
+
if cache["at"] is None or now - cache["at"] >= ttl_s:
|
|
189
|
+
cache["snapshot"], cache["at"] = sg_collect.snapshot(read(nodes_path)), now
|
|
190
|
+
return cache["snapshot"]
|
|
191
|
+
|
|
192
|
+
def health():
|
|
193
|
+
return sg_cli.doctor(sg_cli.DEFAULT_CHECKS_DIR, snapshot(), state_path,
|
|
194
|
+
ctx=read(ctx_path) if ctx_path else {}, record=False)[0]
|
|
195
|
+
|
|
196
|
+
return make_tools(health, snapshot)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def main(argv=None):
|
|
200
|
+
if argv is None:
|
|
201
|
+
argv = sys.argv[1:]
|
|
202
|
+
if "--selftest" in argv:
|
|
203
|
+
return _selftest()
|
|
204
|
+
|
|
205
|
+
def opt(name):
|
|
206
|
+
if name in argv:
|
|
207
|
+
i = argv.index(name)
|
|
208
|
+
if i + 1 < len(argv):
|
|
209
|
+
return argv[i + 1]
|
|
210
|
+
return None
|
|
211
|
+
|
|
212
|
+
nodes = opt("--nodes") or os.environ.get("STACK_GUARDIAN_NODES")
|
|
213
|
+
try:
|
|
214
|
+
ttl = float(opt("--ttl") or 10)
|
|
215
|
+
except ValueError:
|
|
216
|
+
ttl = None
|
|
217
|
+
if not nodes or ttl is None:
|
|
218
|
+
print("stack-guardian-mcp: usage: --nodes FILE (or STACK_GUARDIAN_NODES) [--ctx FILE] [--state FILE] [--ttl SECONDS]",
|
|
219
|
+
file=sys.stderr)
|
|
220
|
+
return 3
|
|
221
|
+
|
|
222
|
+
def write(line):
|
|
223
|
+
sys.stdout.write(line + "\n")
|
|
224
|
+
sys.stdout.flush()
|
|
225
|
+
|
|
226
|
+
serve(sys.stdin, write, default_tools(nodes, opt("--ctx"), opt("--state"), ttl_s=ttl))
|
|
227
|
+
return 0
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _selftest():
|
|
231
|
+
calls = {"h": 0}
|
|
232
|
+
F = [{"code": "ollama.unreachable", "node": "gpu-box", "level": "FAIL"}, {"code": "vram.unattributed", "node": "gpu-box-2", "level": "WARN"}]
|
|
233
|
+
S = {"taken_at": "t", "nodes": {"gpu-box": {"reachable": False}, "gpu-box-2": {"reachable": True}}}
|
|
234
|
+
|
|
235
|
+
def health():
|
|
236
|
+
calls["h"] += 1
|
|
237
|
+
return list(F)
|
|
238
|
+
tools = make_tools(health, lambda: S)
|
|
239
|
+
|
|
240
|
+
def req(i, method, params=None):
|
|
241
|
+
m = {"jsonrpc": "2.0", "id": i, "method": method}
|
|
242
|
+
if params is not None:
|
|
243
|
+
m["params"] = params
|
|
244
|
+
return m
|
|
245
|
+
|
|
246
|
+
def text_of(resp):
|
|
247
|
+
return resp["result"]["content"][0]["text"]
|
|
248
|
+
|
|
249
|
+
def c_initialize():
|
|
250
|
+
r = handle(req(1, "initialize", {}), tools)
|
|
251
|
+
return r["id"] == 1 and r["result"]["protocolVersion"] == PROTOCOL and r["result"]["serverInfo"]["name"] == "stack-guardian"
|
|
252
|
+
|
|
253
|
+
def c_tools_list():
|
|
254
|
+
r = handle(req(2, "tools/list"), tools)
|
|
255
|
+
return [t["name"] for t in r["result"]["tools"]] == ["stack_health", "stack_snapshot", "explain"]
|
|
256
|
+
|
|
257
|
+
def c_notification_silent():
|
|
258
|
+
return handle({"jsonrpc": "2.0", "method": "notifications/initialized"}, tools) is None
|
|
259
|
+
|
|
260
|
+
def c_unknown_method():
|
|
261
|
+
r = handle(req(3, "resources/list"), tools)
|
|
262
|
+
return r["error"]["code"] == -32601 and "id" in r and "result" not in r
|
|
263
|
+
|
|
264
|
+
def c_invalid_request():
|
|
265
|
+
return handle({"jsonrpc": "2.0", "id": 9}, tools)["error"]["code"] == -32600 and handle("x", tools) is None
|
|
266
|
+
|
|
267
|
+
def c_health_all():
|
|
268
|
+
r = handle(req(4, "tools/call", {"name": "stack_health", "arguments": {}}), tools)
|
|
269
|
+
body = json.loads(text_of(r))
|
|
270
|
+
return r["result"]["isError"] is False and body["node"] == "all" and len(body["findings"]) == 2
|
|
271
|
+
|
|
272
|
+
def c_health_node():
|
|
273
|
+
body = json.loads(text_of(handle(req(5, "tools/call", {"name": "stack_health", "arguments": {"node": "gpu-box-2"}}), tools)))
|
|
274
|
+
return [f["code"] for f in body["findings"]] == ["vram.unattributed"] and body["node"] == "gpu-box-2"
|
|
275
|
+
|
|
276
|
+
def c_snapshot_node_and_unknown():
|
|
277
|
+
a = json.loads(text_of(handle(req(6, "tools/call", {"name": "stack_snapshot", "arguments": {"node": "gpu-box"}}), tools)))
|
|
278
|
+
b = json.loads(text_of(handle(req(7, "tools/call", {"name": "stack_snapshot", "arguments": {"node": "zz"}}), tools)))
|
|
279
|
+
return a == {"taken_at": "t", "nodes": {"gpu-box": {"reachable": False}}} and b["error"].startswith("no node 'zz'; known: gpu-box, gpu-box-2")
|
|
280
|
+
|
|
281
|
+
def c_explain_known_and_unknown():
|
|
282
|
+
k = explain("tailnet.relayed")
|
|
283
|
+
u = explain("nope")
|
|
284
|
+
return set(k) == {"summary", "try", "evidence_needed"} and k["try"] and u["summary"] == "unknown code 'nope'"
|
|
285
|
+
|
|
286
|
+
def c_explain_returns_copies():
|
|
287
|
+
explain("model.not_loaded")["try"].append("x")
|
|
288
|
+
return "x" not in EXPLAIN["model.not_loaded"]["try"]
|
|
289
|
+
|
|
290
|
+
def c_unknown_tool_is_error_result():
|
|
291
|
+
r = handle(req(8, "tools/call", {"name": "fix_everything"}), tools)
|
|
292
|
+
return r["result"]["isError"] is True and text_of(r) == "unknown tool: fix_everything"
|
|
293
|
+
|
|
294
|
+
def c_tool_exception_is_error_result():
|
|
295
|
+
bad = make_tools(lambda: 1 / 0, lambda: S)
|
|
296
|
+
r = handle(req(10, "tools/call", {"name": "stack_health", "arguments": {}}), bad)
|
|
297
|
+
return r["result"]["isError"] is True and text_of(r).startswith("ZeroDivisionError")
|
|
298
|
+
|
|
299
|
+
def c_serve_lines():
|
|
300
|
+
out = []
|
|
301
|
+
n = serve(['{"jsonrpc":"2.0","id":1,"method":"ping"}', "", "not json",
|
|
302
|
+
'{"jsonrpc":"2.0","method":"notifications/initialized"}'], out.append, tools)
|
|
303
|
+
parsed = [json.loads(x) for x in out]
|
|
304
|
+
return n == 2 and parsed[0] == {"jsonrpc": "2.0", "id": 1, "result": {}} and parsed[1]["error"]["code"] == -32700
|
|
305
|
+
|
|
306
|
+
def c_health_body_counts_and_status():
|
|
307
|
+
clean = health_body([], "")
|
|
308
|
+
mixed = health_body(F, "")
|
|
309
|
+
warn_only = health_body([F[1], {"code": "x", "node": "n", "level": "info"}], "gpu-box-2")
|
|
310
|
+
return (clean == {"node": "all", "status": "ok", "counts": {"FAIL": 0, "WARN": 0, "INFO": 0, "OK": 0}, "findings": []}
|
|
311
|
+
and mixed["status"] == "fail" and mixed["counts"]["FAIL"] == 1 and mixed["counts"]["WARN"] == 1
|
|
312
|
+
and warn_only["status"] == "warn" and warn_only["counts"]["INFO"] == 1 and warn_only["node"] == "gpu-box-2")
|
|
313
|
+
|
|
314
|
+
def c_read_only_specs():
|
|
315
|
+
return all(t["name"] in ("stack_health", "stack_snapshot", "explain") for t in TOOL_SPECS) and len(TOOL_SPECS) == 3
|
|
316
|
+
|
|
317
|
+
checks = [("initialize", c_initialize), ("tools_list", c_tools_list), ("notification_silent", c_notification_silent),
|
|
318
|
+
("unknown_method", c_unknown_method), ("invalid_request", c_invalid_request), ("health_all", c_health_all),
|
|
319
|
+
("health_node", c_health_node), ("snapshot_node_and_unknown", c_snapshot_node_and_unknown),
|
|
320
|
+
("explain_known_and_unknown", c_explain_known_and_unknown), ("explain_returns_copies", c_explain_returns_copies),
|
|
321
|
+
("unknown_tool_is_error_result", c_unknown_tool_is_error_result),
|
|
322
|
+
("tool_exception_is_error_result", c_tool_exception_is_error_result), ("serve_lines", c_serve_lines),
|
|
323
|
+
("health_body_counts_and_status", c_health_body_counts_and_status), ("read_only_specs", c_read_only_specs)]
|
|
324
|
+
passed = failed = 0
|
|
325
|
+
for name, fn in checks:
|
|
326
|
+
try:
|
|
327
|
+
ok = bool(fn())
|
|
328
|
+
except Exception as exc: # noqa: BLE001
|
|
329
|
+
ok = False
|
|
330
|
+
print("FAIL %s -- raised %s: %s" % (name, type(exc).__name__, exc))
|
|
331
|
+
else:
|
|
332
|
+
print(("ok %s" if ok else "FAIL %s") % name)
|
|
333
|
+
passed += ok
|
|
334
|
+
failed += not ok
|
|
335
|
+
print("mcp_server selftest: %d checks, %d passed, %d failed" % (len(checks), passed, failed))
|
|
336
|
+
return 0 if failed == 0 and checks else 1
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
if __name__ == "__main__":
|
|
340
|
+
sys.exit(main())
|