Files
FabledSteward/docs/core/mcp.md
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

9.5 KiB

MCP server

Steward serves the Model Context Protocol at /mcp. An AI client connected to it can look into everything Steward watches (hosts, monitors, alerts, metrics, containers, playbook runs) and, with a high enough role, change it. A token's role sets that ceiling.

Source: steward/mcp/. The tools live in steward/mcp/tools/, and a plugin's tools live in plugins/<name>/mcp_tools.py. Transport: streamable HTTP, stateless. Every request carries its own token, so a client survives a Steward restart without reconnecting.


Connecting

  1. In Steward, open AI access and create a token. Pick the lowest role that does the job.

  2. Copy the token (it is shown once) and use one of the recipes on that page. They carry the real endpoint URL and the new token. For Claude Code:

    claude mcp add --transport http steward https://steward.example/mcp \
      --header "Authorization: Bearer stw_…"
    
  3. Ask the client to call whoami. It answers with the user, the effective role and what that role allows.

Claude Desktop connects through mcp-remote. Open WebUI and other clients need an MCP server with the Streamable HTTP transport and bearer authentication. The AI access page shows the recipes for both.

Behind a reverse proxy

  • The URL on the AI access page is Settings → General → Public base URL plus /mcp. If that setting is empty, the URL is built from the request's host instead.
  • The proxy must pass the Authorization header through unchanged.
  • It must not buffer responses. Replies may arrive as server-sent events (text/event-stream). For nginx, set proxy_buffering off; on the location.
  • Steward does no Host-header (DNS-rebinding) check on /mcp, because the bearer token is the security boundary. Serve it over HTTPS.

Security model

Rule How it is enforced
Bearer tokens only, no OAuth steward/core/api_tokens.py. Only a hash is stored, and the token is shown once.
A token never acts above its user Effective role = min(token role, user's current role). Lowering a user lowers every token they hold; disabling the user stops them all.
Default deny A tool is reachable only if it was registered with a role (register_tool(fn, role=..., read_only=...)). tools/list shows a caller only the tools their role allows. A call above the role is refused, and the refusal names the role it needs.
Plugin tools follow the plugin A disabled plugin's tools are hidden and refused. The host agent is built in and always on (it can't be disabled), so its tools are always there.
Dry run first run_playbook, create_schedule and set_alert_action default to Ansible check mode (--check --diff). Changes are applied only when the caller passes check=false. A playbook its author flagged # steward:confirm: true also needs confirmed=true. prune_docker previews unless check=false.
Secrets stay put Playbook variables Steward detects as secret reach the run but are never stored, audited or returned. Schedules and alert actions refuse them, since they would have to store them. Inventory variables are listed by name only, and HTTP monitor header values are redacted.
Every change is audited Each write records an audit event as <user> via MCP, with the token id in its detail. list_audit_events(user="<user>") includes those events.
Unknown arguments are refused A misspelt argument is rejected with a "did you mean", rather than silently dropped.

Tokens never mint or revoke tokens; that happens only on the AI access page, in a browser session.


Tools by role

Each role includes the tools of the roles above it in this list.

Viewer: read everything

Area Tools
Start here whoami, get_overview (what is wrong now, ranked, each finding with the call that shows its evidence)
Hosts and metrics list_hosts, get_host, get_host_metrics, query_metrics
Monitors list_monitors, get_monitor
Alerts list_alerts, get_alert_history, list_alert_rules, get_alert_rule_options, list_maintenance_windows
Metric anomalies list_anomalies (metrics that left their normal for the hour, to review first), get_anomaly (one anomaly with each occurrence against what is usual at that hour, the metric around it, what else happened on its host — alerts, Ansible runs, container deaths and starts, maintenance, reboots, other anomalies — and how earlier ones on the same metric were judged), get_metric_normal (the learned normal slot by slot, and the level that would be flagged), get_anomaly_settings (the review window and each floor)
Automation list_playbooks, get_playbook, list_inventory, list_ansible_runs, get_ansible_run, wait_for_run, list_schedules
Audit list_audit_events
Docker plugin list_containers, get_container, list_swarm_services, search_container_logs, get_docker_disk_usage
Host agent (built in, always on) list_agents, list_maintenance_plans (each plan's group, weekly slot, next run and latest run: how far it got, who it skipped and why), get_maintenance_job (a host's update jobs or one job: state, packages updated, whether it rebooted, why it failed)

