-
Notifications
You must be signed in to change notification settings - Fork 2.6k
Description
Initial Checks
- I confirm that I'm using the latest version of MCP Python SDK
- I confirm that I searched for my issue in https://github.com/modelcontextprotocol/python-sdk/issues before opening this issue
Description
Race Condition in StreamableHTTP Transport Causes ClosedResourceError
Description
Starting from v1.12.0, MCP servers in HTTP Streamable mode experience a race condition that causes ClosedResourceError
exceptions when requests fail validation early (e.g., due to incorrect Accept headers). This issue is particularly noticeable with fast-failing requests and can be reproduced consistently.
Root Cause Analysis
NOTE: All code references are based on v1.14.0
with anyio==4.10.0
.
Execution Flow
- Transport Setup: In
streamable_http_manager.py
line 171,connect()
is called, which internally creates amessage_router
task - Message Router: The
message_router
enters anasync for write_stream_reader
loop (line 831 instreamable_http.py
) - Checkpoint Yield: The
write_stream_reader
implementation inanyio.streams.memory.py
line 109 callscheckpoint()
in thereceive()
function, yielding control - Request Handling:
handle_request()
processes the HTTP request - Early Return: If validation fails (e.g., incorrect Accept headers in
_handle_post_request
at line 323 instreamable_http.py
), the request returns immediately - Transport Termination: Back in
streamable_http_manager.py
line 193,http_transport.terminate()
is called, closing all streams includingwrite_stream_reader
- Race Condition: The
message_router
task may still be in thecheckpoint()
yield and hasn't returned to check the stream state - Error: When the
message_router
resumes, it continues toreceive_nowait()
and encounters a closed stream, raisingClosedResourceError
in line 93
Code Locations
- Issue trigger:
streamable_http.py:323
- Early return on validation failure - Stream creation:
streamable_http_manager.py:171
-connect()
call - Message router:
streamable_http.py:831
-async for write_stream_reader
- Stream termination:
streamable_http_manager.py:193
-terminate()
call - Error source:
memory.py:93
-ClosedResourceError
inreceive_nowait()
Reproduction Steps
- Start an MCP server in HTTP Streamable mode
- Send a POST request with incorrect
Accept
headers (missing eitherapplication/json
ortext/event-stream
) - The request will fail validation and return quickly
- Observe the
ClosedResourceError
in the server logs
Example Request
curl -X POST http://localhost:8000/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{"jsonrpc": "2.0", "method": "initialize", "id": 1}'
Error Stack Trace
15:37:00 - mcp.server.streamable_http - ERROR - Error in message router
Traceback (most recent call last):
File "/data/test_project/.venv/lib/python3.10/site-packages/mcp/server/streamable_http.py", line 831, in message_router
async for session_message in write_stream_reader:
File "/data/test_project/.venv/lib/python3.10/site-packages/anyio/abc/_streams.py", line 41, in __anext__
return await self.receive()
File "/data/test_project/.venv/lib/python3.10/site-packages/anyio/streams/memory.py", line 111, in receive
return self.receive_nowait()
File "/data/test_project/.venv/lib/python3.10/site-packages/anyio/streams/memory.py", line 93, in receive_nowait
raise ClosedResourceError
anyio.ClosedResourceError
Workaround
Adding a small delay before the early return can mitigate the race condition:
# In streamable_http.py around line 321
import asyncio
await asyncio.sleep(0.1) # Allow message_router to complete checkpoint
Expected Behavior
The server should handle early request failures gracefully without raising ClosedResourceError
exceptions in the message router.
Impact
This issue affects the reliability of MCP servers in HTTP Streamable mode, particularly when clients send malformed requests or when network conditions cause rapid request/response cycles.
Proposed Solution
This issue occurs due to differences in checkpoint handling logic between anyio and the coroutine scheduler being used, combined with the forced checkpoint operation in receive (the reason for this is unclear - if anyone understands, please help supplement, thank you), and further combined with receive_nowait's internal closed state checking causing application exceptions. This is not an exception of the MCP SDK itself.
However, to address this issue, there can be two solution approaches:
-
Approach One: Add exception handling for
anyio.ClosedResourceError
in the message router loop:try: async for session_message in write_stream_reader: # ... existing code ... except anyio.ClosedResourceError: # Simply ignore the error, or optionally add a warning log # (though the warning log may not be particularly helpful) pass
This approach directly handles the race condition by catching and ignoring the expected exception.
-
Approach Two: Add explicit delays in request validation functions like
_handle_post_request
:async def _handle_post_request(self, scope: Scope, request: Request, receive: Receive, send: Send) -> None: # ... existing code ... if not (has_json and has_sse): response = self._create_error_response( ("Not Acceptable: Client must accept both application/json and text/event-stream"), HTTPStatus.NOT_ACCEPTABLE, ) await response(scope, receive, send) await asyncio.sleep(0.1) # Allow message_router to complete checkpoint return # Validate Content-Type if not self._check_content_type(request): response = self._create_error_response( "Unsupported Media Type: Content-Type must be application/json", HTTPStatus.UNSUPPORTED_MEDIA_TYPE, ) await response(scope, receive, send) await asyncio.sleep(0.1) # Allow message_router to complete checkpoint return
This approach makes the request handling more complex and harder to understand, but it prevents future issues where automatic closing of
write_stream_reader
might be ignored in version updates. -
Approach Three: Looking forward to other solutions that may provide better approaches to handle this race condition.
Related Issues
This issue is related to the broader problem described in #1190, where MCP servers in HTTP Streamable mode are broken starting from v1.12.0.
Example Code
Python & MCP Python SDK
python==3.12.0
mcp==1.14.0