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
+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)