Files
FabledSteward/plugins/syslog/mcp_tools.py
T
bvandeusenandClaude Opus 5.5 fcd58224de
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m32s
CI / integration (push) Successful in 1m42s
CI / publish (push) Successful in 20s
feat(syslog): a device through Docker's address is placed by its name, or a device address set on it (#6096)
The SNMP poll asks each device's sysName in the same GET and keeps it
(snmp_003_sys_name). A syslog source told apart by name finds its own
address from the SNMP device (reported or given name) or host of that
name, so its lines reach the SNMP device's and host's pages again; the
edit page gains Device address, which always wins (syslog_004).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 12:24:58 -04:00

160 lines
8.5 KiB
Python

"""The syslog plugin's MCP tools (#6000): the sources and the lines kept from
them. Registered through register_mcp_tools() in __init__.py, so they exist
only while the plugin is enabled. They read what the receiver stored."""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import select
from steward.core.paging import keyset
from steward.mcp.tools._shape import age_s, clamp_limit, cut_middle, db_session, iso, lookback, now_utc, pick
from steward.models.hosts import Host
from steward.models.users import UserRole
from .models import SyslogLine, SyslogSource
from .parse import facility_name, severity_name
from .service import (AROUND_LINES, AROUND_SEVERITY, LineFilter, addresses_by_name, device_address, filtered,
lines_between, place_for, rates, severity_level, shown_on, source_name, sources_for)
async def list_syslog_sources(state: str = "") -> dict:
"""The devices sending syslog to Steward.
state: "accepted" (added: their lines are kept and searchable),
"offered" (sending but not added: counted, not kept), "ignored";
empty for all.
A device whose lines reach Steward through Docker's own address (a port
published on Swarm's ingress) is told apart by the name its lines carry:
`through_docker` is true and `address` is Docker's, shared with the others.
`device_address` is the device's own: set on the source, or found from the
SNMP device or host its name is (else the address its lines come from).
Each gives its address, the host it is shown on, lines received in all
(`lines_total`) and, for an added source, in the last hour, when it was
last heard and its newest line. search_syslog searches the kept lines.
"""
if state and state not in ("accepted", "offered", "ignored"):
raise ValueError("state must be accepted, offered or ignored, or empty for all.")
now = now_utc()
async with db_session() as db:
stmt = select(SyslogSource).order_by(SyslogSource.last_seen_at.desc())
if state:
stmt = stmt.where(SyslogSource.state == state)
sources = list((await db.execute(stmt)).scalars())
per_hour = await rates(db, [s.id for s in sources if s.state == "accepted"], now)
hosts = {h.id: h.name for h in (await db.execute(select(Host))).scalars()}
named = await addresses_by_name(db) if any(s.sender for s in sources) else None
return {"sources": [{
"source_id": s.id, "name": source_name(s), "address": s.address, "state": s.state,
"through_docker": bool(s.sender), "device_address": device_address(s, named),
"host_id": s.host_id, "host_name": hosts.get(s.host_id), "calls_itself": s.hostname_seen or None,
"lines_total": s.lines_total, "lines_last_hour": per_hour.get(s.id, 0) if s.state == "accepted" else None,
"last_heard": iso(s.last_seen_at), "last_heard_age_s": age_s(s.last_seen_at, now),
"newest_line": {"severity": severity_name(s.last_severity) if s.last_severity is not None else None,
"text": cut_middle(s.last_line)},
} for s in sources]}
async def search_syslog(source: str = "", host: str = "", text: str = "", severity: str = "",
app: str = "", range: str = "1h", before: str = "", limit: int = 50) -> dict:
"""Search the syslog lines Steward has kept from added sources, newest
first: a NAS's, a router's or another device's own log.
source: a source's name, address or id (list_syslog_sources lists them);
empty searches every added source.
host: a host's name or id: the sources shown on that host (bound to it,
or at its address). Empty for all.
text: case-insensitive substring of the message; empty matches every line.
severity: this level and worse: "err" (errors, crit, alert, emerg),
"warning", "notice", "info", "debug", or 0-7. Empty for all.
app: the app or tag that wrote the line (e.g. "kernel", "sshd"), exact.
range: how far back — "1h" (default), "6h", "24h", "7d" or "30d". Each
source keeps its lines up to a size and age limit, so older ones may be gone.
before: an ISO time; only lines received before it. Pass a response's
`next_before` to page further back.
limit: lines to return, default 50, max 200.
`at` is when Steward received the line, the time to trust; `sent_at` is
the device's own clock, given only when the line carried its zone.
"""
_, key = lookback(range)
limit = clamp_limit(limit, 50, 200)
f = LineFilter(severity=severity_level(severity), app=app.strip(), text=text.strip(), range=key)
before_dt = None
if before:
try:
before_dt = datetime.fromisoformat(before.replace("Z", "+00:00"))
except ValueError as exc:
raise ValueError(f"before {before!r} is not an ISO-8601 time; pass a response's next_before.") from exc
async with db_session() as db:
accepted = list((await db.execute(
select(SyslogSource).where(SyslogSource.state == "accepted"))).scalars())
if not accepted:
return {"range": key, "matches": [], "returned": 0, "truncated": False,
"note": "No syslog source is added yet; list_syslog_sources shows who is sending."}
chosen = accepted
if source:
want = source.strip()
by_address = [s for s in accepted if s.address == want]
chosen = by_address or [pick(want, accepted, kind="syslog source",
list_tool="list_syslog_sources", name=source_name)]
if host:
h = pick(host, list((await db.execute(select(Host))).scalars()), kind="host", list_tool="list_hosts")
chosen = sources_for(chosen, h.id, h.address, await addresses_by_name(db))
if not chosen:
return {"range": key, "matches": [], "returned": 0, "truncated": False,
"note": f"No added syslog source is shown on {h.name}."}
names = {s.id: source_name(s) for s in chosen}
stmt = filtered(select(SyslogLine).where(SyslogLine.source_id.in_(list(names))), f)
if before_dt is not None:
stmt = stmt.where(SyslogLine.received_at < before_dt)
rows = list((await db.execute(
keyset(stmt, SyslogLine.received_at, SyslogLine.id, None, size=limit))).scalars())
more = len(rows) > limit
rows = rows[:limit]
out = {"range": key, "returned": len(rows), "truncated": more, "matches": [{
"at": iso(r.received_at), "sent_at": iso(r.sent_at), "source": names[r.source_id],
"severity": severity_name(r.severity), "facility": facility_name(r.facility),
"app": r.app or None, "hostname": r.hostname or None, "message": cut_middle(r.message),
} for r in rows]}
if more:
# Full precision: `at` is cut to the second, and a cursor cut the same
# way would skip lines received earlier in that second.
out["next_before"] = rows[-1].received_at.isoformat()
out["more"] = "More lines are older; pass next_before as `before`, or narrow with `text` or `severity`."
return out
def _as_events(lines: list[SyslogLine], sources: list[SyslogSource]) -> list[dict]:
from steward.mcp.tools._events import event
names = {s.id: source_name(s) for s in sources}
return [event(ln.received_at, "syslog",
f"{severity_name(ln.severity)}{' ' + ln.app if ln.app else ''}: {cut_middle(ln.message, 300)}",
subject=names[ln.source_id]) for ln in reversed(lines)]
async def mcp_events(db, host_id: str, host_name: str, since, until) -> list[dict]:
"""Warnings and worse in a window (get_anomaly) from the syslog sources
shown on a host: bound to it, or at its address."""
sources = await shown_on(db, host_id, None)
return _as_events(await lines_between(db, sources, since, until, AROUND_SEVERITY, AROUND_LINES), sources)
async def mcp_resource_events(db, source: str, resource: str, since, until) -> list[dict]:
"""The same for an SNMP device, which has no host to read events by: the
sources at its address, or bound to its host."""
if source != "snmp":
return []
host_id, address = await place_for(db, source, resource)
if not (host_id or address):
return []
sources = await shown_on(db, host_id, address)
return _as_events(await lines_between(db, sources, since, until, AROUND_SEVERITY, AROUND_LINES), sources)
def register_tools(register) -> None:
for fn in (list_syslog_sources, search_syslog):
register(fn, role=UserRole.viewer, read_only=True)