Summary
With the SSE transport, if the server's lifespan raises after its first await, a client POST /messages/ that arrives in the meantime is answered 202 and then blocks forever in handle_post_message. On SIGTERM, uvicorn then waits on that request task indefinitely (Waiting for background tasks to complete.).
Cause
SseServerTransport.connect_sse creates the per-session streams with buffer size 0, and handle_post_message does await writer.send(session_message) after responding 202. That send completes only when Server.run receives the message or the stream is closed.
The stream ends are closed only in response_wrapper, after EventSourceResponse returns normally:
await EventSourceResponse(...)(scope, receive, send)
await read_stream_writer.aclose()
await write_stream_reader.aclose()
await sse_stream_reader.aclose()
If Server.run raises (here, from the lifespan, before the read loop starts), the task group cancels response_wrapper before those aclose() calls run. connect_sse's finally only removes the session from _read_stream_writers/_session_owners. Nothing ever receives from or closes the stream, so the parked send() never returns.
The await matters for timing. It lets the response task send the endpoint event before the lifespan fails, so a client that POSTs immediately (as MCP clients do) lands in the window. A lifespan that raises without awaiting fails before the endpoint event is sent, so the client never POSTs.
Repro (mcp 2.0.0, uvicorn 0.47.0, Python 3.13, macOS)
server.py:
import asyncio
from contextlib import asynccontextmanager
from mcp.server.mcpserver import MCPServer
@asynccontextmanager
async def lifespan(server):
await asyncio.sleep(0.1) # any await before failing, e.g. loading credentials
raise RuntimeError("startup failed")
yield
mcp = MCPServer("repro", lifespan=lifespan)
if __name__ == "__main__":
mcp.run(transport="sse", host="127.0.0.1", port=8765)
client.py: POSTs initialize as soon as the endpoint event arrives:
import json
import re
import socket
import urllib.request
s = socket.create_connection(("127.0.0.1", 8765))
s.sendall(b"GET /sse HTTP/1.1\r\nHost: 127.0.0.1:8765\r\nAccept: text/event-stream\r\n\r\n")
buf = b""
while not (m := re.search(rb"session_id=([0-9a-f]{32})", buf)):
buf += s.recv(4096)
body = json.dumps({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {
"protocolVersion": "2025-06-18", "capabilities": {},
"clientInfo": {"name": "repro", "version": "0"}}}).encode()
req = urllib.request.Request(
f"http://127.0.0.1:8765/messages/?session_id={m.group(1).decode()}",
data=body, headers={"Content-Type": "application/json"},
)
print(urllib.request.urlopen(req).status) # 202
Steps: run python server.py, run python client.py (prints 202), then send the server SIGTERM.
Expected: the server exits.
Actual: Waiting for background tasks to complete. (CTRL+C to force quit), indefinitely. 5 of 5 runs.
A task dump of the hung server shows the POST request task parked here:
mcp/server/sse.py:262 handle_post_message
mcp/shared/_context_streams.py:37 send
anyio/streams/memory.py:256 send
Possible fix
Close the session's stream ends (or at least read_stream_writer) in connect_sse's finally, so that a pending send() raises instead of waiting forever when the body exits for any reason.
Related
#514 shows the same symptom (SSE server stuck on "Waiting for background tasks to complete" at shutdown) with a different trigger: an open session after normal requests. This case needs no open session or handled requests, only a lifespan failure after an await.
Summary
With the SSE transport, if the server's lifespan raises after its first await, a client POST /messages/ that arrives in the meantime is answered 202 and then blocks forever in handle_post_message. On SIGTERM, uvicorn then waits on that request task indefinitely (Waiting for background tasks to complete.).
Cause
SseServerTransport.connect_sse creates the per-session streams with buffer size 0, and handle_post_message does await writer.send(session_message) after responding 202. That send completes only when Server.run receives the message or the stream is closed.
The stream ends are closed only in response_wrapper, after EventSourceResponse returns normally:
If Server.run raises (here, from the lifespan, before the read loop starts), the task group cancels response_wrapper before those aclose() calls run. connect_sse's finally only removes the session from _read_stream_writers/_session_owners. Nothing ever receives from or closes the stream, so the parked send() never returns.
The await matters for timing. It lets the response task send the endpoint event before the lifespan fails, so a client that POSTs immediately (as MCP clients do) lands in the window. A lifespan that raises without awaiting fails before the endpoint event is sent, so the client never POSTs.
Repro (mcp 2.0.0, uvicorn 0.47.0, Python 3.13, macOS)
server.py:
client.py: POSTs initialize as soon as the endpoint event arrives:
Steps: run python server.py, run python client.py (prints 202), then send the server SIGTERM.
Expected: the server exits.
Actual: Waiting for background tasks to complete. (CTRL+C to force quit), indefinitely. 5 of 5 runs.
A task dump of the hung server shows the POST request task parked here:
Possible fix
Close the session's stream ends (or at least read_stream_writer) in connect_sse's finally, so that a pending send() raises instead of waiting forever when the body exits for any reason.
Related
#514 shows the same symptom (SSE server stuck on "Waiting for background tasks to complete" at shutdown) with a different trigger: an open session after normal requests. This case needs no open session or handled requests, only a lifespan failure after an await.