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>
23 lines
797 B
Python
23 lines
797 B
Python
"""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)
|