MCP-8 #4972 "Connect card, docs, and end-to-end integration test". - AI access page: a "Connect a client" card with the /mcp URL (from Settings → General → Public base URL, else the request host) and recipes for Claude Code, Claude Desktop (mcp-remote) and other Streamable HTTP clients, plus the reverse-proxy note. The token-created page shows the same recipes with the new token filled in, ready to paste. - docs/core/mcp.md: connecting, the reverse-proxy requirements, the security model (token ceiling, default deny, check-mode default, secrets, audit), tools by role, response conventions, adding tools. docs/architecture.md describes the /mcp dispatch outside the blueprint pipeline; docs/reference/code-map.md gains an MCP section. - tests/integration/test_mcp.py: over HTTP, a viewer token lists no write tools and is refused one by name; an operator token reads get_overview and its maintenance-window create/end land in the audit log as "<user> via MCP" with the token id. (Revoked → 401 was already covered.) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6.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
-
In Steward, open AI access and create a token. Pick the lowest role that does the job.
-
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_…" -
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
Authorizationheader through unchanged. - It must not buffer responses. Replies may arrive as server-sent events
(
text/event-stream). For nginx, setproxy_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. |
| 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 |
| 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 plugin | list_agents |
Operator: run playbooks, change monitoring
| Area | Tools |
|---|---|
| Automation | run_playbook, cancel_ansible_run, create_schedule, update_schedule, delete_schedule |
| Monitors | create_monitor, update_monitor, set_monitor_enabled |
| Alerts | create_alert_rule, update_alert_rule, acknowledge_alert, create_maintenance_window, end_maintenance_window |
Admin: destructive and remediation
set_alert_action (the playbook a rule runs when it fires), delete_monitor,
delete_alert_rule, prune_docker.
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 carriesage_s, so the client never does clock arithmetic. - Lists are bounded. A list response carries
totalandtruncated. When it was cut,morenames 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), and optionally mcp_overview(db) to
contribute findings to get_overview. See
Writing a plugin. A tool that changes
anything calls audit_mcp(...) from steward/mcp/tools/_audit.py.