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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user