ClientDisconnect during _handle_post_request crashes stateless session with ClosedResourceError
Initial Checks
- I confirm that I'm using the latest version of MCP Python SDK
- I confirm that I searched for my issue in https://github.com/modelcontextprotocol/python-sdk/issues before opening this issue
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
- MCP server in the HTTP Streamable mode broken #1190 — closed, partially fixed by PR fix: handle ClosedResourceError in StreamableHTTP message router #1384 (message router path only)
- _handle_stateless_request ClosedResourceError #1219 — closed as duplicate of MCP server in the HTTP Streamable mode broken #1190
- ClosedResourceError when using FastMCP(..., stateless_http=True, json_response=True) with MCP Inspector #1658 — closed as duplicate of MCP server in the HTTP Streamable mode broken #1190
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