modelcontextprotocol / modelcontextprotocol/python-sdk

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

Đang mở Phù hợp với người mới
#3,439 3 bình luận 0 reaction 0 người được giao Xem trên GitHub

Chưa có ai nhận issue này.

v2
Ngôn ngữ chính
Python
Star
24.3k
Fork
4k
Merge trung bình
1 ngày 1 giờ
Pull request đã merge (30 ngày)
31

Mô tả

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

Initial checks

  • Reproduced on the latest release, mcp==2.1.1 (original production
    incident was on 2.1.0).
  • Searched existing issues. #1805 and #2958 cover related streamable-HTTP
    lifecycle/resource problems, but not this modern-transport tight loop.

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)


Hướng dẫn đóng góp

Mở hướng dẫn đóng góp

Bắt đầu từ đâu

  1. Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
  2. Bình luận trên issue rằng bạn sẽ nhận — tránh hai người làm cùng một việc.
  3. Fork repository và làm thay đổi trên một nhánh.
  4. Mở pull request có tham chiếu số hiệu của issue.

Hướng nghiên cứu

Bắt đầu trong src/mcp/server/_streamable_http_modern.py tại watch_disconnect, sau đó chạy reproductions/mcp_v2_watch_disconnect_busy_loop.py để quan sát hành vi receive trả về ngay lập tức. Thêm một regression test sử dụng một receive callable như vậy và xác minh rằng các message không phải disconnect sẽ nhường cho event loop mà không busy-looping; test và reproducer phải hoàn tất mà không gây CPU starvation.

Do mô hình lập chỉ mục viết ra từ nội dung của issue.

Đánh giá

Công nghệ
python
Lĩnh vực
backend-api-design
Loại issue
Lỗi
Độ khó
2/5
Thời gian dự kiến
1-3 giờ
Mức độ hoạt động
Sôi nổi
Độ rõ ràng
Đặc tả rõ ràng
Mức phù hợp với người mới
86/100

Nhận issue mới trong hộp thư của bạn

Bản tóm tắt ngắn những issue GitHub phù hợp với người mới.