feat(mcp): monitoring-config write tools — monitors, alert rules, acks, maintenance
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 41s
CI / unit (push) Successful in 1m0s
CI / publish (push) Successful in 58s

MCP-7 #4971 "Monitoring-config write tools: monitors, alert rules, acks,
maintenance".

The validation the forms did inline (or not at all) moves to the services,
and both the routes and the tools call it, so bad input is refused the same
way with the same reason:
- monitors/service: monitor_fields, build_config, normalise_target. Port
  1-65535, status 100-599, timeout 1-60, method GET/HEAD/POST, headers an
  object of strings, a key the type does not take is named. The forms now
  answer 400 with the reason instead of redirecting silently or a 500.
- alerts/service: METRIC_CATALOG and SOURCE_MODULES, resources_for,
  metrics_for, rule_fields (source, resource and metric from the same
  choices the rule form offers), add_rule, acknowledge, window_fields.

Tools: create_monitor, update_monitor (config merged over the stored one;
a rename warns about rules still watching the old name),
set_monitor_enabled, create_alert_rule, update_alert_rule,
acknowledge_alert, create_maintenance_window (duration or end),
end_maintenance_window (ends one in progress, cancels an upcoming one) —
operator. delete_monitor, delete_alert_rule — admin. get_alert_rule_options
— viewer. Every write is audited as "<user> via MCP".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 08:19:54 -04:00
co-authored by Claude Opus 5.5
parent 2ebfdc8cdf
commit 9abe0beecc
9 changed files with 1008 additions and 196 deletions
+212 -5
View File
@@ -1,12 +1,16 @@
"""Alert tools: what is firing, what fired before, the rules, and maintenance windows."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from steward.alerts import service as alert_svc
from steward.models.alerts import AlertEvent, AlertRule, AlertState
from steward.mcp._context import current_caller
from steward.models.alerts import AlertEvent, AlertOperator, AlertRule, AlertState, MaintenanceWindow
from steward.models.users import User, UserRole
from ._audit import audit_mcp
from ._shape import (
age_s, bounded, clamp_limit, db_session, iso, lookback, now_utc, pick, rnd,
)
@@ -184,8 +188,211 @@ async def list_maintenance_windows(include_past: bool = False, limit: int = 50)
return bounded(rows, limit, key="windows")
# ── changes ──────────────────────────────────────────────────────────────────
async def get_alert_rule_options(source_module: str = "") -> dict:
"""What an alert rule can watch: the choices create_alert_rule accepts.
source_module: empty lists every source and its metrics; a source's name
also lists its resources (hosts, containers, monitors… whatever that
source reports under).
A monitor reports under its type (icmp/tcp/dns/http) with its name as the
resource: is_up (1 up, 0 down) and response_ms.
"""
async with db_session() as db:
if not source_module:
return {"sources": {src: await alert_svc.metrics_for(db, src)
for src in alert_svc.SOURCE_MODULES},
"operators": [o.value for o in AlertOperator]}
if source_module not in alert_svc.METRIC_CATALOG:
raise ValueError(f"source_module {source_module!r} is not one of "
f"{', '.join(alert_svc.SOURCE_MODULES)}.")
return {"source_module": source_module,
"metrics": await alert_svc.metrics_for(db, source_module),
"resources": await alert_svc.resources_for(db, source_module),
"operators": [o.value for o in AlertOperator]}
async def _rule(db, rule: str) -> AlertRule:
return pick(rule, await alert_svc.list_rules(db), kind="alert rule", list_tool="list_alert_rules")
def _rule_row(r: AlertRule) -> dict:
return {"rule_id": r.id, "rule_name": r.name, "enabled": r.enabled,
"source_module": r.source_module, "resource": r.resource_name,
"metric": r.metric_name, "condition": _condition(r), "ansible_action": _action(r)}
async def create_alert_rule(name: str, source_module: str, resource: str, metric: str,
operator: str, threshold: float, consecutive_checks: int = 1,
enabled: bool = True) -> dict:
"""Add an alert rule: notify when `metric` of `resource` crosses `threshold`.
source_module, resource, metric: get_alert_rule_options lists the valid
choices; a resource must have reported (or, for a monitor, exist).
operator: ">", "<", ">=", "<=", "==" or "!=".
consecutive_checks: how many checks in a row must breach before it fires
(default 1); higher values ride out a single blip.
Notifications go to the channels set in Steward's Settings. The playbook a
rule runs when it fires is set separately (set_alert_action, admin).
Recorded in the audit log as "<you> via MCP".
"""
caller = current_caller()
async with db_session() as db, db.begin():
fields = await alert_svc.rule_fields(
db, name=name, source_module=source_module, resource_name=resource,
metric_name=metric, operator=operator, threshold=threshold,
consecutive_failures_required=consecutive_checks)
r = await alert_svc.add_rule(db, fields, created_by=caller.user_id, enabled=enabled)
await audit_mcp("alert_rule.created", "alert_rule", r.name,
{"rule_id": r.id, "source": r.source_module, "resource": r.resource_name,
"metric": r.metric_name})
return _rule_row(r)
async def update_alert_rule(rule: str, name: str | None = None, source_module: str | None = None,
resource: str | None = None, metric: str | None = None,
operator: str | None = None, threshold: float | None = None,
consecutive_checks: int | None = None,
enabled: bool | None = None) -> dict:
"""Change an alert rule. Only the arguments you pass change; the rest stay.
rule: its name or id (list_alert_rules). Pass enabled=false to silence it
without deleting it. The changed rule is validated as create_alert_rule
validates a new one. Recorded in the audit log.
"""
async with db_session() as db, db.begin():
r = await _rule(db, rule)
fields = await alert_svc.rule_fields(
db,
name=r.name if name is None else name,
source_module=r.source_module if source_module is None else source_module,
resource_name=r.resource_name if resource is None else resource,
metric_name=r.metric_name if metric is None else metric,
operator=r.operator.value if operator is None else operator,
threshold=r.threshold if threshold is None else threshold,
consecutive_failures_required=(r.consecutive_failures_required
if consecutive_checks is None else consecutive_checks))
for k, v in fields.items():
setattr(r, k, v)
if enabled is not None:
r.enabled = bool(enabled)
changed = sorted(k for k, v in {"name": name, "source_module": source_module,
"resource": resource, "metric": metric, "operator": operator,
"threshold": threshold, "consecutive_checks": consecutive_checks,
"enabled": enabled}.items() if v is not None)
await audit_mcp("alert_rule.updated", "alert_rule", r.name, {"rule_id": r.id, "changed": changed})
return _rule_row(r)
async def delete_alert_rule(rule: str) -> dict:
"""Delete an alert rule and its history. To silence it but keep it,
update_alert_rule(enabled=false). Admin only; recorded in the audit log."""
async with db_session() as db, db.begin():
r = await _rule(db, rule)
await db.delete(r)
await audit_mcp("alert_rule.deleted", "alert_rule", r.name, {"rule_id": r.id})
return {"deleted": r.name, "rule_id": r.id}
async def acknowledge_alert(rule: str) -> dict:
"""Acknowledge a firing alert: you have seen it. It stays "acknowledged"
while the condition holds and resolves when it clears.
rule: the firing rule's name or id (list_alerts). Recorded in the audit log.
"""
caller = current_caller()
async with db_session() as db, db.begin():
r = await _rule(db, rule)
try:
state = await alert_svc.acknowledge(db, r.id, caller.user_id)
except LookupError:
raise ValueError(f"{r.name} has never been evaluated, so there is nothing to "
"acknowledge.") from None
await audit_mcp("alert_rule.acknowledged", "alert_rule", r.name, {"rule_id": r.id})
return {"rule_id": r.id, "rule_name": r.name, "state": state.state.value,
"acknowledged_at": iso(state.acknowledged_at)}
def _when(value: str, what: str) -> datetime:
try:
dt = datetime.fromisoformat(value.strip().replace("Z", "+00:00"))
except ValueError:
raise ValueError(f"{what} {value!r} is not an ISO-8601 time, e.g. 2026-10-06T22:00:00Z.") from None
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
def _window_row(w: MaintenanceWindow, now: datetime) -> dict:
return {"window_id": w.id, "name": w.name, "start_at": iso(w.start_at), "end_at": iso(w.end_at),
"active": w.start_at <= now <= w.end_at,
"scope": {"source_module": w.scope_source, "resource": w.scope_resource}}
async def create_maintenance_window(name: str, duration_minutes: int = 0, end_at: str = "",
start_at: str = "", source_module: str = "",
resource: str = "") -> dict:
"""Hold back alert notifications while you work on something.
Give the end as duration_minutes OR end_at (ISO-8601; a time with no zone
is UTC). start_at defaults to now.
source_module, resource: limit the window to one source, or to one
resource of it; both empty silences everything.
Alerts still change state during a window, so list_alerts shows them;
only notifications are held. Recorded in the audit log.
"""
now = now_utc()
start = _when(start_at, "start_at") if (start_at or "").strip() else now
if bool(duration_minutes) == bool((end_at or "").strip()):
raise ValueError("give exactly one of duration_minutes or end_at.")
if duration_minutes:
if duration_minutes < 0:
raise ValueError("duration_minutes must be positive.")
end = start + timedelta(minutes=duration_minutes)
else:
end = _when(end_at, "end_at")
fields = alert_svc.window_fields(name=name, start_at=start, end_at=end,
scope_source=source_module, scope_resource=resource)
w = MaintenanceWindow(created_by=current_caller().user_id, **fields)
async with db_session() as db, db.begin():
db.add(w)
await audit_mcp("maintenance_window.created", "maintenance_window", w.name,
{"window_id": w.id, "scope": alert_svc.window_scope(w),
"start_at": iso(w.start_at), "end_at": iso(w.end_at)})
return _window_row(w, now)
async def end_maintenance_window(window: str) -> dict:
"""End a maintenance window now, so notifications resume.
window: its name or id (list_maintenance_windows). A window in progress
ends now and stays on record; one that has not started yet is removed.
Recorded in the audit log.
"""
now = now_utc()
async with db_session() as db, db.begin():
windows = await alert_svc.list_maintenance_windows(db)
w = pick(window, windows, kind="maintenance window", list_tool="list_maintenance_windows")
if w.end_at <= now:
raise ValueError(f"{w.name} already ended at {iso(w.end_at)}.")
if w.start_at > now:
await db.delete(w)
outcome = "cancelled"
else:
w.end_at = now
outcome = "ended"
await audit_mcp(f"maintenance_window.{outcome}", "maintenance_window", w.name,
{"window_id": w.id})
return {**_window_row(w, now), "outcome": outcome}
def register(register_tool) -> None:
register_tool(list_alerts, role=UserRole.viewer, read_only=True)
register_tool(get_alert_history, role=UserRole.viewer, read_only=True)
register_tool(list_alert_rules, role=UserRole.viewer, read_only=True)
register_tool(list_maintenance_windows, role=UserRole.viewer, read_only=True)
for fn in (list_alerts, get_alert_history, list_alert_rules, list_maintenance_windows,
get_alert_rule_options):
register_tool(fn, role=UserRole.viewer, read_only=True)
for fn in (create_alert_rule, update_alert_rule, acknowledge_alert,
create_maintenance_window, end_maintenance_window):
register_tool(fn, role=UserRole.operator, read_only=False)
register_tool(delete_alert_rule, role=UserRole.admin, read_only=False)
+134
View File
@@ -5,11 +5,13 @@ import json
from sqlalchemy import select
from steward.models.alerts import AlertRule
from steward.models.hosts import Host
from steward.models.monitors import MONITOR_TYPE_VALUES, Monitor
from steward.models.users import UserRole
from steward.monitors import service as monitor_svc
from ._audit import audit_mcp
from ._shape import (
age_s, bounded, clamp_limit, db_session, iso, now_utc, pick, redact_url, rnd,
)
@@ -137,6 +139,138 @@ async def get_monitor(monitor: str, failures: int = 10) -> dict:
}
# ── changes ──────────────────────────────────────────────────────────────────
async def _host_id(db, host: str) -> str | None:
if not (host or "").strip():
return None
hosts = (await db.execute(select(Host))).scalars()
return pick(host, hosts, kind="host", list_tool="list_hosts").id
def _saved(m: Monitor, host_names: dict[str, str]) -> dict:
return {
"monitor_id": m.id,
"monitor_name": m.name,
"type": m.type,
"target": redact_url(m.target),
"host_name": host_names.get(m.host_id) if m.host_id else None,
"enabled": m.enabled,
"check_interval_s": m.check_interval_seconds or None,
"config": _config(m),
"next": f'get_monitor(monitor="{m.id}")',
}
async def create_monitor(name: str, type: str, target: str, host: str = "",
config: dict | None = None, check_interval_s: int = 0,
enabled: bool = True) -> dict:
"""Add an uptime check. Recorded in the audit log as "<you> via MCP".
type: "icmp" (ping), "tcp" (connect to a port), "dns" (resolve a name)
or "http" (request a URL).
target: an address or hostname; for http a URL (a bare host gets https://).
host: a Steward host's name or id to file the check under; empty for a
free-standing destination.
config, by type (every key optional, defaults shown):
tcp: {"port": 80}
dns: {"expected_ip": null} (set to fail when the name resolves elsewhere)
http: {"method": "GET" | "HEAD" | "POST", "expected_status": 200,
"content_match": "", "headers": {}, "timeout_seconds": 10 (1-60),
"follow_redirects": true, "verify_ssl": true}
icmp takes none.
check_interval_s: seconds between checks; 0 (default) uses the global
interval from Settings.
The check's metrics are reported under source_module = its type and
resource = its name, which is what an alert rule on it watches.
"""
fields = monitor_svc.monitor_fields(type, target=target, name=name,
check_interval_seconds=check_interval_s, opts=config or {})
async with db_session() as db, db.begin():
m = Monitor(host_id=await _host_id(db, host), enabled=enabled, **fields)
db.add(m)
_monitors, host_names = await _load(db)
await audit_mcp("monitor.created", "monitor", m.name,
{"monitor_id": m.id, "type": m.type, "target": redact_url(m.target)})
return _saved(m, host_names)
async def update_monitor(monitor: str, name: str | None = None, target: str | None = None,
host: str | None = None, config: dict | None = None,
check_interval_s: int | None = None) -> dict:
"""Change a monitor. Only the arguments you pass change; the rest stay.
monitor: its name or id (list_monitors).
host: a host's name or id to file it under, or "" to unlink it.
config: the keys to change, merged over its current config (see
create_monitor for the keys of each type). A monitor's type cannot
change; delete it and create another.
Alert rules watch a monitor by its name, so a rename returns `warning`
naming the rules still on the old name.
Recorded in the audit log.
"""
async with db_session() as db, db.begin():
monitors, host_names = await _load(db)
m = pick(monitor, monitors, kind="monitor", list_tool="list_monitors")
current = {k: v for k, v in m.config.items() if k in monitor_svc.CONFIG_KEYS[m.type]}
fields = monitor_svc.monitor_fields(
m.type, target=m.target if target is None else target,
name=m.name if name is None else name,
check_interval_seconds=(m.check_interval_seconds if check_interval_s is None
else check_interval_s),
opts={**current, **(config or {})})
old_name = m.name
for k, v in fields.items():
setattr(m, k, v)
if host is not None:
m.host_id = await _host_id(db, host)
stranded = []
if m.name != old_name:
stranded = list((await db.execute(
select(AlertRule.name).where(AlertRule.source_module == m.type,
AlertRule.resource_name == old_name))).scalars())
changed = sorted(k for k, v in {"name": name, "target": target, "host": host, "config": config,
"check_interval_s": check_interval_s}.items() if v is not None)
await audit_mcp("monitor.updated", "monitor", m.name, {"monitor_id": m.id, "changed": changed})
out = _saved(m, host_names)
if stranded:
out["warning"] = (f"Alert rules watch this monitor by name and still watch {old_name!r}: "
f"{', '.join(stranded)}. update_alert_rule(resource=\"{m.name}\") "
"points each at the new name.")
return out
async def set_monitor_enabled(monitor: str, enabled: bool) -> dict:
"""Pause (enabled=false) or resume a monitor. A paused monitor runs no checks
and raises no alerts. Recorded in the audit log."""
async with db_session() as db, db.begin():
monitors, host_names = await _load(db)
m = pick(monitor, monitors, kind="monitor", list_tool="list_monitors")
m.enabled = bool(enabled)
await audit_mcp("monitor.enabled" if m.enabled else "monitor.disabled", "monitor", m.name,
{"monitor_id": m.id})
return _saved(m, host_names)
async def delete_monitor(monitor: str) -> dict:
"""Delete a monitor and its check history. To stop it but keep its history,
set_monitor_enabled(enabled=false) instead. Admin only; recorded in the
audit log."""
async with db_session() as db, db.begin():
monitors, _host_names = await _load(db)
m = pick(monitor, monitors, kind="monitor", list_tool="list_monitors")
await db.delete(m)
await audit_mcp("monitor.deleted", "monitor", m.name,
{"monitor_id": m.id, "type": m.type, "target": redact_url(m.target)})
return {"deleted": m.name, "monitor_id": m.id}
def register(register_tool) -> None:
register_tool(list_monitors, role=UserRole.viewer, read_only=True)
register_tool(get_monitor, role=UserRole.viewer, read_only=True)
for fn in (create_monitor, update_monitor, set_monitor_enabled):
register_tool(fn, role=UserRole.operator, read_only=False)
register_tool(delete_monitor, role=UserRole.admin, read_only=False)