feat(mcp): monitoring-config write tools — monitors, alert rules, acks, maintenance
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:
@@ -0,0 +1,97 @@
|
||||
"""The monitoring-config write tools: their validation refusals and their roles.
|
||||
|
||||
Monitor-config validation is covered in tests/core/test_monitors.py (the form
|
||||
and the tools share it). The writes themselves, their audit rows and the
|
||||
rename warning run against Postgres in
|
||||
tests/integration/test_mcp_monitoring_writes.py.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from steward.alerts import service as alert_svc
|
||||
from steward.mcp.tools import alerts
|
||||
from steward.models.alerts import AlertOperator
|
||||
from steward.models.users import UserRole
|
||||
|
||||
NOW = datetime(2026, 10, 6, 12, 0, tzinfo=timezone.utc)
|
||||
|
||||
OPERATOR_TOOLS = {"create_monitor", "update_monitor", "set_monitor_enabled", "create_alert_rule",
|
||||
"update_alert_rule", "acknowledge_alert", "create_maintenance_window",
|
||||
"end_maintenance_window"}
|
||||
ADMIN_TOOLS = {"delete_monitor", "delete_alert_rule"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def known(monkeypatch):
|
||||
"""A source with one resource and its catalog metrics, without a database."""
|
||||
async def resources(db, src):
|
||||
return ["nas"] if src == "host_agent" else []
|
||||
monkeypatch.setattr(alert_svc, "resources_for", resources)
|
||||
|
||||
|
||||
async def _fields(**over):
|
||||
args = dict(name="cpu hot", source_module="host_agent", resource_name="nas",
|
||||
metric_name="cpu_pct", operator=">", threshold="90", consecutive_failures_required=2)
|
||||
args.update(over)
|
||||
return await alert_svc.rule_fields(None, **args)
|
||||
|
||||
|
||||
async def test_rule_fields_accepts_the_form_choices(known):
|
||||
f = await _fields()
|
||||
assert f["operator"] is AlertOperator.gt and f["threshold"] == 90.0
|
||||
assert f["consecutive_failures_required"] == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("over, why", [
|
||||
({"name": " "}, "needs a name"),
|
||||
({"source_module": "zabbix"}, "'zabbix' is not one of icmp, tcp"),
|
||||
({"resource_name": "web01"}, "host_agent has no resource 'web01'. Known: nas"),
|
||||
({"metric_name": "cpu"}, "does not report 'cpu'. Its metrics: cpu_pct"),
|
||||
({"operator": "=>"}, "'=>' is not one of >, <"),
|
||||
({"threshold": "high"}, "threshold must be a number"),
|
||||
({"consecutive_failures_required": 0}, "at least 1"),
|
||||
])
|
||||
async def test_rule_fields_refuses_with_a_reason(known, over, why):
|
||||
with pytest.raises(ValueError, match=why):
|
||||
await _fields(**over)
|
||||
|
||||
|
||||
def test_window_fields():
|
||||
f = alert_svc.window_fields(name="patching", start_at=NOW, end_at=NOW + timedelta(hours=1),
|
||||
scope_source="", scope_resource="nas")
|
||||
assert f["scope_source"] is None and f["scope_resource"] is None # resource needs a source
|
||||
with pytest.raises(ValueError, match="must end after it starts"):
|
||||
alert_svc.window_fields(name="x", start_at=NOW, end_at=NOW, scope_source=None,
|
||||
scope_resource=None)
|
||||
with pytest.raises(ValueError, match="'nope' is not one of"):
|
||||
alert_svc.window_fields(name="x", start_at=NOW, end_at=NOW + timedelta(minutes=1),
|
||||
scope_source="nope", scope_resource=None)
|
||||
|
||||
|
||||
async def test_maintenance_window_needs_exactly_one_end():
|
||||
with pytest.raises(ValueError, match="exactly one of duration_minutes or end_at"):
|
||||
await alerts.create_maintenance_window("x")
|
||||
with pytest.raises(ValueError, match="exactly one of duration_minutes or end_at"):
|
||||
await alerts.create_maintenance_window("x", duration_minutes=30, end_at="2026-10-06T13:00:00Z")
|
||||
with pytest.raises(ValueError, match="not an ISO-8601 time"):
|
||||
await alerts.create_maintenance_window("x", end_at="tonight")
|
||||
|
||||
|
||||
def test_when_reads_a_zoneless_time_as_utc():
|
||||
assert alerts._when("2026-10-06T22:00:00", "end_at") == datetime(2026, 10, 6, 22, tzinfo=timezone.utc)
|
||||
assert alerts._when("2026-10-06T22:00:00Z", "end_at").utcoffset() == timedelta(0)
|
||||
|
||||
|
||||
def test_write_tools_are_registered_with_their_roles(app):
|
||||
mcp = app.mcp_server
|
||||
for name in OPERATOR_TOOLS:
|
||||
assert mcp.tool_roles[name] is UserRole.operator, name
|
||||
assert mcp._tool_manager.get_tool(name).annotations.read_only_hint is False, name
|
||||
for name in ADMIN_TOOLS:
|
||||
assert mcp.tool_roles[name] is UserRole.admin, name
|
||||
assert mcp.tool_roles["get_alert_rule_options"] is UserRole.viewer
|
||||
for name in OPERATOR_TOOLS | ADMIN_TOOLS | {"get_alert_rule_options"}:
|
||||
assert (mcp._tool_manager.get_tool(name).description or "").strip(), name
|
||||
Reference in New Issue
Block a user