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
Personne n'a encore pris cette issue.
- Langage dominant
- Python
- Étoiles
- 24.3k
- Forks
- 4k
- Merge moyen
- 1 j 1 h
- PR mergées (30 j)
- 31
Description
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.)
Guide de contribution
Ouvrir le guide de contribution
Par où commencer
- Lisez l'issue en entier, puis le guide de contribution du projet.
- Signalez en commentaire que vous la prenez — cela évite que deux personnes fassent le même travail.
- Forkez le dépôt et travaillez sur une branche.
- Ouvrez une pull request qui référence le numéro de l'issue.
Piste de recherche
Commencez par _handle_stateful_request et run_server dans streamable_http_manager.py, puis suivez _handle_post_request et _create_error_response dans streamable_http.py à l’aide de la reproduction fournie de l’échec du lifespan. Confirmez comment l’exception de session est perdue lorsque le flux mémoire se ferme. Le travail est terminé lorsque la requête ou le client expose l’exception d’origine et que l’envoi suivant ne produit pas de deuxième ClosedResourceError.
Rédigé par le modèle d'indexation à partir du texte de l'issue.
Évaluation
- Stack technique
- python
- Domaine
- backend-api-design, networking
- Type d'issue
- Bug
- Difficulté
- 4/5
- Temps estimé
- 3-5 jours
- Activité
- Calme
- Clarté
- Plutôt claire
- Accessibilité débutants
- 52/100