modelcontextprotocol / modelcontextprotocol/python-sdk

Stateful Streamable HTTP: in-session exceptions are masked as an empty -32603; real cause only in a separate "Session crashed" log

未關閉
#2,741 2 則留言 0 個 reaction 已指派 0 人 在 GitHub 檢視

還沒有人認領這個 Issue。

bug needs confirmation P2
主要語言
Python
星號
24.3k
分支
4k
平均合併
1 天 1 小時
30 天內合併 PR
31

描述

Initial Checks
Description

Summary

In stateful Streamable HTTP (stateless_http=False), when a session's app.run() raises, the client receives JSON-RPC -32603 with the empty message "Error handling POST request: ", and the only prominent server log is anyio.ClosedResourceError. The real exception is logged separately as Session <id> crashed and is never tied to the request — so the actual cause is nearly invisible from the client or a quick log scan.

This is distinct from the DoS crash fixed in 1.10.0 (GHSA-j975-95f5-7wqh): the process stays up. This is the remaining error-propagation / diagnosability gap.

Observed

Repro server is in Example Code below; served via uvicorn repro:app --port 8765 and hit with one initialize POST.

Client response — the real RuntimeError is absent:

HTTP/1.1 500 Internal Server Error
{"jsonrpc":"2.0","id":"server-error","error":{"code":-32603,"message":"Error handling POST request: "}}

Server log — the truth, in a separate task:

Session <id> crashed
  RuntimeError: BOOM-distinctive-root-cause
Error handling POST request
  anyio.ClosedResourceError
Exception in ASGI application
  anyio.ClosedResourceError

(My real-world trigger was OSError: [Errno 24] inotify instance limit reached from a per-session resource; every request then 500'd with an opaque ClosedResourceError, hiding the OSError entirely.)

Root cause

_handle_stateful_request (streamable_http_manager.py) starts run_server (runs app.run() on the session's memory streams), then concurrently calls http_transport.handle_request(). If app.run() raises, run_server logs Session … crashed and connect() tears down the streams. The concurrent _handle_post_request (streamable_http.py) then:

  1. raises ClosedResourceError at writer.send(session_message) (session read-stream already closed) — it never sees the real exception, only "stream closed";
  2. builds the 500 via _create_error_response(f"Error handling POST request: {err}"), but err is now that ClosedResourceError, whose str() is empty → empty client message;
  3. calls writer.send(Exception(err)) into the already-closed stream, raising ClosedResourceError again.

The real exception is decoupled from, and invisible to, the request and the client.

Expected

The client error and/or request-path log should surface the actual exception that crashed the session, not an opaque empty -32603.

Suggested fix

  • Capture run_server's exception on the transport and surface it from _handle_post_request.
  • Guard the trailing writer.send(Exception(err)) with try/except (ClosedResourceError, BrokenResourceError).
  • When str(err) is empty, fall back to repr(err) / the exception type.

Related

  • GHSA-j975-95f5-7wqh / #967 (1.10.0) fixed the crash, not propagation.
  • #1219, #1658 — stateless ClosedResourceError-on-connect.
  • #1304 — stateless lifespan-per-request.

This is the stateful error-propagation gap that remains.

Example Code
# Minimal repro — confirmed on mcp 1.27.2 (latest), Python 3.14
# Serve with:  uvicorn repro:app --port 8765
from contextlib import asynccontextmanager
from mcp.server.fastmcp import FastMCP
from mcp.server.transport_security import TransportSecuritySettings

@asynccontextmanager
async def lifespan(server):
    # FastMCP's server lifespan re-enters PER SESSION in stateful mode.
    # Simulate any per-session startup failure:
    raise RuntimeError("BOOM-distinctive-root-cause")
    yield {}

mcp = FastMCP(
    "repro",
    stateless_http=False,
    json_response=True,
    lifespan=lifespan,
    transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False),
)

@mcp.tool()
def ping() -> str:
    return "pong"

app = mcp.streamable_http_app()

# Then send one initialize POST (single line):
# curl -s -i -X POST http://127.0.0.1:8765/mcp -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"c","version":"1"}}}'
Python & MCP Python SDK
Python 3.14 · mcp 1.27.2 (latest) · anyio 4.13 · starlette 1.2.1. (Originally observed on 1.27.1; confirmed identical on 1.27.2.)

貢獻指南

開啟貢獻指南

從這裡開始

  1. 先讀完整個 Issue,再讀專案的貢獻指南。
  2. 在 Issue 下留言說明你要接手 —— 這能避免兩個人做同樣的事。
  3. Fork 儲存庫,在一個分支上完成修改。
  4. 送出 Pull Request,並在描述裡引用這個 Issue 編號。

研究方向

先從 streamable_http_manager.py 中的 _handle_stateful_request 和 run_server 開始,接著使用提供的 lifespan 失敗重現追蹤 streamable_http.py 中的 _handle_post_request 和 _create_error_response。確認 memory stream 關閉時 session exception 是如何遺失的。當 request 或 client 暴露原始 exception,且後續傳送不會產生第二個 ClosedResourceError 時,即視為完成。

由索引模型根據 Issue 內容生成。

評估

技術堆疊
python
領域
backend-api-design, networking
Issue 類型
缺陷
難度
4/5
預估耗時
3-5 天
活躍度
冷清
描述清晰度
基本清楚
新手友好度
52/100

把新 issue 寄到你的電子郵件信箱

精選適合新手參與的 GitHub issue 摘要。