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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
"""End-to-end tests for the /mcp HTTP endpoint mounted on the Quart app.
|
||||
|
||||
These exercise the ASGI dispatch + auth middleware. The api_keys lookup is
|
||||
mocked so the tests don't require a database, matching the pattern in
|
||||
test_api_keys.py.
|
||||
"""
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from fabledassistant.app import create_app
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_endpoint_unauthenticated_returns_401():
|
||||
app = create_app()
|
||||
async with app.test_client() as client:
|
||||
resp = await client.post(
|
||||
"/mcp",
|
||||
json={"jsonrpc": "2.0", "id": 1, "method": "ping"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_endpoint_invalid_token_returns_401():
|
||||
app = create_app()
|
||||
with patch(
|
||||
"fabledassistant.mcp.auth.lookup_key",
|
||||
AsyncMock(return_value=None),
|
||||
):
|
||||
async with app.test_client() as client:
|
||||
resp = await client.post(
|
||||
"/mcp",
|
||||
json={"jsonrpc": "2.0", "id": 1, "method": "ping"},
|
||||
headers={"Authorization": "Bearer fmcp_invalid"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_endpoint_valid_token_passes_auth():
|
||||
"""With a valid Bearer, the request reaches FastMCP. FastMCP may return
|
||||
200 (initialize handshake), 400, or a streaming response — anything other
|
||||
than 401 confirms auth passed and the request was handed off.
|
||||
"""
|
||||
fake_key = MagicMock()
|
||||
fake_key.user_id = 7
|
||||
fake_key.scope = "write"
|
||||
app = create_app()
|
||||
with patch(
|
||||
"fabledassistant.mcp.auth.lookup_key",
|
||||
AsyncMock(return_value=fake_key),
|
||||
):
|
||||
async with app.test_client() as client:
|
||||
resp = await client.post(
|
||||
"/mcp",
|
||||
json={
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "initialize",
|
||||
"params": {
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {},
|
||||
"clientInfo": {"name": "test", "version": "0"},
|
||||
},
|
||||
},
|
||||
headers={
|
||||
"Authorization": "Bearer fmcp_valid",
|
||||
"Accept": "application/json, text/event-stream",
|
||||
},
|
||||
)
|
||||
assert resp.status_code != 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_mcp_paths_bypass_mcp_dispatch():
|
||||
"""A non-/mcp path must not be routed to the FastMCP sub-app. Hitting an
|
||||
unknown /api/* route should return 404 from Quart, not 401 from the MCP
|
||||
middleware."""
|
||||
app = create_app()
|
||||
async with app.test_client() as client:
|
||||
resp = await client.get("/api/this-route-does-not-exist")
|
||||
assert resp.status_code != 401
|
||||
Reference in New Issue
Block a user