test(mcp): drive ASGI app directly, skip Quart test_client
Quart's test_client expects its request pipeline to populate app._preserved_context. Our /mcp middleware deliberately bypasses that pipeline (forwarding straight to FastMCP), so test_client's teardown blew up with AttributeError. The middleware is correct; the test harness was wrong. Build raw ASGI scope/receive/send and call app.asgi_app directly — which is what production hypercorn does anyway. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+84
-45
@@ -1,9 +1,16 @@
|
|||||||
"""End-to-end tests for the /mcp HTTP endpoint mounted on the Quart app.
|
"""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
|
These exercise the ASGI dispatch + auth middleware by driving the app's ASGI
|
||||||
mocked so the tests don't require a database, matching the pattern in
|
callable directly. We avoid Quart's test_client() because it expects Quart's
|
||||||
test_api_keys.py.
|
request pipeline to set `app._preserved_context` as a side effect, but our
|
||||||
|
ASGI middleware forwards /mcp requests to FastMCP without touching the Quart
|
||||||
|
pipeline (correct production behavior), which causes test_client to fail on
|
||||||
|
teardown.
|
||||||
|
|
||||||
|
The api_keys lookup is mocked so the tests don't require a database, matching
|
||||||
|
the pattern in test_api_keys.py.
|
||||||
"""
|
"""
|
||||||
|
import json
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -11,15 +18,55 @@ import pytest
|
|||||||
from fabledassistant.app import create_app
|
from fabledassistant.app import create_app
|
||||||
|
|
||||||
|
|
||||||
|
async def _send_request(
|
||||||
|
app, method: str, path: str,
|
||||||
|
headers: dict | None = None, body: bytes | None = None,
|
||||||
|
) -> tuple[int, bytes]:
|
||||||
|
"""Drive the app's ASGI callable directly and collect the response."""
|
||||||
|
raw_headers = [(k.lower().encode(), v.encode()) for k, v in (headers or {}).items()]
|
||||||
|
if body is not None:
|
||||||
|
raw_headers.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_headers,
|
||||||
|
"client": ("testclient", 50000),
|
||||||
|
"server": ("testserver", 80),
|
||||||
|
}
|
||||||
|
receive_messages = [{
|
||||||
|
"type": "http.request", "body": body or b"", "more_body": False,
|
||||||
|
}]
|
||||||
|
sent: list[dict] = []
|
||||||
|
|
||||||
|
async def receive():
|
||||||
|
if receive_messages:
|
||||||
|
return receive_messages.pop(0)
|
||||||
|
return {"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")
|
||||||
|
body_chunks = b"".join(
|
||||||
|
m.get("body", b"") for m in sent if m["type"] == "http.response.body"
|
||||||
|
)
|
||||||
|
return start["status"], body_chunks
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_mcp_endpoint_unauthenticated_returns_401():
|
async def test_mcp_endpoint_unauthenticated_returns_401():
|
||||||
app = create_app()
|
app = create_app()
|
||||||
async with app.test_client() as client:
|
status, _ = await _send_request(app, "POST", "/mcp")
|
||||||
resp = await client.post(
|
assert status == 401
|
||||||
"/mcp",
|
|
||||||
json={"jsonrpc": "2.0", "id": 1, "method": "ping"},
|
|
||||||
)
|
|
||||||
assert resp.status_code == 401
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -29,56 +76,48 @@ async def test_mcp_endpoint_invalid_token_returns_401():
|
|||||||
"fabledassistant.mcp.auth.lookup_key",
|
"fabledassistant.mcp.auth.lookup_key",
|
||||||
AsyncMock(return_value=None),
|
AsyncMock(return_value=None),
|
||||||
):
|
):
|
||||||
async with app.test_client() as client:
|
status, _ = await _send_request(
|
||||||
resp = await client.post(
|
app, "POST", "/mcp",
|
||||||
"/mcp",
|
headers={"Authorization": "Bearer fmcp_invalid"},
|
||||||
json={"jsonrpc": "2.0", "id": 1, "method": "ping"},
|
)
|
||||||
headers={"Authorization": "Bearer fmcp_invalid"},
|
assert status == 401
|
||||||
)
|
|
||||||
assert resp.status_code == 401
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_mcp_endpoint_valid_token_passes_auth():
|
async def test_mcp_endpoint_valid_token_passes_auth():
|
||||||
"""With a valid Bearer, the request reaches FastMCP. FastMCP may return
|
"""With a valid Bearer, the request reaches FastMCP. Anything other than 401
|
||||||
200 (initialize handshake), 400, or a streaming response — anything other
|
confirms auth passed and the request was handed off to the sub-app."""
|
||||||
than 401 confirms auth passed and the request was handed off.
|
|
||||||
"""
|
|
||||||
fake_key = MagicMock()
|
fake_key = MagicMock()
|
||||||
fake_key.user_id = 7
|
fake_key.user_id = 7
|
||||||
fake_key.scope = "write"
|
fake_key.scope = "write"
|
||||||
app = create_app()
|
app = create_app()
|
||||||
|
initialize_body = json.dumps({
|
||||||
|
"jsonrpc": "2.0", "id": 1, "method": "initialize",
|
||||||
|
"params": {
|
||||||
|
"protocolVersion": "2024-11-05",
|
||||||
|
"capabilities": {},
|
||||||
|
"clientInfo": {"name": "test", "version": "0"},
|
||||||
|
},
|
||||||
|
}).encode()
|
||||||
with patch(
|
with patch(
|
||||||
"fabledassistant.mcp.auth.lookup_key",
|
"fabledassistant.mcp.auth.lookup_key",
|
||||||
AsyncMock(return_value=fake_key),
|
AsyncMock(return_value=fake_key),
|
||||||
):
|
):
|
||||||
async with app.test_client() as client:
|
status, _ = await _send_request(
|
||||||
resp = await client.post(
|
app, "POST", "/mcp",
|
||||||
"/mcp",
|
headers={
|
||||||
json={
|
"Authorization": "Bearer fmcp_valid",
|
||||||
"jsonrpc": "2.0",
|
"Content-Type": "application/json",
|
||||||
"id": 1,
|
"Accept": "application/json, text/event-stream",
|
||||||
"method": "initialize",
|
},
|
||||||
"params": {
|
body=initialize_body,
|
||||||
"protocolVersion": "2024-11-05",
|
)
|
||||||
"capabilities": {},
|
assert status != 401
|
||||||
"clientInfo": {"name": "test", "version": "0"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
headers={
|
|
||||||
"Authorization": "Bearer fmcp_valid",
|
|
||||||
"Accept": "application/json, text/event-stream",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
assert resp.status_code != 401
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_non_mcp_paths_bypass_mcp_dispatch():
|
async def test_non_mcp_paths_bypass_mcp_dispatch():
|
||||||
"""A non-/mcp path must not be routed to the FastMCP sub-app. Hitting an
|
"""A non-/mcp path must reach Quart's normal routing, not the MCP dispatch."""
|
||||||
unknown /api/* route should return 404 from Quart, not 401 from the MCP
|
|
||||||
middleware."""
|
|
||||||
app = create_app()
|
app = create_app()
|
||||||
async with app.test_client() as client:
|
status, _ = await _send_request(app, "GET", "/api/this-route-does-not-exist")
|
||||||
resp = await client.get("/api/this-route-does-not-exist")
|
assert status != 401
|
||||||
assert resp.status_code != 401
|
|
||||||
|
|||||||
Reference in New Issue
Block a user