Files
FabledGrimoire/tests/test_mcp_endpoint.py
T
bvandeusenandClaude Opus 5.5 0f5ef0be63
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 56s
CI & Build / integration (push) Successful in 1m4s
CI & Build / Python tests (push) Successful in 1m55s
CI & Build / Build & push image (push) Successful in 44s
rename(547): the server is grimoire — package, image, DB defaults, backups
Step 2 of the Scribe -> Grimoire rename.

- src/scribe -> src/grimoire, and every import, patch target, module path,
  hypercorn entry point and `python -m grimoire.restore` with it; pyproject,
  uv.lock and the frontend package are named grimoire.
- Image git.fabledsword.com/bvandeusen/fabledgrimoire; CI's Postgres service
  runs as grimoire / grimoire_test.
- Compose files default the DB user, database and (prod) volume to grimoire,
  each overridable from .env, so an install created as Scribe keeps its data
  by setting POSTGRES_USER, POSTGRES_DB and PGDATA_VOLUME.
- Backups are written as grimoire-<ts>.tar with format grimoire-backup-tar.
  Archives named scribe-* with the scribe-backup-tar manifest still restore
  and are still counted at the destination (folder and S3), with tests.
- Exports, the MCP auth realm, the ASGI scope key and the persistent logger
  carry the new name.

Kept on purpose: the secret-sealing key label stays "scribe.secrets.v1",
because every stored secret was derived under it.

Plugin identity (plugin name, hooks, env vars, .scribe marker, skills) is
step 3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 10:50:45 -04:00

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 grimoire.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(
"grimoire.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(
"grimoire.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.