Skip to content

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

Description

@SingingOwl

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

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.)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions