From 45aae6d58558328e3cae495ca97d350a8c7383c5 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 30 Oct 2026 19:04:34 -0400 Subject: [PATCH] feat(syslog): list patterns and add a ready-made one through MCP Patterns could only be added on the Syslog page, so an agent asked to map them on the NASes couldn't. list_syslog_patterns gives each pattern's metric and the ready-made ones not added; add_syslog_preset (admin, audited) adds one. The key-picking helper moves beside the patterns for both callers. Co-Authored-By: Claude Opus 5.5 --- docs/core/mcp.md | 2 +- docs/plugins/syslog.md | 2 +- plugins/syslog/mcp_tools.py | 54 ++++++++++++++++++++++++++++++-- plugins/syslog/patterns.py | 10 ++++++ plugins/syslog/routes.py | 15 ++------- tests/integration/test_syslog.py | 32 +++++++++++++++++++ 6 files changed, 99 insertions(+), 16 deletions(-) diff --git a/docs/core/mcp.md b/docs/core/mcp.md index abcb742..e8852f8 100644 --- a/docs/core/mcp.md +++ b/docs/core/mcp.md @@ -85,7 +85,7 @@ Each role includes the tools of the roles above it in this list. | 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 | -| Syslog plugin | `list_syslog_sources` (the devices sending syslog: added, offered or ignored, each with its rate, last heard and newest line), `search_syslog` (an added source's kept lines, or a host's, filtered by text, severity and worse, app and time, newest first) | +| Syslog plugin | `list_syslog_sources` (the devices sending syslog: added, offered or ignored, each with its rate, last heard and newest line), `search_syslog` (an added source's kept lines, or a host's, filtered by text, severity and worse, app and time, newest first), `list_syslog_patterns` (the phrases and severities counted, the metric each writes and the ready-made ones not added), `add_syslog_preset` (admin: add a ready-made one) | | 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/docs/plugins/syslog.md b/docs/plugins/syslog.md index 5d3aa7e..5c7c5e8 100644 --- a/docs/plugins/syslog.md +++ b/docs/plugins/syslog.md @@ -26,7 +26,7 @@ puts them beside Steward's other readings. by the anomaly's host or SNMP device. Each anomaly row has "Syslog around it". Host metrics charts and SNMP device charts mark each error with a dashed red line. -- **MCP.** `list_syslog_sources` and `search_syslog`. +- **MCP.** `list_syslog_sources`, `search_syslog`, `list_syslog_patterns` and `add_syslog_preset`. ## Deploying diff --git a/plugins/syslog/mcp_tools.py b/plugins/syslog/mcp_tools.py index 0131598..7a766fe 100644 --- a/plugins/syslog/mcp_tools.py +++ b/plugins/syslog/mcp_tools.py @@ -8,12 +8,14 @@ from datetime import datetime from sqlalchemy import select from steward.core.paging import keyset +from steward.mcp.tools._audit import audit_mcp 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 .models import SyslogLine, SyslogPattern, SyslogSource from .parse import facility_name, severity_name +from .patterns import PRESETS, PRESETS_BY_KEY, SOURCE_MODULE, free_key, resource_of 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) @@ -127,6 +129,53 @@ async def search_syslog(source: str = "", host: str = "", text: str = "", severi return out +async def list_syslog_patterns() -> dict: + """The phrases and severities counted in the kept syslog lines, and the + ready-made ones not added yet. + + Each pattern's count of matching lines over its window is the metric + `metric` of source module `source_module`, per added source (`resources`, + empty until a source is added), written every minute with zeros included. + An alert rule on it ("> 0") fires on the first match and clears once the + window has passed. add_syslog_preset adds a ready-made one. + """ + async with db_session() as db: + patterns = list((await db.execute(select(SyslogPattern).order_by(SyslogPattern.name))).scalars()) + accepted = list((await db.execute( + select(SyslogSource).where(SyslogSource.state == "accepted"))).scalars()) + taken = {p.key for p in patterns} + return {"source_module": SOURCE_MODULE, "patterns": [{ + "key": p.key, "name": p.name, "metric": p.metric, "phrases": p.phrases or [], + "severity_and_worse": severity_name(p.max_severity) if p.max_severity is not None else None, + "window_minutes": p.window_minutes, "enabled": p.enabled, + "resources": [resource_of(s) for s in accepted if p.source_id in (None, s.id)], + } for p in patterns], "presets_not_added": [{ + "key": p.key, "name": p.name, "about": p.about, + } for p in PRESETS if p.key not in taken]} + + +async def add_syslog_preset(key: str) -> dict: + """Add a ready-made pattern, counted for every added source. The same as + "Add" under Syslog → Patterns. Disk I/O errors, disk resets, NFS server + trouble, filesystem errors, degraded arrays and errors and worse: + list_syslog_patterns gives their keys. Recorded in the audit log as + " via MCP". + """ + preset = PRESETS_BY_KEY.get((key or "").strip()) + if preset is None: + raise ValueError(f"No preset {key!r}. Presets: {', '.join(p.key for p in PRESETS)}.") + async with db_session() as db, db.begin(): + if (await db.execute(select(SyslogPattern).where(SyslogPattern.key == preset.key))).scalars().first(): + raise ValueError(f"{preset.name!r} is already added.") + pattern = SyslogPattern(key=await free_key(db, preset.key), name=preset.name, + phrases=list(preset.phrases), max_severity=preset.max_severity) + db.add(pattern) + await db.flush() + added = {"pattern_id": pattern.id, "key": pattern.key, "name": pattern.name, "metric": pattern.metric} + await audit_mcp("syslog.pattern_added", "syslog_pattern", preset.name, {"preset": preset.key}) + return added + + 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} @@ -155,5 +204,6 @@ async def mcp_resource_events(db, source: str, resource: str, since, until) -> l def register_tools(register) -> None: - for fn in (list_syslog_sources, search_syslog): + for fn in (list_syslog_sources, search_syslog, list_syslog_patterns): register(fn, role=UserRole.viewer, read_only=True) + register(add_syslog_preset, role=UserRole.admin, read_only=False) diff --git a/plugins/syslog/patterns.py b/plugins/syslog/patterns.py index 5a7ec56..1a68c93 100644 --- a/plugins/syslog/patterns.py +++ b/plugins/syslog/patterns.py @@ -87,6 +87,16 @@ def key_for(name: str) -> str: return _KEY.sub("_", name.lower()).strip("_")[:48] or "pattern" +async def free_key(db, wanted: str) -> str: + """`wanted`, or that with a number where another pattern has it.""" + taken = set((await db.execute(select(SyslogPattern.key))).scalars()) + key, n = wanted, 2 + while key in taken: + key = f"{wanted[:44]}_{n}" + n += 1 + return key + + def parse_form(form) -> dict: """A pattern's fields from its form, or PatternError saying what to fix.""" name = (form.get("name") or "").strip()[:100] diff --git a/plugins/syslog/routes.py b/plugins/syslog/routes.py index 51ac5bd..b750a7c 100644 --- a/plugins/syslog/routes.py +++ b/plugins/syslog/routes.py @@ -21,7 +21,7 @@ from steward.models.users import UserRole from .models import SyslogLine, SyslogPattern, SyslogSource from .network import on_own_network from .parse import SEVERITIES, facility_name, severity_name -from .patterns import (PRESETS, PRESETS_BY_KEY, SOURCE_MODULE, WINDOWS, PatternError, key_for, parse_form, +from .patterns import (PRESETS, PRESETS_BY_KEY, SOURCE_MODULE, WINDOWS, PatternError, free_key, key_for, parse_form, resource_of) from .receiver import LISTENER, MAX_SENDERS, send_to, settings from .service import (AROUND_LINES, AROUND_SEVERITY, MARK_SEVERITY, MAX_MARKS, PANEL_LINES, PANEL_SPAN, @@ -339,15 +339,6 @@ async def patterns_index(): any_sources=any(s.state == "accepted" for s in sources.values())) -async def _free_key(db, wanted: str) -> str: - taken = set((await db.execute(select(SyslogPattern.key))).scalars()) - key, n = wanted, 2 - while key in taken: - key = f"{wanted[:44]}_{n}" - n += 1 - return key - - async def _accepted_sources(db) -> list: return list((await db.execute(select(SyslogSource).where(SyslogSource.state == "accepted") .order_by(SyslogSource.name))).scalars()) @@ -368,7 +359,7 @@ async def add_preset(key: str): abort(404) async with current_app.db_sessionmaker() as db: async with db.begin(): - pattern = SyslogPattern(key=await _free_key(db, preset.key), name=preset.name, + pattern = SyslogPattern(key=await free_key(db, preset.key), name=preset.name, phrases=list(preset.phrases), max_severity=preset.max_severity) db.add(pattern) await db.flush() @@ -402,7 +393,7 @@ async def create_pattern(): async with db.begin(): if values["source_id"] and await db.get(SyslogSource, values["source_id"]) is None: values["source_id"] = None - pattern = SyslogPattern(key=await _free_key(db, key_for(values["name"])), **values) + pattern = SyslogPattern(key=await free_key(db, key_for(values["name"])), **values) db.add(pattern) await db.flush() pattern_id = pattern.id diff --git a/tests/integration/test_syslog.py b/tests/integration/test_syslog.py index 70698c3..54ccb5e 100644 --- a/tests/integration/test_syslog.py +++ b/tests/integration/test_syslog.py @@ -290,6 +290,38 @@ def test_a_source_page_filters_its_lines_and_hosts_show_them(app): asyncio.run(go()) +def test_a_preset_is_added_once_through_mcp(app): + from sqlalchemy import delete + + from steward.core.api_tokens import Caller + from steward.mcp import _context + from steward.models.users import UserRole + + tools = _mod("mcp_tools") + SyslogPattern = _mod("models").SyslogPattern + + async def go(): + _context._app_ctx.set(app) + _context._caller_ctx.set(Caller(user_id=None, username="admin", role=UserRole.admin, token_id=0)) + async with app.db_sessionmaker() as s: + async with s.begin(): + await s.execute(delete(SyslogPattern).where(SyslogPattern.key == "nfs")) + with pytest.raises(ValueError, match="No preset"): + await tools.add_syslog_preset("no-such-preset") + added = await tools.add_syslog_preset("nfs") + assert added["metric"] == "lines_matching_nfs" + listed = await tools.list_syslog_patterns() + assert "nfs" in {p["key"] for p in listed["patterns"]} + assert "nfs" not in {p["key"] for p in listed["presets_not_added"]} + with pytest.raises(ValueError, match="already added"): + await tools.add_syslog_preset("nfs") + async with app.db_sessionmaker() as s: + async with s.begin(): + await s.execute(delete(SyslogPattern).where(SyslogPattern.key == "nfs")) + + asyncio.run(go()) + + def test_search_syslog_filters_and_pages(app): from steward.core.api_tokens import Caller from steward.mcp import _context