Skip to content

Commit 29db7b3

Browse files
committed
fix: surface stateful HTTP session crash cause
1 parent 616476f commit 29db7b3

3 files changed

Lines changed: 62 additions & 4 deletions

File tree

src/mcp/server/streamable_http.py

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
import re
1111
from abc import ABC, abstractmethod
1212
from collections.abc import AsyncGenerator, Awaitable, Callable
13-
from contextlib import asynccontextmanager
13+
from contextlib import asynccontextmanager, suppress
1414
from dataclasses import dataclass
1515
from http import HTTPStatus
1616
from typing import Any
@@ -171,6 +171,7 @@ def __init__(
171171
] = {}
172172
self._sse_stream_writers: dict[RequestId, MemoryObjectSendStream[dict[str, str]]] = {}
173173
self._terminated = False
174+
self._session_run_error: BaseException | None = None
174175
# Idle timeout cancel scope; managed by the session manager.
175176
self.idle_scope: anyio.CancelScope | None = None
176177

@@ -179,6 +180,16 @@ def is_terminated(self) -> bool:
179180
"""Check if this transport has been explicitly terminated."""
180181
return self._terminated
181182

183+
def note_session_run_error(self, exc: BaseException) -> None:
184+
self._session_run_error = exc
185+
186+
def _post_error_message(self, err: BaseException) -> str:
187+
display_error = self._session_run_error or err
188+
display_error_text = str(display_error) or type(display_error).__name__
189+
if display_error is not err and str(display_error):
190+
display_error_text = f"{type(display_error).__name__}: {display_error_text}"
191+
return f"Error handling POST request: {display_error_text}"
192+
182193
def close_sse_stream(self, request_id: RequestId) -> None:
183194
"""Close SSE connection for a specific request without terminating the stream.
184195
@@ -363,6 +374,10 @@ async def _clean_up_memory_streams(self, request_id: RequestId) -> None:
363374
# Remove the request stream from the mapping
364375
self._request_streams.pop(request_id, None)
365376

377+
async def _clean_up_post_request_stream(self, request_id: RequestId | None) -> None:
378+
if request_id is not None:
379+
await self._clean_up_memory_streams(request_id)
380+
366381
async def handle_request(self, scope: Scope, receive: Receive, send: Send) -> None:
367382
"""Application entry point that handles all HTTP requests."""
368383
request = Request(scope, receive)
@@ -443,6 +458,7 @@ async def _handle_post_request(self, scope: Scope, request: Request, receive: Re
443458
writer = self._read_stream_writer
444459
if writer is None: # pragma: no cover
445460
raise ValueError("No read stream writer available. Ensure connect() is called first.")
461+
request_id: RequestId | None = None
446462
try:
447463
# Validate Accept header
448464
if not await self._validate_accept_header(request, scope, send):
@@ -637,14 +653,16 @@ async def sse_writer():
637653

638654
except Exception as err:
639655
logger.exception("Error handling POST request")
656+
await self._clean_up_post_request_stream(request_id)
640657
response = self._create_error_response(
641-
f"Error handling POST request: {err}",
658+
self._post_error_message(err),
642659
HTTPStatus.INTERNAL_SERVER_ERROR,
643660
INTERNAL_ERROR,
644661
)
645662
await response(scope, receive, send)
646663
if writer: # pragma: no cover
647-
await writer.send(Exception(err))
664+
with suppress(anyio.BrokenResourceError, anyio.ClosedResourceError):
665+
await writer.send(Exception(err))
648666
return # pragma: no cover
649667

650668
async def _handle_get_request(self, request: Request, send: Send) -> None:

src/mcp/server/streamable_http_manager.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -274,7 +274,8 @@ async def run_server(*, task_status: TaskStatus[None] = anyio.TASK_STATUS_IGNORE
274274
self._server_instances.pop(http_transport.mcp_session_id, None)
275275
self._session_owners.pop(http_transport.mcp_session_id, None)
276276
await http_transport.terminate()
277-
except Exception:
277+
except Exception as exc:
278+
http_transport.note_session_run_error(exc)
278279
logger.exception(f"Session {http_transport.mcp_session_id} crashed")
279280
finally:
280281
if ( # pragma: no branch

tests/server/test_streamable_http_manager.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,45 @@ async def mock_receive(): # pragma: no cover
209209
assert not manager._server_instances, "No sessions should be tracked after the only session crashes"
210210

211211

212+
@pytest.mark.anyio
213+
async def test_stateful_json_response_includes_session_crash_cause():
214+
app = Server("test-crash-cause")
215+
app.run = AsyncMock(side_effect=RuntimeError("BOOM-distinctive-root-cause"))
216+
manager = StreamableHTTPSessionManager(app=app, json_response=True)
217+
218+
sent_messages: list[Message] = []
219+
response_body = b""
220+
221+
async def mock_send(message: Message) -> None:
222+
nonlocal response_body
223+
sent_messages.append(message)
224+
if message["type"] == "http.response.body":
225+
response_body += message.get("body", b"")
226+
227+
async def mock_receive() -> Message:
228+
body = {
229+
"jsonrpc": "2.0",
230+
"id": 1,
231+
"method": "initialize",
232+
"params": {
233+
"protocolVersion": "2025-06-18",
234+
"capabilities": {},
235+
"clientInfo": {"name": "test-client", "version": "1.0"},
236+
},
237+
}
238+
return {"type": "http.request", "body": json.dumps(body).encode(), "more_body": False}
239+
240+
async with manager.run():
241+
await manager.handle_request(_request_scope(), mock_receive, mock_send)
242+
243+
response_start = next(msg for msg in sent_messages if msg["type"] == "http.response.start")
244+
assert response_start["status"] == 500
245+
246+
error_data = json.loads(response_body)
247+
assert error_data["error"]["code"] == -32603
248+
assert "RuntimeError: BOOM-distinctive-root-cause" in error_data["error"]["message"]
249+
250+
212251
@pytest.mark.anyio
213252
async def test_stateless_requests_memory_cleanup():
214253
"""Test that stateless requests actually clean up resources using real transports."""

0 commit comments

Comments
 (0)