Skip to content

ClientDisconnect during _handle_post_request crashes stateless session with ClosedResourceError #2064

@einarfd

Description

@einarfd

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_messagesend_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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions