Skip to content

ClosedResourceError During MCP Stateless Server Shutdown When Sending Log Messages #1967

Closed

Description

@jonfinerty

Initial Checks

Description

I'm seeing occasional errors from our MCP server, which I think are a race condition during shutdown.

I think this is a similar but distinct issue to the race condition in #1190 and #1384 as this is during send_log_message

I haven't been able to get a consistent reproduce yet, will update when I can.

Stack Trace

+ Exception Group Traceback (most recent call last):
  |   File "/app/.venv/lib/python3.13/site-packages/mcp/server/streamable_http_manager.py", line 180, in run_stateless_server
  |     await self.app.run(
  |     ...<4 lines>...
  |     )
  |   File "/app/.venv/lib/python3.13/site-packages/fastmcp/server/low_level.py", line 202, in run
  |     async with AsyncExitStack() as stack:
  |                ~~~~~~~~~~~~~~^^
  |   File "/usr/local/lib/python3.13/contextlib.py", line 768, in __aexit__
  |     raise exc
  |   File "/usr/local/lib/python3.13/contextlib.py", line 751, in __aexit__
  |     cb_suppress = await cb(*exc_details)
  |                   ^^^^^^^^^^^^^^^^^^^^^^
  |   File "/app/.venv/lib/python3.13/site-packages/mcp/shared/session.py", line 238, in __aexit__
  |     return await self._task_group.__aexit__(exc_type, exc_val, exc_tb)
  |            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  |   File "/app/.venv/lib/python3.13/site-packages/anyio/_backends/_asyncio.py", line 783, in __aexit__
  |     raise BaseExceptionGroup(
  |         "unhandled errors in a TaskGroup", self._exceptions
  |     ) from None
  | ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)
  +-+---------------- 1 ----------------
    | Exception Group Traceback (most recent call last):
    |   File "/app/.venv/lib/python3.13/site-packages/fastmcp/server/low_level.py", line 214, in run
    |     async with anyio.create_task_group() as tg:
    |                ~~~~~~~~~~~~~~~~~~~~~~~^^
    |   File "/app/.venv/lib/python3.13/site-packages/anyio/_backends/_asyncio.py", line 783, in __aexit__
    |     raise BaseExceptionGroup(
    |         "unhandled errors in a TaskGroup", self._exceptions
    |     ) from None
    | ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)
    +-+---------------- 1 ----------------
      | Traceback (most recent call last):
      |   File "/app/.venv/lib/python3.13/site-packages/mcp/server/lowlevel/server.py", line 694, in _handle_message
      |     await session.send_log_message(
      |     ...<3 lines>...
      |     )
      |   File "/app/.venv/lib/python3.13/site-packages/mcp/server/session.py", line 213, in send_log_message
      |     await self.send_notification(
      |     ...<10 lines>...
      |     )
      |   File "/app/.venv/lib/python3.13/site-packages/mcp/shared/session.py", line 335, in send_notification
      |     await self._write_stream.send(session_message)
      |   File "/app/.venv/lib/python3.13/site-packages/anyio/streams/memory.py", line 249, in send
      |     self.send_nowait(item)
      |     ~~~~~~~~~~~~~~~~^^^^^^
      |   File "/app/.venv/lib/python3.13/site-packages/anyio/streams/memory.py", line 218, in send_nowait
      |     raise ClosedResourceError
      | anyio.ClosedResourceError
      +------------------------------------

Python & MCP Python SDK

MCP SDK Version: 1.25.0
via fastmcp Version: 2.14.4
Python Version: 3.13.5

