From f0c26f0554c4fa89b7554b7fb76adbb4b12da7bd Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Fri, 18 Sep 2026 00:49:21 -0700 Subject: [PATCH] fix(mcp): name the HTTP status, URL and body behind "Server returned an error response" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mcp >= 2.0's Streamable HTTP client folds any non-2xx whose body it cannot parse as a JSON-RPC error into the opaque `-32603 Server returned an error response`. Hermes printed that text verbatim in the SSE-fallback warning and in the "both transports failed" ConnectionError, so users saw no status, no URL and none of the server's own words (e.g. `400 {"code":-32020,"message":"Unsupported MCP-Protocol-Version"}`) and had to reach for curl to learn what the server actually said (#114350, #113359). - `_make_http_rejection_recorder`: response hook on the owned SDK-httpx client that remembers the last 4xx/5xx (status, method, URL, head of the body; SSE bodies are never read). Sibling of the redirect-header stripper hook. - `_describe_http_failure`: appends that detail only when the root cause is the SDK's opaque -32603 text, so a real JSON-RPC error or an httpx status error is never duplicated. - `_run_http`: the fallback warning and both raise paths (both-transports ConnectionError; the no-fallback re-raise after a proven session, strict redirect headers or a non-rejection) carry the detail. Debug-logs the endpoint each connect attempt uses. - Docs: troubleshooting entry for reading the new message. Verified live against a real Streamable HTTP server (`hermes mcp test`, temp HERMES_HOME): base prints `Streamable HTTP: Server returned an error response`; fixed head prints `... (HTTP 400 from POST http://127.0.0.1:PORT/mcp: {"jsonrpc":"2.0","id":null,"error":{"code":-32020,...}})`. Control: a 400 served as application/json already surfaces the JSON-RPC message and gets no appendix; servers negotiating `initialize` down to 2025-06-18 (fixture and a real FastMCP on mcp 1.12.4) connect and list tools on base and fix alike — the pinned mcp 2.0.0 stamps the negotiated version on every post-handshake request (wire-recorded), so the sticky seed in `_run_http` is not the cause of the reported 400 and stays as designed (#14816). Co-authored-by: JoaoMarcos44 --- tests/tools/test_mcp_sse_fallback.py | 58 +++++++++++++++++++++++++ tools/mcp_tool.py | 3 ++ tools/mcp_tool_errors.py | 43 ++++++++++++++++++ tools/mcp_tool_transport.py | 17 ++++++-- website/docs/user-guide/features/mcp.md | 16 +++++++ 5 files changed, 133 insertions(+), 4 deletions(-) diff --git a/tests/tools/test_mcp_sse_fallback.py b/tests/tools/test_mcp_sse_fallback.py index 16b5b957b2..cc5433bdb8 100644 --- a/tests/tools/test_mcp_sse_fallback.py +++ b/tests/tools/test_mcp_sse_fallback.py @@ -84,3 +84,61 @@ def test_both_transports_failing_names_both_and_suggests_config(monkeypatch): asyncio.run(task._run_http(dict(_CONFIG))) assert calls == ["HTTP", "SSE"] or calls == ["legacy HTTP", "SSE"] assert task._sse_fallback is False # failed fallback must not latch + + +def test_opaque_sdk_rejection_is_reported_with_the_servers_status_and_body(monkeypatch, caplog): + """mcp >= 2.0 folds a non-JSON 4xx into ``-32603 Server returned an error response``; the + warning and the both-transports ConnectionError must still name the HTTP status, the URL that + was requested and the body the server sent (#114350, #113359). A root that already carries the + status (httpx ``HTTPStatusError``) is left alone — no duplicated detail.""" + from tools.mcp_tool_errors import _make_http_rejection_recorder + from tools.mcp_tool import sdk_httpx + + httpx2 = sdk_httpx() + body = '{"jsonrpc":"2.0","error":{"code":-32020,"message":"Unsupported MCP-Protocol-Version"}}' + + async def _real_client_roundtrip(status, content_type): + """The recorder on a real SDK-httpx client, through the streaming API the SDK uses; the + SDK's own later ``aread()`` must still see the bytes.""" + sink: dict = {} + transport = httpx2.MockTransport( + lambda req: httpx2.Response(status, text=body, headers={"content-type": content_type})) + async with httpx2.AsyncClient(transport=transport, event_hooks={ + "response": [_make_http_rejection_recorder(sink)]}) as client: + async with client.stream("POST", "http://127.0.0.1:1/mcp", json={}) as resp: + assert (await resp.aread()).decode() == body + return sink + + assert asyncio.run(_real_client_roundtrip(200, "application/json")) == {} # 2xx: nothing recorded + recorded = asyncio.run(_real_client_roundtrip(400, "text/plain; charset=utf-8")) + assert recorded == {"status": 400, "method": "POST", "url": "http://127.0.0.1:1/mcp", "body": body} + + def _connect_sees(rejection): + task, _calls = _task(monkeypatch, ExceptionGroup("g", [_SdkInternalError()]), + sse_exc=ConnectionRefusedError("no sse")) + monkeypatch.setattr(MCPServerTask, "_streamable_http_transport", + lambda self, *a, **k: self._http_rejection.update(rejection) or object()) + with pytest.raises(ConnectionError) as info: + asyncio.run(task._run_http(dict(_CONFIG))) + return str(info.value) + + with caplog.at_level("WARNING", logger="tools.mcp_tool"): + message = _connect_sees(recorded) + detail = "Server returned an error response (HTTP 400 from POST http://127.0.0.1:1/mcp: " + body + ")" + assert detail in message and "SSE: no sse" in message + assert any(detail in rec.getMessage() for rec in caplog.records), caplog.text + + # No rejection observed (the hook never fired): the SDK text stands alone, no fabricated detail. + assert "Streamable HTTP: Server returned an error response; SSE" in _connect_sees({}) + + +def test_opaque_rejection_without_fallback_surfaces_the_status(monkeypatch): + """After a proven session the SSE fallback is off; the opaque SDK error still leaves ``_run_http`` + naming the recorded rejection instead of the bare ``Server returned an error response``.""" + task, calls = _task(monkeypatch, ExceptionGroup("g", [_SdkInternalError()])) + task._ever_connected = True + monkeypatch.setattr(MCPServerTask, "_streamable_http_transport", lambda self, *a, **k: self._http_rejection.update( + status=503, method="POST", url="http://127.0.0.1:1/mcp", body="upstream down") or object()) + with pytest.raises(ConnectionError, match=r"HTTP 503 from POST http://127\.0\.0\.1:1/mcp: upstream down"): + asyncio.run(task._run_http(dict(_CONFIG))) + assert "SSE" not in calls diff --git a/tools/mcp_tool.py b/tools/mcp_tool.py index 69fee3176c..b4abae11ef 100644 --- a/tools/mcp_tool.py +++ b/tools/mcp_tool.py @@ -348,6 +348,9 @@ class MCPServerTask(MCPServerRunMixin, MCPServerTransportMixin, MCPServerHealthM self._ever_connected: bool = False # Latched when the Streamable HTTP -> SSE fallback connects: reconnects reuse SSE directly. self._sse_fallback: bool = False + # Status/URL/body of the last HTTP rejection the Streamable HTTP client saw; names the real + # cause when the SDK reports only ``Server returned an error response``. + self._http_rejection: dict = {} # True from park until proven healthy again; logs the revival once. self._was_parked: bool = False # Why the server is parked (the revival_reason handed to _park), None once healthy again. diff --git a/tools/mcp_tool_errors.py b/tools/mcp_tool_errors.py index d74bdbb050..2052d99afd 100644 --- a/tools/mcp_tool_errors.py +++ b/tools/mcp_tool_errors.py @@ -82,6 +82,49 @@ def _is_streamable_http_rejection(exc: BaseException) -> bool: return code == -32603 and "server returned an error response" in str(root).lower() +_HTTP_REJECTION_BODY_CHARS = 300 + + +def _make_http_rejection_recorder(sink: dict): + """httpx response hook for the owned Streamable HTTP client: remembers the last 4xx/5xx the server + sent (status, method, URL, head of the body). mcp >= 2.0 folds a non-2xx whose body it cannot + parse as a JSON-RPC error into the opaque ``-32603 Server returned an error response`` — the + status and the server's own words (e.g. ``400 {"code":-32020,"message":"Unsupported + MCP-Protocol-Version"}``) never reach the exception, so this is the only place they can be + observed. SSE bodies are never read (a stream would block the hook).""" + + async def _record(response): + if response.status_code < 400: + return + body = "" + if response.headers.get("content-type", "").split(";")[0].strip().lower() != "text/event-stream": + try: + raw = await response.aread() # buffered: the SDK's own aread() afterwards sees the same bytes + body = " ".join(raw[:_HTTP_REJECTION_BODY_CHARS * 4].decode("utf-8", "replace").split()) + except Exception: # the failure itself is still reported, just without the body + body = "" + sink.update(status=response.status_code, method=response.request.method, + url=str(response.request.url), body=body[:_HTTP_REJECTION_BODY_CHARS]) + + return _record + + +def _describe_http_failure(exc: BaseException, rejection: dict) -> str: + """``str(root cause)`` of a Streamable HTTP connect failure; when that root is the SDK's opaque + ``-32603 Server returned an error response`` and the recorder saw the rejection, the HTTP status, + request URL and body head are appended so the message names what the server actually said.""" + root = _unwrap_exception_group(exc) + text = str(root) + opaque = (getattr(getattr(root, "error", None), "code", None) == -32603 + and "server returned an error response" in text.lower()) + if not (opaque and rejection): + return text + detail = f"HTTP {rejection['status']} from {rejection['method']} {rejection['url']}" + if rejection["body"]: + detail += f": {rejection['body']}" + return f"{text} ({detail})" + + def _unwrap_exception_group(exc: BaseException) -> BaseException: """Root-cause leaf of anyio ``(Base)ExceptionGroup`` wrappers (group ``str()`` is opaque). A ``KeyboardInterrupt``/``SystemExit`` leaf anywhere is re-raised, never flattened into a loggable diff --git a/tools/mcp_tool_transport.py b/tools/mcp_tool_transport.py index 1c4372f180..835a2bfe65 100644 --- a/tools/mcp_tool_transport.py +++ b/tools/mcp_tool_transport.py @@ -12,7 +12,7 @@ from typing import Dict, Optional, Set from utils import normalize_proxy_url from agent.proxy_bypass import is_loopback_host, should_bypass_proxy from agent import runtime_cwd as _runtime_cwd -from tools.mcp_tool_errors import NonMcpEndpointError, _apply_identity_header, _handshake_rejected_as_modern, _is_streamable_http_rejection, _make_mcp_body_cap_transport, _make_redirect_header_stripper, _resolve_client_cert, _unwrap_exception_group +from tools.mcp_tool_errors import NonMcpEndpointError, _apply_identity_header, _describe_http_failure, _handshake_rejected_as_modern, _is_streamable_http_rejection, _make_http_rejection_recorder, _make_mcp_body_cap_transport, _make_redirect_header_stripper, _resolve_client_cert, _unwrap_exception_group from tools.mcp_tool_lifecycle import _filter_mcp_children, _orphan_stdio_pid_servers, _orphan_stdio_pids, _stdio_pgids, _stdio_pids from tools.mcp_tool_common import _core from tools import mcp_tool_config as _config @@ -442,7 +442,8 @@ class MCPServerTransportMixin: inner_transport = httpx.AsyncHTTPTransport(verify=ssl_verify, **_present(cert=client_cert)) client_kwargs: dict = {"follow_redirects": True, "timeout": httpx.Timeout(float(connect_timeout), read=300.0), **({"headers": headers} if headers else {}), - "event_hooks": {"response": [_strip_auth_on_cross_origin_redirect]}, + "event_hooks": {"response": [_strip_auth_on_cross_origin_redirect, + _make_http_rejection_recorder(self._http_rejection)]}, "transport": _make_mcp_body_cap_transport(httpx, inner_transport), **_present(mounts=_mcp_proxy_mounts(httpx, url, ssl_verify, client_cert, self.name), auth=oauth_auth)} @@ -462,6 +463,8 @@ class MCPServerTransportMixin: "mcp.client.streamable_http is not available. " "Upgrade the mcp package to get HTTP support.") url = config["url"] + logger.debug("MCP server '%s': connecting to %s", self.name, url) + self._http_rejection = {} # last 4xx/5xx the owned client saw this attempt (recorder hook) headers = dict(config.get("headers") or {}) # Agent Plugins v1 strict_redirect_headers: configured headers MUST NOT follow a cross-origin # redirect — capture their names BEFORE client-generated headers are merged in. @@ -486,6 +489,9 @@ class MCPServerTransportMixin: try: return await self._serve_transport(transport, label, float(connect_timeout)) except Exception as exc: + # The SDK folds a non-2xx it cannot parse into ``-32603 Server returned an error response``; + # the recorder hook kept the status/URL/body the server actually sent (#114350, #113359). + http_detail = _describe_http_failure(exc, self._http_rejection) # SSE-only servers (or their load balancers) reject the Streamable HTTP chunked # ``initialize`` POST — with a 400-family status or an opaque SDK INTERNAL_ERROR — # previously a permanent failure with 0 active tools unless the user set @@ -496,12 +502,15 @@ class MCPServerTransportMixin: # transport mismatch — ``_is_streamable_http_rejection`` matches neither), and never # with ``strict_redirect_headers`` (SSE cannot enforce that boundary). if (self._ever_connected or common[-1] or not _is_streamable_http_rejection(exc)): + if http_detail != str(_unwrap_exception_group(exc)): # opaque SDK error + a recorded rejection + raise ConnectionError(f"MCP server '{self.name}': Streamable HTTP connect failed " + f"({http_detail})") from exc raise logger.warning( "MCP server '%s': Streamable HTTP rejected the initial connect (%s) — retrying " "over SSE. If this connects, set `transport: sse` for this server in config.yaml " "to skip the failed attempt on future startups.", - self.name, _unwrap_exception_group(exc)) + self.name, http_detail) try: self._sse_fallback = True return await self._serve_transport(self._sse_transport(*common), "SSE", float(connect_timeout)) @@ -511,7 +520,7 @@ class MCPServerTransportMixin: self._sse_fallback = False raise ConnectionError( f"MCP server '{self.name}': both Streamable HTTP and SSE transports failed " - f"(Streamable HTTP: {_unwrap_exception_group(exc)}; SSE: " + f"(Streamable HTTP: {http_detail}; SSE: " f"{_unwrap_exception_group(sse_exc)}). Check the URL points at an MCP " "endpoint, or pin `transport: sse` if the server is SSE-only.") from sse_exc diff --git a/website/docs/user-guide/features/mcp.md b/website/docs/user-guide/features/mcp.md index 3dfb377f26..fced6aaf4e 100644 --- a/website/docs/user-guide/features/mcp.md +++ b/website/docs/user-guide/features/mcp.md @@ -800,6 +800,22 @@ npx --version Then verify your config and restart Hermes. +### Remote (HTTP) server rejects the connection + +`hermes mcp test ` reports what the server actually answered. When the MCP SDK can only say +`Server returned an error response` (a 4xx/5xx whose body is not a JSON-RPC error), Hermes appends +the HTTP status, the URL it requested and the start of the response body: + +``` +Streamable HTTP: Server returned an error response (HTTP 400 from POST http://host:27200/mcp: +{"jsonrpc":"2.0","error":{"code":-32020,"message":"Unsupported MCP-Protocol-Version"}}) +``` + +Read the status and body first: a `400`/`405` on the `initialize` POST usually means the endpoint +speaks SSE only (set `transport: sse`) or a proxy in front of it rejects the request; a `401`/`403` +means the token or OAuth grant is wrong; an HTML body means the URL points at a web page, not an MCP +endpoint. `hermes logs --level debug` additionally shows the exact endpoint each connect attempt used. + ### Tools not appearing Possible causes: