feat(mcp): mount /mcp endpoint with bearer-token auth

Wires FastMCP's streamable-HTTP ASGI sub-app into the Quart app via
asgi_app replacement. Requests under /mcp are stripped, auth-checked
against api_keys, and forwarded to FastMCP with fable_user_id set on
the ASGI scope. All other paths pass through to the original Quart
dispatch unchanged.

Tests cover the three auth paths (no header, invalid token, valid
token) plus a regression check that non-/mcp paths bypass the MCP
dispatch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-26 19:15:42 -04:00
parent caa504913f
commit 94f7a6de37
3 changed files with 130 additions and 2 deletions
+2
View File
@@ -34,6 +34,7 @@ from fabledassistant.routes.search import search_bp
from fabledassistant.routes.voice import voice_bp
from fabledassistant.routes.profile import profile_bp
from fabledassistant.routes.knowledge import knowledge_bp
from fabledassistant.mcp import mount_mcp
STATIC_DIR = Path(__file__).parent / "static"
logger = logging.getLogger(__name__)
@@ -441,4 +442,5 @@ def create_app() -> Quart:
return jsonify({"error": "Internal server error"}), 500
return "Internal Server Error", 500
mount_mcp(app)
return app
+44 -2
View File
@@ -31,8 +31,50 @@ def build_mcp_server() -> FastMCP:
def mount_mcp(app: Quart) -> None:
"""Mount the FastMCP streamable-HTTP ASGI sub-app at /mcp on the Quart app.
Phase 1.2 scaffold: just builds and exposes the instance for tests. The
actual ASGI wiring with bearer-auth happens in Task 1.4.
A small ASGI middleware between Quart and the FastMCP sub-app validates the
Bearer token against the api_keys table. Authenticated requests have their
user_id attached to the ASGI scope under "fable_user_id" for tool handlers
to read in later phases.
"""
from fabledassistant.mcp.auth import resolve_bearer_to_user_id
mcp = build_mcp_server()
mcp_asgi = mcp.streamable_http_app()
app.mcp_instance = mcp
async def auth_wrapped(scope, receive, send):
if scope["type"] != "http":
return await mcp_asgi(scope, receive, send)
# ASGI headers are lowercase bytes per spec; lowercase explicitly to be safe.
headers = {k.decode().lower(): v.decode() for k, v in scope.get("headers", [])}
user_id = await resolve_bearer_to_user_id(headers.get("authorization"))
if user_id is None:
await send({
"type": "http.response.start",
"status": 401,
"headers": [
(b"content-type", b"application/json"),
(b"www-authenticate", b'Bearer realm="fable-mcp"'),
],
})
await send({
"type": "http.response.body",
"body": b'{"error":"unauthorized"}',
})
return
scope["fable_user_id"] = user_id
await mcp_asgi(scope, receive, send)
original_asgi = app.asgi_app
async def dispatch(scope, receive, send):
if scope["type"] == "http":
path = scope.get("path", "")
if path == "/mcp" or path.startswith("/mcp/"):
sub_scope = dict(scope)
sub_scope["path"] = path[len("/mcp"):] or "/"
sub_scope["root_path"] = scope.get("root_path", "") + "/mcp"
return await auth_wrapped(sub_scope, receive, send)
return await original_asgi(scope, receive, send)
app.asgi_app = dispatch