Activity

  1. maxisbey commented on Jan 28, 2026

    @maxisbey
    Contributor

    Could you please provide a minimal reproducible example code snippet we can run to get the error?

  2. added
    bugSomething isn't working
    needs reproneeds additional information to be able to reproduce bug
    on Jan 28, 2026
  3. felixweinberger commented on Jan 28, 2026

    @felixweinberger
    Contributor

    thanks for the detailed stack trace. this does look like a distinct race from #1384 — the write stream is getting closed before the log notification can be sent.

    can you share a minimal reproduction when you get one? ideally a small script we can run to trigger the shutdown race. info about your transport setup (streamable HTTP?) and what's triggering the log messages during shutdown would help too.

  4. tokoyoda commented on Feb 4, 2026

    @tokoyoda

    I hit what appears to be the same ClosedResourceError on mcp 1.26.0 with streamable-http transport (stateful mode). The crash occurs reliably whenever the MCP client disconnects while a long-running tool handler is still in progress. Below is a minimal reproduction and root cause analysis.

    Reproduction

    #!/usr/bin/env python3
    # /// script
    # requires-python = "==3.14.*"
    # dependencies = ["mcp==1.26.0", "httpx==0.28.1"]
    # ///
    """
    Reproduce ClosedResourceError when DELETE is sent during in-flight tool handler.
    
        uv run reproduce_minimal.py
    
    https://github.com/modelcontextprotocol/python-sdk/issues/1967
    """
    
    import asyncio
    import httpx
    from mcp.server import FastMCP
    
    # ---------- Server ----------
    
    server = FastMCP("repro-server", host="localhost", port=9999)
    
    
    @server.tool(description="Simulates a long-running operation")
    async def slow_task(seconds: float = 3.0) -> dict:
        print(f"[server] slow_task: sleeping {seconds}s ...")
        await asyncio.sleep(seconds)
        print("[server] slow_task: done, sending response ...")
        return {"ok": True}
    
    
    # ---------- Client ----------
    
    BASE = "http://localhost:9999/mcp"
    H = {"Accept": "application/json, text/event-stream", "Content-Type": "application/json"}
    
    
    async def run_client():
        """Init session -> call slow_task -> DELETE mid-flight -> observe crash."""
        await asyncio.sleep(2)  # wait for server startup
    
        async with httpx.AsyncClient(timeout=30) as c:
            # 1. Initialize
            r = await c.post(BASE, json={
                "jsonrpc": "2.0", "id": 1, "method": "initialize",
                "params": {
                    "protocolVersion": "2024-11-05",
                    "capabilities": {},
                    "clientInfo": {"name": "repro", "version": "0.1"},
                },
            }, headers=H)
            sid = r.headers["mcp-session-id"]
            print(f"[client] session: {sid[:12]}...")
            sh = {**H, "mcp-session-id": sid}
    
            # 2. Initialized notification
            await c.post(BASE, json={"jsonrpc": "2.0", "method": "notifications/initialized"}, headers=sh)
    
        # 3. Call slow_task (fire-and-forget with short timeout)
        async def call_slow():
            async with httpx.AsyncClient(timeout=httpx.Timeout(1.0)) as c:
                try:
                    await c.post(BASE, json={
                        "jsonrpc": "2.0", "id": 2, "method": "tools/call",
                        "params": {"name": "slow_task", "arguments": {"seconds": 3}},
                    }, headers=sh)
                except httpx.ReadTimeout:
                    pass  # expected
    
        asyncio.create_task(call_slow())
        await asyncio.sleep(1)  # let task start on server
    
        # 4. DELETE -> terminate session while slow_task is still running
        print("[client] sending DELETE to terminate session mid-task ...")
        async with httpx.AsyncClient(timeout=5) as c:
            r = await c.delete(BASE, headers=sh)
            print(f"[client] DELETE response: {r.status_code}")
    
        # 5. Wait for server to hit the error
        print("[client] waiting for slow_task to finish on server ...")
        await asyncio.sleep(3)
        print("[client] done")
    
    
    # ---------- Main ----------
    
    async def main():
        import threading
        t = threading.Thread(target=lambda: server.run(transport="streamable-http"), daemon=True)
        t.start()
        await run_client()
    
    asyncio.run(main())

    Root Cause

    terminate() closes _write_stream immediately without waiting for in-flight handlers. When a handler finishes and calls respond() → _send_response() → _write_stream.send(), it raises ClosedResourceError which propagates unhandled through the TaskGroup, crashing the session.

    1. Client sends DELETE → _handle_delete_request → terminate() closes all streams
    2. _receive_loop() exits its async with block → _write_stream closed
    3. Server.run() TaskGroup waits for in-flight handlers
    4. Handler calls message.respond() → _send_response() → _write_stream.send() → ClosedResourceError

    The existing ClosedResourceError catch in _receive_loop only guards the read side, not the write path used by handlers.

    Note: streamablehttp_client defaults to terminate_on_close=True, so every normal client disconnect sends DELETE — meaning any long-running tool handler will crash the session.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingneeds reproneeds additional information to be able to reproduce bug

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions