You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
[v1.x] Backport #2257 and cut a release: the stateless Streamable HTTP client-disconnect fix (#2064) ships only in 2.0.0 pre-releases; latest stable v1.28.1 still carries the crashing send_log_message call #3142
Note on the first box: we are on v1.28.1, the latest stable release; the only later versions are the 2.0.0 pre-releases (2.0.0a1 through 2.0.0b2).
Description
What this asks for. Please backport PR #2257 ("fix: don't send log notification on transport error", merge commit 62eb08e, merged into main on 2026-03-09) to the v1.x branch and cut a v1.x patch release. This is filed on the bug template because the tracker has no backport template and blank issues are disabled; the underlying bug reports (#2064, #1967) are closed as completed, but the fix that closed them has never shipped in any stable release.
What we're seeing (reproduced deterministically on v1.28.1 with the example below). When a client aborts an in-flight POST to a stateless Streamable HTTP server — a routine, benign network event (canceled request, closed tab, dropped connection) — the server emits two ERROR-level records per disconnect, 10/10 across both FIN and RST closes:
ERROR:mcp.server.streamable_http:Error handling POST request — a logger.exception with a full traceback bottoming in starlette.requests.ClientDisconnect raised from body = await request.body() in _handle_post_request (src/mcp/server/streamable_http.py, line 491 in v1.28.1).
ERROR:mcp.server.lowlevel.server:Received exception from stream: — the POST handler forwards Exception(err) into the session's incoming stream, and str() of an Exception wrapping a ClientDisconnect is empty, so the record ends with an empty tail.
Complete server log for a run of five mid-body FIN aborts (python client.py abort-fin 5; site-packages paths shortened to <venv>; the record group repeats byte-identically once per abort, so the four repetitions are elided):
INFO: Started server process [24176]
INFO: Waiting for application startup.
INFO:mcp.server.streamable_http_manager:StreamableHTTP session manager started
INFO: Application startup complete.
INFO: Uvicorn running on http://127.0.0.1:8912 (Press CTRL+C to quit)
ERROR:mcp.server.streamable_http:Error handling POST request
Traceback (most recent call last):
File "<venv>\Lib\site-packages\mcp\server\streamable_http.py", line 491, in _handle_post_request
body = await request.body()
^^^^^^^^^^^^^^^^^^^^
File "<venv>\Lib\site-packages\starlette\requests.py", line 257, in body
async for chunk in self.stream():
chunks.append(chunk)
File "<venv>\Lib\site-packages\starlette\requests.py", line 251, in stream
raise ClientDisconnect()
starlette.requests.ClientDisconnect
INFO:mcp.server.streamable_http:Terminating session: None
ERROR:mcp.server.lowlevel.server:Received exception from stream:
[this record group repeats identically four more times, once per abort]
Signature counts for that run: Error handling POST request = 5, Received exception from stream = 5, Stateless session crashed = 0. The RST variant (abort-rst 5, SO_LINGER-0 hard close) produces byte-identical record groups with the same counts. The control mode (a normal initialize + tools/call through the SDK's own client) logs zero ERROR records:
INFO: Started server process [1964]
INFO: Waiting for application startup.
INFO:mcp.server.streamable_http_manager:StreamableHTTP session manager started
INFO: Application startup complete.
INFO: Uvicorn running on http://127.0.0.1:8912 (Press CTRL+C to quit)
INFO: 127.0.0.1:62410 - "POST /mcp/ HTTP/1.1" 200 OK
INFO:mcp.server.streamable_http:Terminating session: None
INFO: 127.0.0.1:62411 - "POST /mcp/ HTTP/1.1" 202 Accepted
INFO:mcp.server.streamable_http:Terminating session: None
INFO: 127.0.0.1:62412 - "POST /mcp/ HTTP/1.1" 200 OK
INFO:mcp.server.lowlevel.server:Processing request of type CallToolRequest
INFO:mcp.server.streamable_http:Terminating session: None
INFO: 127.0.0.1:62413 - "POST /mcp/ HTTP/1.1" 200 OK
INFO:mcp.server.lowlevel.server:Processing request of type ListToolsRequest
INFO:mcp.server.streamable_http:Terminating session: None
The crash defect behind #2064 is still present in every released version. After forwarding the exception, the case Exception(): branch of _handle_message (src/mcp/server/lowlevel/server.py) calls await session.send_log_message(level="error", data="Internal Server Error", logger="mcp.server.exception_handler"). BaseSession.send_notification (src/mcp/shared/session.py) writes to the session's write stream with no closed-stream guard, and the stateless session manager calls http_transport.terminate() — which closes that stream — right after the POST handler returns. That is exactly the anyio.ClosedResourceError -> logger.exception("Stateless session crashed") cascade reported in #2064 and #1967. PR #2257 removed the doomed call, but release containment (checked via the GitHub compare API) shows v2.0.0a1...62eb08e as "behind" with ahead_by: 0 (contained in all 2.0.0 pre-releases), while v1.27.0...62eb08e, v1.28.1...62eb08e, and the v1.x branch tip compared against 62eb08e are all "diverged" (absent): the v1.x tip's lowlevel/server.py still contains the send_log_message call. No stable release has ever shipped this fix, and the 2026-03-11 question on #2064 about a v1.x backport was never answered.
Why bare released v1.x usually escapes the crash — and why that is not a fix. On bare v1.28.1 the example below does not reach the third record (0/13 runs: 5 FIN aborts, 5 RST aborts, 3 aborts with a tool's log notification in flight): the finally: tg.cancel_scope.cancel() in Server.run (part of the v1.x transport-close fix from #2334, released in v1.27.0) cancels the in-flight exception handler at the await checkpoint() that precedes send_nowait inside anyio's MemoryObjectSendStream.send, so the handler dies by cancellation before the doomed send executes. That protection is a cancellation-timing artifact of run(), not a guard on the send itself: any server that overrides or reimplements Server.run — as downstream frameworks do — silently loses it, and we have hit the full three-record cascade in production on v1.28.1 under exactly such an override (both ERRORs above plus logger.exception("Stateless session crashed") from streamable_http_manager.py, with an exception group bottoming in anyio.ClosedResourceError raised from the send_log_message call). Backporting #2257 removes the doomed call itself, making released v1.x safe regardless of how run() is driven.
What we would expect to see. A client disconnect is not a server error: at most a DEBUG/INFO record, no traceback, no Exception forwarded into the session stream, and no possible session-task-group crash. We are aware that the ERROR-plus-traceback logging of ClientDisconnect itself is tracked in the still-open #1648 and is unfixed even on current main (whose streamable_http.py has no ClientDisconnect handling at all), so this issue deliberately asks only for what already has a merged fix upstream:
Backport fix: don't send log notification on transport error #2257 (cherry-pick of 62eb08e) to v1.x — small and non-breaking: it removes one notification call and keeps the local logger.error, matching CONTRIBUTING.md's routing of "Bug fixes for v1 (non-breaking fixes)" to the v1.x branch.
Cut a v1.x patch release containing it, since the README states v1.x "is the only stable release line and remains recommended for production" and "continues to receive critical bug fixes and security patches".
Related issues and PRs found while searching (why this is not a duplicate re-report): #2064 (the exact stateless crash; closed via #2257, main only), #1967 (same send_log_message crash class at shutdown; closed via #2257), #1648 (open: ClientDisconnect returns HTTP 500 with an ERROR traceback — the logging aspect, not re-requested here), #2328 / #2306 / #2334 (respond-path ClosedResourceError; fixed and released in v1.27.0, but not covering the case Exception(): send_log_message path), #1219 (older report of the same stateless class), #2741 (stateful sibling), #2502 (open v1.x PR, see above).
Example Code
# server.py"""Minimal repro server: mcp 1.28.1 stateless streamable HTTP.A lowlevel Server wrapped by StreamableHTTPSessionManager(stateless=True),mounted in a minimal Starlette app, served by uvicorn.Tools: ping - returns immediately. slow_log - sleeps 1.5 s, then sends a log notification via session.send_log_message (used to test a client abort while a log notification is still in flight)."""importcontextlibimportloggingfromcollections.abcimportAsyncIteratorimportanyioimportuvicornfromstarlette.applicationsimportStarlettefromstarlette.routingimportMountfromstarlette.typesimportReceive, Scope, Sendimportmcp.typesastypesfrommcp.server.lowlevelimportServerfrommcp.server.streamable_http_managerimportStreamableHTTPSessionManagerlogging.basicConfig(level=logging.INFO)
app=Server("repro-server")
@app.list_tools()asyncdeflist_tools() ->list[types.Tool]:
return [
types.Tool(
name="ping",
description="Returns pong.",
inputSchema={"type": "object", "properties": {}},
),
types.Tool(
name="slow_log",
description="Sleeps, then sends a log notification.",
inputSchema={"type": "object", "properties": {}},
),
]
@app.call_tool()asyncdefcall_tool(name: str, arguments: dict) ->list[types.TextContent]:
ifname=="slow_log":
ctx=app.request_contextawaitanyio.sleep(1.5)
awaitctx.session.send_log_message(
level="info", data="tool progress", logger="repro-tool"
)
return [types.TextContent(type="text", text="logged")]
return [types.TextContent(type="text", text="pong")]
session_manager=StreamableHTTPSessionManager(
app=app,
event_store=None,
json_response=False,
stateless=True,
)
asyncdefhandle_streamable_http(scope: Scope, receive: Receive, send: Send) ->None:
awaitsession_manager.handle_request(scope, receive, send)
@contextlib.asynccontextmanagerasyncdeflifespan(_app: Starlette) ->AsyncIterator[None]:
asyncwithsession_manager.run():
yieldstarlette_app=Starlette(
routes=[Mount("/mcp", app=handle_streamable_http)],
lifespan=lifespan,
)
if__name__=="__main__":
uvicorn.run(starlette_app, host="127.0.0.1", port=8912, log_level="info")
# client.py"""Client for the minimal repro.Modes (optional trailing integer = repeat count, default 1): control - normal initialize + tools/call via the mcp client (expect clean server logs) abort-fin - POST with Content-Length larger than the bytes sent, then FIN close mid-body abort-rst - same partial body, but hard RST close (SO_LINGER 0) abort-during-log - complete, valid tools/call POST for the slow_log tool, then close the connection while the tool is still sleeping (before it sends its log notification)"""importasyncioimportsocketimportstructimportsysimporttimeHOST="127.0.0.1"PORT=8912MCP_URL=f"http://{HOST}:{PORT}/mcp/"asyncdefcontrol() ->None:
frommcpimportClientSessionfrommcp.client.streamable_httpimportstreamablehttp_clientasyncwithstreamablehttp_client(MCP_URL) as (read_stream, write_stream, _):
asyncwithClientSession(read_stream, write_stream) assession:
awaitsession.initialize()
result=awaitsession.call_tool("ping", {})
print("CONTROL tools/call result:", result.content[0].text)
defabort_partial_body(rst: bool) ->None:
"""Send headers plus a partial JSON body, then close before the body completes."""body_prefix=b'{"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "ping"'headers= (
f"POST /mcp/ HTTP/1.1\r\n"f"Host: {HOST}:{PORT}\r\n""Accept: application/json, text/event-stream\r\n""Content-Type: application/json\r\n"f"Content-Length: {len(body_prefix) +1000}\r\n""\r\n"
).encode()
sock=socket.create_connection((HOST, PORT))
try:
sock.sendall(headers)
sock.sendall(body_prefix)
# Let the server enter request.body() and consume the partial body.time.sleep(0.3)
ifrst:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, struct.pack("ii", 1, 0))
finally:
sock.close()
print(f"ABORT ({'RST'ifrstelse'FIN'}): connection closed mid-body")
defabort_during_tool() ->None:
"""Send a complete tools/call for slow_log, then close mid-execution."""body= (
b'{"jsonrpc": "2.0", "id": 1, "method": "tools/call",'b' "params": {"name": "slow_log", "arguments": {}}}'
)
headers= (
f"POST /mcp/ HTTP/1.1\r\n"f"Host: {HOST}:{PORT}\r\n""Accept: application/json, text/event-stream\r\n""Content-Type: application/json\r\n"f"Content-Length: {len(body)}\r\n""\r\n"
).encode()
sock=socket.create_connection((HOST, PORT))
try:
sock.sendall(headers+body)
# Tool sleeps 1.5 s before sending its log notification; close at 0.5 s# so the notification is sent after the client is gone.time.sleep(0.5)
finally:
sock.close()
print("ABORT (during slow_log): connection closed mid-tool, before log notification")
# Keep the process alive long enough for the tool to wake and try the send.time.sleep(2.0)
if__name__=="__main__":
mode=sys.argv[1]
repeat=int(sys.argv[2]) iflen(sys.argv) >2else1foriinrange(repeat):
ifmode=="control":
asyncio.run(control())
elifmode=="abort-fin":
abort_partial_body(rst=False)
elifmode=="abort-rst":
abort_partial_body(rst=True)
elifmode=="abort-during-log":
abort_during_tool()
else:
raiseSystemExit(f"unknown mode: {mode}")
ifrepeat>1andi<repeat-1:
time.sleep(0.5)
Steps to reproduce:
python server.py (terminal 1)
python client.py control (terminal 2) — clean logs, zero ERROR records
python client.py abort-fin 5 — five ERROR record pairs as shown above
python client.py abort-rst 5 — the same signature via a hard RST close
python client.py abort-during-log 3 — clean on the bare SDK (see the cancellation-timing explanation above)
The full three-record cascade was observed in production with anyio 4.14.1; the repro environment has anyio 4.14.2, and the MemoryObjectSendStream.send checkpoint-before-send_nowait ordering relevant to the cancellation-timing explanation above is identical in both versions.
Initial Checks
Note on the first box: we are on v1.28.1, the latest stable release; the only later versions are the 2.0.0 pre-releases (2.0.0a1 through 2.0.0b2).
Description
What this asks for. Please backport PR #2257 ("fix: don't send log notification on transport error", merge commit 62eb08e, merged into
mainon 2026-03-09) to thev1.xbranch and cut a v1.x patch release. This is filed on the bug template because the tracker has no backport template and blank issues are disabled; the underlying bug reports (#2064, #1967) are closed as completed, but the fix that closed them has never shipped in any stable release.What we're seeing (reproduced deterministically on v1.28.1 with the example below). When a client aborts an in-flight POST to a stateless Streamable HTTP server — a routine, benign network event (canceled request, closed tab, dropped connection) — the server emits two ERROR-level records per disconnect, 10/10 across both FIN and RST closes:
ERROR:mcp.server.streamable_http:Error handling POST request— alogger.exceptionwith a full traceback bottoming instarlette.requests.ClientDisconnectraised frombody = await request.body()in_handle_post_request(src/mcp/server/streamable_http.py, line 491 in v1.28.1).ERROR:mcp.server.lowlevel.server:Received exception from stream:— the POST handler forwardsException(err)into the session's incoming stream, andstr()of anExceptionwrapping aClientDisconnectis empty, so the record ends with an empty tail.Complete server log for a run of five mid-body FIN aborts (
python client.py abort-fin 5; site-packages paths shortened to<venv>; the record group repeats byte-identically once per abort, so the four repetitions are elided):Signature counts for that run:
Error handling POST request= 5,Received exception from stream= 5,Stateless session crashed= 0. The RST variant (abort-rst 5, SO_LINGER-0 hard close) produces byte-identical record groups with the same counts. The control mode (a normalinitialize+tools/callthrough the SDK's own client) logs zero ERROR records:The crash defect behind #2064 is still present in every released version. After forwarding the exception, the
case Exception():branch of_handle_message(src/mcp/server/lowlevel/server.py) callsawait session.send_log_message(level="error", data="Internal Server Error", logger="mcp.server.exception_handler").BaseSession.send_notification(src/mcp/shared/session.py) writes to the session's write stream with no closed-stream guard, and the stateless session manager callshttp_transport.terminate()— which closes that stream — right after the POST handler returns. That is exactly theanyio.ClosedResourceError->logger.exception("Stateless session crashed")cascade reported in #2064 and #1967. PR #2257 removed the doomed call, but release containment (checked via the GitHub compare API) showsv2.0.0a1...62eb08eas "behind" withahead_by: 0(contained in all 2.0.0 pre-releases), whilev1.27.0...62eb08e,v1.28.1...62eb08e, and thev1.xbranch tip compared against62eb08eare all "diverged" (absent): thev1.xtip'slowlevel/server.pystill contains thesend_log_messagecall. No stable release has ever shipped this fix, and the 2026-03-11 question on #2064 about a v1.x backport was never answered.Why bare released v1.x usually escapes the crash — and why that is not a fix. On bare v1.28.1 the example below does not reach the third record (0/13 runs: 5 FIN aborts, 5 RST aborts, 3 aborts with a tool's log notification in flight): the
finally: tg.cancel_scope.cancel()inServer.run(part of the v1.x transport-close fix from #2334, released in v1.27.0) cancels the in-flight exception handler at theawait checkpoint()that precedessend_nowaitinside anyio'sMemoryObjectSendStream.send, so the handler dies by cancellation before the doomed send executes. That protection is a cancellation-timing artifact ofrun(), not a guard on the send itself: any server that overrides or reimplementsServer.run— as downstream frameworks do — silently loses it, and we have hit the full three-record cascade in production on v1.28.1 under exactly such an override (both ERRORs above pluslogger.exception("Stateless session crashed")fromstreamable_http_manager.py, with an exception group bottoming inanyio.ClosedResourceErrorraised from thesend_log_messagecall). Backporting #2257 removes the doomed call itself, making released v1.x safe regardless of howrun()is driven.What we would expect to see. A client disconnect is not a server error: at most a DEBUG/INFO record, no traceback, no
Exceptionforwarded into the session stream, and no possible session-task-group crash. We are aware that the ERROR-plus-traceback logging ofClientDisconnectitself is tracked in the still-open #1648 and is unfixed even on currentmain(whosestreamable_http.pyhas noClientDisconnecthandling at all), so this issue deliberately asks only for what already has a merged fix upstream:v1.x— small and non-breaking: it removes one notification call and keeps the locallogger.error, matching CONTRIBUTING.md's routing of "Bug fixes for v1 (non-breaking fixes)" to thev1.xbranch.Related issues and PRs found while searching (why this is not a duplicate re-report): #2064 (the exact stateless crash; closed via #2257,
mainonly), #1967 (same send_log_message crash class at shutdown; closed via #2257), #1648 (open:ClientDisconnectreturns HTTP 500 with an ERROR traceback — the logging aspect, not re-requested here), #2328 / #2306 / #2334 (respond-pathClosedResourceError; fixed and released in v1.27.0, but not covering thecase Exception():send_log_message path), #1219 (older report of the same stateless class), #2741 (stateful sibling), #2502 (open v1.x PR, see above).Example Code
Steps to reproduce:
python server.py(terminal 1)python client.py control(terminal 2) — clean logs, zero ERROR recordspython client.py abort-fin 5— five ERROR record pairs as shown abovepython client.py abort-rst 5— the same signature via a hard RST closepython client.py abort-during-log 3— clean on the bare SDK (see the cancellation-timing explanation above)Python & MCP Python SDK
The full three-record cascade was observed in production with anyio 4.14.1; the repro environment has anyio 4.14.2, and the
MemoryObjectSendStream.sendcheckpoint-before-send_nowaitordering relevant to the cancellation-timing explanation above is identical in both versions.