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>
39 lines
1.3 KiB
Python
39 lines
1.3 KiB
Python
"""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
|