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

Abierto
#2,741 2 comentarios 0 reacciones 0 asignados Ver en GitHub

Nadie ha tomado este issue todavía.

bug needs confirmation P2
Lenguaje dominante
Python
Estrellas
24.3k
Forks
4k
Merge medio
1 d 1 h
PR fusionados (30 d)
31

Descripción

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

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

Guía de contribución

Abrir la guía de contribución

Primeros pasos

  1. Lee el issue completo y luego la guía de contribución del proyecto.
  2. Comenta en el issue que vas a ocuparte — evita que dos personas hagan lo mismo.
  3. Haz un fork del repositorio y trabaja en una rama.
  4. Abre un pull request que haga referencia al número del issue.

Línea de trabajo

Comience con _handle_stateful_request y run_server en streamable_http_manager.py y, a continuación, siga _handle_post_request y _create_error_response en streamable_http.py usando la reproducción proporcionada del fallo de lifespan. Confirme cómo se pierde la excepción de la sesión cuando se cierra el flujo de memoria. Se considera terminado cuando la solicitud o el cliente expone la excepción original y el envío posterior no produce un segundo ClosedResourceError.

Escrito por el modelo de indexación a partir del texto del issue.

Evaluación

Stack tecnológico
python
Área
backend-api-design, networking
Tipo de issue
Error
Dificultad
4/5
Tiempo estimado
3-5 días
Estado de actividad
Tranquilo
Claridad
Bastante claro
Aptitud para principiantes
52/100

Recibe los nuevos issues en tu correo

Un resumen breve de issues de GitHub para principiantes.