Repository navigation
Streamable HTTP notification sends a second response when the session writer is closed #3651
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
A stateful StreamableHTTPServerTransport can attempt to send a second ASGI response after already completing a 202 Accepted response.
When a notification is received while the session's incoming writer is closed:
-
_handle_post_requestsends and completes a202 Acceptedresponse. -
It then calls
writer.send(session_message). -
The closed writer raises
anyio.ClosedResourceError. -
The outer exception handler constructs a
500response and calls it with the same ASGIsend. -
Uvicorn rejects the second
http.response.startbecause the response has already completed:RuntimeError: Unexpected ASGI message 'http.response.start' sent, after response already completed.
The exception chain is:
anyio.ClosedResourceError
at streamable_http.py:629
await writer.send(session_message)
During handling of the above exception:
RuntimeError: Unexpected ASGI message 'http.response.start' sent, after response already completed.
at streamable_http.py:724
await response(scope, receive, send)
I reproduced this directly against mcp==2.3.0 without FastMCP application behavior. We have also observed the same chain in production on mcp==2.2.0.
Expected behavior: after an ASGI response has been completed, an error while forwarding the notification should not cause another response to be sent. The transport could either detect the closed writer before acknowledging the notification or handle the closure without attempting a second response.
From the caller's perspective, the 202 has already been returned. The visible impact is primarily an unhandled server exception and misleading error telemetry rather than a different HTTP response.
This may share underlying session-lifecycle behavior with #2741, but this report is specifically about the second http.response.start after the notification has already been acknowledged.
AI assistance disclosure: I used an AI coding assistant to help investigate the production traceback and reduce it to a minimal reproduction. I reviewed and ran the reproduction myself.
Example Code
import json
import logging
import traceback
from functools import partial
from typing import NoReturn
import anyio
from mcp.server.streamable_http import StreamableHTTPServerTransport
from pydantic import BaseModel
from starlette.types import Message, Scope
class RequestState(BaseModel):
body_delivered: bool
class ResponseState(BaseModel):
started: bool
completed: bool
async def receive_request(*, body: bytes, state: RequestState) -> Message:
if not state.body_delivered:
state.body_delivered = True
return {"type": "http.request", "body": body, "more_body": False}
return {"type": "http.disconnect"}
async def send_response(message: Message, *, state: ResponseState) -> None:
if not state.started:
if message["type"] != "http.response.start":
raise RuntimeError(f"Expected ASGI message 'http.response.start', but got '{message['type']}'.")
state.started = True
return
if not state.completed:
if message["type"] != "http.response.body":
raise RuntimeError(f"Expected ASGI message 'http.response.body', but got '{message['type']}'.")
state.completed = not message.get("more_body", False)
return
raise RuntimeError(f"Unexpected ASGI message '{message['type']}' sent, after response already completed.")
def find_closed_resource_error(error: BaseException) -> anyio.ClosedResourceError | None:
seen: set[int] = set()
current: BaseException | None = error
while current is not None and id(current) not in seen:
if isinstance(current, anyio.ClosedResourceError):
return current
seen.add(id(current))
current = current.__cause__ or current.__context__
return None
def request_scope() -> Scope:
return {
"type": "http",
"asgi": {"version": "3.0", "spec_version": "2.3"},
"http_version": "1.1",
"method": "POST",
"scheme": "http",
"path": "/mcp",
"raw_path": b"/mcp",
"query_string": b"",
"root_path": "",
"headers": [
(b"accept", b"application/json, text/event-stream"),
(b"content-type", b"application/json"),
(b"mcp-protocol-version", b"2025-11-25"),
(b"mcp-session-id", b"repro-session"),
],
"client": ("127.0.0.1", 50000),
"server": ("127.0.0.1", 8080),
}
async def reproduce() -> None:
logging.getLogger("mcp.server.streamable_http").setLevel(logging.CRITICAL)
transport = StreamableHTTPServerTransport(mcp_session_id="repro-session")
# A stateful session can remain addressable briefly after its central stream closes.
async with transport.connect():
pass
body = json.dumps(
{
"jsonrpc": "2.0",
"method": "notifications/initialized",
}
).encode()
request_state = RequestState(body_delivered=False)
response_state = ResponseState(started=False, completed=False)
try:
await transport.handle_request(
request_scope(),
partial(receive_request, body=body, state=request_state),
partial(send_response, state=response_state),
)
except RuntimeError as error:
closed_resource_error = find_closed_resource_error(error)
if "after response already completed" not in str(error) or closed_resource_error is None:
raise
traceback.print_exception(error)
print("\nReproduced the ClosedResourceError -> duplicate http.response.start failure.")
return
raise AssertionError("The expected ASGI failure was not reproduced")
def main() -> NoReturn:
anyio.run(reproduce)
raise SystemExit(0)
if __name__ == "__main__":
main()Python & MCP Python SDK
Python 3.11.15
mcp 2.3.0
Also reproduced in production with:
mcp 2.2.0
Relevant dependencies:
anyio 4.14.2
starlette 1.0.1
uvicorn 0.46.0
Activity
bunnysayzz commentedon Oct 7, 2026
this bit me while reading the streamable http handler. for notifications
_handle_post_requestsends the 202 first (streamable_http.py:624), thenwriter.send(session_message)(:629) can raise ClosedResourceError when the session writer is already closed, and the outer except (:717) tries to send a 500 on the already-completed connection. fix is catching ClosedResourceError around the writer send in the notification path instead of falling through to the 500. could I get assigned? happy to put up a PR.dharaneesh-sys commentedon Oct 7, 2026
Confirmed repro on main @ 91941ed + mcp 2.3.0, Python 3.11, anyio 4.14 / starlette 1.0 / uvicorn 0.46 — same chain: 202 Accepted completed at _handle_post_request, then writer.send() raises ClosedResourceError (streamable_http.py:629), outer handler tries second http.response.start (:724) → RuntimeError: after response already completed.
Intended behavior question: after the 202 is completed, should a forwarding failure just be logged and swallowed (no second response), or should we check writer-closed before acking the notification?
Approach I'd take if you're open to an outside PR: track response_completed in _handle_post_request, and in the except path skip building/sending the 500 when already completed — just logger.exception(). Non-completed path unchanged. Plus regression test in tests/server/test_streamable_http_router.py with in-memory transport: close session stream, send notifications/initialized, assert single http.response.start.
I hit this pattern via Starlette/Uvicorn ASGI work and would like to fix it if you're open to it. I already have a tested fix on a branch in my fork (fix-3651-no-double-response) ready to push as a PR once assigned.
AI disclosure: used an assistant to reduce the traceback, reviewed and ran the reproduction myself.
Kludex commentedon Oct 10, 2026
Both reports reproduce the notification path sending
202, then encountering a closed session writer and attempting a second ASGI response. This is tracked in #3631, so I’m closing this as a duplicate. AI-assisted triage; I reviewed both reports.