Files
FabledSteward/tests/mcp/_http.py
T
bvandeusenandClaude Opus 5.5 3984f639e8
CI / lint (push) Successful in 2s
CI / integration (push) Failing after 35s
CI / unit (push) Failing after 1m0s
CI / publish (push) Skipped
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>
2026-10-06 07:24:00 -04:00

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"}