diff --git a/src/fabledassistant/app.py b/src/fabledassistant/app.py index 58bb9a7..2ecde12 100644 --- a/src/fabledassistant/app.py +++ b/src/fabledassistant/app.py @@ -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 diff --git a/src/fabledassistant/mcp/server.py b/src/fabledassistant/mcp/server.py index 4856655..c97eaac 100644 --- a/src/fabledassistant/mcp/server.py +++ b/src/fabledassistant/mcp/server.py @@ -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 diff --git a/tests/test_mcp_endpoint.py b/tests/test_mcp_endpoint.py new file mode 100644 index 0000000..e1577cb --- /dev/null +++ b/tests/test_mcp_endpoint.py @@ -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