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>
51 lines
1.9 KiB
Python
51 lines
1.9 KiB
Python
"""Drive /mcp through the app's ASGI callable, as a client's HTTP request would.
|
|
|
|
Quart's test_client never reaches /mcp (it is dispatched before Quart's
|
|
pipeline), so MCP tests call app.asgi_app directly.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
|
|
|
|
async def asgi_request(app, method, path, headers=None, body=None):
|
|
raw = [(k.lower().encode(), v.encode()) for k, v in (headers or {}).items()]
|
|
if body is not None:
|
|
raw.append((b"content-length", str(len(body)).encode()))
|
|
scope = {
|
|
"type": "http", "asgi": {"version": "3.0", "spec_version": "2.3"},
|
|
"http_version": "1.1", "method": method, "scheme": "http",
|
|
"path": path, "raw_path": path.encode(), "query_string": b"",
|
|
"root_path": "", "headers": raw,
|
|
"client": ("testclient", 50000), "server": ("testserver", 80),
|
|
}
|
|
pending = [{"type": "http.request", "body": body or b"", "more_body": False}]
|
|
sent = []
|
|
|
|
async def receive():
|
|
return pending.pop(0) if pending else {"type": "http.disconnect"}
|
|
|
|
async def send(message):
|
|
sent.append(message)
|
|
|
|
await app.asgi_app(scope, receive, send)
|
|
start = next(m for m in sent if m["type"] == "http.response.start")
|
|
data = b"".join(m.get("body", b"") for m in sent if m["type"] == "http.response.body")
|
|
return start["status"], dict(start.get("headers", [])), data
|
|
|
|
|
|
def rpc_result(data: bytes) -> dict:
|
|
"""The JSON-RPC message from a JSON or a single-event SSE response body."""
|
|
text = data.decode()
|
|
if text.lstrip().startswith("{"):
|
|
return json.loads(text)
|
|
lines = [ln[5:].strip() for ln in text.splitlines() if ln.startswith("data:")]
|
|
return json.loads(lines[-1])
|
|
|
|
|
|
def rpc(method, params=None, id_=1) -> bytes:
|
|
return json.dumps({"jsonrpc": "2.0", "id": id_, "method": method, "params": params or {}}).encode()
|
|
|
|
|
|
HEADERS = {"Content-Type": "application/json", "Accept": "application/json, text/event-stream"}
|