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>
167 lines
11 KiB
Markdown
167 lines
11 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. 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) |
|
|
| Traefik plugin | `list_routers` (each router graded as the Traefik page grades it, with the status codes behind a problem, what they mean and the swarm service behind it), `get_router` (one router's history, codes, normal and anomalies), `list_certificates` (served certificates, days left, the routers they cover), `get_traffic` (the access log's clients, countries and routers, and Traefik's own figures) |
|
|
| UniFi plugin | `get_network` (the WAN, clients, Wi-Fi networks, speed tests and devices by status), `list_devices`, `list_clients` (everyone seen, or the newest poll's top talkers), `list_network_events`, `list_alarms`, `get_network_config` (networks and VLANs, port forwards, firewall rules) |
|
|
| SNMP plugin | `list_snmp_devices` (each device's state, newest readings in their units and the readings it didn't return), `get_snmp_device` (one device's readings over a range). Never the community or v3 keys |
|
|
| Prometheus plugin | `list_prometheus_targets` (each target's state and its newest scrape: series kept, dropped and over the cap), `get_prometheus_target` (a target's stored series by metric, with labels and newest values). Never the token, and a password in a URL is masked |
|
|
|
|
### 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; for a resource with no host, optionally
|
|
`mcp_resource_events(db, source, resource, since, until)`. Core adds the
|
|
stretches Steward itself wasn't recording to every anomaly. See
|
|
[Writing a plugin](../plugins/writing-a-plugin.md). A tool that changes
|
|
anything calls `audit_mcp(...)` from `steward/mcp/tools/_audit.py`.
|