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.
@@ -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())