Files
FabledSteward/docs/core/mcp.md
T
bvandeusenandClaude Opus 5.5 e122860cdc
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m29s
CI / unit (push) Successful in 1m39s
CI / publish (push) Successful in 1m6s
feat(mcp): write, test and delete this instance's own playbooks (#5748)
#5748 "MCP can write, edit and test this instance's own playbooks
(steward-local)": two admin tools beside run_playbook, so an agent can
build automation for these hosts that isn't part of Steward itself.

- save_playbook(playbook, content, source="steward-local", replace=false)
  goes through the editor's own validate_playbook_yaml and
  write_playbook. A playbook that isn't a list of plays comes back with
  the YAML error and its line, and nothing is written. An existing file
  is overwritten only with replace=true. steward-builtin is refused
  (Steward's playbooks change in its repository), as are git sources
  (their repo owns them).
- After writing, `ansible-playbook --syntax-check` runs against a
  throwaway localhost inventory (no host contacted, 60 s), so a module
  name Ansible can't resolve shows before any run. The result rides the
  reply; the file is kept either way.
- delete_playbook(playbook, source="steward-local").
- Both audit as ansible.playbook.created/updated/deleted, "<user> via
  MCP", with the size and never the content.

The testing loop is the existing one: run_playbook defaults to check
mode, on one target, then get_ansible_run. The integration test does
that loop with the real ansible-playbook.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 12:01:39 -04:00

146 lines
7.6 KiB
Markdown

# MCP server
Steward serves the [Model Context Protocol](https://modelcontextprotocol.io) 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:
```sh
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. |
| 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) |
| 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`, `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 normal, so its hours are learned, or real) |
### Admin: destructive and remediation
`set_alert_action` (the playbook a rule runs when it fires), `delete_monitor`,
`delete_alert_rule`, `prune_docker`.
`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.
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)`, and optionally `mcp_overview(db)` to
contribute findings to `get_overview`. See
[Writing a plugin](../plugins/writing-a-plugin.md). A tool that changes
anything calls `audit_mcp(...)` from `steward/mcp/tools/_audit.py`.