-
Notifications
You must be signed in to change notification settings - Fork 3.1k
Description
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