feat(mcp): diagnostic tools — overview, hosts, monitors, alerts, metrics, audit
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 36s
CI / unit (push) Successful in 58s
CI / publish (push) Successful in 1m2s

MCP-4 of milestone 461 "MCP server: Steward as an LLM-inspectable,
LLM-operable hub". Twelve read-only viewer tools:

- get_overview: what is wrong now, ranked critical-then-oldest. Failing
  monitors with their error and when the run of failures began, firing,
  pending and still-breaching acknowledged alerts, TLS certificates expiring
  within 14 days, agents that stopped reporting, hosts over the configured
  CPU/memory/disk thresholds (Settings > Thresholds), failed Ansible runs in
  the last 24h, and findings from enabled plugins through a new optional
  `mcp_overview(db)` plugin hook. Each finding names the call that shows
  its evidence.
- list_hosts, get_host, get_host_metrics
- list_monitors, get_monitor (HTTP header values and URL passwords are
  redacted)
- list_alerts, get_alert_history, list_alert_rules, list_maintenance_windows
- query_metrics: the whole metric store, narrowed one argument at a time
  (sources > resources > latest values > a series summary)
- list_audit_events

Shared shaping in steward/mcp/tools/_shape.py: UTC ISO times with age_s,
bounded lists that say when and how they were cut, series as
min/avg/max/p95/last plus a ≤60-point series, lookup by id or
case-insensitive name with a did-you-mean, and a range argument that
refuses an unknown key instead of reading it as 24h.

