From 3474cdff4616d1a0c04cd558943d44a1a69d521e Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 6 Oct 2026 08:25:41 -0400 Subject: [PATCH] feat(mcp): connect card, MCP docs, and an end-to-end roles/audit test over HTTP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 " via MCP" with the token id. (Revoked → 401 was already covered.) Co-Authored-By: Claude Opus 5.5 --- docs/architecture.md | 24 ++++ docs/core/mcp.md | 132 ++++++++++++++++++++++ docs/reference/code-map.md | 22 ++++ steward/ai_access/routes.py | 11 +- steward/templates/ai_access/_connect.html | 56 +++++++++ steward/templates/ai_access/created.html | 2 + steward/templates/ai_access/index.html | 5 + tests/integration/test_api_tokens.py | 7 +- tests/integration/test_mcp.py | 71 ++++++++++++ 9 files changed, 328 insertions(+), 2 deletions(-) create mode 100644 docs/core/mcp.md create mode 100644 steward/templates/ai_access/_connect.html diff --git a/docs/architecture.md b/docs/architecture.md index 0bef185..4cb75f5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -44,6 +44,30 @@ All routes are Quart Blueprints registered in `app.py`: Plugin blueprints are mounted automatically by `load_plugins()` using the plugin directory name as the URL prefix. +`/ai-access/` (`ai_access_bp`, `steward/ai_access/routes.py`) is where a signed-in user manages their API tokens. + +### `/mcp`: outside the blueprint pipeline + +`mount_mcp(app)` (`steward/mcp/server.py`) wraps `app.asgi_app`. A request to +`/mcp` is handed to the MCP SDK's streamable-HTTP app before Quart sees it, so +no blueprint, session cookie or `before_request` hook runs for it. Each +request then goes through these steps: + +1. **Database gate.** If the database is unreachable, the request gets a 503 + with a readable message. +2. **Bearer resolution.** `api_tokens.resolve_token()` turns the token into a + `Caller` (user, effective role, token id). A missing, revoked or expired + token gets a 401. +3. **Caller context.** The `Caller` and the app are set in ContextVars + (`steward/mcp/_context.py`). Tools read them through `current_caller()` and + `mcp_app()`, because Quart's `current_app` and `session` do not exist there. +4. **Role gate.** `StewardMCPServer.refusal()` hides and refuses tools above + the caller's role, and plugin tools whose plugin is disabled. + +The SDK's session manager runs in Quart's `before_serving`/`after_serving` +lifecycle. The server is stateless: no MCP session outlives a request. See +[MCP server](core/mcp.md) for the security model and the tools. + --- ## Scheduler diff --git a/docs/core/mcp.md b/docs/core/mcp.md new file mode 100644 index 0000000..5148d1f --- /dev/null +++ b/docs/core/mcp.md @@ -0,0 +1,132 @@ +# 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` | +| 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 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`. diff --git a/docs/reference/code-map.md b/docs/reference/code-map.md index 1bb9282..395c3db 100644 --- a/docs/reference/code-map.md +++ b/docs/reference/code-map.md @@ -136,3 +136,25 @@ Quick reference for where key functions, models, and entry points live in the co | `steward/migrations/versions/` | Core schema (hosts, users, monitors, alerts, app_settings) | | `plugins/traefik/migrations/versions/` | `traefik_metrics` table | | `alembic.ini` | Alembic config; `version_locations` lists all migration directories | + +--- + +## MCP server and API tokens + +| What | File | Function / Class | +|---|---|---| +| Mount `/mcp` (ASGI dispatch, bearer auth, DB gate) | `steward/mcp/server.py` | `mount_mcp(app)` | +| Server, role registry, refusals | `steward/mcp/server.py` | `StewardMCPServer`, `register_tool()`, `refusal()` | +| Plugin tools and overview findings | `steward/mcp/server.py` | `register_plugin(name, module)` | +| Caller and app inside a tool | `steward/mcp/_context.py` | `current_caller()`, `mcp_app()` | +| Core tool modules | `steward/mcp/tools/` | `register_all()`; one module per area | +| Response conventions | `steward/mcp/tools/_shape.py` | `iso`, `age_s`, `bounded`, `lookback`, `summarize`, `pick` | +| Audit a write as " via MCP" | `steward/mcp/tools/_audit.py` | `audit_mcp()` | +| "What is wrong now" | `steward/mcp/tools/overview.py` | `get_overview()` | +| Playbooks, runs, schedules, alert actions | `steward/mcp/tools/automation.py` | `run_playbook()`, `wait_for_run()`, … | +| Monitor validation (forms and tools) | `steward/monitors/service.py` | `monitor_fields()`, `build_config()` | +| Alert rule / window validation | `steward/alerts/service.py` | `rule_fields()`, `window_fields()`, `acknowledge()` | +| Docker and host-agent tools | `plugins/docker/mcp_tools.py`, `plugins/host_agent/mcp_tools.py` | `register_tools()`, `mcp_overview()` | +| Token model | `steward/models/api_tokens.py` | `ApiToken` | +| Mint, resolve, revoke tokens | `steward/core/api_tokens.py` | `create_token()`, `resolve_token()`, `revoke_token()` | +| Token management page and connect card | `steward/ai_access/routes.py`, `steward/templates/ai_access/` | `ai_access_bp` | diff --git a/steward/ai_access/routes.py b/steward/ai_access/routes.py index 9f3ef04..298d286 100644 --- a/steward/ai_access/routes.py +++ b/steward/ai_access/routes.py @@ -14,6 +14,8 @@ from sqlalchemy import select from steward.auth.middleware import current_user_id, require_role from steward.core import api_tokens from steward.core.audit import log_audit +from steward.core.settings import public_base_url +from steward.mcp.server import MCP_PATH from steward.models.users import User, UserRole ai_access_bp = Blueprint("ai_access", __name__, url_prefix="/ai-access") @@ -21,6 +23,11 @@ ai_access_bp = Blueprint("ai_access", __name__, url_prefix="/ai-access") _EXPIRY_CHOICES = [("", "Never"), ("30", "30 days"), ("90", "90 days"), ("365", "1 year")] +def _mcp_url() -> str: + """The /mcp endpoint as a client outside this request reaches it.""" + return public_base_url(request) + MCP_PATH + + async def _session_user(db) -> User: user_id = current_user_id() if not user_id: @@ -42,6 +49,7 @@ async def _render_index(db, user: User, *, error: str | None = None, status: int grantable=api_tokens.grantable_roles(user.role), expiry_choices=_EXPIRY_CHOICES, error=error, + mcp_url=_mcp_url(), ) return body, status @@ -81,7 +89,8 @@ async def create(): await log_audit(current_app, user.id, user.username, "api_token.create", entity_type="api_token", entity_id=str(token.id), detail={"name": token.name, "role": token.role.value}) - return await render_template("ai_access/created.html", raw_token=raw, token=token) + return await render_template("ai_access/created.html", raw_token=raw, token=token, + mcp_url=_mcp_url()) @ai_access_bp.post("/tokens//revoke") diff --git a/steward/templates/ai_access/_connect.html b/steward/templates/ai_access/_connect.html new file mode 100644 index 0000000..cf2e428 --- /dev/null +++ b/steward/templates/ai_access/_connect.html @@ -0,0 +1,56 @@ +{#- How to point an AI client at /mcp. `mcp_url` is the endpoint; `token` is the + raw token on the page that just created one, else a placeholder. -#} +{% set tok = token or "stw_…your token…" %} +{% set pre = "background:var(--bg);padding:0.75rem;border-radius:4px;overflow-x:auto;font-size:0.78rem;color:var(--text);margin:0.5rem 0 0;white-space:pre;" %} +
+
Connect a client
+
+ Steward's MCP endpoint. Clients send the token as + Authorization: Bearer … on every request. +
+
+ + +
+ +
+ Claude Code +
claude mcp add --transport http steward {{ mcp_url }} \
+  --header "Authorization: Bearer {{ tok }}"
+
+ +
+ Claude Desktop +
+ Add to claude_desktop_config.json. It bridges through + mcp-remote, which needs Node.js. +
+
{
+  "mcpServers": {
+    "steward": {
+      "command": "npx",
+      "args": ["mcp-remote", "{{ mcp_url }}",
+               "--header", "Authorization:${STEWARD_AUTH}"],
+      "env": { "STEWARD_AUTH": "Bearer {{ tok }}" }
+    }
+  }
+}
+
+ +
+ Open WebUI and other clients +
+ Add an MCP server using the Streamable HTTP transport, with the URL + above and bearer authentication set to the token. In Open WebUI this is an external + tool connection of type MCP; menu names vary between versions. +
+
+ +
+ Behind a reverse proxy, the URL above comes from Settings → General → Public + base URL when it is set. The proxy must pass the Authorization + header through and not buffer responses, since replies stream. +
+
diff --git a/steward/templates/ai_access/created.html b/steward/templates/ai_access/created.html index f097b45..7cabaf9 100644 --- a/steward/templates/ai_access/created.html +++ b/steward/templates/ai_access/created.html @@ -33,6 +33,8 @@ If it leaks, revoke it from the AI access page.

+ {% with token = raw_token %}{% include "ai_access/_connect.html" %}{% endwith %} + Back to AI access {% endblock %} diff --git a/steward/templates/ai_access/index.html b/steward/templates/ai_access/index.html index eda6d1f..e4fac24 100644 --- a/steward/templates/ai_access/index.html +++ b/steward/templates/ai_access/index.html @@ -116,4 +116,9 @@ + +
+ {% set token = none %} + {% include "ai_access/_connect.html" %} +
{% endblock %} diff --git a/tests/integration/test_api_tokens.py b/tests/integration/test_api_tokens.py index ed85164..208030b 100644 --- a/tests/integration/test_api_tokens.py +++ b/tests/integration/test_api_tokens.py @@ -139,6 +139,8 @@ def test_routes_create_list_and_revoke(app): assert "You have no tokens yet" in page # An operator can't offer admin. assert 'value="admin"' not in page + # The connect card names the endpoint, with a placeholder for the token. + assert 'value="http://localhost/mcp"' in page and "claude mcp add" in page resp = await client.post("/ai-access/tokens", form={ "name": "laptop", "role": "admin", "expires_in_days": ""}) @@ -148,7 +150,10 @@ def test_routes_create_list_and_revoke(app): resp = await client.post("/ai-access/tokens", form={ "name": "laptop", "role": "operator", "expires_in_days": "30"}) assert resp.status_code == 200 - raw = re.search(r'value="(stw_[^"]+)"', await resp.get_data(as_text=True)).group(1) + created = await resp.get_data(as_text=True) + raw = re.search(r'value="(stw_[^"]+)"', created).group(1) + # The recipes on the created page carry the new token, ready to paste. + assert f"Bearer {raw}" in created and "mcp-remote" in created caller = await api_tokens.resolve_token(app, raw) assert caller is not None and caller.role == UserRole.operator diff --git a/tests/integration/test_mcp.py b/tests/integration/test_mcp.py index 8efd13a..d0dc66e 100644 --- a/tests/integration/test_mcp.py +++ b/tests/integration/test_mcp.py @@ -72,3 +72,74 @@ def test_mcp_over_http_with_a_minted_token(app): assert status == 401 asyncio.run(_go()) + + +def _tool_result(body) -> dict: + return rpc_result(body)["result"] + + +@_NEEDS_DB +def test_roles_reads_and_audited_writes_over_http(app): + """Over HTTP: a viewer sees no write tools and is refused one by name; an + operator reads get_overview and makes an audited change.""" + from sqlalchemy import select + from steward.core import api_tokens + from steward.models.audit import AuditEvent + from steward.models.users import User, UserRole + + async def _go(): + uid = str(uuid.uuid4()) + username = f"mcp-op-{uid[:8]}" + async with app.db_sessionmaker() as s: + async with s.begin(): + s.add(User(id=uid, username=username, email=f"{uid}@test", + password_hash="x", role=UserRole.operator, is_active=True)) + async with app.db_sessionmaker() as s: + viewer_raw, _ = await api_tokens.create_token( + s, user_id=uid, user_role=UserRole.operator, name="ro", role=UserRole.viewer) + op_raw, op_token = await api_tokens.create_token( + s, user_id=uid, user_role=UserRole.operator, name="rw", role=UserRole.operator) + await s.commit() + viewer = {**_HEADERS, "Authorization": f"Bearer {viewer_raw}"} + operator = {**_HEADERS, "Authorization": f"Bearer {op_raw}"} + + async with app.mcp_server.session_manager.run(): + _, _, body = await asgi_request(app, "POST", "/mcp", viewer, rpc("tools/list", id_=1)) + names = {t["name"] for t in _tool_result(body)["tools"]} + assert "get_overview" in names and "list_playbooks" in names + assert not names & {"run_playbook", "create_monitor", "delete_alert_rule"} + + _, _, body = await asgi_request(app, "POST", "/mcp", viewer, rpc( + "tools/call", {"name": "create_maintenance_window", + "arguments": {"name": "x", "duration_minutes": 5}}, id_=2)) + refused = _tool_result(body) if "result" in rpc_result(body) else rpc_result(body)["error"] + assert "needs the operator role" in json.dumps(refused) + + _, _, body = await asgi_request(app, "POST", "/mcp", operator, rpc( + "tools/call", {"name": "get_overview", "arguments": {}}, id_=3)) + result = _tool_result(body) + assert not result.get("isError"), result + overview = json.loads(result["content"][0]["text"]) + assert {"generated_at", "all_clear", "counts", "findings"} <= set(overview) + + name = f"it-window-{uid[:8]}" + _, _, body = await asgi_request(app, "POST", "/mcp", operator, rpc( + "tools/call", {"name": "create_maintenance_window", + "arguments": {"name": name, "duration_minutes": 5}}, id_=4)) + result = _tool_result(body) + assert not result.get("isError"), result + window_id = json.loads(result["content"][0]["text"])["window_id"] + _, _, body = await asgi_request(app, "POST", "/mcp", operator, rpc( + "tools/call", {"name": "end_maintenance_window", + "arguments": {"window": window_id}}, id_=5)) + assert not _tool_result(body).get("isError") + + async with app.db_sessionmaker() as s: + rows = list((await s.execute(select(AuditEvent).where( + AuditEvent.user_id == uid, AuditEvent.entity_id == name))).scalars()) + assert sorted(e.action for e in rows) == ["maintenance_window.created", + "maintenance_window.ended"] + assert {e.username for e in rows} == {f"{username} via MCP"} + assert all(json.loads(e.detail_json)["token_id"] == op_token.id for e in rows) + + asyncio.run(_go())