fix(mcp): name the HTTP status, URL and body behind "Server returned an error response"

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 <joaomarcosdias444@gmail.com>
This commit is contained in:
teknium1
2026-09-18 00:49:21 -07:00
committed by Teknium
parent 84c6e50339
commit f0c26f0554
5 changed files with 133 additions and 4 deletions

View File

@@ -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

View File

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

View File

@@ -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

View File

@@ -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

View File

@@ -800,6 +800,22 @@ npx --version
Then verify your config and restart Hermes.
### Remote (HTTP) server rejects the connection
`hermes mcp test <name>` 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: