Skip to content

v1.30.0: normal streamable-HTTP session close logs "Error in standalone SSE writer" (anyio.ClosedResourceError) at ERROR #3833

Open

Description

@nerpatech

Initial Checks

Release line

1.x (maintenance line, v1.x branch)

Description

With 1.30.0 on the server side, closing a stateful streamable-HTTP session the normal way logs an ERROR with a full traceback:

ERROR mcp.server.streamable_http: Error in standalone SSE writer
Traceback (most recent call last):
  File ".../mcp/server/streamable_http.py", line 723, in standalone_sse_writer
    async for event_message in standalone_stream_reader:
  File ".../anyio/abc/_streams.py", line 37, in __anext__
    return await self.receive()
  File ".../anyio/streams/memory.py", line 141, in receive
    raise ClosedResourceError from None
anyio.ClosedResourceError

Here, closing the normal way means the client's streamablehttp_client context exits and sends DELETE. Nothing actually fails: the calls succeed and the server keeps serving. But every session that ends puts an ERROR traceback in the server log, and that buries real errors.

Which side and which version: it depends only on the server's SDK version. I crossed the two versions:

server SDK client SDK ERROR records on close
1.29.1 1.29.1 0
1.30.0 1.30.0 1
1.30.0 1.29.1 1
1.29.1 1.30.0 0

What seems to happen (from reading the 1.30.0 source):

  • The DELETE runs StreamableHTTPServerTransport.terminate().
  • terminate() calls _clean_up_memory_streams(GET_STREAM_KEY).
  • That closes the receive stream standalone_sse_writer is still iterating, so the iteration raises ClosedResourceError instead of ending.
  • The writer's except Exception: logger.exception("Error in standalone SSE writer") reports it.

1.30.0 moved the GET handler's stream cleanup into a finally, which looks like what changed the shutdown order.

Expected: a client-initiated session termination is not logged at ERROR. Either the writer stops cleanly when its stream is closed by terminate(), or ClosedResourceError during termination is treated as a normal end, logged at DEBUG at most.

We work around it in our server with a logging.Filter on mcp.server.streamable_http that demotes exactly this record (that message plus ClosedResourceError) to DEBUG.

Example Code

"""Prints 1 ERROR record on mcp 1.30.0, 0 on 1.29.1."""
import asyncio, logging, socket, threading, time

import uvicorn
from mcp.client.session import ClientSession
from mcp.client.streamable_http import streamablehttp_client
from mcp.server.fastmcp import FastMCP

errors = []

class Collect(logging.Handler):
    def emit(self, record):
        if record.levelno >= logging.ERROR:
            errors.append(record)

logging.getLogger("mcp.server.streamable_http").addHandler(Collect())

server = FastMCP("repro")

@server.tool()
def echo(text: str) -> str:
    return text

with socket.socket() as s:
    s.bind(("127.0.0.1", 0))
    port = s.getsockname()[1]

uv = uvicorn.Server(uvicorn.Config(server.streamable_http_app(),
                                   host="127.0.0.1", port=port, log_level="warning"))
threading.Thread(target=uv.run, daemon=True).start()
while not uv.started:
    time.sleep(0.05)

async def main():
    async with streamablehttp_client(f"http://127.0.0.1:{port}/mcp") as (read, write, _):
        async with ClientSession(read, write) as session:
            await session.initialize()
            await session.list_tools()
    await asyncio.sleep(1)  # let the server process the DELETE

asyncio.run(main())
uv.should_exit = True
print(len(errors), "ERROR record(s):",
      [(r.getMessage(), type(r.exc_info[1]).__name__) for r in errors])
# 1.30.0: 1 ERROR record(s): [('Error in standalone SSE writer', 'ClosedResourceError')]
# 1.29.1: 0 ERROR record(s): []

Python & MCP Python SDK

Python 3.12.8, Linux
mcp 1.30.0 (server side; 1.29.1 does not show it)
anyio 4.16.0, starlette 1.10.0, sse-starlette 3.7.0, uvicorn 0.54.0

Activity

  1. added
    bugSomething isn't working
    v1Affects the v1.x maintenance line
    on Nov 7, 2026
  2. umeGe commented on Nov 8, 2026

    @umeGe

    Hi! Reproduced this on the v1.x line: a normal client-initiated session close (DELETE -> terminate()) leaves ERROR mcp.server.streamable_http: Error in standalone SSE writer with an anyio.ClosedResourceError traceback, because terminate() closes the GET stream while standalone_sse_writer is still iterating it.

    I have a small fix prepared: catch anyio.ClosedResourceError separately in standalone_sse_writer and log it at DEBUG (all other exceptions keep logger.exception), plus an in-process regression test that drives a GET request into the transport, calls terminate() and asserts no ERROR record from mcp.server.streamable_http — it reproduces the exact traceback and fails on unpatched v1.x, passes with the fix (transport/termination subset: 13 passed, ruff clean).

    I opened it as #3834, but the require-linked-issue bot closed it because this issue is not assigned. Could a maintainer assign this issue to me so I can submit the fix? Thanks!

  3. media-ctrl commented on Nov 10, 2026

    @media-ctrl

    There is a precedent for exactly this in the same file, and it makes the fix a small symmetry change rather than a new decision.

    The per-request SSE writer already treats that condition:

    except anyio.ClosedResourceError:
        logger.debug("SSE stream closed by close_sse_stream()")
    except Exception:
        logger.exception("Error in SSE writer")
    

    standalone_sse_writer runs the same loop over its own pair and has only the broad except Exception, so the identical condition lands at ERROR with a traceback. Where the condition comes from is visible in _clean_up_memory_streams: it closes the receive end of the pair, and a writer awaiting on that end gets anyio.ClosedResourceError instead of a clean end of its async for.

    Two additions to your test, since this divergence survived unnoticed:

    • Cover both writers. _run_sse_writer carries # pragma: no cover, which is a plausible reason one path got the narrow handler and the other did not.
    • Assert on the record level for a stream closed while a writer is live, not only for terminate(). The condition means another owner ended the stream, which on a closing session is expected. BrokenResourceError should keep its ERROR, because that one means the send end vanished under a live receiver.

    Process note: the linked-issue bot closed your PR because the issue is unassigned and a contributor cannot self-assign, so a maintainer has to assign it before a fix can land there.

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 workingv1Affects the v1.x maintenance line

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions