feat(mcp): /mcp endpoint with bearer auth and role-gated tools
Second step of milestone 461. An MCP client (Claude Code, Claude Desktop via mcp-remote, Open WebUI) connects to /mcp with a personal API token. - mcp>=2.2 (SDK v2 MCPServer). Mounted by wrapping app.asgi_app, so /mcp never enters Quart's pipeline; stateless streamable HTTP so a redeploy never strands a client on a session id the new process didn't issue. - Auth: Authorization: Bearer stw_… resolves to a Caller (user + effective role) in a ContextVar; 401 with WWW-Authenticate otherwise. The web pipeline's database gate runs first, so a DB restart answers 503 instead of failing inside a tool. - Registry, default-deny: register_tool(fn, role=, read_only=) is the only door; role and read_only are required. Tools above the caller's role, or from a disabled plugin, are neither listed nor callable. Undeclared arguments are refused (not silently dropped); a tool's ValueError reaches the agent as its reason; a DB failure mid-tool says so. - Plugins contribute tools via an optional register_mcp_tools(register) export, all-or-nothing, replaced on hot-reload. Documented in docs/plugins/writing-a-plugin.md. - First tool: whoami. - Tests drive app.asgi_app directly (Quart's test client never reaches /mcp); the integration test mints a real token against Postgres and runs initialize → tools/list → whoami → revoke → 401. Task #4966 "MCP-2 — /mcp transport". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
"""The MCP endpoint (/mcp): Steward as a server an LLM client can inspect and drive."""
|
||||
from .server import mount_mcp
|
||||
|
||||
__all__ = ["mount_mcp"]
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Per-request MCP context: who is calling, and which app they are calling.
|
||||
|
||||
The /mcp transport bypasses Quart's request pipeline, so neither `session`
|
||||
nor `current_app` exists inside a tool. The ASGI auth wrapper in server.py
|
||||
sets these from the resolved bearer token before dispatching; tools read them
|
||||
through `current_caller()` and `mcp_app()`.
|
||||
|
||||
Neither falls back to a default: a tool reached without an authenticated
|
||||
caller is a bug in the transport, and it raises rather than acting for nobody.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from contextvars import ContextVar
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from quart import Quart
|
||||
from steward.core.api_tokens import Caller
|
||||
|
||||
_caller_ctx: ContextVar["Caller | None"] = ContextVar("steward_mcp_caller", default=None)
|
||||
_app_ctx: ContextVar["Quart | None"] = ContextVar("steward_mcp_app", default=None)
|
||||
|
||||
|
||||
def current_caller() -> "Caller":
|
||||
caller = _caller_ctx.get()
|
||||
if caller is None:
|
||||
raise RuntimeError("no MCP caller in context: the request was not bearer-authenticated")
|
||||
return caller
|
||||
|
||||
|
||||
def current_caller_or_none() -> "Caller | None":
|
||||
return _caller_ctx.get()
|
||||
|
||||
|
||||
def mcp_app() -> "Quart":
|
||||
app = _app_ctx.get()
|
||||
if app is None:
|
||||
raise RuntimeError("no Steward app in MCP context")
|
||||
return app
|
||||
@@ -0,0 +1,292 @@
|
||||
"""Steward's MCP server: the StewardMCPServer instance and its mount at /mcp.
|
||||
|
||||
The /mcp path is dispatched to the MCP SDK's streamable-HTTP ASGI app BEFORE
|
||||
Quart sees it (wrapping `app.asgi_app`), so Quart's session, before_request
|
||||
hooks and error pages do not apply. This module supplies the equivalents:
|
||||
|
||||
- bearer auth: a personal API token resolves to a Caller (user + effective
|
||||
role), set in a ContextVar for the tools — 401 otherwise;
|
||||
- the database gate: the same ride-out-a-restart probe the web pipeline uses,
|
||||
answering 503 rather than failing deep in a tool;
|
||||
- role gating, default-deny: every tool is registered with a minimum role and
|
||||
a tool the caller's role doesn't reach is neither listed nor callable.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import difflib
|
||||
import functools
|
||||
import json
|
||||
import logging
|
||||
|
||||
from mcp.server.mcpserver import MCPServer
|
||||
from mcp.server.mcpserver.exceptions import ToolError, UnexpectedToolError
|
||||
from mcp.server.transport_security import TransportSecuritySettings
|
||||
from mcp.types import ToolAnnotations
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from steward.auth.middleware import role_meets
|
||||
from steward.models.users import UserRole
|
||||
|
||||
from . import _context
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MCP_PATH = "/mcp"
|
||||
|
||||
# Claude Code injects only the first ~2,048 characters of a server's
|
||||
# instructions; keep this well under (test_instructions_fit). The workflow
|
||||
# across tools lives here; each tool's own contract lives in its docstring.
|
||||
INSTRUCTIONS = """\
|
||||
Steward is a self-hosted monitoring and infrastructure hub: hosts, uptime
|
||||
monitors, host-agent metrics, Docker containers and logs, alerts, and Ansible
|
||||
automation. You act as the user who created your token, at that token's role.
|
||||
|
||||
Start broad, then narrow: get_overview says what is wrong right now; follow
|
||||
its ids into get_host / get_monitor / get_container, then metrics and logs.
|
||||
List tools are bounded: a response with `truncated: true` says which argument
|
||||
fetches more. Times are UTC ISO-8601; units are in field names (_pct, _ms,
|
||||
_bytes).
|
||||
|
||||
Changes are audited under the user's name. run_playbook is a dry run (Ansible
|
||||
check mode) unless you pass check=false; dry-run first and show the user the
|
||||
diff before applying. If a tool refuses for lack of role, whoami says what
|
||||
this token can do.
|
||||
"""
|
||||
|
||||
_DB_UNAVAILABLE = (
|
||||
"Steward is running but can't reach its database right now, so nothing was "
|
||||
"read or changed. This is usually a database restart; retry in a minute."
|
||||
)
|
||||
|
||||
|
||||
def _is_db_failure(exc: BaseException | None) -> bool:
|
||||
seen: set[int] = set()
|
||||
while exc is not None and id(exc) not in seen:
|
||||
seen.add(id(exc))
|
||||
if isinstance(exc, (SQLAlchemyError, OSError, TimeoutError)):
|
||||
return True
|
||||
exc = exc.__cause__ or exc.__context__
|
||||
return False
|
||||
|
||||
|
||||
class StewardMCPServer(MCPServer):
|
||||
"""MCPServer with per-tool minimum roles, plugin-scoped tools and strict args."""
|
||||
|
||||
def __init__(self, app, **kwargs):
|
||||
super().__init__("steward", instructions=INSTRUCTIONS.strip(), **kwargs)
|
||||
self._steward_app = app
|
||||
self.tool_roles: dict[str, UserRole] = {}
|
||||
self.tool_plugins: dict[str, str] = {}
|
||||
|
||||
# ── registration ─────────────────────────────────────────────────────────
|
||||
def register_tool(self, fn, *, role: UserRole, read_only: bool, plugin: str | None = None) -> None:
|
||||
"""The only way a tool reaches the server: name, minimum role, effect.
|
||||
|
||||
`role` and `read_only` are required — there is no default for a
|
||||
mutating tool to inherit by omission.
|
||||
"""
|
||||
name = fn.__name__
|
||||
if name in self.tool_roles:
|
||||
raise ValueError(f"MCP tool {name!r} is already registered")
|
||||
if not isinstance(role, UserRole):
|
||||
raise TypeError(f"MCP tool {name!r}: role must be a UserRole, got {role!r}")
|
||||
self.add_tool(
|
||||
fn, name=name,
|
||||
annotations=ToolAnnotations(read_only_hint=read_only,
|
||||
destructive_hint=None if read_only else True),
|
||||
)
|
||||
self.tool_roles[name] = role
|
||||
if plugin:
|
||||
self.tool_plugins[name] = plugin
|
||||
|
||||
def register_plugin(self, name: str, module) -> None:
|
||||
"""Add a plugin's tools via its optional `register_mcp_tools(register)`.
|
||||
|
||||
Idempotent per plugin (hot-reload drops its previous tools first) and
|
||||
tolerant: a raising hook is logged and leaves the plugin with no tools,
|
||||
never a broken server.
|
||||
"""
|
||||
self._drop_plugin_tools(name)
|
||||
hook = getattr(module, "register_mcp_tools", None)
|
||||
if hook is None:
|
||||
return
|
||||
try:
|
||||
hook(functools.partial(self.register_tool, plugin=name))
|
||||
except Exception:
|
||||
# All or nothing: a hook that fails halfway leaves no partial set.
|
||||
self._drop_plugin_tools(name)
|
||||
logger.exception("Plugin %r: register_mcp_tools() raised; its MCP tools are unavailable", name)
|
||||
|
||||
def _drop_plugin_tools(self, name: str) -> None:
|
||||
for tool_name in [t for t, p in self.tool_plugins.items() if p == name]:
|
||||
self.remove_tool(tool_name)
|
||||
self.tool_roles.pop(tool_name, None)
|
||||
self.tool_plugins.pop(tool_name, None)
|
||||
|
||||
# ── gating ───────────────────────────────────────────────────────────────
|
||||
def _plugin_enabled(self, plugin: str) -> bool:
|
||||
cfg = (self._steward_app.config.get("PLUGINS") or {}).get(plugin)
|
||||
return isinstance(cfg, dict) and bool(cfg.get("enabled"))
|
||||
|
||||
def refusal(self, name: str, caller) -> str | None:
|
||||
"""Why `caller` may not use tool `name`, or None if they may."""
|
||||
required = self.tool_roles.get(name)
|
||||
if required is None:
|
||||
return f"There is no Steward tool named {name!r}."
|
||||
plugin = self.tool_plugins.get(name)
|
||||
if plugin and not self._plugin_enabled(plugin):
|
||||
return (f"{name} belongs to the {plugin} plugin, which is disabled in this "
|
||||
"Steward. An admin can enable it under Settings → Plugins.")
|
||||
if caller is None:
|
||||
return "This request is not authenticated."
|
||||
if not role_meets(caller.role, required):
|
||||
return (f"{name} needs the {required.value} role; this connection acts as "
|
||||
f"{caller.role.value}. A token's role is capped at its user's role — "
|
||||
"create a token with a higher role on Steward's AI access page if "
|
||||
"your account allows it.")
|
||||
return None
|
||||
|
||||
async def list_tools(self):
|
||||
caller = _context.current_caller_or_none()
|
||||
tools = await super().list_tools()
|
||||
return [t for t in tools if self.refusal(t.name, caller) is None]
|
||||
|
||||
async def call_tool(self, name, arguments, context=None):
|
||||
refusal = self.refusal(name, _context.current_caller_or_none())
|
||||
if refusal:
|
||||
raise ToolError(refusal)
|
||||
|
||||
# Reject undeclared arguments: pydantic would silently drop a misnamed
|
||||
# one and run the tool with that field's default.
|
||||
tool = self._tool_manager.get_tool(name)
|
||||
declared = set((tool.parameters or {}).get("properties", {})) if tool else set()
|
||||
unknown = sorted(set(arguments or {}) - declared)
|
||||
if unknown:
|
||||
hints = []
|
||||
for arg in unknown:
|
||||
close = difflib.get_close_matches(arg, sorted(declared), n=1)
|
||||
hints.append(f"'{arg}'" + (f" (did you mean '{close[0]}'?)" if close else ""))
|
||||
raise ToolError(
|
||||
f"{name} does not accept {', '.join(hints)}. It accepts: "
|
||||
f"{', '.join(sorted(declared)) or 'no arguments'}. Nothing was run."
|
||||
)
|
||||
|
||||
try:
|
||||
return await super().call_tool(name, arguments, context)
|
||||
except UnexpectedToolError as exc:
|
||||
cause = exc.__cause__
|
||||
# ValueError is how a Steward tool refuses: what, why, what instead.
|
||||
if isinstance(cause, ValueError):
|
||||
raise ToolError(f"{name}: {cause}") from cause
|
||||
if _is_db_failure(cause):
|
||||
logger.warning("MCP tool %s hit a database failure: %r", name, cause)
|
||||
raise ToolError(_DB_UNAVAILABLE) from cause
|
||||
raise
|
||||
|
||||
|
||||
def build_mcp_server(app) -> StewardMCPServer:
|
||||
from .tools import register_all
|
||||
mcp = StewardMCPServer(app)
|
||||
register_all(mcp.register_tool)
|
||||
return mcp
|
||||
|
||||
|
||||
async def _send_json(send, status: int, payload: dict, extra_headers=()) -> None:
|
||||
await send({
|
||||
"type": "http.response.start",
|
||||
"status": status,
|
||||
"headers": [(b"content-type", b"application/json"), *extra_headers],
|
||||
})
|
||||
await send({"type": "http.response.body", "body": json.dumps(payload).encode()})
|
||||
|
||||
|
||||
def mount_mcp(app) -> StewardMCPServer:
|
||||
"""Mount the MCP endpoint at /mcp and return the server.
|
||||
|
||||
stateless_http: every request stands alone on its bearer token. The
|
||||
stateful default strands Claude Code after a redeploy — it reconnects with
|
||||
a session id the new process never issued and won't re-initialize on the
|
||||
404 — so stateless is what makes a restart invisible to clients.
|
||||
|
||||
DNS-rebinding protection is off: its default allow-list is localhost only,
|
||||
so any deployment behind a reverse proxy would get 421s. That protection
|
||||
guards a browser rebinding DNS onto a localhost server; here the bearer
|
||||
token is the security boundary.
|
||||
"""
|
||||
# Looked up through their modules per request (not bound here) so a test
|
||||
# can stand in for the token store or the database probe.
|
||||
from steward import database
|
||||
from steward.core import api_tokens
|
||||
|
||||
from steward.core.plugin_manager import get_loaded_plugin_modules
|
||||
|
||||
mcp = build_mcp_server(app)
|
||||
for name, module in sorted(get_loaded_plugin_modules().items()):
|
||||
mcp.register_plugin(name, module)
|
||||
mcp_asgi = mcp.streamable_http_app(
|
||||
streamable_http_path=MCP_PATH,
|
||||
stateless_http=True,
|
||||
transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False),
|
||||
)
|
||||
app.mcp_server = mcp
|
||||
|
||||
# The SDK's session manager owns a task group that must be running before
|
||||
# it serves; host it in Quart's serving lifecycle.
|
||||
@app.before_serving
|
||||
async def _start_mcp() -> None:
|
||||
cm = mcp.session_manager.run()
|
||||
await cm.__aenter__()
|
||||
app._mcp_session_cm = cm
|
||||
|
||||
@app.after_serving
|
||||
async def _stop_mcp() -> None:
|
||||
cm = getattr(app, "_mcp_session_cm", None)
|
||||
if cm is not None:
|
||||
await cm.__aexit__(None, None, None)
|
||||
|
||||
async def handle_mcp(scope, receive, send):
|
||||
headers = {k.decode("latin-1").lower(): v.decode("latin-1")
|
||||
for k, v in scope.get("headers", [])}
|
||||
auth = headers.get("authorization", "")
|
||||
raw = auth[7:].strip() if auth[:7].lower() == "bearer " else ""
|
||||
|
||||
engine = getattr(app, "_db_engine", None)
|
||||
try:
|
||||
if engine is not None:
|
||||
await database.ensure_database_reachable(engine)
|
||||
caller = await api_tokens.resolve_token(app, raw)
|
||||
except (database.DatabaseUnavailable, SQLAlchemyError, OSError) as exc:
|
||||
logger.error("MCP request refused, database unreachable: %r", exc)
|
||||
await _send_json(send, 503, {"error": "database_unavailable", "message": _DB_UNAVAILABLE})
|
||||
return
|
||||
|
||||
if caller is None:
|
||||
await _send_json(
|
||||
send, 401,
|
||||
{"error": "unauthorized",
|
||||
"message": "Send a Steward API token as 'Authorization: Bearer stw_…'. "
|
||||
"Create one on Steward's AI access page."},
|
||||
extra_headers=[(b"www-authenticate", b'Bearer realm="steward-mcp"')],
|
||||
)
|
||||
return
|
||||
|
||||
caller_token = _context._caller_ctx.set(caller)
|
||||
app_token = _context._app_ctx.set(app)
|
||||
try:
|
||||
await mcp_asgi(scope, receive, send)
|
||||
finally:
|
||||
_context._caller_ctx.reset(caller_token)
|
||||
_context._app_ctx.reset(app_token)
|
||||
|
||||
original_asgi = app.asgi_app
|
||||
|
||||
async def dispatch(scope, receive, send):
|
||||
if scope["type"] == "http":
|
||||
path = scope.get("path", "")
|
||||
if path == MCP_PATH or path.startswith(MCP_PATH + "/"):
|
||||
# Path passes through untouched: the SDK app routes on /mcp itself.
|
||||
return await handle_mcp(scope, receive, send)
|
||||
return await original_asgi(scope, receive, send)
|
||||
|
||||
app.asgi_app = dispatch
|
||||
return mcp
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Core MCP tool modules.
|
||||
|
||||
Each module defines plain `async def` tools at module level (tests call them
|
||||
directly) and a `register(register_tool)` that lists them with their minimum
|
||||
role. `register_tool(fn, role=..., read_only=...)` takes both as required
|
||||
keywords: a tool with no declared role is unreachable, and there is no default
|
||||
read_only for a mutating tool to inherit by forgetting.
|
||||
|
||||
A tool's docstring IS its contract with the agent — it ships as the tool's
|
||||
description. Say what it is for, when to reach for it over its siblings, what
|
||||
each argument's empty value means, and what comes back.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from . import session
|
||||
|
||||
_MODULES = (session,)
|
||||
|
||||
|
||||
def register_all(register_tool) -> None:
|
||||
for module in _MODULES:
|
||||
module.register(register_tool)
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Session tools: who the connected client is acting as."""
|
||||
from __future__ import annotations
|
||||
|
||||
from steward.mcp._context import current_caller
|
||||
from steward.models.users import UserRole
|
||||
|
||||
_ROLE_MEANS = {
|
||||
UserRole.viewer: "Read-only: every list_*/get_* tool. Nothing that changes Steward or your hosts.",
|
||||
UserRole.operator: "Viewer tools, plus running playbooks and changing monitors, alerts and schedules.",
|
||||
UserRole.admin: "Everything, including deleting monitors and alert rules.",
|
||||
}
|
||||
|
||||
|
||||
async def whoami() -> dict:
|
||||
"""Who this connection acts as in Steward, and what that role may do.
|
||||
|
||||
Call this first if a tool refuses you for lack of role: `role` is the
|
||||
LOWER of the token's ceiling and its user's current role, so it can be
|
||||
less than the token was created with. A higher role needs a new token
|
||||
from Steward's AI access page, and only up to the user's own role.
|
||||
"""
|
||||
caller = current_caller()
|
||||
return {
|
||||
"user": caller.username,
|
||||
"user_id": caller.user_id,
|
||||
"role": caller.role.value,
|
||||
"role_allows": _ROLE_MEANS[caller.role],
|
||||
"token_id": caller.token_id,
|
||||
}
|
||||
|
||||
|
||||
def register(register_tool) -> None:
|
||||
register_tool(whoami, role=UserRole.viewer, read_only=True)
|
||||
Reference in New Issue
Block a user