# 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//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 ` via MCP`, with the token id in its detail. `list_audit_events(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`. 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`.