From f72ed97034069603a0639670e4b90919a6127d68 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 6 Oct 2026 10:27:20 -0400 Subject: [PATCH] feat(mcp): get_host groups a host's parts by kind and says what it cut #5012 "get_host cuts `parts` without saying so" - The agent's sub-resources come back as mounts (fullest first), interfaces and disk_io (busiest first) and temperatures (hottest first), each bounded on its own with total, truncated and a `more` that names parts_limit and query_metrics. - Per-core CPU is a summary (count, min/avg/max, three busiest), so 38 cores no longer push every interface out of the reply. - New argument parts_limit (default 20, max 200). Co-Authored-By: Claude Opus 5.5 --- steward/mcp/tools/hosts.py | 80 ++++++++++++++++++++++++++--- tests/integration/test_mcp_tools.py | 4 +- tests/mcp/test_tools.py | 28 ++++++++++ 3 files changed, 104 insertions(+), 8 deletions(-) diff --git a/steward/mcp/tools/hosts.py b/steward/mcp/tools/hosts.py index 0ea4d5e..e4bc580 100644 --- a/steward/mcp/tools/hosts.py +++ b/steward/mcp/tools/hosts.py @@ -37,6 +37,63 @@ def _agent_glance(a: dict | None) -> dict | None: } +_PART_GROUPS = ( # (prefix, group, the field that names one part) + ("net:", "interfaces", "interface"), + ("diskio:", "disk_io", "device"), + ("temp:", "temperatures", "sensor"), +) +# Sort each group by what makes a part worth a look first. +_PART_ORDER = { + "mounts": lambda r: -(r.get("disk_used_pct") or 0), + "interfaces": lambda r: -((r.get("net_rx_bps") or 0) + (r.get("net_tx_bps") or 0)), + "disk_io": lambda r: -((r.get("disk_read_bps") or 0) + (r.get("disk_write_bps") or 0)), + "temperatures": lambda r: -(r.get("temp_c") or 0), + "other": lambda r: r["part"], +} + + +def agent_parts(parts: dict[str, dict], host_name: str, limit: int) -> dict: + """A host's sub-resources grouped by kind, each group bounded on its own. + + `parts` maps the part name (the resource after ":") to its latest + metrics. Per-core CPU is summarised, not listed: the host's cpu_pct + already says how busy it is, and 30+ cores would crowd out the rest. + """ + groups: dict[str, list] = {"mounts": [], "interfaces": [], "disk_io": [], + "temperatures": [], "other": []} + cores = [] + for name, metrics in parts.items(): + if name.startswith("core") and name[4:].isdigit(): + if metrics.get("cpu_pct") is not None: + cores.append((int(name[4:]), metrics["cpu_pct"])) + continue + for prefix, group, field in _PART_GROUPS: + if name.startswith(prefix): + groups[group].append({field: name[len(prefix):], "part": name, **metrics}) + break + else: + if any(m.startswith("disk_") for m in metrics): + groups["mounts"].append({"mount": name, "part": name, **metrics}) + else: + groups["other"].append({"part": name, **metrics}) + more = (f"Pass a larger `parts_limit` (max 200), or read one part with query_metrics(" + f'source_module="host_agent", resource="{host_name}:").') + out: dict = {} + for group, rows in groups.items(): + if rows or group != "other": + rows.sort(key=_PART_ORDER[group]) + out[group] = bounded(rows, limit, key="items", more=more) + if cores: + pcts = [pct for _, pct in cores] + busiest = sorted(cores, key=lambda c: (-c[1], c[0]))[:3] + out["cpu_cores"] = { + "count": len(cores), "min_pct": rnd(min(pcts)), + "avg_pct": rnd(sum(pcts) / len(pcts)), "max_pct": rnd(max(pcts)), + "busiest": [{"core": i, "cpu_pct": rnd(pct)} for i, pct in busiest], + } + return out + + async def list_hosts(limit: int = 100) -> dict: """Every registered host with its health at a glance. @@ -73,17 +130,27 @@ async def list_hosts(limit: int = 100) -> dict: return bounded(rows, limit, key="hosts") -async def get_host(host: str) -> dict: +async def get_host(host: str, parts_limit: int = 20) -> dict: """One host in depth: its monitors, agent snapshot, active alerts and Ansible link. host: the host's name (case-insensitive) or id, as list_hosts shows. + parts_limit: how many of each kind of sub-resource to list (default 20, + max 200). Returns the host's monitors (latest result, error, uptime), the agent's - latest values — host-level metrics plus each sub-resource such as a mount - or interface under `agent.parts` — alerts currently pending, firing or - acknowledged on this host's resources, and the Ansible inventory target - linked to it with its groups (`ansible_target`: null when unlinked). + latest values, alerts currently pending, firing or acknowledged on this + host's resources, and the Ansible inventory target linked to it with its + groups (`ansible_target`: null when unlinked). + + The agent's values are `agent.metrics` (host-level) and its sub-resources + grouped by kind: `mounts` (fullest first), `interfaces` and `disk_io` + (busiest first), `temperatures` (hottest first), each with `items`, + `total` and `truncated`; `cpu_cores` summarises per-core CPU (count, + min/avg/max, the three busiest). One part's history is query_metrics with + source_module="host_agent" and resource=":", where `part` is + the item's `part`. """ + parts_limit = clamp_limit(parts_limit, 20, 200) async with db_session() as db: h = pick(host, await _all_hosts(db), kind="host", list_tool="list_hosts") monitors = await host_svc.host_monitors(db, h.id) @@ -110,8 +177,7 @@ async def get_host(host: str) -> dict: agent = { **(_agent_glance(glance) or {}), "metrics": host_level, - "parts": dict(list(sorted(parts.items()))[:40]), - "parts_total": len(parts), + **agent_parts(parts, h.name, parts_limit), } prefix = h.name + ":" diff --git a/tests/integration/test_mcp_tools.py b/tests/integration/test_mcp_tools.py index 9e38b3e..84211b5 100644 --- a/tests/integration/test_mcp_tools.py +++ b/tests/integration/test_mcp_tools.py @@ -139,7 +139,9 @@ def test_diagnostic_tools_over_a_seeded_fleet(app): 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 + mount = detail["agent"]["mounts"] + assert mount["items"][0]["mount"] == "/" and mount["items"][0]["disk_used_pct"] == 41.0 + assert mount["total"] == 1 and mount["truncated"] is False assert [a["rule_id"] for a in detail["active_alerts"]] == [rule_id] assert detail["ansible_target"] is None diff --git a/tests/mcp/test_tools.py b/tests/mcp/test_tools.py index 465b2c9..6db2ae5 100644 --- a/tests/mcp/test_tools.py +++ b/tests/mcp/test_tools.py @@ -238,3 +238,31 @@ def test_diagnostic_tools_are_registered_read_only_for_viewers(app): 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" + + +def test_get_host_groups_parts_by_kind_and_summarises_cores(): + from steward.mcp.tools.hosts import agent_parts + parts = {f"core{i}": {"cpu_pct": float(i)} for i in range(38)} + parts.update({ + "/": {"disk_used_pct": 40.0, "disk_used_bytes": 4.0, "disk_total_bytes": 10.0}, + "/data": {"disk_used_pct": 91.0}, + "net:eth0": {"net_rx_bps": 10.0, "net_tx_bps": 5.0}, + "net:br-1": {"net_rx_bps": 900.0, "net_tx_bps": 0.0}, + "net:eth1": {"net_rx_bps": 0.0, "net_tx_bps": 0.0}, + "diskio:sda": {"disk_read_bps": 1.0, "disk_write_bps": 2.0}, + "temp:Package id 0": {"temp_c": 61.0}, + }) + got = agent_parts(parts, "box", limit=2) + # Cores are a summary, so they can't crowd out the rest. + assert got["cpu_cores"]["count"] == 38 and got["cpu_cores"]["max_pct"] == 37.0 + assert [c["core"] for c in got["cpu_cores"]["busiest"]] == [37, 36, 35] + assert [m["mount"] for m in got["mounts"]["items"]] == ["/data", "/"] # fullest first + # Busiest interface first; the cut is said, with how to get the rest. + nets = got["interfaces"] + assert [n["interface"] for n in nets["items"]] == ["br-1", "eth0"] + assert nets["total"] == 3 and nets["truncated"] is True + assert "parts_limit" in nets["more"] and 'resource="box:"' in nets["more"] + assert got["disk_io"]["items"][0]["device"] == "sda" + assert got["temperatures"]["items"][0] == {"sensor": "Package id 0", "part": "temp:Package id 0", + "temp_c": 61.0} + assert "other" not in got