Skip to content

Race condition in StreamableHTTP: zero-buffer memory streams cause deadlock with concurrent SSE responses #1764

@Ctariy

Description

@Ctariy

Initial Checks

Description

Bug Description

SSE connections hang indefinitely when using StreamableHTTPServerTransport in stateless mode with responses containing 3+ items. The issue is caused by zero-buffer memory streams that block send() until receive() is called, creating a race condition between the response writer and the SSE stream iterator.

Related issues: #262 describes similar symptoms (client hangs on call_tool()) but root cause wasn't identified. This issue provides the specific cause and fix.

Expected Behavior

All tool responses should complete regardless of response size.

Actual Behavior

  • 1-2 items: Response returns immediately (~150ms)
  • 3+ items: Request hangs indefinitely (deadlock)

Root Cause Analysis

The issue is in mcp/server/streamable_http.py:

Line 412 - zero-buffer request stream
self._request_streams[request_id] = anyio.create_memory_object_streamEventMessage

Line 460 - zero-buffer SSE stream
sse_stream_writer, sse_stream_reader = anyio.create_memory_object_streamdict[str, str]

Race condition flow:

  1. tg.start_soon(response, ...) starts SSE response task (non-blocking)
  2. await writer.send(session_message) sends request to MCP server
  3. MCP server processes quickly and calls message_router
  4. message_router tries await request_streams[id][0].send(EventMessage(...))
  5. DEADLOCK: If SSE writer hasn't started iterating yet, send() blocks forever

With zero-buffer streams, send() blocks until the receiver calls receive(). When the MCP server processes faster than the SSE writer task starts, deadlock occurs.

Why timing matters:

  • Small responses (1-2 items): SSE writer task starts before MCP response arrives → works
  • Larger responses (3+ items): MCP processes faster → response arrives before SSE iterator starts → blocked forever

Proposed Fix

Increase buffer size from 0 to a reasonable value (e.g., 10 or 100):

Line 412
self._request_streams[request_id] = anyio.create_memory_object_streamEventMessage

Line 460
sse_stream_writer, sse_stream_reader = anyio.create_memory_object_streamdict[str, str]

Alternative fix: Use await tg.start() instead of tg.start_soon() to ensure SSE writer is ready before sending requests (requires EventSourceResponse to support task status protocol).

Workaround

We've applied this fix via sed patch in our Dockerfile:

RUN sed -i 's/create_memory_object_stream[EventMessage](0)/create_memory_object_streamEventMessage/g'
/usr/local/lib/python3.11/site-packages/mcp/server/streamable_http.py &&
sed -i 's/create_memory_object_stream[dict[str, str]](0)/create_memory_object_streamdict[str, str]/g'
/usr/local/lib/python3.11/site-packages/mcp/server/streamable_http.py
This resolves the issue in our production environment.

Example Code

from fastmcp import FastMCP
import json

mcp = FastMCP("test-server")

@mcp.tool()
async def test_tool() -> str:
    # Returns JSON with 3+ items - will hang
    return json.dumps({
        "results": [{"n": "a"}, {"n": "b"}, {"n": "c"}]
    })

app = mcp.http_app(path="/mcp", stateless_http=True)

1. Call the tool via HTTP POST to /mcp
2. Response hangs indefinitely for tools returning 3+ items in arrays
3. Tools returning 1-2 items work correctly

Python & MCP Python SDK

- MCP SDK version: 1.23.3
- Python version: 3.11
- FastMCP version: 2.13.1
- Transport: StreamableHTTP with stateless_http=True

Metadata

Metadata

Assignees

No one assigned

    Labels

    needs confirmationNeeds confirmation that the PR is actually required or needed.needs reproneeds additional information to be able to reproduce bug

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions