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:
@@ -29,6 +29,11 @@ _FAILED_PLUGINS: dict[str, str] = {}
|
||||
# {"plugin": <name>, "label": str, "href": str, "section": str}
|
||||
_PLUGIN_NAV: list[dict] = []
|
||||
|
||||
# Loaded plugin modules, by name, for the MCP server: a plugin contributes
|
||||
# agent tools through an optional register_mcp_tools(register) export, which
|
||||
# steward/mcp/server.py calls when it mounts (and on hot-reload, below).
|
||||
_PLUGIN_MODULES: dict[str, object] = {}
|
||||
|
||||
|
||||
def get_plugin_failures() -> dict[str, str]:
|
||||
"""Return a copy of the failed-plugin registry (name → error message)."""
|
||||
@@ -70,6 +75,23 @@ def _collect_plugin_nav(name: str, module) -> None:
|
||||
})
|
||||
|
||||
|
||||
def get_loaded_plugin_modules() -> dict[str, object]:
|
||||
"""Loaded plugin modules by name (a copy), for hooks consumed after loading."""
|
||||
return dict(_PLUGIN_MODULES)
|
||||
|
||||
|
||||
def _collect_plugin_module(app: "Quart", name: str, module) -> None:
|
||||
"""Remember a loaded plugin module; hand it to a live MCP server if mounted.
|
||||
|
||||
At boot the MCP server mounts after plugins load and reads them via
|
||||
get_loaded_plugin_modules(); a plugin hot-loaded later is registered here.
|
||||
"""
|
||||
_PLUGIN_MODULES[name] = module
|
||||
mcp_server = getattr(app, "mcp_server", None)
|
||||
if mcp_server is not None:
|
||||
mcp_server.register_plugin(name, module)
|
||||
|
||||
|
||||
def resolve_plugin_path(plugin_dirs: list[Path], name: str) -> Path | None:
|
||||
"""Return the first plugin root that contains `name`, else None.
|
||||
|
||||
@@ -264,6 +286,7 @@ def load_plugins(app: "Quart") -> None:
|
||||
_FAILED_PLUGINS.pop(name, None) # clear any stale failure from a previous reload attempt
|
||||
_LOADED_PLUGINS.add(name)
|
||||
_collect_plugin_nav(name, module)
|
||||
_collect_plugin_module(app, name, module)
|
||||
logger.info("Plugin %r loaded (v%s)", name, meta.get("version", "?"))
|
||||
|
||||
|
||||
@@ -483,6 +506,7 @@ def hot_reload_plugin(app: "Quart", name: str) -> tuple[bool, str]:
|
||||
|
||||
_LOADED_PLUGINS.add(name)
|
||||
_collect_plugin_nav(name, module)
|
||||
_collect_plugin_module(app, name, module)
|
||||
logger.info("Plugin %r hot-reloaded (v%s)", name, meta.get("version", "?"))
|
||||
return True, f"Plugin activated (v{meta.get('version', '?')})"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user