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

Đang mở
#2,741 2 bình luận 0 reaction 0 người được giao Xem trên GitHub

Chưa có ai nhận issue này.

bug needs confirmation P2
Ngôn ngữ chính
Python
Star
24.3k
Fork
4k
Merge trung bình
1 ngày 1 giờ
Pull request đã merge (30 ngày)
31

Mô tả

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

Hướng dẫn đóng góp

Mở hướng dẫn đóng góp

Bắt đầu từ đâu

  1. Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
  2. Bình luận trên issue rằng bạn sẽ nhận — tránh hai người làm cùng một việc.
  3. Fork repository và làm thay đổi trên một nhánh.
  4. Mở pull request có tham chiếu số hiệu của issue.

Hướng nghiên cứu

Bắt đầu với _handle_stateful_request và run_server trong streamable_http_manager.py, sau đó lần theo _handle_post_request và _create_error_response trong streamable_http.py bằng bản tái hiện lỗi lifespan được cung cấp. Xác nhận cách ngoại lệ của session bị mất khi memory stream đóng. Được xem là hoàn tất khi request hoặc client để lộ ngoại lệ ban đầu và lần gửi tiếp theo không tạo ra ClosedResourceError thứ hai.

Do mô hình lập chỉ mục viết ra từ nội dung của issue.

Đánh giá

Công nghệ
python
Lĩnh vực
backend-api-design, networking
Loại issue
Lỗi
Độ khó
4/5
Thời gian dự kiến
3-5 ngày
Mức độ hoạt động
Ít trao đổi
Độ rõ ràng
Khá rõ ràng
Mức phù hợp với người mới
52/100

Nhận issue mới trong hộp thư của bạn

Bản tóm tắt ngắn những issue GitHub phù hợp với người mới.