Skip to content

Commit 088c67b

Browse files
author
g97iulio1609
committed
fix: preserve HTTP status codes in streamable HTTP transport error responses
The streamable HTTP transport had two error propagation issues: 1. Non-2xx responses used generic error messages ('Session terminated', 'Server returned an error response') that discarded the HTTP status code, making it impossible to distinguish auth errors (401/403) from server errors (500) or not-found (404). 2. Connection/transport errors (timeouts, DNS failures, connection refused) raised inside _handle_post_request were not caught, causing callers to hang indefinitely waiting for a response on the read stream. Fix: - Consolidate 404 and general >= 400 handlers into a single block that reads the response body and includes 'HTTP {status}: {body}' in the error message - Wrap _handle_post_request in try/except to catch transport errors and forward them as JSONRPCError through the read stream before re-raising Fixes #2110
1 parent 62575ed commit 088c67b

1 file changed

Lines changed: 52 additions & 38 deletions

File tree

src/mcp/client/streamable_http.py

Lines changed: 52 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -257,48 +257,62 @@ async def _handle_post_request(self, ctx: RequestContext) -> None:
257257
message = ctx.session_message.message
258258
is_initialization = self._is_initialization_request(message)
259259

260-
async with ctx.client.stream(
261-
"POST",
262-
self.url,
263-
json=message.model_dump(by_alias=True, mode="json", exclude_unset=True),
264-
headers=headers,
265-
) as response:
266-
if response.status_code == 202:
267-
logger.debug("Received 202 Accepted")
268-
return
269-
270-
if response.status_code == 404: # pragma: no branch
271-
if isinstance(message, JSONRPCRequest): # pragma: no branch
272-
error_data = ErrorData(code=INVALID_REQUEST, message="Session terminated")
273-
session_message = SessionMessage(JSONRPCError(jsonrpc="2.0", id=message.id, error=error_data))
274-
await ctx.read_stream_writer.send(session_message)
275-
return
260+
try:
261+
async with ctx.client.stream(
262+
"POST",
263+
self.url,
264+
json=message.model_dump(by_alias=True, mode="json", exclude_unset=True),
265+
headers=headers,
266+
) as response:
267+
if response.status_code == 202:
268+
logger.debug("Received 202 Accepted")
269+
return
276270

277-
if response.status_code >= 400:
278-
if isinstance(message, JSONRPCRequest):
279-
error_data = ErrorData(code=INTERNAL_ERROR, message="Server returned an error response")
280-
session_message = SessionMessage(JSONRPCError(jsonrpc="2.0", id=message.id, error=error_data))
281-
await ctx.read_stream_writer.send(session_message)
282-
return
271+
if response.status_code >= 400:
272+
# Read body for error detail
273+
await response.aread()
274+
body_text = response.text[:200] if response.text else ""
275+
error_msg = f"HTTP {response.status_code}: {body_text}" if body_text else f"HTTP {response.status_code}"
276+
if isinstance(message, JSONRPCRequest):
277+
error_data = ErrorData(
278+
code=INTERNAL_ERROR,
279+
message=error_msg,
280+
)
281+
session_message = SessionMessage(
282+
JSONRPCError(jsonrpc="2.0", id=message.id, error=error_data)
283+
)
284+
await ctx.read_stream_writer.send(session_message)
285+
return
283286

284-
if is_initialization:
285-
self._maybe_extract_session_id_from_response(response)
287+
if is_initialization:
288+
self._maybe_extract_session_id_from_response(response)
286289

287-
# Per https://modelcontextprotocol.io/specification/2025-06-18/basic#notifications:
288-
# The server MUST NOT send a response to notifications.
290+
# Per https://modelcontextprotocol.io/specification/2025-06-18/basic#notifications:
291+
# The server MUST NOT send a response to notifications.
292+
if isinstance(message, JSONRPCRequest):
293+
content_type = response.headers.get("content-type", "").lower()
294+
if content_type.startswith("application/json"):
295+
await self._handle_json_response(
296+
response, ctx.read_stream_writer, is_initialization, request_id=message.id
297+
)
298+
elif content_type.startswith("text/event-stream"):
299+
await self._handle_sse_response(response, ctx, is_initialization)
300+
else:
301+
logger.error(f"Unexpected content type: {content_type}")
302+
error_data = ErrorData(code=INVALID_REQUEST, message=f"Unexpected content type: {content_type}")
303+
error_msg = SessionMessage(JSONRPCError(jsonrpc="2.0", id=message.id, error=error_data))
304+
await ctx.read_stream_writer.send(error_msg)
305+
except Exception as exc:
306+
# Propagate connection/transport errors to the caller via the read stream
307+
# so they don't hang waiting for a response that will never arrive.
289308
if isinstance(message, JSONRPCRequest):
290-
content_type = response.headers.get("content-type", "").lower()
291-
if content_type.startswith("application/json"):
292-
await self._handle_json_response(
293-
response, ctx.read_stream_writer, is_initialization, request_id=message.id
294-
)
295-
elif content_type.startswith("text/event-stream"):
296-
await self._handle_sse_response(response, ctx, is_initialization)
297-
else:
298-
logger.error(f"Unexpected content type: {content_type}")
299-
error_data = ErrorData(code=INVALID_REQUEST, message=f"Unexpected content type: {content_type}")
300-
error_msg = SessionMessage(JSONRPCError(jsonrpc="2.0", id=message.id, error=error_data))
301-
await ctx.read_stream_writer.send(error_msg)
309+
error_data = ErrorData(code=INTERNAL_ERROR, message=str(exc))
310+
session_message = SessionMessage(
311+
JSONRPCError(jsonrpc="2.0", id=message.id, error=error_data)
312+
)
313+
with contextlib.suppress(Exception):
314+
await ctx.read_stream_writer.send(session_message)
315+
raise
302316

303317
async def _handle_json_response(
304318
self,

0 commit comments

Comments
 (0)