fix: SessionRegistryMiddleware compatible with MCP SDK 1.27.0 SSE#177
Conversation
MCP SDK 1.27.0 changed initialize responses from JSON to SSE
(text/event-stream). The middleware's ASGI wrappers were incompatible:
1. _buffer_body: replay_receive returned synthetic http.disconnect after
replay, which caused EventSourceResponse to abort before sending
headers ("ASGI callable returned without starting response" 500).
Fix: forward to the real receive after replay so SSE disconnect
detection works against the actual client connection.
2. _handle_subsequent: buffered ALL response parts before sending,
which breaks SSE streaming. Fix: stream 2xx responses immediately,
only buffer error responses (400/404) for potential cross-node
re-init interception. Error responses are always JSON, not SSE.
3. _reinitialize: init_receive and replay_receive returned body once
then nothing, causing the same SSE abort. Fix: block with
anyio.sleep_forever() after body delivery so the SSE transport
has a live receive for disconnect detection.
Fixes #176.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
… 725 Exercises the _reinitialize code path with SSE responses that call receive() for disconnect detection. The two remaining uncovered lines (494, 541) are unreachable returns after anyio.sleep_forever() — they exist only to satisfy the type checker. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace anyio.sleep_forever() + unreachable return with while/sleep loop that mypy and coverage both handle correctly. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
Adding QA Active — reviewing SSE compatibility fix. |
cmeans
left a comment
There was a problem hiding this comment.
QA Review — Round 1
Critical fix for MCP SDK 1.27.0 SSE compatibility. The three changes are well-targeted:
_buffer_body— forwards to realreceive()after replay instead of synthetic disconnect. PreventsEventSourceResponsefrom aborting._handle_subsequent— 2xx streams immediately (SSE-safe), errors buffered for re-init. Clean split._reinitialize—init_receive/replay_receiveblock withanyio.sleep(3600)loop instead of returning disconnect.
SSE test stubs faithfully reproduce the EventSourceResponse task group pattern (stream + disconnect listener), which validates the fix against the actual failure mode.
Checks
| Check | Result |
|---|---|
| 57/57 session tests pass | ✅ |
| 725/725 full suite pass | ✅ |
| README test count matches (725) | ✅ |
| CHANGELOG entry present | ✅ |
| CI all green | ✅ |
Findings
1. [Substantive] CHANGELOG says anyio.sleep_forever() but code uses while True: await anyio.sleep(3600)
The CHANGELOG entry reads "uses anyio.sleep_forever() for disconnect detection" but the actual code uses while True: await anyio.sleep(3600). Either update the CHANGELOG to match the code, or use anyio.sleep_forever() in the code (which would be cleaner — single call, no loop).
PR checkboxes
4/5 checked. Test 4 (Claude.ai connection) requires production deployment.
Verdict
Finding #1 is doc drift — CHANGELOG doesn't match code. Everything else is clean.
|
Applying QA Failed — CHANGELOG says |
…rever) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Dev responseQA finding: CHANGELOG says sleep_forever but code uses sleep loop
Ready for re-review. |
cmeans
left a comment
There was a problem hiding this comment.
QA Review — Round 2
CHANGELOG fixed — now says "blocks until task group cancellation" instead of naming anyio.sleep_forever(). Matches the actual implementation. CI green. Zero findings. Verdict: Pass.
|
Applying Ready for QA Signoff — CHANGELOG fixed, CI green, zero findings. |
Summary
replay_receivereturned synthetichttp.disconnectafter body replay, causingEventSourceResponseto abort before sending headers (500 error)_buffer_body: forwards to realreceiveafter replay instead of synthetic disconnect_handle_subsequent: streams 2xx responses immediately (SSE-safe), only buffers errors for re-init interception_reinitialize:init_receive/replay_receiveblock withanyio.sleep(3600)loop instead of faking disconnectFixes #176.
QA
Prerequisites
pip install -e ".[dev]"AWARENESS_PORT=8421) withAWARENESS_SESSION_DATABASE_URLsetManual tests (via MCP tools)
pytest tests/— 725 passed🤖 Generated with Claude Code