Skip to content

Commit f4ae434

Browse files
Skip 500 after completed POST response on closed writer
Notification POSTs send 202 Accepted before forwarding to the session writer. If the writer is closed, the resulting ClosedResourceError was answered with a second response (500), which Uvicorn rejects. Track whether the POST response was already sent and log-and-drop instead. Fixes #3651.
1 parent 91941ed commit f4ae434

2 files changed

Lines changed: 53 additions & 7 deletions

File tree

‎src/mcp/server/streamable_http.py‎

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -560,6 +560,7 @@ async def _handle_post_request(self, scope: Scope, request: Request, receive: Re
560560
writer = self._read_stream_writer
561561
if writer is None: # pragma: no cover
562562
raise ValueError("No read stream writer available. Ensure connect() is called first.")
563+
response_sent = False
563564
try:
564565
# Validate Accept header
565566
if not await self._validate_accept_header(request, scope, send):
@@ -623,6 +624,7 @@ async def _handle_post_request(self, scope: Scope, request: Request, receive: Re
623624
HTTPStatus.ACCEPTED,
624625
)
625626
await response(scope, receive, send)
627+
response_sent = True
626628

627629
# Process the message after sending the response
628630
session_message = SessionMessage(message, metadata=self._message_metadata(request))
@@ -716,13 +718,19 @@ async def _handle_post_request(self, scope: Scope, request: Request, receive: Re
716718

717719
except Exception as err:
718720
logger.exception("Error handling POST request")
719-
response = self._create_error_response(
720-
"Error handling POST request",
721-
HTTPStatus.INTERNAL_SERVER_ERROR,
722-
INTERNAL_ERROR,
723-
)
724-
await response(scope, receive, send)
725-
await writer.send(Exception(err))
721+
if response_sent:
722+
logger.debug("Not sending error response: POST response already sent")
723+
else:
724+
response = self._create_error_response(
725+
"Error handling POST request",
726+
HTTPStatus.INTERNAL_SERVER_ERROR,
727+
INTERNAL_ERROR,
728+
)
729+
await response(scope, receive, send)
730+
try:
731+
await writer.send(Exception(err))
732+
except (anyio.ClosedResourceError, anyio.BrokenResourceError):
733+
logger.debug("Writer closed while forwarding POST error; dropping exception")
726734
return
727735

728736
async def _handle_get_request(self, request: Request, send: Send) -> None:

‎tests/server/test_streamable_http_router.py‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
"""Regression coverage for the StreamableHTTP per-session response router."""
22

3+
import logging
4+
35
import anyio
46
import pytest
57
from mcp_types import JSONRPCMessage, JSONRPCResponse
@@ -157,3 +159,39 @@ async def test_terminated_transport_answers_404() -> None:
157159

158160
assert post.sent[0]["type"] == "http.response.start"
159161
assert post.sent[0]["status"] == 404
162+
163+
164+
@pytest.mark.anyio
165+
async def test_closed_writer_notification_post_sends_single_202(
166+
caplog: pytest.LogCaptureFixture,
167+
) -> None:
168+
"""A notification POST answered 202 before writer.send fails must not send a second response (#3651).
169+
170+
The 202 completes the ASGI response, so the closed-writer error is logged
171+
and dropped instead of answered with a 500 that Uvicorn rejects.
172+
"""
173+
transport = StreamableHTTPServerTransport(mcp_session_id="repro-session")
174+
async with transport.connect():
175+
pass
176+
post = _AsgiPost(
177+
b'{"jsonrpc": "2.0", "method": "notifications/initialized"}',
178+
[
179+
(b"accept", b"application/json, text/event-stream"),
180+
(b"content-type", b"application/json"),
181+
(b"mcp-session-id", b"repro-session"),
182+
],
183+
)
184+
sent: list[Message] = []
185+
186+
async def strict_send(message: Message) -> None:
187+
if message["type"] == "http.response.start" and any(m["type"] == "http.response.start" for m in sent):
188+
raise RuntimeError("Unexpected ASGI message after completed")
189+
sent.append(message)
190+
191+
with caplog.at_level(logging.ERROR, logger="mcp.server.streamable_http"):
192+
await transport.handle_request(post.scope, post.receive, strict_send)
193+
194+
starts = [m for m in sent if m["type"] == "http.response.start"]
195+
assert len(starts) == 1
196+
assert starts[0]["status"] == 202
197+
assert "Error handling POST request" in caplog.text

0 commit comments

Comments
 (0)