modelcontextprotocol / modelcontextprotocol/python-sdk

Streamable HTTP: ASGI application returns before the full SSE body is sent

Open
#3,494 2 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

v1 v2
Dominant language
Python
Stars
24.3k
Forks
4k
Avg merge
1d 1h
Merged PRs (30d)
31

Description

Release line: 2.x (current stable) — also reproducible on the 1.x maintenance line, see below.

Description

On mcp 1.x and 2.x, a streamable-HTTP POST response can stop before the server sends the full SSE body. uvicorn writes ASGI callable returned without completing response and closes the connection. The client receives httpx.RemoteProtocolError: peer closed connection without sending complete message body. The failure occurs on the first initialize POST of a session. Session setup fails.

We observed this behavior in GitHub Actions and in controlled reproduction environments.

The failure rate grows with two factors: CPU contention, and repeated uvicorn server start/stop cycles in one process.

Verified results, each with a fresh server per attempt:

Experiment mcp 2.2.0 mcp 1.30.0
One server per process, moderate CPU load 24 of 25 runs failed 0 of 25 runs failed
20 servers in one process, 1 CPU limit 17 of 20 failed 17 of 20 failed
One server, 200 sequential POSTs 0 failed 0 failed

In the 20-server test, the first two or three servers pass. All later servers fail. One reused server never fails. This pattern points at state that accumulates across server restarts on one event loop.

Expected behavior

The server must always send the last body chunk of the response. The client must always receive the complete stream for a request that has an answer.

Suspected cause (inference)

This section is an inference from code reading.

  • The POST handler sends the response through an EventSourceResponse object.
  • The response runs several helper tasks in one shared task group.
  • The first task that ends cancels all other tasks in the group.
  • The stream task sets active = False before it sends the last body chunk.
  • If the last send operation waits, the disconnect task can end first and cancel the send.
  • The h11 receive() function in uvicorn returns immediately after it consumes the request body. This lets the disconnect task run in a loop.
Example Code
"""Repro: streamable-HTTP SSE response terminates mid-body."""
import anyio, httpx, uvicorn
from fastmcp import FastMCP

mcp = FastMCP("repro")

@mcp.tool()
def echo(message: str) -> str:
    return message

INIT = {
    "jsonrpc": "2.0", "id": 1, "method": "initialize",
    "params": {"protocolVersion": "2025-06-18", "capabilities": {},
               "clientInfo": {"name": "repro", "version": "0"}},
}
HEADERS = {"Accept": "application/json, text/event-stream"}

async def one_attempt() -> str | None:
    config = uvicorn.Config(mcp.http_app(transport="streamable-http"),
                            host="127.0.0.1", port=0, log_level="error")
    server = uvicorn.Server(config)
    async with anyio.create_task_group() as tg:
        tg.start_soon(server.serve)
        while not server.started:
            await anyio.sleep(0.05)
        port = server.servers[0].sockets[0].getsockname()[1]
        try:
            async with httpx.AsyncClient() as client:
                r = await client.post(f"http://127.0.0.1:{port}/mcp", json=INIT, headers=HEADERS)
                r.raise_for_status()
        except (httpx.HTTPError, RuntimeError) as error:
            return f"{type(error).__name__}: {error}"
        finally:
            server.should_exit = True
    return None

async def main() -> None:
    failures = 0
    for attempt in range(20):
        if (error := await one_attempt()) is not None:
            failures += 1
            print(f"attempt {attempt}: {error}")
    print(f"RESULT: {failures}/20 initialize POSTs failed")

anyio.run(main)

Run it in a CPU-limited container:

  1. Start a container: docker run --rm --cpus=1 -it python:3.13-slim bash.
  2. Install the dependencies: pip install fastmcp==4.0.3 uvicorn==0.52.4 httpx anyio.
  3. Run the script.

Expected result: 20 of 20 attempts pass. Actual result: approximately 17 of 20 attempts fail from the third or fourth server onwards.

Python & MCP Python SDK

Python 3.13; mcp 2.2.0 (newest 2.x) and mcp 1.30.0 (newest 1.x); fastmcp 4.0.3 / 3.4.7; uvicorn 0.52.4 (h11); starlette 1.6.0; anyio 4.14.2; httpx 0.28.1; Linux (Ubuntu 24.04, Debian slim; x86_64 and arm64). The closest existing issues (#2150, #3441) describe different defects.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with the POST handler's EventSourceResponse and its shared task group, then reproduce the failure with the provided one_attempt loop under the stated CPU limit. Trace the stream, disconnect, and h11 receive interactions; done means repeated initialize POSTs deliver the complete SSE body across server restarts without the ASGI-returned error.

Written by the indexing model from the issue text.

Assessment

Tech stack
python
Domain
api, backend
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
50/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.