Skip to content

[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

Description

@alex-feel

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

  1. 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).
  2. 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:

  1. 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.
  2. 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".
  3. Alternatively or additionally, land the open v1.x hardening PR [v1.x] Drop responses/notifications when write stream is already closed #2502 ("Drop responses/notifications when write stream is already closed"), which turns this whole family of late sends into silent drops.

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).
"""

import contextlib
import logging
from collections.abc import AsyncIterator

import anyio
import uvicorn
from starlette.applications import Starlette
from starlette.routing import Mount
from starlette.types import Receive, Scope, Send

import mcp.types as types
from mcp.server.lowlevel import Server
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager

logging.basicConfig(level=logging.INFO)

app = Server("repro-server")


@app.list_tools()
async def list_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()
async def call_tool(name: str, arguments: dict) -> list[types.TextContent]:
    if name == "slow_log":
        ctx = app.request_context
        await anyio.sleep(1.5)
        await ctx.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,
)


async def handle_streamable_http(scope: Scope, receive: Receive, send: Send) -> None:
    await session_manager.handle_request(scope, receive, send)


@contextlib.asynccontextmanager
async def lifespan(_app: Starlette) -> AsyncIterator[None]:
    async with session_manager.run():
        yield


starlette_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)
"""

import asyncio
import socket
import struct
import sys
import time

HOST = "127.0.0.1"
PORT = 8912
MCP_URL = f"http://{HOST}:{PORT}/mcp/"


async def control() -> None:
    from mcp import ClientSession
    from mcp.client.streamable_http import streamablehttp_client

    async with streamablehttp_client(MCP_URL) as (read_stream, write_stream, _):
        async with ClientSession(read_stream, write_stream) as session:
            await session.initialize()
            result = await session.call_tool("ping", {})
            print("CONTROL tools/call result:", result.content[0].text)


def abort_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)
        if rst:
            sock.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, struct.pack("ii", 1, 0))
    finally:
        sock.close()
    print(f"ABORT ({'RST' if rst else 'FIN'}): connection closed mid-body")


def abort_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]) if len(sys.argv) > 2 else 1
    for i in range(repeat):
        if mode == "control":
            asyncio.run(control())
        elif mode == "abort-fin":
            abort_partial_body(rst=False)
        elif mode == "abort-rst":
            abort_partial_body(rst=True)
        elif mode == "abort-during-log":
            abort_during_tool()
        else:
            raise SystemExit(f"unknown mode: {mode}")
        if repeat > 1 and i < repeat - 1:
            time.sleep(0.5)

Steps to reproduce:

  1. python server.py (terminal 1)
  2. python client.py control (terminal 2) — clean logs, zero ERROR records
  3. python client.py abort-fin 5 — five ERROR record pairs as shown above
  4. python client.py abort-rst 5 — the same signature via a hard RST close
  5. python client.py abort-during-log 3 — clean on the bare SDK (see the cancellation-timing explanation above)

Python & MCP Python SDK

Python 3.14.0 (Windows 11)
mcp==1.28.1 (latest stable; venv created with uv; mcp and uvicorn installed explicitly, everything else resolved transitively)

pip freeze:
annotated-types==0.7.0
anyio==4.14.2
attrs==26.1.0
certifi==2026.6.17
cffi==2.1.0
click==8.4.2
colorama==0.4.6
cryptography==49.0.0
h11==0.16.0
httpcore==1.0.9
httpx==0.28.1
httpx-sse==0.4.3
idna==3.18
jsonschema==4.26.0
jsonschema-specifications==2025.9.1
mcp==1.28.1
pycparser==3.0
pydantic==2.13.4
pydantic-settings==2.14.2
pydantic_core==2.46.4
PyJWT==2.13.0
python-dotenv==1.2.2
python-multipart==0.0.32
pywin32==312
referencing==0.37.0
rpds-py==2026.6.3
sse-starlette==3.4.6
starlette==1.3.1
typing-inspection==0.4.2
typing_extensions==4.16.0
uvicorn==0.51.0

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions