Files
FabledSteward/steward/mcp/server.py
T
bvandeusenandClaude Opus 5.5 0945d78290
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m30s
CI / integration (push) Successful in 1m34s
CI / publish (push) Successful in 1m8s
feat(mcp): understand and judge a metric anomaly: what happened, what was usual, what else went on (#5837)
get_anomaly gives each occurrence against what is usual at that hour, the
metric around the newest one with its normal, what else happened on the
resource's host around each occurrence (alert transitions, Ansible runs,
other anomalies there and a count elsewhere, plus plugins' own events
through a new mcp_events hook: the host agent's maintenance jobs and
downtime, Docker's container starts, deaths and OOM kills), and how earlier
anomalies on the same metric were judged.

get_metric_normal reads the learned slots and the level that would be
flagged. review_anomaly takes one id or several, all or nothing, with a
note. get_anomaly_settings and (admin) set_anomaly_settings read and change
the review window and each normal's floor, as Settings → Thresholds does.

metric_series.points takes an optional `until`; core.alerts gains
host_of_resource; floor_setting names its floor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 13:17:34 -04:00

312 lines
14 KiB
Python

"""Steward's MCP server: the StewardMCPServer instance and its mount at /mcp.
The /mcp path is dispatched to the MCP SDK's streamable-HTTP ASGI app BEFORE
Quart sees it (wrapping `app.asgi_app`), so Quart's session, before_request
hooks and error pages do not apply. This module supplies the equivalents:
- bearer auth: a personal API token resolves to a Caller (user + effective
role), set in a ContextVar for the tools — 401 otherwise;
- the database gate: the same ride-out-a-restart probe the web pipeline uses,
answering 503 rather than failing deep in a tool;
- role gating, default-deny: every tool is registered with a minimum role and
a tool the caller's role doesn't reach is neither listed nor callable.
"""
from __future__ import annotations
import difflib
import functools
import json
import logging
from mcp.server.mcpserver import MCPServer
from mcp.server.mcpserver.exceptions import ToolError, UnexpectedToolError
from mcp.server.transport_security import TransportSecuritySettings
from mcp.types import ToolAnnotations
from sqlalchemy.exc import SQLAlchemyError
from steward.auth.middleware import role_meets
from steward.models.users import UserRole
from . import _context
logger = logging.getLogger(__name__)
MCP_PATH = "/mcp"
# Claude Code injects only the first ~2,048 characters of a server's
# instructions; keep this well under (test_instructions_fit). The workflow
# across tools lives here; each tool's own contract lives in its docstring.
INSTRUCTIONS = """\
Steward is a self-hosted monitoring and infrastructure hub: hosts, uptime
monitors, host-agent metrics, Docker containers and logs, alerts, and Ansible
automation. You act as the user who created your token, at that token's role.
Start broad, then narrow: get_overview says what is wrong right now; follow
its ids into get_host / get_monitor / get_container, then metrics and logs.
List tools are bounded: a response with `truncated: true` says which argument
fetches more. Times are UTC ISO-8601; units are in field names (_pct, _ms,
_bytes).
Changes are audited under the user's name. run_playbook is a dry run (Ansible
check mode) unless you pass check=false; dry-run first and show the user the
diff before applying. If a tool refuses for lack of role, whoami says what
this token can do.
"""
_DB_UNAVAILABLE = (
"Steward is running but can't reach its database right now, so nothing was "
"read or changed. This is usually a database restart; retry in a minute."
)
def _is_db_failure(exc: BaseException | None) -> bool:
seen: set[int] = set()
while exc is not None and id(exc) not in seen:
seen.add(id(exc))
if isinstance(exc, (SQLAlchemyError, OSError, TimeoutError)):
return True
exc = exc.__cause__ or exc.__context__
return False
class StewardMCPServer(MCPServer):
"""MCPServer with per-tool minimum roles, plugin-scoped tools and strict args."""
def __init__(self, app, **kwargs):
super().__init__("steward", instructions=INSTRUCTIONS.strip(), **kwargs)
self._steward_app = app
self.tool_roles: dict[str, UserRole] = {}
self.tool_plugins: dict[str, str] = {}
# plugin name -> its `mcp_overview(db)`: findings it adds to get_overview.
self.overview_sources: dict[str, object] = {}
# plugin name -> its `mcp_events(db, host_id, host_name, since, until)`:
# what it knows happened on a host, for get_anomaly.
self.event_sources: dict[str, object] = {}
# ── registration ─────────────────────────────────────────────────────────
def register_tool(self, fn, *, role: UserRole, read_only: bool, plugin: str | None = None) -> None:
"""The only way a tool reaches the server: name, minimum role, effect.
`role` and `read_only` are required — there is no default for a
mutating tool to inherit by omission.
"""
name = fn.__name__
if name in self.tool_roles:
raise ValueError(f"MCP tool {name!r} is already registered")
if not isinstance(role, UserRole):
raise TypeError(f"MCP tool {name!r}: role must be a UserRole, got {role!r}")
self.add_tool(
fn, name=name,
annotations=ToolAnnotations(read_only_hint=read_only,
destructive_hint=None if read_only else True),
)
self.tool_roles[name] = role
if plugin:
self.tool_plugins[name] = plugin
def register_plugin(self, name: str, module) -> None:
"""Add a plugin's tools via its optional `register_mcp_tools(register)`,
its optional `mcp_overview(db)` as a get_overview findings source, and
its optional `mcp_events(db, host_id, host_name, since, until)` as a
source of what happened on a host (get_anomaly).
Idempotent per plugin (hot-reload drops its previous tools first) and
tolerant: a raising hook is logged and leaves the plugin with no tools,
never a broken server.
"""
self._drop_plugin_tools(name)
self.overview_sources.pop(name, None)
overview = getattr(module, "mcp_overview", None)
if callable(overview):
self.overview_sources[name] = overview
self.event_sources.pop(name, None)
events = getattr(module, "mcp_events", None)
if callable(events):
self.event_sources[name] = events
hook = getattr(module, "register_mcp_tools", None)
if hook is None:
return
try:
hook(functools.partial(self.register_tool, plugin=name))
except Exception:
# All or nothing: a hook that fails halfway leaves no partial set.
self._drop_plugin_tools(name)
logger.exception("Plugin %r: register_mcp_tools() raised; its MCP tools are unavailable", name)
def _drop_plugin_tools(self, name: str) -> None:
for tool_name in [t for t, p in self.tool_plugins.items() if p == name]:
self.remove_tool(tool_name)
self.tool_roles.pop(tool_name, None)
self.tool_plugins.pop(tool_name, None)
# ── gating ───────────────────────────────────────────────────────────────
def plugin_enabled(self, plugin: str) -> bool:
cfg = (self._steward_app.config.get("PLUGINS") or {}).get(plugin)
return isinstance(cfg, dict) and bool(cfg.get("enabled"))
def refusal(self, name: str, caller) -> str | None:
"""Why `caller` may not use tool `name`, or None if they may."""
required = self.tool_roles.get(name)
if required is None:
return f"There is no Steward tool named {name!r}."
plugin = self.tool_plugins.get(name)
if plugin and not self.plugin_enabled(plugin):
return (f"{name} belongs to the {plugin} plugin, which is disabled in this "
"Steward. An admin can enable it under Settings → Plugins.")
if caller is None:
return "This request is not authenticated."
if not role_meets(caller.role, required):
return (f"{name} needs the {required.value} role; this connection acts as "
f"{caller.role.value}. A token's role is capped at its user's role — "
"create a token with a higher role on Steward's AI access page if "
"your account allows it.")
return None
async def list_tools(self):
caller = _context.current_caller_or_none()
tools = await super().list_tools()
return [t for t in tools if self.refusal(t.name, caller) is None]
async def call_tool(self, name, arguments, context=None):
refusal = self.refusal(name, _context.current_caller_or_none())
if refusal:
raise ToolError(refusal)
# Reject undeclared arguments: pydantic would silently drop a misnamed
# one and run the tool with that field's default.
tool = self._tool_manager.get_tool(name)
declared = set((tool.parameters or {}).get("properties", {})) if tool else set()
unknown = sorted(set(arguments or {}) - declared)
if unknown:
hints = []
for arg in unknown:
close = difflib.get_close_matches(arg, sorted(declared), n=1)
hints.append(f"'{arg}'" + (f" (did you mean '{close[0]}'?)" if close else ""))
raise ToolError(
f"{name} does not accept {', '.join(hints)}. It accepts: "
f"{', '.join(sorted(declared)) or 'no arguments'}. Nothing was run."
)
try:
return await super().call_tool(name, arguments, context)
except UnexpectedToolError as exc:
cause = exc.__cause__
# ValueError is how a Steward tool refuses: what, why, what instead.
if isinstance(cause, ValueError):
raise ToolError(f"{name}: {cause}") from cause
if _is_db_failure(cause):
logger.warning("MCP tool %s hit a database failure: %r", name, cause)
raise ToolError(_DB_UNAVAILABLE) from cause
raise
def build_mcp_server(app) -> StewardMCPServer:
from .tools import register_all
mcp = StewardMCPServer(app)
register_all(mcp.register_tool)
return mcp
async def _send_json(send, status: int, payload: dict, extra_headers=()) -> None:
await send({
"type": "http.response.start",
"status": status,
"headers": [(b"content-type", b"application/json"), *extra_headers],
})
await send({"type": "http.response.body", "body": json.dumps(payload).encode()})
def mount_mcp(app) -> StewardMCPServer:
"""Mount the MCP endpoint at /mcp and return the server.
stateless_http: every request stands alone on its bearer token. The
stateful default strands Claude Code after a redeploy — it reconnects with
a session id the new process never issued and won't re-initialize on the
404 — so stateless is what makes a restart invisible to clients.
DNS-rebinding protection is off: its default allow-list is localhost only,
so any deployment behind a reverse proxy would get 421s. That protection
guards a browser rebinding DNS onto a localhost server; here the bearer
token is the security boundary.
"""
# Looked up through their modules per request (not bound here) so a test
# can stand in for the token store or the database probe.
from steward import database
from steward.core import api_tokens
from steward.core.plugin_manager import get_loaded_plugin_modules
mcp = build_mcp_server(app)
for name, module in sorted(get_loaded_plugin_modules().items()):
mcp.register_plugin(name, module)
mcp_asgi = mcp.streamable_http_app(
streamable_http_path=MCP_PATH,
stateless_http=True,
transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False),
)
app.mcp_server = mcp
# The SDK's session manager owns a task group that must be running before
# it serves; host it in Quart's serving lifecycle.
@app.before_serving
async def _start_mcp() -> None:
cm = mcp.session_manager.run()
await cm.__aenter__()
app._mcp_session_cm = cm
@app.after_serving
async def _stop_mcp() -> None:
cm = getattr(app, "_mcp_session_cm", None)
if cm is not None:
await cm.__aexit__(None, None, None)
async def handle_mcp(scope, receive, send):
headers = {k.decode("latin-1").lower(): v.decode("latin-1")
for k, v in scope.get("headers", [])}
auth = headers.get("authorization", "")
raw = auth[7:].strip() if auth[:7].lower() == "bearer " else ""
engine = getattr(app, "_db_engine", None)
try:
if engine is not None:
await database.ensure_database_reachable(engine)
caller = await api_tokens.resolve_token(app, raw)
except (database.DatabaseUnavailable, SQLAlchemyError, OSError) as exc:
logger.error("MCP request refused, database unreachable: %r", exc)
await _send_json(send, 503, {"error": "database_unavailable", "message": _DB_UNAVAILABLE})
return
if caller is None:
await _send_json(
send, 401,
{"error": "unauthorized",
"message": "Send a Steward API token as 'Authorization: Bearer stw_…'. "
"Create one on Steward's AI access page."},
extra_headers=[(b"www-authenticate", b'Bearer realm="steward-mcp"')],
)
return
caller_token = _context._caller_ctx.set(caller)
app_token = _context._app_ctx.set(app)
host = headers.get("host", "")
url_token = _context._base_url_ctx.set(f"{scope.get('scheme', 'http')}://{host}" if host else "")
try:
await mcp_asgi(scope, receive, send)
finally:
_context._caller_ctx.reset(caller_token)
_context._app_ctx.reset(app_token)
_context._base_url_ctx.reset(url_token)
original_asgi = app.asgi_app
async def dispatch(scope, receive, send):
if scope["type"] == "http":
path = scope.get("path", "")
if path == MCP_PATH or path.startswith(MCP_PATH + "/"):
# Path passes through untouched: the SDK app routes on /mcp itself.
return await handle_mcp(scope, receive, send)
return await original_asgi(scope, receive, send)
app.asgi_app = dispatch
return mcp