Repository navigation
Streamable HTTP notification triggers duplicate response after HTTP 202 #3631
Description
Initial Checks
- I confirm that I'm using the newest release of my line (the latest 2.x, or the latest 1.x if I'm still on v1)
- I confirm that I searched for my issue in https://github.com/modelcontextprotocol/python-sdk/issues before opening this issue
Release line
2.x (current stable)
Description
Summary
MCP 2.1.1 can send HTTP 202 for a JSON-RPC notification and then raise ClosedResourceError when writing the notification to a closed internal session writer.
The outer error handler then attempts to send a second HTTP error response after the 202 response has already started. This can result in an unhandled ExceptionGroup or duplicate-response error.
Environment
- MCP: 2.1.1
- FastMCP: 4.0.3
- Python: 3.11
- Transport: Streamable HTTP
- Notification:
notifications/initialized
Expected behavior
Once MCP has sent HTTP 202 for a notification, a closed internal session writer should not trigger:
- A second HTTP response.
- An unhandled exception.
- A second write attempt to the closed writer.
Actual behavior
The notification path currently:
- Sends HTTP 202 Accepted.
- Calls
writer.send(session_message). - Raises
ClosedResourceErrorbecause the writer is closed. - Enters the broad POST error handler.
- Attempts to send an HTTP 500 response after the 202 response has already started.
- Attempts another write through the closed writer.
This can result in an unhandled ExceptionGroup or duplicate-response failure.
Minimal reproduction
- Create a
StreamableHTTPServerTransportwith JSON response mode enabled. - Call
transport.connect(). - Close
transport._read_stream_writer. - Send a JSON-RPC
notifications/initializedPOST request throughtransport.handle_requestwith ASGI exceptions enabled. - Observe the unhandled exception after the initial HTTP 202 response has started.
Evidence
The closed writer failure originates in:
mcp/shared/_context_streams.py:37
The notification handler sends HTTP 202 before attempting to write the notification to the internal session stream.
Related issues
This is related to the closed-writer handling discussed in #2064, but the failure occurs on the MCP v2 notification path.
Temporary workaround
I added a version-specific safeguard that:
- Preserves the original HTTP 202 response.
- Prevents the invalid secondary HTTP response.
- Prevents the final
ClosedResourceErrorfrom escaping.
The SDK still emits the initial Error handling POST request log because that logging occurs before the workaround can intercept the secondary failure.
Example Code
Install the reproduction dependencies:
python -m pip install "mcp==2.1.1" "httpx==0.28.1"
Run this script:
import asyncio
import httpx
from mcp.server.streamable_http import StreamableHTTPServerTransport
async def main() -> None:
transport = StreamableHTTPServerTransport(
mcp_session_id=None,
is_json_response_enabled=True,
)
async with transport.connect():
assert transport._read_stream_writer is not None
# Simulate a terminated MCP session after the transport connects.
await transport._read_stream_writer.aclose()
async with httpx.AsyncClient(
transport=httpx.ASGITransport(
app=transport.handle_request,
raise_app_exceptions=True,
),
base_url="http://testserver",
) as client:
response = await client.post(
"/mcp",
headers={
"accept": "application/json",
"content-type": "application/json",
},
json={
"jsonrpc": "2.0",
"method": "notifications/initialized",
"params": {},
},
)
print(response.status_code)
asyncio.run(main())
Actual Result
The notification handler first sends `HTTP 202 Accepted`.
It then calls:
await writer.send(session_message)
The closed writer raises:
anyio.ClosedResourceError
The outer error handler then attempts a second HTTP error response after the `202` response started. With `raise_app_exceptions=True`, the reproduction exits nonzero with an `ExceptionGroup` that contains the closed-writer failure and duplicate-response error.
Relevant trace locations:
mcp/server/streamable_http.py
mcp/shared/_context_streams.py:37
anyio/streams/memory.pyPython & MCP Python SDK
Python: 3.11.14
MCP Python SDK: 2.1.1
FastMCP: 4.0.3 in the production incident container
Transport: Streamable HTTP
Activity
premanand8800 commentedon Oct 3, 2026
I tried the repro against current main (2118f14) and it fails the same way there.
From what I can tell the problem is the order of things in the notification branch of
_handle_post_request. It sends the 202 first and only then callswriter.send(session_message). When the session stream is already closed that send raisesClosedResourceError, and the catch allexcept Exceptionbelow it tries to send a 500 even though the 202 has already gone out. It then writes the error into the same closed stream again, which fails a second time.The fix I tried is small: wrap that one
writer.sendin a try/except foranyio.ClosedResourceErrorandanyio.BrokenResourceErrorand just log at debug level. That's what_streamable_http_modern.pyalready does innotify()when its stream is gone, so it stays consistent with the rest of the SDK. Since the 202 is already out, there isn't anything left to tell the client.I also turned the repro into a test in
tests/shared/test_streamable_http.pythat checks we get exactly one 202. It fails on main with the ExceptionGroup and passes with the change, and the full suite still passes with coverage at 100%. The branch is here if it helps: https://github.com/premanand8800/python-sdk/tree/fix/notification-202-closed-session@lecton-apptio you opened this, so if you'd rather fix it yourself, go ahead. I'm happy either way.
Full disclosure: I used Claude Code to help dig into this and test it, but I went through the change myself and can answer questions about it.
rupak-eng commentedon Oct 4, 2026
Reproduced on current
main(2118f14f) with the issue's harness: after the 202 response starts,writer.send(session_message)raisesClosedResourceErrorand the generic POST error handler then attempts a second HTTP response, which fails (AssertionErrorfrom the ASGI transport: response already started) — so the client-facing failure is the duplicate response, not just the closed writer.Approach taken locally (additive, no public-API change): in the notification branch of
_handle_post_request, catchClosedResourceErrorfrom thewriter.sendafter the 202 is sent and drop the message with a debug log instead of falling into the generic error handler. That handler both attempts the second response and retrieswriter.send(Exception(err)), so catching at the site covers all three expectations in the issue (no second response, no unhandled exception, no second write to the closed writer). The normal path (open writer) is untouched.A regression test (notification POST with a closed session writer asserts 202 and no escaping exception; it fails on unpatched
main) plus the repo's checks (ruff check,ruff format --check,pyrightclean; existing server streamable suites green) pass. Fix is implemented and tested in a branch on my fork — happy to open a PR if this is assigned or taggedhelp wanted.(Disclosing: prepared with AI assistance; I ran the repro and tests myself and can explain the change.)
Varshith-Kali commentedon Oct 7, 2026
I've reproduced this on current main as well — same pair of exceptions as described above: the notification branch sends the 202, then
writer.send(session_message)raisesClosedResourceErroronce the session tears down, and the outer handler attempts a second response on the completed ASGI scope. I have a tested fix for this (regression tests included) from the duplicate #3641 — happy to retarget the PR here. Could you assign #3631 to me?