feat(mcp): SNMP devices and Prometheus targets: their state, readings and series (#5840)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m26s
CI / integration (push) Successful in 1m33s
CI / publish (push) Successful in 19s

The snmp plugin registers list_snmp_devices and get_snmp_device; the
prometheus plugin registers list_prometheus_targets and
get_prometheus_target. Both are viewer-level, read what the poller and
scraper stored, and exist only while their plugin is enabled.
get_overview names a device or target whose last poll or scrape failed;
stale, pending and off are left out.

A device's community and v3 keys and a target's token are never
returned, and a password in a target URL is masked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 13:31:46 -04:00
co-authored by Claude Opus 5.5
parent 2d88315bb1
commit eee8469a17
7 changed files with 337 additions and 0 deletions
+2
View File
@@ -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
+10
View File
@@ -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)
+114
View File
@@ -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)
+10
View File
@@ -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)
+105
View File
@@ -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)
@@ -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)
+49
View File
@@ -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")