New core reads in steward/core/metric_series.py (sources and resources
via loose index scans, latest values via DISTINCT ON, series across the
raw/hourly-rollup seam). monitors/service.py gains monitor_states,
down_since and recent_failures; agent_overview_by_host now carries the
fullest disk and the last sample time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 07:39:41 -04:00
co-authored by Claude Opus 5.5
parent 312ea95bcf
commit 41c7509366
15 changed files with 1708 additions and 9 deletions
+201
View File
@@ -0,0 +1,201 @@
"""Integration: the MCP diagnostic tools against real Postgres.
Seeds one small, uniquely named slice of a fleet — a host with a failing ping
monitor, an agent reporting high CPU, a firing alert, an HTTPS monitor whose
certificate is about to expire, a failed Ansible run, an active maintenance
window and an audit event — then calls each tool as a viewer and checks that
its slice comes back shaped as the tool promises. The database is shared with
other tests, so assertions look for the seeded rows rather than exact totals.
"""
from __future__ import annotations
import asyncio
import json
import os
import uuid
from datetime import datetime, timedelta, timezone
import pytest
pytestmark = pytest.mark.integration
_NEEDS_DB = pytest.mark.skipif(
not os.environ.get("STEWARD_DATABASE_URL"),
reason="integration test needs a live Postgres (STEWARD_DATABASE_URL)",
)
@pytest.fixture
def app():
if not os.environ.get("STEWARD_DATABASE_URL"):
pytest.skip("needs Postgres")
from steward.app import create_app
return create_app(testing=False)
@_NEEDS_DB
def test_diagnostic_tools_over_a_seeded_fleet(app):
from steward.core.api_tokens import Caller
from steward.core.audit import log_audit
from steward.mcp import _context
from steward.mcp.tools import alerts, audit, hosts, metrics, monitors, overview
from steward.models.alerts import (
AlertEvent, AlertOperator, AlertRule, AlertState, AlertStateEnum, MaintenanceWindow,
)
from steward.models.ansible import AnsibleRun, AnsibleRunStatus
from steward.models.hosts import Host
from steward.models.metrics import PluginMetric
from steward.models.monitors import Monitor, MonitorResult
from steward.models.users import User, UserRole
now = datetime.now(timezone.utc)
tag = uuid.uuid4().hex[:8]
uid, hid, rule_id = str(uuid.uuid4()), str(uuid.uuid4()), str(uuid.uuid4())
ping_id, web_id, run_id = str(uuid.uuid4()), str(uuid.uuid4()), str(uuid.uuid4())
host_name, username = f"mcp-host-{tag}", f"mcp-{tag}"
async def _seed():
async with app.db_sessionmaker() as s:
async with s.begin():
s.add(User(id=uid, username=username, email=f"{tag}@e.test",
password_hash="x", role=UserRole.admin, is_active=True))
s.add(Host(id=hid, name=host_name, address="10.9.9.9"))
async with s.begin():
s.add(Monitor(id=ping_id, name=f"ping-{tag}", type="icmp",
target="10.9.9.9", host_id=hid, config_json="{}", enabled=True))
s.add(Monitor(id=web_id, name=f"web-{tag}", type="http",
target="https://bob:hunter2@web.test/health",
config_json=json.dumps({"headers": {"Authorization": "Bearer s3cret"}}),
enabled=True))
async with s.begin():
s.add(MonitorResult(monitor_id=ping_id, checked_at=now - timedelta(minutes=10),
is_up=True, response_ms=1.0))
s.add(MonitorResult(monitor_id=ping_id, checked_at=now - timedelta(minutes=5),
is_up=False, error_msg="timeout"))
s.add(MonitorResult(monitor_id=ping_id, checked_at=now - timedelta(minutes=1),
is_up=False, error_msg="timeout"))
s.add(MonitorResult(monitor_id=web_id, checked_at=now - timedelta(minutes=1),
is_up=True, status_code=200, response_ms=40.0,
tls_expires_at=now + timedelta(days=5)))
for i, v in enumerate((10.0, 20.0, 95.0)):
s.add(PluginMetric(source_module="host_agent", resource_name=host_name,
metric_name="cpu_pct", value=v,
recorded_at=now - timedelta(minutes=40 - i * 20, seconds=30)))
s.add(PluginMetric(source_module="host_agent", resource_name=host_name,
metric_name="mem_used_pct", value=50.0,
recorded_at=now - timedelta(seconds=30)))
s.add(PluginMetric(source_module="host_agent", resource_name=host_name + ":/",
metric_name="disk_used_pct", value=41.0,
recorded_at=now - timedelta(seconds=30)))
s.add(AlertRule(id=rule_id, name=f"cpu hot {tag}", source_module="host_agent",
resource_name=host_name, metric_name="cpu_pct",
operator=AlertOperator.gt, threshold=90.0,
consecutive_failures_required=1, enabled=True, created_by=uid))
async with s.begin():
s.add(AlertState(rule_id=rule_id, state=AlertStateEnum.firing,
last_transition_at=now - timedelta(minutes=2)))
s.add(AlertEvent(rule_id=rule_id, from_state="inactive", to_state="firing",
metric_value=95.0, transitioned_at=now - timedelta(minutes=2)))
s.add(MaintenanceWindow(name=f"patching {tag}", start_at=now - timedelta(hours=1),
end_at=now + timedelta(hours=1), scope_source="host_agent",
scope_resource=host_name, created_by=uid))
s.add(AnsibleRun(id=run_id, playbook_path=f"site-{tag}.yml", source_name="t",
status=AnsibleRunStatus.failed, started_at=now - timedelta(hours=1),
finished_at=now - timedelta(minutes=55),
results={"failures": ["web01: apt lock held"]}))
await log_audit(app, uid, username, "host.created", "host", hid, {"name": host_name})
async def _go():
await _seed()
_context._app_ctx.set(app)
_context._caller_ctx.set(Caller(user_id=uid, username=username,
role=UserRole.viewer, token_id=0))
# ── get_overview ───────────────────────────────────────────────────
ov = await overview.get_overview()
assert ov["all_clear"] is False
mine = {(f["kind"], f["subject"]): f for f in ov["findings"]
if tag in f["subject"] or f["subject"] == host_name}
down = mine[("monitor_down", f"ping-{tag}")]
assert down["severity"] == "critical" and "timeout" in down["summary"]
assert down["ids"]["monitor_id"] == ping_id
# down_since is the first failure after the last success, not the latest check.
assert 250 <= down["age_s"] <= 400
assert mine[("tls_expiring", f"web-{tag}")]["severity"] == "warning"
assert mine[("alert_firing", f"cpu hot {tag}")]["ids"]["rule_id"] == rule_id
assert mine[("host_cpu_high", host_name)]["severity"] == "critical"
assert "apt lock held" in mine[("ansible_run_failed", f"site-{tag}.yml")]["summary"]
assert any(w["name"] == f"patching {tag}" for w in ov["active_maintenance"])
sev = [f["severity"] for f in ov["findings"]]
assert sev == sorted(sev, key=lambda s: {"critical": 0, "warning": 1}[s])
# ── hosts ──────────────────────────────────────────────────────────
listed = {h["host_name"]: h for h in (await hosts.list_hosts(limit=500))["hosts"]}
h = listed[host_name]
assert h["status"] == "down" and h["agent"]["reporting"] is True
assert h["agent"]["cpu_pct"] == 95.0
detail = await hosts.get_host(host_name.upper()) # names match case-insensitively
assert detail["host_id"] == hid
assert [m["state"] for m in detail["monitors"]] == ["down"]
assert detail["monitors"][0]["error"] == "timeout"
assert detail["agent"]["parts"]["/"]["disk_used_pct"] == 41.0
assert [a["rule_id"] for a in detail["active_alerts"]] == [rule_id]
assert detail["ansible_target"] is None
series = await hosts.get_host_metrics(hid, "cpu_pct", "1h")
assert series["max"] == 95.0 and series["points"] >= 1 and series["series"]
listing = await hosts.get_host_metrics(host_name)
assert {m["metric"] for m in listing["metrics"]} == {"cpu_pct", "mem_used_pct"}
with pytest.raises(ValueError, match="no metric 'nope'"):
await hosts.get_host_metrics(host_name, "nope")
with pytest.raises(ValueError, match="list_hosts"):
await hosts.get_host(f"no-such-{tag}")
# ── monitors ───────────────────────────────────────────────────────
down_list = await monitors.list_monitors(state="down", limit=500)
ping = next(m for m in down_list["monitors"] if m["monitor_id"] == ping_id)
assert ping["host_name"] == host_name and ping["down_since"]
web = await monitors.get_monitor(f"web-{tag}")
assert "hunter2" not in web["target"] and "bob:***@" in web["target"]
assert web["config"]["headers"] == {"Authorization": "<redacted>"}
assert 4 <= web["tls_expires_in_days"] <= 5.1
ping_detail = await monitors.get_monitor(ping_id, failures=5)
assert ping_detail["heartbeat"] == "+--"
assert [f["error"] for f in ping_detail["recent_failures"]] == ["timeout", "timeout"]
with pytest.raises(ValueError, match="is not one of"):
await monitors.list_monitors(state="broken")
# ── alerts ─────────────────────────────────────────────────────────
firing = [a for a in (await alerts.list_alerts())["alerts"] if a["rule_id"] == rule_id]
assert firing[0]["state"] == "firing" and firing[0]["condition"] == "cpu_pct > 90"
hist = await alerts.get_alert_history(rule=f"cpu hot {tag}", range="24h")
assert [(e["from_state"], e["to_state"]) for e in hist["events"]] == [("inactive", "firing")]
rules = {r["rule_id"]: r for r in (await alerts.list_alert_rules(limit=500))["rules"]}
assert rules[rule_id]["state"] == "firing"
windows = (await alerts.list_maintenance_windows(limit=500))["windows"]
assert any(w["name"] == f"patching {tag}" and w["active"] for w in windows)
# ── query_metrics, one argument at a time ──────────────────────────
assert "host_agent" in (await metrics.query_metrics())["source_modules"]
res = (await metrics.query_metrics("host_agent", limit=1000))["resources"]
assert host_name in res and host_name + ":/" in res
latest = await metrics.query_metrics("host_agent", host_name)
assert {m["metric"] for m in latest["metrics"]} == {"cpu_pct", "mem_used_pct"}
cpu = await metrics.query_metrics("host_agent", host_name, "cpu_pct", "1h")
assert cpu["last"] == 95.0
with pytest.raises(ValueError, match="no metrics recorded"):
await metrics.query_metrics(f"nothing-{tag}")
with pytest.raises(ValueError, match="is not one of"):
await metrics.query_metrics("host_agent", host_name, "cpu_pct", "2w")
# ── audit ──────────────────────────────────────────────────────────
events = (await audit.list_audit_events(action="host.", user=username))["events"]
assert [(e["action"], e["entity_id"]) for e in events] == [("host.created", hid)]
assert events[0]["detail"] == {"name": host_name}
# ── through the SDK: arguments validate and results serialise ──────
await app.mcp_server.call_tool("list_hosts", {"limit": 5})
await app.mcp_server.call_tool("get_overview", {})
asyncio.run(_go())
+236
View File
@@ -0,0 +1,236 @@
"""The diagnostic tools' pure parts: response shaping, lookup, redaction, ranking.
The queries behind each tool run against real Postgres in
tests/integration/test_mcp_tools.py; these cover the logic that decides what
an agent is told.
"""
from __future__ import annotations
import json
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace as NS
from unittest.mock import MagicMock
import pytest
from steward.mcp.tools import _shape, overview
from steward.mcp.tools.monitors import _config
from steward.models.alerts import AlertOperator
from steward.models.ansible import AnsibleRunStatus
from steward.models.users import UserRole
NOW = datetime(2026, 10, 6, 12, 0, tzinfo=timezone.utc)
MCP4_TOOLS = {
"get_overview", "list_hosts", "get_host", "get_host_metrics", "query_metrics",
"list_monitors", "get_monitor", "list_alerts", "get_alert_history",
"list_alert_rules", "list_maintenance_windows", "list_audit_events",
}
# ── shaping ──────────────────────────────────────────────────────────────────
def test_iso_is_utc_with_z_and_naive_times_are_read_as_utc():
assert _shape.iso(NOW) == "2026-10-06T12:00:00Z"
assert _shape.iso(datetime(2026, 10, 6, 12, 0)) == "2026-10-06T12:00:00Z"
assert _shape.iso(None) is None
def test_age_s_never_negative():
assert _shape.age_s(NOW - timedelta(seconds=90), NOW) == 90
assert _shape.age_s(NOW + timedelta(seconds=5), NOW) == 0
def test_clamp_limit():
assert _shape.clamp_limit(0, 50, 500) == 50
assert _shape.clamp_limit(-3, 50, 500) == 50
assert _shape.clamp_limit(10, 50, 500) == 10
assert _shape.clamp_limit(9999, 50, 500) == 500
def test_bounded_says_when_and_how_it_cut():
whole = _shape.bounded([1, 2], 5, key="xs")
assert whole == {"xs": [1, 2], "total": 2, "truncated": False}
cut = _shape.bounded(list(range(10)), 3, key="xs", more="Filter by `state`.")
assert cut["xs"] == [0, 1, 2] and cut["total"] == 10 and cut["truncated"] is True
assert cut["more"] == "Showing 3 of 10. Filter by `state`."
def test_lookback_refuses_an_unknown_range_instead_of_guessing():
since, key = _shape.lookback("7d")
assert key == "7d"
with pytest.raises(ValueError, match="'2w' is not one of"):
_shape.lookback("2w")
def test_summarize_and_downsample():
pts = [(NOW + timedelta(minutes=i), float(i)) for i in range(1, 101)]
s = _shape.summarize(pts, n=10)
assert (s["points"], s["min"], s["max"], s["last"]) == (100, 1.0, 100.0, 100.0)
assert s["avg"] == 50.5 and s["p95"] == 95.0
assert len(s["series"]) == 10
assert s["series"][0] == ["2026-10-06T12:01:00Z", 5.5]
assert _shape.summarize([]) == {"points": 0, "series": []}
def _rows(*names):
return [NS(id=f"id-{i}", name=n) for i, n in enumerate(names)]
def test_pick_by_id_then_case_insensitive_name():
rows = _rows("nas", "web01")
assert _shape.pick("id-1", rows, kind="host", list_tool="list_hosts").name == "web01"
assert _shape.pick("NAS", rows, kind="host", list_tool="list_hosts").id == "id-0"
def test_pick_explains_a_miss_and_an_ambiguity():
with pytest.raises(ValueError, match=r"Did you mean 'web01'\?.*list_hosts"):
_shape.pick("web1", _rows("nas", "web01"), kind="host", list_tool="list_hosts")
with pytest.raises(ValueError, match="2 hosts are named 'nas'.*id-0, id-1"):
_shape.pick("nas", _rows("nas", "NAS"), kind="host", list_tool="list_hosts")
with pytest.raises(ValueError, match="pass a host name or id"):
_shape.pick("", _rows("nas"), kind="host", list_tool="list_hosts")
def test_redact_url_hides_only_the_password():
assert _shape.redact_url("https://bob:pw@h.test:8443/x?q=1") == "https://bob:***@h.test:8443/x?q=1"
assert _shape.redact_url("https://h.test/x") == "https://h.test/x"
assert _shape.redact_url("10.0.0.1") == "10.0.0.1"
def test_monitor_config_redacts_header_values():
m = NS(config_json=json.dumps({"method": "GET", "headers": {"Authorization": "Bearer x"}}))
assert _config(m) == {"method": "GET", "headers": {"Authorization": "<redacted>"}}
assert _config(NS(config_json="not json")) == {}
# ── overview findings ────────────────────────────────────────────────────────
def _monitor(mid, *, enabled=True, host_id=None):
return NS(id=mid, name=f"mon-{mid}", type="http", target="https://x.test",
enabled=enabled, host_id=host_id)
def _result(*, up, error=None, tls_days=None):
return NS(is_up=up, error_msg=error, checked_at=NOW - timedelta(minutes=1),
tls_expires_at=NOW + timedelta(days=tls_days) if tls_days is not None else None)
def test_monitor_findings():
mons = [_monitor("a", host_id="h1"), _monitor("b"), _monitor("c"), _monitor("d", enabled=False),
_monitor("e")]
states = {
"a": {"latest": _result(up=False, error="503"), "down_since": NOW - timedelta(hours=2)},
"b": {"latest": _result(up=True, tls_days=2)},
"c": {"latest": _result(up=True, tls_days=10)},
"d": {"latest": _result(up=False)},
"e": {"latest": _result(up=True, tls_days=60)},
}
out = {f["subject"]: f for f in overview.monitor_findings(mons, states, {"h1": "nas"}, NOW)}
assert set(out) == {"mon-a", "mon-b", "mon-c"} # disabled and healthy left out
down = out["mon-a"]
assert down["severity"] == "critical" and down["kind"] == "monitor_down"
assert "on nas" in down["summary"] and "503" in down["summary"]
assert down["since"] == "2026-10-06T10:00:00Z"
assert down["next"] == 'get_monitor(monitor="a")'
assert (out["mon-b"]["severity"], out["mon-c"]["severity"]) == ("critical", "warning")
def test_alert_findings_severity_by_state():
rule = NS(id="r1", name="cpu", metric_name="cpu_pct", operator=AlertOperator.gt,
threshold=90.0, source_module="host_agent", resource_name="nas")
rows = [(NS(state=NS(value=s), last_transition_at=NOW), rule)
for s in ("firing", "pending", "acknowledged")]
sev = {f["kind"]: f["severity"] for f in overview.alert_findings(rows)}
assert sev == {"alert_firing": "critical", "alert_pending": "warning",
"alert_acknowledged": "warning"}
THRESHOLDS = {"cpu": {"warn": 80, "crit": 90}, "mem": {"warn": 80, "crit": 90},
"disk": {"warn": 80, "crit": 90}}
def test_agent_findings_use_the_configured_thresholds():
hosts = [NS(id="h1", name="nas"), NS(id="h2", name="web"), NS(id="h3", name="db")]
agent = {
"nas": {"fresh": True, "cpu_pct": 95.0, "mem_used_pct": 85.0, "disk_used_pct_worst": 10.0},
"web": {"fresh": False, "cpu_pct": 99.0, "last_at": NOW - timedelta(minutes=30)},
}
out = overview.agent_findings(hosts, agent, THRESHOLDS)
got = {(f["subject"], f["kind"]): f["severity"] for f in out}
assert got == {
("nas", "host_cpu_high"): "critical",
("nas", "host_mem_high"): "warning",
# A stale agent's old numbers are not reported as current load.
("web", "agent_stale"): "warning",
}
def test_run_findings_quote_the_failures():
run = NS(id="r", playbook_path="site.yml", source_name="infra",
status=AnsibleRunStatus.failed, started_at=NOW, finished_at=NOW,
results={"failures": ["web01: apt lock held"]})
f = overview.run_findings([run])[0]
assert f["kind"] == "ansible_run_failed" and "apt lock held" in f["summary"]
def test_rank_puts_critical_first_then_oldest():
fs = [overview.finding("warning", "k", "w-old", "", since=NOW - timedelta(days=1)),
overview.finding("critical", "k", "c-new", "", since=NOW - timedelta(minutes=1)),
overview.finding("critical", "k", "c-old", "", since=NOW - timedelta(hours=1))]
assert [f["subject"] for f in overview.rank(fs)] == ["c-old", "c-new", "w-old"]
async def test_plugin_overview_sources_skip_disabled_plugins_and_survive_failures(monkeypatch):
calls = []
async def good(db):
calls.append("good")
return [overview.finding("warning", "container_unhealthy", "redis", "unhealthy")]
async def broken(db):
raise RuntimeError("boom")
async def off(db):
calls.append("off")
return []
class _CM:
async def __aenter__(self):
return object()
async def __aexit__(self, *a):
return False
monkeypatch.setattr(overview, "db_session", lambda: _CM())
server = MagicMock()
server.overview_sources = {"docker": good, "snmp": broken, "unifi": off}
server.plugin_enabled = lambda p: p != "unifi"
found, unavailable = await overview._plugin_findings(server)
assert [f["subject"] for f in found] == ["redis"]
assert unavailable == ["snmp"] and calls == ["good"]
def test_register_plugin_picks_up_an_overview_source():
from steward.mcp.server import StewardMCPServer
app = MagicMock()
app.config = {"PLUGINS": {}}
mcp = StewardMCPServer(app)
async def mcp_overview(db):
return []
mcp.register_plugin("docker", NS(mcp_overview=mcp_overview))
assert mcp.overview_sources == {"docker": mcp_overview}
mcp.register_plugin("docker", NS()) # reloaded without it
assert mcp.overview_sources == {}
# ── registration ─────────────────────────────────────────────────────────────
def test_diagnostic_tools_are_registered_read_only_for_viewers(app):
mcp = app.mcp_server
assert MCP4_TOOLS <= set(mcp.tool_roles)
for name in MCP4_TOOLS:
assert mcp.tool_roles[name] is UserRole.viewer, name
assert mcp._tool_manager.get_tool(name).annotations.read_only_hint is True, name
assert (mcp._tool_manager.get_tool(name).description or "").strip(), f"{name} has no docstring"