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
+29 -41
View File
@@ -5,7 +5,6 @@ type-aware add/edit form, one dashboard widget. A monitor may be linked to a
Host (host_id) or watch a free-standing custom destination (host_id = NULL).
"""
from __future__ import annotations
import json
import uuid
from datetime import datetime, timezone
@@ -20,35 +19,24 @@ from steward.monitors import service as monitor_svc
monitors_bp = Blueprint("monitors", __name__, url_prefix="/monitors")
def _build_config(form, mtype: str) -> dict:
"""Assemble the type-specific config dict from submitted form fields."""
def _form_opts(form, mtype: str) -> dict:
"""The submitted fields of `mtype`'s config, for monitor_svc.build_config.
An unchecked checkbox is absent from the form, so it reads as False."""
if mtype == MonitorType.tcp.value:
return {"port": int(form.get("port") or 80)}
return {"port": form.get("port")}
if mtype == MonitorType.dns.value:
return {"expected_ip": (form.get("expected_ip", "").strip() or None)}
return {"expected_ip": form.get("expected_ip")}
if mtype == MonitorType.http.value:
try:
headers = json.loads(form.get("headers_json") or "{}")
except (ValueError, TypeError):
headers = {}
return {
"method": form.get("method", "GET").upper(),
"expected_status": int(form.get("expected_status", 200) or 200),
"content_match": form.get("content_match", "").strip(),
"headers": headers,
"timeout_seconds": int(form.get("timeout_seconds", 10) or 10),
"method": form.get("method"),
"expected_status": form.get("expected_status"),
"content_match": form.get("content_match"),
"headers": form.get("headers_json"),
"timeout_seconds": form.get("timeout_seconds"),
"follow_redirects": "follow_redirects" in form,
"verify_ssl": "verify_ssl" in form,
}
return {} # icmp
def _normalise_target(mtype: str, target: str) -> str:
"""For http, default a bare host to https://; others pass through."""
target = target.strip()
if mtype == MonitorType.http.value and target and not target.startswith(("http://", "https://")):
return "https://" + target
return target
return {}
@monitors_bp.get("/")
@@ -80,22 +68,17 @@ async def rows():
async def add_monitor():
form = await request.form
mtype = form.get("type", "").strip()
target = _normalise_target(mtype, form.get("target", ""))
if mtype not in MONITOR_TYPE_VALUES or not target:
return redirect(url_for("monitors.index"))
try:
fields = monitor_svc.monitor_fields(
mtype, target=form.get("target", ""), name=form.get("name", ""),
check_interval_seconds=form.get("check_interval_seconds"),
opts=_form_opts(form, mtype))
except ValueError as exc:
return str(exc), 400
host_id = form.get("host_id", "").strip() or None
monitor = Monitor(
id=str(uuid.uuid4()),
name=form.get("name", "").strip() or target,
type=mtype,
target=target,
host_id=host_id,
config_json=json.dumps(_build_config(form, mtype)),
enabled=True,
check_interval_seconds=int(form.get("check_interval_seconds", 0) or 0),
created_at=datetime.now(timezone.utc),
)
monitor = Monitor(id=str(uuid.uuid4()), host_id=host_id, enabled=True,
created_at=datetime.now(timezone.utc), **fields)
async with current_app.db_sessionmaker() as db:
async with db.begin():
db.add(monitor)
@@ -129,10 +112,15 @@ async def edit_monitor(monitor_id: str):
return redirect(url_for("monitors.index"))
# Type is immutable on edit (changes the meaning of config/target);
# to switch type, delete and re-add.
m.name = form.get("name", "").strip() or m.target
m.target = _normalise_target(m.type, form.get("target", m.target))
m.config_json = json.dumps(_build_config(form, m.type))
m.check_interval_seconds = int(form.get("check_interval_seconds", 0) or 0)
try:
fields = monitor_svc.monitor_fields(
m.type, target=form.get("target", m.target), name=form.get("name", ""),
check_interval_seconds=form.get("check_interval_seconds"),
opts=_form_opts(form, m.type))
except ValueError as exc:
return str(exc), 400
for k, v in fields.items():
setattr(m, k, v)
return redirect(url_for("monitors.index"))