Repository navigation
Description
Initial Checks
- I confirm that I'm using the newest release of my line (the latest 1.x, 1.30.0)
- I confirm that I searched for my issue in https://github.com/modelcontextprotocol/python-sdk/issues before opening this issue
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
DELETErunsStreamableHTTPServerTransport.terminate(). terminate()calls_clean_up_memory_streams(GET_STREAM_KEY).- That closes the receive stream
standalone_sse_writeris still iterating, so the iteration raisesClosedResourceErrorinstead 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
umeGe commentedon Nov 8, 2026
Hi! Reproduced this on the
v1.xline: a normal client-initiated session close (DELETE ->terminate()) leavesERROR mcp.server.streamable_http: Error in standalone SSE writerwith ananyio.ClosedResourceErrortraceback, becauseterminate()closes the GET stream whilestandalone_sse_writeris still iterating it.I have a small fix prepared: catch
anyio.ClosedResourceErrorseparately instandalone_sse_writerand log it at DEBUG (all other exceptions keeplogger.exception), plus an in-process regression test that drives a GET request into the transport, callsterminate()and asserts no ERROR record frommcp.server.streamable_http— it reproduces the exact traceback and fails on unpatchedv1.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!
media-ctrl commentedon Nov 10, 2026
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:
standalone_sse_writerruns the same loop over its own pair and has only the broadexcept 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 getsanyio.ClosedResourceErrorinstead of a clean end of itsasync for.Two additions to your test, since this divergence survived unnoticed:
_run_sse_writercarries# pragma: no cover, which is a plausible reason one path got the narrow handler and the other did not.terminate(). The condition means another owner ended the stream, which on a closing session is expected.BrokenResourceErrorshould 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.