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
Nobody has claimed this yet.
- Dominant language
- Python
- Stars
- 24.3k
- Forks
- 4k
- Avg merge
- 1d 1h
- Merged PRs (30d)
- 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.)
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with _handle_stateful_request and run_server in streamable_http_manager.py, then trace _handle_post_request and _create_error_response in streamable_http.py using the provided lifespan-failure reproduction. Confirm how the session exception is lost when the memory stream closes. Done means the request or client exposes the original exception and the trailing send does not produce a second ClosedResourceError.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- python
- Domain
- backend-api-design, networking
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 52/100