Operator: run playbooks, change monitoring

Area Tools
Automation run_playbook, cancel_ansible_run, create_schedule, update_schedule, delete_schedule, update_group_members (put hosts into an inventory group or take them out; a group alert rule follows within a minute)
Monitors create_monitor, update_monitor, set_monitor_enabled
Alerts create_alert_rule, update_alert_rule, acknowledge_alert, send_test_alert, create_maintenance_window, end_maintenance_window
Metric anomalies review_anomaly (mark one or several normal, so their hours are learned, or real, with a note saying why)

Admin: destructive and remediation

set_anomaly_settings (the anomaly review window and each normal's floor, as Settings → Thresholds sets them), set_alert_action (the playbook a rule runs when it fires), delete_monitor, delete_alert_rule, prune_docker, run_maintenance (update a host now, rebooting only if the update needs it, as the host page's Update now does).

save_playbook and delete_playbook write this instance's own playbooks into steward-local, the same as Steward's editor (#5748). That is automation for these hosts that isn't part of Steward itself; Steward's built-in playbooks are changed in its repository, and a git source by its repo, so both are refused. A save must parse as a list of plays or nothing is written; it won't overwrite an existing playbook without replace=true; and it then runs ansible-playbook --syntax-check (no host contacted) and says what that found. The loop for building one is save → run_playbook in check mode on one target → get_ansible_run → fix and save again → apply with check=false. The audit row records the size, never the content.

list_backups, check_backup and make_backup are Settings → Backups for an agent (#5836), admin only like the page, since a backup holds every secret Steward keeps. list_backups gives the schedule, the next run and the last 30 runs with the check each one got after it was sent (bucket=true also lists the bucket, which an append-only key may refuse). check_backup fetches one and compares it with what is in place, changing nothing. make_backup starts one in the background and is audited. Restoring stays in the browser, behind its typed confirmation. get_overview reports, for every role, a scheduled backup that failed, is overdue, or has no bucket set up.

The client sees each tool's full contract: arguments, what an empty value means, and what comes back. That contract is the tool's docstring, so read the docstrings in steward/mcp/tools/ for the detail.


Response conventions

Every tool follows these conventions (steward/mcp/tools/_shape.py):

  • Times are ISO-8601 UTC with a Z. Anything with a "when" also carries age_s, so the client never does clock arithmetic.
  • Lists are bounded. A list response carries total and truncated. When it was cut, more names the argument that fetches the rest. Nothing is cut silently.
  • Time series come back as a summary (min/avg/max/p95/last) plus a series of at most 60 points, never raw rows.
  • Lookup by name or id. A miss suggests close matches and names the tool that lists the valid choices.
  • Refusals are readable. Bad input becomes a tool error that says what was wrong, why, and what to do instead, and nothing is changed. The tools share their validation with the web forms (monitors/service.py, alerts/service.py), so a value the UI refuses is refused here for the same reason.
  • Database outages return a plain "Steward cannot reach its database" rather than a stack trace.

Adding tools

Core tools: add an async def to a module in steward/mcp/tools/ and list it in that module's register() with its minimum role and read_only. A plugin exposes register_mcp_tools(register), optionally mcp_overview(db) to contribute findings to get_overview, and optionally mcp_events(db, host_id, host_name, since, until): what it knows happened on a host in a window, as a list of steward.mcp.tools._events.event(...), which get_anomaly shows beside an anomaly. See Writing a plugin. A tool that changes anything calls audit_mcp(...) from steward/mcp/tools/_audit.py.