Skip to content

[v2] modern streamable HTTP watch_disconnect can busy-loop and peg a CPU core #3439

Closed as not planned

Description

@Hil333

[v2] modern streamable HTTP watch_disconnect can busy-loop and peg a CPU core

Initial checks

Description

The modern (2026-07-28) streamable-HTTP server path can monopolize the event
loop when its ASGI receive callable returns non-http.disconnect messages
without actually suspending. In production this pegged one core, held the GIL,
starved unrelated FastAPI routes (including /health), and made the Docker
container unhealthy.

The loop is in src/mcp/server/_streamable_http_modern.py:

async def watch_disconnect(cancel_scope: anyio.CancelScope) -> None:
    while (await receive()).get("type") != "http.disconnect":
        pass  # pragma: no cover
    cancel_scope.cancel()

Our immediate trigger was body-buffering authentication middleware. It
correctly replayed the consumed request body once, but then incorrectly kept
returning a completed http.request event. We fixed that middleware to
delegate to the original receive channel after the single replay. However, a
bad or unusual ASGI receive stream should not let an SDK disconnect watcher
starve the whole process indefinitely.

Environment

  • MCP Python SDK: 2.1.0 in production; reproduced on 2.1.1
  • Python: 3.12
  • Server: uvicorn / FastAPI / Starlette
  • Transport: modern streamable HTTP, including subscriptions/listen
  • Reverse proxy: Nginx Proxy Manager

Minimal deterministic reproduction

The standalone script included below invokes handle_modern_request with a valid
2026-07-28 subscriptions/listen envelope and an ASGI receive callable that
returns an already-finished http.request. It caps the replay at one million
events so the process exits rather than spinning forever:

$ python mcp_v2_watch_disconnect_busy_loop.py
receive() returned 999,999 non-disconnect events in 0.105s
Reproduced: watch_disconnect polled receive() without yielding.

The same script is stored in the reporting application's repository as
reproductions/mcp_v2_watch_disconnect_busy_loop.py.

Production evidence

The py-spy dump of the affected uvicorn process repeatedly showed the
MainThread here:

mcp/server/_streamable_http_modern.py, in watch_disconnect
    while (await receive()).get("type") != "http.disconnect":
        pass

Controlled test:

  1. With the Claude MCP connector fully disconnected and no MCP traffic, the
    container stayed healthy and CPU-normal for 15+ minutes.
  2. Every time the connector was reconnected and used, the container became
    unhealthy at 100% CPU within roughly 7-45 minutes.
  3. Restarting the container cleared the condition immediately; it returned
    after a later MCP session.
  4. Background reconciliation and CalDAV tasks were independently ruled out.

Expected behavior

Unexpected/non-disconnect ASGI messages must not turn the disconnect watcher
into an unthrottled poll that prevents other event-loop tasks from running.

Suggested direction

Add an explicit checkpoint for every non-disconnect message (and a regression
test with an immediately returning receive callable), for example:

async def watch_disconnect(cancel_scope: anyio.CancelScope) -> None:
    while (await receive()).get("type") != "http.disconnect":
        await anyio.lowlevel.checkpoint()
    cancel_scope.cancel()

This does not excuse invalid body-replay middleware, but it bounds the blast
radius and prevents one malformed receive lifecycle from taking down every
request served by the process.

Full reproducer

"""Deterministic reproduction for mcp v2's modern HTTP disconnect busy loop.

Run with Python 3.12 and mcp==2.1.0 or 2.1.1:

    python reproductions/mcp_v2_watch_disconnect_busy_loop.py

The ASGI receive callable deliberately models body-replay middleware that
returns an already-finished ``http.request`` after the body was consumed. The
SDK should not let that condition monopolize the event loop while it waits for
``http.disconnect``.
"""

from __future__ import annotations

import json
import time

import anyio
from mcp.server import MCPServer
from mcp.server._streamable_http_modern import handle_modern_request


REPLAY_LIMIT = 1_000_000
PROTOCOL_VERSION = "2026-07-28"


async def main() -> None:
    server = MCPServer("watch-disconnect-repro")
    body = json.dumps(
        {
            "jsonrpc": "2.0",
            "id": 1,
            "method": "subscriptions/listen",
            "params": {
                "notifications": {},
                "_meta": {
                    "io.modelcontextprotocol/protocolVersion": PROTOCOL_VERSION,
                    "io.modelcontextprotocol/clientCapabilities": {},
                    "io.modelcontextprotocol/clientInfo": {
                        "name": "watch-disconnect-repro",
                        "version": "1",
                    },
                },
            },
        }
    ).encode()
    scope = {
        "type": "http",
        "asgi": {"version": "3.0"},
        "http_version": "1.1",
        "method": "POST",
        "scheme": "http",
        "path": "/mcp",
        "raw_path": b"/mcp",
        "query_string": b"",
        "server": ("127.0.0.1", 8000),
        "client": ("127.0.0.1", 50000),
        "headers": [
            (b"host", b"127.0.0.1:8000"),
            (b"content-type", b"application/json"),
            (b"accept", b"application/json, text/event-stream"),
            (b"content-length", str(len(body)).encode()),
            (b"mcp-protocol-version", PROTOCOL_VERSION.encode()),
            (b"mcp-method", b"subscriptions/listen"),
        ],
    }
    receive_calls = 0
    sent_body = False

    async def receive() -> dict:
        nonlocal receive_calls, sent_body
        receive_calls += 1
        if not sent_body:
            sent_body = True
            return {"type": "http.request", "body": body, "more_body": False}
        if receive_calls <= REPLAY_LIMIT:
            # No await occurs before returning: this exposes the SDK's tight
            # ``while (await receive()) ...: pass`` loop deterministically.
            return {"type": "http.request", "body": b"", "more_body": False}
        return {"type": "http.disconnect"}

    async def send(_message: dict) -> None:
        pass

    started = time.perf_counter()
    await handle_modern_request(
        server._lowlevel_server,
        None,
        True,
        None,
        scope,
        receive,
        send,
    )
    elapsed = time.perf_counter() - started
    replayed = receive_calls - 2  # initial request and final disconnect
    print(f"receive() returned {replayed:,} non-disconnect events in {elapsed:.3f}s")
    if replayed != REPLAY_LIMIT - 1:
        raise SystemExit("The vulnerable watch_disconnect path was not reached")
    print("Reproduced: watch_disconnect polled receive() without yielding.")


if __name__ == "__main__":
    anyio.run(main)

Activity

  1. added
    v2Affects the v2 line (2.x on main)
    on Sep 3, 2026
  2. jstar0 commented on Sep 4, 2026

    @jstar0

    Confirmed on current main at d060b36.

    I built a provider-neutral ASGI reproduction with a receive callable that returns 100,000 immediate non-disconnect http.request messages before the final http.disconnect. The current watch_disconnect loop completed all 100,000 iterations while a sibling task remained unscheduled; the sibling only became runnable after the final disconnect (sibling_before_disconnect=false). This makes the reported event-loop starvation mechanism observable without a provider or network.

    The existing test test_disconnect_cancels_handler_and_runs_exit_stack in tests/server/test_streamable_http_modern.py exercises a receive callable that blocks until the handler starts, so it does not cover an immediate-message tight loop.

    A narrow regression could run a sibling heartbeat while immediate non-disconnect messages are delivered and assert that the heartbeat runs before the final disconnect. The implementation candidate is an anyio checkpoint after each non-disconnect message (or the equivalent primitive the maintainers prefer); that preserves the existing cancellation boundary while preventing an unbounded busy loop from monopolising the event loop.

    Does this match the intended contract for #3439? If so, please assign the issue or add help wanted so an external PR is eligible. I will not open a PR before that contribution gate is satisfied.

    I used an AI coding assistant to help construct the local reproduction and reviewed the result and measurements myself.

  3. UgaTheDev commented on Sep 5, 2026

    @UgaTheDev

    Confirmed the loop shape on current main: watch_disconnect awaits receive() in a while with an empty body, so a receive that returns non-disconnect messages without suspending never yields. I have a fix ready that replaces the pass with await anyio.lowlevel.checkpoint() (the only occurrence of this pattern under src/mcp/server/), plus a regression test that drives handle_modern_request with a receive returning ten immediate non-disconnect messages and asserts a sibling task still gets scheduled before the loop finishes. All 67 tests in tests/server/test_streamable_http_modern.py pass with it.

    Per CONTRIBUTING, could a maintainer assign this to me (or label it help wanted) so I can open the PR?

  4. zsxh1990 commented on Sep 7, 2026

    @zsxh1990

    I'd like to investigate and fix this.

    Symptom: watch_disconnect in streamable HTTP client busy-loops when the SSE stream ends without a proper disconnect signal, pegging a CPU core.

    Approach: Add an asyncio.sleep backoff in the watch loop when no events are received, and properly detect stream termination. Will check the v2 streamable HTTP transport code for the exact loop structure.

    This is my first contribution to the v2 transport layer - will start by reproducing the issue locally.

  5. maxisbey commented on Oct 1, 2026

    @maxisbey
    Contributor

    Thanks for the detailed report and for tracking it down to the middleware.

    I'm going to close this as not an SDK bug. The watcher only spins when receive() keeps returning messages that aren't http.disconnect without ever waiting, and a conformant ASGI server doesn't do that. By the time the watcher starts, the request body has been read in full, so the only message left to arrive is the disconnect. Under uvicorn it sees exactly one http.request and then one http.disconnect.

    The loop is also the same shape as the disconnect watchers in Starlette (StreamingResponse.listen_for_disconnect) and sse-starlette, so a receive wrapper that keeps replaying a finished http.request would spin those too. The fix belongs in the middleware, which you've already done.

    AI Disclaimer

  6. zsxh1990 commented on Oct 2, 2026

    @zsxh1990

    Accepting the close — and one small note for the record.

    I checked the parity claim myself before replying: starlette/responses.py:242-246 (listen_for_disconnect) and sse_starlette/sse.py:386-393 are both a bare await receive() in a loop with no checkpoint. So the same shape is everywhere a disconnect is watched, and changing only this SDK would not have reduced anyone's exposure. That part of the reasoning holds.

    Worth noting the report was solid, not speculative: a deterministic reproducer, a py-spy trace pinned to the line, and an independent measurement that a sibling task stays unscheduled for 100,000 immediate non-disconnect messages. The diagnosis was right and the middleware is fixed.

    The only thing I would add, as a scope preference rather than a bug claim: if the project ever wants defense-in-depth against a non-conformant receive, await anyio.lowlevel.checkpoint() after each non-disconnect message bounds the blast radius to one request instead of the whole process. It is one line and does not change the cancellation boundary. @UgaTheDev and @jstar0 already have that ready if the maintainers ever want it.

    No argument with the close. Thanks for the careful write-up on the ASGI contract.

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

    v2Affects the v2 line (2.x on main)

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions