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
まだ誰も着手していません。
- 主要言語
- Python
- スター
- 24.3k
- フォーク
- 4k
- 平均マージ
- 1日 1時間
- マージ済み PR(30日)
- 31
説明
Initial Checks
- I confirm that I'm using the latest version of MCP Python SDK
- I confirm that I searched for my issue in https://github.com/modelcontextprotocol/python-sdk/issues before opening this issue
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:
- raises
ClosedResourceErroratwriter.send(session_message)(session read-stream already closed) — it never sees the real exception, only "stream closed"; - builds the 500 via
_create_error_response(f"Error handling POST request: {err}"), buterris now thatClosedResourceError, whosestr()is empty → empty client message; - calls
writer.send(Exception(err))into the already-closed stream, raisingClosedResourceErroragain.
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))withtry/except (ClosedResourceError, BrokenResourceError). - When
str(err)is empty, fall back torepr(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.)
コントリビューションガイド
はじめの一歩
- issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
- 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
- リポジトリをフォークし、ブランチを切って変更します。
- issue 番号を参照したプルリクエストを送ります。
調査の方向性
streamable_http_manager.py の _handle_stateful_request と run_server から始め、提供されている lifespan 失敗の再現を使って、streamable_http.py の _handle_post_request と _create_error_response を追跡します。メモリーストリームが閉じたときにセッション例外がどのように失われるかを確認します。リクエストまたはクライアントが元の例外を公開し、後続の送信で 2 つ目の ClosedResourceError が発生しなければ完了です。
索引モデルが issue の本文から書いたものです。
評価
- 技術スタック
- python
- 領域
- backend-api-design, networking
- issue の種類
- バグ
- 難易度
- 4/5
- 見積もり時間
- 3〜5日
- 活発さ
- 静か
- 明瞭さ
- おおむね明確
- 初心者へのやさしさ
- 52/100