diff --git a/docs/core/mcp.md b/docs/core/mcp.md index e3ec549..a376623 100644 --- a/docs/core/mcp.md +++ b/docs/core/mcp.md @@ -82,6 +82,8 @@ Each role includes the tools of the roles above it in this list. | Host agent (built in, always on) | `list_agents`, `list_maintenance_plans` (each plan's group, weekly slot, next run and latest run: how far it got, who it skipped and why), `get_maintenance_job` (a host's update jobs or one job: state, packages updated, whether it rebooted, why it failed) | | Traefik plugin | `list_routers` (each router graded as the Traefik page grades it, with the status codes behind a problem, what they mean and the swarm service behind it), `get_router` (one router's history, codes, normal and anomalies), `list_certificates` (served certificates, days left, the routers they cover), `get_traffic` (the access log's clients, countries and routers, and Traefik's own figures) | | UniFi plugin | `get_network` (the WAN, clients, Wi-Fi networks, speed tests and devices by status), `list_devices`, `list_clients` (everyone seen, or the newest poll's top talkers), `list_network_events`, `list_alarms`, `get_network_config` (networks and VLANs, port forwards, firewall rules) | +| SNMP plugin | `list_snmp_devices` (each device's state, newest readings in their units and the readings it didn't return), `get_snmp_device` (one device's readings over a range). Never the community or v3 keys | +| Prometheus plugin | `list_prometheus_targets` (each target's state and its newest scrape: series kept, dropped and over the cap), `get_prometheus_target` (a target's stored series by metric, with labels and newest values). Never the token, and a password in a URL is masked | ### Operator: run playbooks, change monitoring diff --git a/plugins/prometheus/__init__.py b/plugins/prometheus/__init__.py index a34993b..dab8870 100644 --- a/plugins/prometheus/__init__.py +++ b/plugins/prometheus/__init__.py @@ -68,3 +68,13 @@ def get_audit_entities() -> dict: from .models import PrometheusTarget, PrometheusWidgetTemplate return {"prometheus_target": (PrometheusTarget, "/plugins/prometheus/targets/{}"), "prometheus_template": (PrometheusWidgetTemplate, "/plugins/prometheus/templates/custom:{}")} + + +def register_mcp_tools(register) -> None: + from .mcp_tools import register_tools + register_tools(register) + + +async def mcp_overview(db) -> list[dict]: + from .mcp_tools import mcp_overview as findings + return await findings(db) diff --git a/plugins/prometheus/mcp_tools.py b/plugins/prometheus/mcp_tools.py new file mode 100644 index 0000000..21fbba2 --- /dev/null +++ b/plugins/prometheus/mcp_tools.py @@ -0,0 +1,114 @@ +"""The prometheus plugin's MCP tools and its get_overview findings (#5840). + +Registered through register_mcp_tools() in __init__.py, so they exist only +while the plugin is enabled. They read what the scraper stored; nothing +reaches a target, and a target's bearer token (or a password in its URL) +is never returned. +""" +from __future__ import annotations + +from sqlalchemy import select + +from steward.core.metric_series import latest_values +from steward.mcp.tools._shape import age_s, bounded, clamp_limit, db_session, iso, now_utc, pick, redact_url +from steward.mcp.tools.overview import finding +from steward.models.metrics import PluginMetricLatest +from steward.models.users import UserRole + +from .models import PrometheusTarget +from .routes import group_series +from .service import OWN_METRICS, SOURCE, target_state + +REACHES = ("down", "stale", "pending", "up", "off") +_ORDER = {r: i for i, r in enumerate(REACHES)} + + +def _target_view(t: PrometheusTarget, own: dict, now) -> dict: + state = target_state(t, now) + return {"target_id": t.id, "name": t.name, "url": redact_url(t.url), "enabled": t.enabled, + "reach": state.reach, "says": state.says, "interval_s": t.interval_seconds, + "scraped_at": iso(t.last_scraped_at), "ok_at": iso(t.last_ok_at), "ok_age_s": age_s(t.last_ok_at, now), + "series_kept": t.last_kept, "series_dropped": t.last_dropped, "series_capped": t.last_capped, + "series_cap": t.series_cap, "keep": list(t.keep or []), "drop": list(t.drop or []), + "scrape_ms": own.get("scrape_ms")} + + +async def list_prometheus_targets() -> dict: + """The /metrics endpoints Steward scrapes, not answering first. + + `reach`: "down" (the last scrape failed; `says` is why), "stale" (not + scraped recently), "pending", "up" or "off". `series_kept` / + `series_dropped` count the newest scrape's series the keep/drop patterns + let through or left out; `series_capped` those past `series_cap`, which + were not stored. get_prometheus_target lists a target's series; + query_metrics(source_module="prometheus", resource=…, metric=…) reads + one's history. + """ + now = now_utc() + async with db_session() as db: + targets = list((await db.execute(select(PrometheusTarget).order_by(PrometheusTarget.name))).scalars()) + rows = (await db.execute(select(PluginMetricLatest).where( + PluginMetricLatest.source_module == SOURCE, + PluginMetricLatest.resource_name.in_([t.name for t in targets] or [""]), + PluginMetricLatest.metric_name == "scrape_ms"))).scalars() + own = {r.resource_name: {"scrape_ms": r.value} for r in rows} + views = [_target_view(t, own.get(t.name, {}), now) for t in targets] + views.sort(key=lambda v: (_ORDER[v["reach"]], v["name"].lower())) + return {"targets": views} + + +async def get_prometheus_target(target: str, search: str = "", limit: int = 100) -> dict: + """One target's stored series, grouped by metric name, each with the + labels that tell its series apart and the newest value in its unit. + + target: its name or id (list_prometheus_targets). + search: part of a metric name; empty for all. + limit: metric groups to return, default 100, max 500. + + A state set (a series per state, one of them 1) is folded to the + current state. `resource` is what query_metrics takes for a series' + history (source_module "prometheus"). + """ + now = now_utc() + async with db_session() as db: + t = pick(target, list((await db.execute(select(PrometheusTarget))).scalars()), + kind="Prometheus target", list_tool="list_prometheus_targets") + latest = await latest_values(db, SOURCE, t.name, include_children=True) + own = {r.metric_name: r.value for r in latest if r.resource_name == t.name and r.metric_name in OWN_METRICS} + groups = group_series(t.name, latest) + if search: + groups = [g for g in groups if search.lower() in g["metric"].lower()] + + def row(r: dict, states: bool) -> dict: + if states: + return {"labels": r["labels"], "resource": r["resource"], "state": r["text"], "states": r["states"]} + return {"labels": r["labels"], "resource": r["resource"], "value": r["value"], + "shown": r["text"] or iso(r["moment"]), "at": iso(r["at"])} + + shaped = [{"metric": g["metric"], "series": [row(r, g["states"]) for r in g["rows"][:50]], + **({"more_series": len(g["rows"]) - 50} if len(g["rows"]) > 50 else {})} for g in groups] + out = {"target": _target_view(t, own, now)} + out.update(bounded(shaped, clamp_limit(limit, 100, 500), key="metrics", + more="Pass a `search`, or a larger `limit`.")) + return out + + +def target_findings(views: list[dict]) -> list[dict]: + """A target whose last scrape failed. Stale and off are the plugin or + the admin's choice, not the endpoint, so they are left out.""" + return [finding("warning", "prometheus_target_down", v["name"], + f"Prometheus target {v['name']} isn't answering: {v['says']}", + since=v["_ok_at"], ids={"target_id": v["target_id"]}, + next=f'get_prometheus_target(target="{v["name"]}")') + for v in views if v["reach"] == "down"] + + +async def mcp_overview(db) -> list[dict]: + now = now_utc() + targets = list((await db.execute(select(PrometheusTarget))).scalars()) + return target_findings([_target_view(t, {}, now) | {"_ok_at": t.last_ok_at} for t in targets]) + + +def register_tools(register) -> None: + for fn in (list_prometheus_targets, get_prometheus_target): + register(fn, role=UserRole.viewer, read_only=True) diff --git a/plugins/snmp/__init__.py b/plugins/snmp/__init__.py index 1aa6228..d6ba40e 100644 --- a/plugins/snmp/__init__.py +++ b/plugins/snmp/__init__.py @@ -39,3 +39,13 @@ def get_audit_entities() -> dict: """Audit rows about a device name it and link to its page.""" from .models import SnmpDevice return {"snmp_device": (SnmpDevice, "/plugins/snmp/devices/{}")} + + +def register_mcp_tools(register) -> None: + from .mcp_tools import register_tools + register_tools(register) + + +async def mcp_overview(db) -> list[dict]: + from .mcp_tools import mcp_overview as findings + return await findings(db) diff --git a/plugins/snmp/mcp_tools.py b/plugins/snmp/mcp_tools.py new file mode 100644 index 0000000..cde1dd1 --- /dev/null +++ b/plugins/snmp/mcp_tools.py @@ -0,0 +1,105 @@ +"""The snmp plugin's MCP tools and its get_overview findings (#5840). + +Registered through register_mcp_tools() in __init__.py, so they exist only +while the plugin is enabled. They read what the poller stored; nothing asks a +device anything, and a device's community and v3 keys are never returned. +""" +from __future__ import annotations + +from sqlalchemy import select + +from steward.core.metric_series import points +from steward.mcp._context import mcp_app +from steward.mcp.tools._shape import age_s, db_session, iso, lookback, now_utc, pick, summarize +from steward.mcp.tools.overview import finding +from steward.models.users import UserRole + +from .models import SnmpDevice +from .readings import stored_unit +from .routes import _latest, shown_readings +from .service import device_state, readings_for + +REACHES = ("down", "stale", "pending", "up") +_ORDER = {r: i for i, r in enumerate(REACHES)} + + +def _poll_seconds() -> int: + return int(mcp_app().config["PLUGINS"]["snmp"].get("poll_interval_seconds", 60)) + + +def _device_view(d: SnmpDevice, values: dict, now, poll: int) -> dict: + state = device_state(d, poll, now) + names = {r.key: r.name for r in readings_for(d)} + return {"device_id": d.id, "name": d.name, "address": d.address, "port": d.port, "version": d.version, + "host_id": d.host_id, "reach": state.reach, "says": state.says, + "polled_at": iso(d.last_polled_at), "ok_at": iso(d.last_ok_at), "ok_age_s": age_s(d.last_ok_at, now), + "readings": [{"key": r["key"], "name": r["name"], "value": r["text"], "counter": r["counter"]} + for r in shown_readings(d, values)], + "missing": [names.get(k, k) for k in d.missing or []]} + + +async def list_snmp_devices(reach: str = "") -> dict: + """The devices Steward polls over SNMP (switches, UPSes, printers, NAS + boxes), not answering first. + + reach: "down" (the last poll failed; `says` is why), "stale" (not polled + recently: the plugin may be off), "pending" (never polled), "up"; empty + for all. + + Each device gives its newest readings in their units (counters as + rates), and `missing`: readings it is configured for that the device + didn't return. get_snmp_device gives a device's history. + """ + if reach and reach not in REACHES: + raise ValueError(f"reach must be one of {', '.join(REACHES)}, or empty for all.") + now, poll = now_utc(), _poll_seconds() + async with db_session() as db: + devices = list((await db.execute(select(SnmpDevice).order_by(SnmpDevice.name))).scalars()) + values = await _latest(db, [d.name for d in devices]) + rows = sorted((_device_view(d, values.get(d.name, {}), now, poll) for d in devices), + key=lambda v: (_ORDER[v["reach"]], v["name"].lower())) + return {"devices": [r for r in rows if not reach or r["reach"] == reach], "total": len(rows)} + + +async def get_snmp_device(device: str, range: str = "24h") -> dict: + """One SNMP device: its state, newest readings, and each reading's + history over `range` (min/avg/max/last and ≤60 points, in the unit the + reading is stored in: `unit`). + + device: its name or id (list_snmp_devices). + range: "1h", "6h", "24h" (default), "7d" or "30d". + """ + since, key = lookback(range) + now, poll = now_utc(), _poll_seconds() + async with db_session() as db: + devices = list((await db.execute(select(SnmpDevice))).scalars()) + d = pick(device, devices, kind="SNMP device", list_tool="list_snmp_devices") + values = (await _latest(db, [d.name])).get(d.name, {}) + history = {r.key: (stored_unit(r), await points(db, "snmp", d.name, r.key, since)) + for r in readings_for(d)} + out = _device_view(d, values, now, poll) + out["range"] = key + out["history"] = {k: {"unit": unit, **summarize(pts)} for k, (unit, pts) in history.items()} + return out + + +def device_findings(views: list[dict]) -> list[dict]: + """A device whose last poll failed. A stale one is the plugin or poller, + not the device, so it is left out.""" + return [finding("warning", "snmp_device_down", v["name"], + f"SNMP device {v['name']} ({v['address']}) isn't answering: {v['says']}", + since=v["_ok_at"], ids={"device_id": v["device_id"]}, + next=f'get_snmp_device(device="{v["name"]}")') + for v in views if v["reach"] == "down"] + + +async def mcp_overview(db) -> list[dict]: + now, poll = now_utc(), _poll_seconds() + devices = list((await db.execute(select(SnmpDevice))).scalars()) + views = [_device_view(d, {}, now, poll) | {"_ok_at": d.last_ok_at} for d in devices] + return device_findings(views) + + +def register_tools(register) -> None: + for fn in (list_snmp_devices, get_snmp_device): + register(fn, role=UserRole.viewer, read_only=True) diff --git a/tests/plugins/prometheus/test_mcp_tools.py b/tests/plugins/prometheus/test_mcp_tools.py new file mode 100644 index 0000000..d01acf5 --- /dev/null +++ b/tests/plugins/prometheus/test_mcp_tools.py @@ -0,0 +1,47 @@ +"""The prometheus plugin's MCP tools (#5840): which targets the overview +names, what a target row carries (never its token or a password in its URL), +and the tools it registers.""" +from datetime import datetime, timedelta, timezone +from types import SimpleNamespace as NS + +import plugins.prometheus as prometheus_plugin +from plugins.prometheus import mcp_tools as t +from steward.models.users import UserRole + +NOW = datetime(2026, 10, 15, 12, 0, tzinfo=timezone.utc) + + +def _target(name, *, enabled=True, scraped=timedelta(seconds=30), error=None, + url="http://archives:8080/metrics"): + return NS(id=f"id-{name}", name=name, url=url, token="bearer-s3cret", enabled=enabled, + interval_seconds=60, last_scraped_at=NOW - scraped if scraped else None, + last_ok_at=NOW - timedelta(hours=1), last_error=error, last_kept=120, last_dropped=30, + last_capped=0, series_cap=1000, keep=["archives_*"], drop=[]) + + +def test_only_a_target_whose_scrape_failed_is_an_overview_finding(): + views = [t._target_view(x, {}, NOW) | {"_ok_at": x.last_ok_at} for x in ( + _target("archives", error="connection refused"), _target("fine"), + _target("off", enabled=False, error="connection refused"), + _target("stale", scraped=timedelta(days=1), error="connection refused"), + _target("new", scraped=None))] + [f] = t.target_findings(views) + assert f["kind"] == "prometheus_target_down" and f["subject"] == "archives" + assert "connection refused" in f["summary"] and f["next"] == 'get_prometheus_target(target="archives")' + + +def test_a_target_row_never_carries_its_token_or_url_password(): + row = t._target_view(_target("archives", url="http://scrape:hunter2@archives:8080/metrics"), + {"scrape_ms": 42.0}, NOW) + text = repr(row) + assert "bearer-s3cret" not in text and "hunter2" not in text + assert row["url"] == "http://scrape:***@archives:8080/metrics" + assert row["scrape_ms"] == 42.0 and row["series_kept"] == 120 and row["reach"] == "up" + + +def test_the_plugin_registers_its_tools_for_viewers(): + got = {} + prometheus_plugin.register_mcp_tools(lambda fn, role, read_only: got.update({fn.__name__: (role, read_only)})) + assert got == {name: (UserRole.viewer, True) + for name in ("list_prometheus_targets", "get_prometheus_target")} + assert callable(prometheus_plugin.mcp_overview) diff --git a/tests/plugins/snmp/test_mcp_tools.py b/tests/plugins/snmp/test_mcp_tools.py new file mode 100644 index 0000000..ca0bcfd --- /dev/null +++ b/tests/plugins/snmp/test_mcp_tools.py @@ -0,0 +1,49 @@ +"""The snmp plugin's MCP tools (#5840): which devices the overview names, what +a device row carries (never its community or v3 keys), and the tools it +registers.""" +from datetime import datetime, timedelta, timezone +from types import SimpleNamespace as NS + +import pytest + +import plugins.snmp as snmp_plugin +from plugins.snmp import mcp_tools as t +from steward.models.users import UserRole + +NOW = datetime(2026, 10, 15, 12, 0, tzinfo=timezone.utc) + + +def _device(name, *, polled=timedelta(seconds=30), error=None): + return NS(id=f"id-{name}", name=name, address="10.0.0.9", port=161, version="2c", + community="s3cret", v3_user="", v3_auth_key="authkey", v3_priv_key="privkey", + presets=[], interfaces="", custom=None, host_id=None, + last_polled_at=NOW - polled if polled else None, last_ok_at=NOW - timedelta(hours=1), + last_error=error, missing=[]) + + +def test_only_a_device_whose_poll_failed_is_an_overview_finding(): + views = [t._device_view(d, {}, NOW, 60) | {"_ok_at": d.last_ok_at} for d in ( + _device("ups", error="Timed out"), _device("switch"), + _device("printer", polled=timedelta(days=1), error="Timed out"), _device("nas", polled=None))] + [f] = t.device_findings(views) + assert f["kind"] == "snmp_device_down" and f["subject"] == "ups" and "Timed out" in f["summary"] + assert f["next"] == 'get_snmp_device(device="ups")' + + +def test_a_device_row_never_carries_its_secrets(): + row = t._device_view(_device("ups"), {}, NOW, 60) + text = repr(row) + assert "s3cret" not in text and "authkey" not in text and "privkey" not in text + assert row["reach"] == "up" and row["device_id"] == "id-ups" + + +def test_the_plugin_registers_its_tools_for_viewers(): + got = {} + snmp_plugin.register_mcp_tools(lambda fn, role, read_only: got.update({fn.__name__: (role, read_only)})) + assert got == {name: (UserRole.viewer, True) for name in ("list_snmp_devices", "get_snmp_device")} + assert callable(snmp_plugin.mcp_overview) + + +async def test_list_snmp_devices_refuses_an_unknown_reach(): + with pytest.raises(ValueError, match="reach must be one of"): + await t.list_snmp_devices(reach="broken")