CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Successful in 53s
CI & Build / Python tests (push) Successful in 1m37s
CI & Build / Build & push image (push) Successful in 31s
- server.py: `mcp.server.mcpserver.MCPServer`; StrictArgsFastMCP becomes StrictArgsMCPServer, whose call_tool takes and forwards v2's `context` and raises ToolError (the SDK logs anything else as an unexpected crash; the message reaches the caller either way). - stateless_http and transport_security moved from the constructor to `streamable_http_app(...)` in mount_mcp, with their reasons. - Per-request user identity is unchanged: the contextvar set around the ASGI call reaches the handler on both v2 paths (legacy stateless spawns from the request task; the 2026-07-28 modern path opens its task group inside the request). - pyproject: mcp[cli]>=2.2, no ceiling (installs are --locked). uv.lock regenerated with --upgrade-package mcp in the ci-python image: only mcp and its own dependencies moved (106 → 110 packages). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
134 lines
4.8 KiB
Python
134 lines
4.8 KiB
Python
"""End-to-end tests for the /mcp HTTP endpoint mounted on the Quart app.
|
|
|
|
These exercise the ASGI dispatch + auth middleware by driving the app's ASGI
|
|
callable directly. We avoid Quart's test_client() because it expects Quart's
|
|
request pipeline to set `app._preserved_context` as a side effect, but our
|
|
ASGI middleware forwards /mcp requests to MCPServer 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
|
|
|
|
import pytest
|
|
|
|
from scribe.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
|
|
async def test_mcp_endpoint_unauthenticated_returns_401():
|
|
app = create_app()
|
|
status, _ = await _send_request(app, "POST", "/mcp")
|
|
assert status == 401
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_mcp_endpoint_invalid_token_returns_401():
|
|
app = create_app()
|
|
with patch(
|
|
"scribe.mcp.auth.lookup_key",
|
|
AsyncMock(return_value=None),
|
|
):
|
|
status, _ = await _send_request(
|
|
app, "POST", "/mcp",
|
|
headers={"Authorization": "Bearer fmcp_invalid"},
|
|
)
|
|
assert status == 401
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_mcp_endpoint_valid_token_passes_auth():
|
|
"""With a valid Bearer, the request must successfully reach MCPServer's
|
|
initialize handler. Asserting `!= 401` is too weak: it lets a 404 from
|
|
a path-mismatch (the original bug) through. MCPServer responds 200 to a
|
|
well-formed initialize handshake.
|
|
|
|
MCPServer's session manager normally starts via Quart's @before_serving
|
|
hook in production. This raw-ASGI test doesn't go through Quart's
|
|
serving lifecycle, so we manually enter the session manager."""
|
|
fake_key = MagicMock()
|
|
fake_key.user_id = 7
|
|
fake_key.scope = "write"
|
|
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()
|
|
async with app.mcp_instance.session_manager.run():
|
|
with patch(
|
|
"scribe.mcp.auth.lookup_key",
|
|
AsyncMock(return_value=fake_key),
|
|
):
|
|
status, _ = await _send_request(
|
|
app, "POST", "/mcp",
|
|
headers={
|
|
# MCPServer's transport_security module enforces a Host
|
|
# header (DNS-rebinding protection); without it the
|
|
# request gets a 421 Misdirected Request.
|
|
"Host": "testserver",
|
|
"Authorization": "Bearer fmcp_valid",
|
|
"Content-Type": "application/json",
|
|
"Accept": "application/json, text/event-stream",
|
|
},
|
|
body=initialize_body,
|
|
)
|
|
assert status == 200, f"expected 200 from MCPServer initialize, got {status}"
|
|
|
|
|
|
# Note: there's no explicit "non-/mcp paths bypass the middleware" test here
|
|
# because driving Quart's full request pipeline through a hand-rolled ASGI
|
|
# scope (no lifespan startup, no hypercorn state) doesn't produce a response.
|
|
# The bypass behavior is implicit: if the middleware ate non-/mcp requests,
|
|
# the rest of the test suite (~250 tests hitting /api/*) would break.
|