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 clientPOST /messages/that arrives in the meantime is answered202and then blocks forever inhandle_post_message. On SIGTERM, uvicorn then waits on that request task indefinitely (Waiting for background tasks to complete.).Cause
SseServerTransport.connect_ssecreates the per-session streams with buffer size 0, andhandle_post_messagedoesawait writer.send(session_message)after responding202. That send completes only whenServer.runreceives the message or the stream is closed.The stream ends are closed only in
response_wrapper, afterEventSourceResponsereturns normally:If
Server.runraises (here, from the lifespan, before the read loop starts), the task group cancelsresponse_wrapperbefore thoseaclose()calls run.connect_sse'sfinallyonly removes the session from_read_stream_writers/_session_owners. Nothing ever receives from or closes the stream, so the parkedsend()never returns.The
awaitmatters for timing. It lets the response task send theendpointevent 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: POSTsinitializeas soon as the endpoint event arrives:Steps: run
python server.py, runpython client.py(prints202), 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) inconnect_sse'sfinally, so that a pendingsend()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.