Initial Checks
Description
When a client disconnects while a stateless streamable-HTTP server is reading the request body, _handle_post_request catches the
ClientDisconnect but the error handler in _handle_message (lowlevel/server.py:694) then tries to send_log_message() back to the
client. Since the session was already terminated and the write stream closed, this raises ClosedResourceError, which is unhandled and
crashes the stateless session with an ExceptionGroup.
This is a different code path from what PR #1384 fixed. That PR addressed ClosedResourceError in the message router loop. This bug
is in the error recovery path: catch exception → try to log it to client → write stream already closed → crash.
Versions
- mcp: 1.26.0 (also reproduced on 1.25.0; believed to affect >= 1.12.0)
- Python: 3.14.2 (also reproducible on 3.12+)
- starlette: 0.48.0
- uvicorn: 0.34.3
Steps to reproduce
Run the attached repro script to reproduce the problem.
Expected behavior
The server should log a warning about the client disconnect and cleanly discard the failed request, without crashing the stateless session.
Root cause
In lowlevel/server.py, _handle_message has a catch-all exception handler (line ~690) that calls session.send_log_message() to notify
the client about the error. When the error is a client disconnect, the write stream is already closed, so send_log_message →
send_notification → _write_stream.send() raises ClosedResourceError. This is unhandled in the TaskGroup and crashes the session.
A possible fix would be to catch ClosedResourceError (and/or BrokenResourceError) in the error handler at _handle_message, since
failing to notify a disconnected client is expected and harmless.
Related issues
None of these cover the _handle_post_request → _handle_message → send_log_message path.
Example Code
"""Minimal reproduction: MCP SDK crashes with ClosedResourceError on client disconnect."""
import asyncio
import contextlib
import logging
import time
import httpx
import mcp.types as types
import uvicorn
from mcp.server.lowlevel.server import Server
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
from starlette.applications import Starlette
from starlette.routing import Mount
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
mcp_server = Server(name="repro-server", version="0.1.0")
@mcp_server.list_tools()
async def list_tools() -> list[types.Tool]:
return [
types.Tool(
name="hello",
description="A trivial tool.",
inputSchema={"type": "object", "properties": {}},
)
]
@mcp_server.call_tool()
async def call_tool(name: str, arguments: dict) -> list[types.TextContent]:
return [types.TextContent(type="text", text="hello")]
session_manager = StreamableHTTPSessionManager(app=mcp_server, stateless=True)
@contextlib.asynccontextmanager
async def lifespan(app: Starlette):
async with session_manager.run():
yield
app = Starlette(
routes=[Mount("/", app=session_manager.handle_request)],
lifespan=lifespan,
)
async def run_client() -> None:
await asyncio.sleep(1)
url = "http://127.0.0.1:19876/"
# Step 1: Normal MCP initialize
async with httpx.AsyncClient() as client:
await client.post(url, json={
"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": {
"protocolVersion": "2025-03-26",
"capabilities": {},
"clientInfo": {"name": "repro-client", "version": "0.1.0"},
},
}, headers={"Content-Type": "application/json", "Accept": "application/json, text/event-stream"})
await client.post(url, json={
"jsonrpc": "2.0", "method": "notifications/initialized",
}, headers={"Content-Type": "application/json", "Accept": "application/json, text/event-stream"})
# Step 2: Send truncated body, then disconnect
_, writer = await asyncio.open_connection("127.0.0.1", 19876)
writer.write((
"POST / HTTP/1.1\r\nHost: 127.0.0.1:19876\r\n"
"Content-Type: application/json\r\nAccept: application/json, text/event-stream\r\n"
"Content-Length: 10000\r\n\r\n"
'{"jsonrpc":"2.0","id":2,"method":"tools/call"'
).encode())
await writer.drain()
await asyncio.sleep(0.5)
writer.close()
await writer.wait_closed()
await asyncio.sleep(3)
async def main() -> None:
config = uvicorn.Config(app, host="127.0.0.1", port=19876, log_level="warning")
server = uvicorn.Server(config)
server_task = asyncio.create_task(server.serve())
try:
await run_client()
finally:
server.should_exit = True
await server_task
if __name__ == "__main__":
start = time.monotonic()
asyncio.run(main())
print(f"Done in {time.monotonic() - start:.1f}s — check ERROR logs above.")
Python & MCP Python SDK
mcp 1.26.0 (also reproduced on 1.25.0; believed to affect >= 1.12.0)
Python 3.14.2 (also reproducible on 3.12+)
starlette 0.48.0
uvicorn 0.34.3
Initial Checks
Description
When a client disconnects while a stateless streamable-HTTP server is reading the request body,
_handle_post_requestcatches theClientDisconnectbut the error handler in_handle_message(lowlevel/server.py:694) then tries tosend_log_message()back to theclient. Since the session was already terminated and the write stream closed, this raises
ClosedResourceError, which is unhandled andcrashes the stateless session with an
ExceptionGroup.This is a different code path from what PR #1384 fixed. That PR addressed
ClosedResourceErrorin the message router loop. This bugis in the error recovery path: catch exception → try to log it to client → write stream already closed → crash.
Versions
Steps to reproduce
Run the attached repro script to reproduce the problem.
Expected behavior
The server should log a warning about the client disconnect and cleanly discard the failed request, without crashing the stateless session.
Root cause
In
lowlevel/server.py,_handle_messagehas a catch-all exception handler (line ~690) that callssession.send_log_message()to notifythe client about the error. When the error is a client disconnect, the write stream is already closed, so
send_log_message→send_notification→_write_stream.send()raisesClosedResourceError. This is unhandled in the TaskGroup and crashes the session.A possible fix would be to catch
ClosedResourceError(and/orBrokenResourceError) in the error handler at_handle_message, sincefailing to notify a disconnected client is expected and harmless.
Related issues
None of these cover the
_handle_post_request→_handle_message→send_log_messagepath.Example Code
Python & MCP Python SDK