Files
FabledSteward/tests/mcp/_http.py
T
bvandeusenandClaude Opus 5.5 970adf41e7
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 37s
CI / unit (push) Successful in 1m0s
CI / publish (push) Successful in 57s
test(mcp): stay connected until the SSE reply ends; drop cross-context reset
The test client reported http.disconnect as soon as the request body was
read, and the SDK's SSE response cancels its stream on disconnect, so
tools/list came back 200 with an empty body. Model a real client: wait
for the response (with a 15s deadline), then disconnect.

The caller fixture reset its ContextVar from sync teardown, a different
context from the async test that set it; each async test already runs in
its own context copy, so the reset is dropped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 07:25:23 -04:00

63 lines
2.5 KiB
Python

"""Drive /mcp through the app's ASGI callable, as a client's HTTP request would.
Quart's test_client never reaches /mcp (it is dispatched before Quart's
pipeline), so MCP tests call app.asgi_app directly.
"""
from __future__ import annotations
import asyncio
import json
async def asgi_request(app, method, path, headers=None, body=None):
raw = [(k.lower().encode(), v.encode()) for k, v in (headers or {}).items()]
if body is not None:
raw.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,
"client": ("testclient", 50000), "server": ("testserver", 80),
}
pending = [{"type": "http.request", "body": body or b"", "more_body": False}]
sent = []
responded = asyncio.Event()
# Like a real client, stay connected until the response is complete. An
# SSE response listens for http.disconnect and cancels its stream on it,
# so reporting a disconnect as soon as the body is read empties the reply.
async def receive():
if pending:
return pending.pop(0)
await responded.wait()
return {"type": "http.disconnect"}
async def send(message):
sent.append(message)
if message["type"] == "http.response.body" and not message.get("more_body"):
responded.set()
await asyncio.wait_for(app.asgi_app(scope, receive, send), timeout=15)
start = next(m for m in sent if m["type"] == "http.response.start")
data = b"".join(m.get("body", b"") for m in sent if m["type"] == "http.response.body")
return start["status"], dict(start.get("headers", [])), data
def rpc_result(data: bytes) -> dict:
"""The JSON-RPC message from a JSON or a single-event SSE response body."""
text = data.decode()
if text.lstrip().startswith("{"):
return json.loads(text)
lines = [ln[5:].strip() for ln in text.splitlines() if ln.startswith("data:")]
if not lines:
raise AssertionError(f"no JSON-RPC message in response body: {text!r}")
return json.loads(lines[-1])
def rpc(method, params=None, id_=1) -> bytes:
return json.dumps({"jsonrpc": "2.0", "id": id_, "method": method, "params": params or {}}).encode()
HEADERS = {"Content-Type": "application/json", "Accept": "application/json, text/event-stream"}