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

@SingingOwl

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
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions