fix(mcp): get_anomaly shows Steward's own restarts beside every anomaly, and a router's containers (#5862)

All 16 Traefik 4xx anomalies on 2026-10-15 came back with nothing
around them, though 15 were Steward's own restarts. A router has no
host, and every events source was read by host.

- Core: each break of more than 90 s in Steward's once-a-minute health
  sample is a "steward" event beside every anomaly, placed or not.
- Plugins: an optional mcp_resource_events(db, source, resource, since,
  until) for a resource with no host. Traefik's gives the container
  events of the swarm service behind a router, on any host.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 15:58:28 -04:00
co-authored by Claude Opus 5.5
parent a24b9da69d
commit 91bb199d14
8 changed files with 139 additions and 10 deletions
+7
View File
@@ -72,3 +72,10 @@ def register_mcp_tools(register) -> None:
async def mcp_overview(db) -> list[dict]:
from .mcp_tools import mcp_overview as findings
return await findings(db)
async def mcp_resource_events(db, source: str, resource: str, since, until) -> list[dict]:
if source != "traefik":
return []
from .mcp_tools import service_events
return await service_events(db, resource, since, until)
+24
View File
@@ -22,6 +22,7 @@ from steward.mcp.tools._shape import (
from steward.mcp.tools.overview import finding
from steward.models.users import UserRole
from . import meaning
from . import routes as page
from .models import TraefikAccessSummary, TraefikCert, TraefikGlobalStat
from .scheduler import error_page_services, router_domains
@@ -316,6 +317,29 @@ async def mcp_overview(db) -> list[dict]:
return router_findings(current) + cert_findings(served, now, thresholds)
async def service_events(db, router: str, since, until) -> list[dict]:
"""What happened to the containers of the swarm service behind a router
(starts with their image, deaths, OOM kills, health changes), on every
host: a router anomaly has no host of its own to read events by (#5862).
[] without the Docker plugin, or when no single service matches."""
from steward.core.plugin_manager import loaded_plugin_submodule
from steward.mcp.tools._events import PER_SOURCE, event
from steward.models.hosts import Host
models = loaded_plugin_submodule("docker", "models")
if models is None:
return []
service = meaning.swarm_service(router, await page.swarm_service_names(db))
if service is None:
return []
e = models.DockerEvent
rows = (await db.execute(
select(e, Host.name).join(Host, Host.id == e.host_id)
.where(e.container_name.startswith(f"{service}.", autoescape=True), e.at >= since, e.at <= until)
.order_by(e.at).limit(PER_SOURCE))).all()
return [event(ev.at, "container", f"{ev.container_name} {ev.event}" + (f" ({ev.detail})" if ev.detail else ""),
subject=f"{host}/{ev.container_name}") for ev, host in rows]
def register_tools(register) -> None:
for fn in (list_routers, get_router, list_certificates, get_traffic):
register(fn, role=UserRole.viewer, read_only=True)