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:
- raises
ClosedResourceError at writer.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}"), but err is now that ClosedResourceError, whose str() is empty → empty client message;
- 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
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.)
Initial Checks
Description
Summary
In stateful Streamable HTTP (
stateless_http=False), when a session'sapp.run()raises, the client receives JSON-RPC-32603with the empty message"Error handling POST request: ", and the only prominent server log isanyio.ClosedResourceError. The real exception is logged separately asSession <id> crashedand 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 8765and hit with oneinitializePOST.Client response — the real
RuntimeErroris absent:Server log — the truth, in a separate task:
(My real-world trigger was
OSError: [Errno 24] inotify instance limit reachedfrom a per-session resource; every request then 500'd with an opaqueClosedResourceError, hiding theOSErrorentirely.)Root cause
_handle_stateful_request(streamable_http_manager.py) startsrun_server(runsapp.run()on the session's memory streams), then concurrently callshttp_transport.handle_request(). Ifapp.run()raises,run_serverlogsSession … crashedandconnect()tears down the streams. The concurrent_handle_post_request(streamable_http.py) then:ClosedResourceErroratwriter.send(session_message)(session read-stream already closed) — it never sees the real exception, only "stream closed";_create_error_response(f"Error handling POST request: {err}"), buterris now thatClosedResourceError, whosestr()is empty → empty client message;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
run_server's exception on the transport and surface it from_handle_post_request.writer.send(Exception(err))withtry/except (ClosedResourceError, BrokenResourceError).str(err)is empty, fall back torepr(err)/ the exception type.Related
ClosedResourceError-on-connect.This is the stateful error-propagation gap that remains.
Example Code
Python & MCP Python SDK