Google's hosted Gmail/Drive MCP servers advertise no registration_endpoint
in their ASM, so the mcp SDK's create_client_registration_request falls
back to guessing POST {server_origin}/register. Those servers answer with
an opaque 404 (OAuthRegistrationError: Registration failed: 404) and the
gateway burns the reconnect ladder on an unrecoverable failure while the
CLI looks healthy because tools/list is unauthenticated on Google servers.
- Guard: HermesMCPOAuthProvider intercepts the fabricated registration
POST in the bidirectional auth_flow bridge and raises an actionable
OAuthRegistrationError (create an OAuth client, add oauth.client_id/
client_secret under mcp_servers.<name>.oauth in config.yaml) instead of
sending the request. Fires only when the URL is the exact SDK fallback
AND the ASM lacks a registration_endpoint, so servers advertising a
real RFC 7591 endpoint pass through untouched.
- Surfacing: needs_reauth tool errors now include the registration
guidance, and the generic 'MCP call failed' path unwraps TaskGroup
exception groups so the root cause reaches the model instead of
'unhandled errors in a TaskGroup (1 sub-exception)'.
- humanize_oauth_registration_error: 404 registration failures get the
same pre-registered-client guidance as 403 (CLI login path).
Tests: full discovery dance (401 -> PRM -> ASM without registration_endpoint
-> fabricated POST) drives the bridge and asserts the guard raises before
any network send; humanize 404 + passthrough cases; _auth_error_detail and
group unwrap surfacing.
Rebuilt on current main, where mcp_tool.py was decomposed and mcp 2.0
rides on httpx2: the surfacing helpers live in tools/mcp_tool_errors.py
and feed the shared _dispatch / _NEEDS_REAUTH_MSG paths in
tools/mcp_tool_handlers.py; the guard compares with the request's own URL
type and only fires once ASM was actually discovered (an undiscovered
ASM keeps main's discovery-context error, #113771), and closes the inner
SDK generator before raising so context.lock is released immediately.
329 lines
12 KiB
Python
329 lines
12 KiB
Python
"""Tests for exception-group unwrapping and failure classification in
|
|
``tools/mcp_tool.py`` (#65673, #66092).
|
|
|
|
The MCP SDK's anyio TaskGroups wrap real errors in ``BaseExceptionGroup``,
|
|
whose ``str()`` is "unhandled errors in a TaskGroup (N sub-exceptions)" —
|
|
useless in logs. ``_unwrap_exception_group`` digs out the root cause;
|
|
``_classify_mcp_failure`` decides whether a failure is worth retrying.
|
|
"""
|
|
|
|
import asyncio
|
|
import errno
|
|
import logging
|
|
|
|
import pytest
|
|
|
|
from tools.mcp_tool_errors import (
|
|
InvalidMcpUrlError, NonMcpEndpointError, _auth_error_detail, _classify_mcp_failure, _mcp_call_failed_message,
|
|
_unwrap_exception_group)
|
|
from tools.mcp_tool import MCPServerTask
|
|
|
|
|
|
def _group(*excs, msg="unhandled errors in a TaskGroup") -> BaseExceptionGroup:
|
|
return BaseExceptionGroup(msg, list(excs))
|
|
|
|
|
|
# ── _unwrap_exception_group ──────────────────────────────────────────────────
|
|
|
|
class TestUnwrapExceptionGroup:
|
|
def test_plain_exception_passes_through(self):
|
|
exc = ConnectionError("boom")
|
|
assert _unwrap_exception_group(exc) is exc
|
|
|
|
def test_single_level_group(self):
|
|
inner = BrokenPipeError()
|
|
assert _unwrap_exception_group(_group(inner)) is inner
|
|
|
|
|
|
def test_system_exit_reraises(self):
|
|
with pytest.raises(SystemExit):
|
|
_unwrap_exception_group(_group(SystemExit(2)))
|
|
|
|
|
|
# ── _classify_mcp_failure ────────────────────────────────────────────────────
|
|
|
|
class TestClassifyMcpFailure:
|
|
@pytest.mark.parametrize("exc", [
|
|
ConnectionError("connection refused"),
|
|
ConnectionResetError("reset by peer"),
|
|
BrokenPipeError(),
|
|
EOFError(),
|
|
TimeoutError("read timeout"),
|
|
OSError(errno.ECONNRESET, "reset"),
|
|
RuntimeError("something odd"),
|
|
])
|
|
def test_transient_failures(self, exc):
|
|
assert _classify_mcp_failure(exc) == "transient"
|
|
|
|
|
|
def test_closed_resource_transient(self):
|
|
anyio = pytest.importorskip("anyio")
|
|
assert _classify_mcp_failure(anyio.ClosedResourceError()) == "transient"
|
|
|
|
|
|
def test_permanent_inside_taskgroup(self):
|
|
# Classification must apply to the UNWRAPPED root cause.
|
|
g = _group(_group(FileNotFoundError("cmd not found")))
|
|
assert _classify_mcp_failure(g) == "permanent"
|
|
|
|
|
|
# ── _auth_error_detail / _mcp_call_failed_message ────────────────────────────
|
|
|
|
class TestAuthErrorSurfacing:
|
|
def test_registration_detail_surfaces(self):
|
|
"""The actionable registration guidance raised by the DCR guard must
|
|
reach the needs_reauth tool error (GH#78190)."""
|
|
pytest.importorskip("mcp.client.auth")
|
|
from mcp.client.auth import OAuthRegistrationError
|
|
|
|
exc = OAuthRegistrationError(
|
|
"MCP OAuth 'gmail': this provider does not support automatic "
|
|
"client registration ... add it under config.yaml "
|
|
"mcp_servers.<name>.oauth (client_id, client_secret), then run "
|
|
"`hermes mcp login gmail`."
|
|
)
|
|
detail = _auth_error_detail(exc)
|
|
assert detail.startswith(" ")
|
|
assert "config.yaml" in detail
|
|
assert "client_id" in detail
|
|
|
|
def test_registration_detail_unwraps_group(self):
|
|
"""The transport raises the auth error inside a TaskGroup wrapper —
|
|
the detail must still be found."""
|
|
pytest.importorskip("mcp.client.auth")
|
|
from mcp.client.auth import OAuthRegistrationError
|
|
|
|
exc = OAuthRegistrationError("config.yaml guidance")
|
|
assert "config.yaml" in _auth_error_detail(_group(exc))
|
|
|
|
def test_non_registration_no_detail(self):
|
|
assert _auth_error_detail(ValueError("nope")) == ""
|
|
assert _auth_error_detail(_group(TimeoutError("t"))) == ""
|
|
|
|
def test_mcp_call_failed_message_unwraps_group(self):
|
|
"""The generic error path must surface the group root cause instead
|
|
of the opaque TaskGroup wrapper text."""
|
|
pytest.importorskip("mcp.client.auth")
|
|
from mcp.client.auth import OAuthRegistrationError
|
|
|
|
exc = OAuthRegistrationError("registration guidance text")
|
|
msg = _mcp_call_failed_message(_group(exc))
|
|
assert "OAuthRegistrationError" in msg
|
|
assert "registration guidance text" in msg
|
|
assert "TaskGroup" not in msg
|
|
|
|
def test_needs_reauth_tool_error_carries_guidance(self, monkeypatch):
|
|
"""Through the real recovery handler: an unrecoverable registration
|
|
failure returns needs_reauth WITH the guard's guidance."""
|
|
pytest.importorskip("mcp.client.auth")
|
|
import json
|
|
from mcp.client.auth import OAuthRegistrationError
|
|
from tools import mcp_tool_handlers as handlers
|
|
|
|
monkeypatch.setattr(handlers._loop, "_run_on_mcp_loop", lambda *_a, **_k: False)
|
|
out = json.loads(handlers._handle_auth_error_and_retry(
|
|
"gmail", OAuthRegistrationError("Create an OAuth client (config.yaml)."),
|
|
lambda: None, "call_tool"))
|
|
assert out["needs_reauth"] is True
|
|
assert "requires re-authentication. Create an OAuth client (config.yaml). Run" in out["error"]
|
|
|
|
|
|
# ── Keepalive failure log surfaces the root cause ────────────────────────────
|
|
|
|
@pytest.mark.no_isolate
|
|
def test_keepalive_failure_logs_root_cause(monkeypatch, tmp_path, caplog):
|
|
"""A keepalive that dies with a TaskGroup-wrapped BrokenPipeError (empty
|
|
str) must log 'BrokenPipeError', not 'unhandled errors in a TaskGroup'."""
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
|
|
from tools import mcp_tool
|
|
|
|
class _Task(MCPServerTask):
|
|
async def _keepalive_probe(self):
|
|
raise _group(BrokenPipeError())
|
|
|
|
task = _Task("pipey")
|
|
task._config = {"keepalive_interval": 0.01}
|
|
task.session = object()
|
|
|
|
monkeypatch.setattr(mcp_tool, "_MIN_KEEPALIVE_INTERVAL", 0.01)
|
|
|
|
async def _scenario():
|
|
with caplog.at_level(logging.WARNING, logger="tools.mcp_tool"):
|
|
reason = await task._wait_for_lifecycle_event()
|
|
assert reason == "reconnect"
|
|
|
|
asyncio.run(_scenario())
|
|
|
|
keepalive_logs = [
|
|
r.getMessage() for r in caplog.records if "keepalive failed" in r.getMessage()
|
|
]
|
|
assert keepalive_logs, "keepalive failure was not logged"
|
|
assert any("BrokenPipeError" in m for m in keepalive_logs), keepalive_logs
|
|
assert not any("unhandled errors in a TaskGroup" in m for m in keepalive_logs)
|
|
|
|
|
|
# ── run() parks permanent failures immediately ───────────────────────────────
|
|
|
|
@pytest.mark.no_isolate
|
|
def test_permanent_failure_parks_without_retry_ladder(monkeypatch, tmp_path, caplog):
|
|
"""A stdio command that doesn't exist (FileNotFoundError) must park after
|
|
ONE attempt — not burn _MAX_INITIAL_CONNECT_RETRIES identical warnings."""
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
|
|
from tools import mcp_tool
|
|
|
|
_real_sleep = asyncio.sleep
|
|
|
|
async def _fast_sleep(_delay, *a, **kw):
|
|
await _real_sleep(0)
|
|
|
|
monkeypatch.setattr(mcp_tool.asyncio, "sleep", _fast_sleep)
|
|
|
|
state = {"transport_calls": 0, "parked": False}
|
|
|
|
async def _scenario():
|
|
class _Task(MCPServerTask):
|
|
def _is_http(self):
|
|
return False
|
|
|
|
def _deregister_tools(self):
|
|
state["parked"] = True
|
|
self._registered_tool_names = []
|
|
|
|
async def _run_stdio(self, config):
|
|
state["transport_calls"] += 1
|
|
raise FileNotFoundError("nonexistent-mcp-command")
|
|
|
|
task = _Task("missing-cmd")
|
|
|
|
with caplog.at_level(logging.DEBUG, logger="tools.mcp_tool"):
|
|
run_task = asyncio.ensure_future(task.run({"command": "nope"}))
|
|
for _ in range(500):
|
|
await _real_sleep(0)
|
|
if state["parked"]:
|
|
break
|
|
|
|
assert state["parked"], "permanent failure never parked"
|
|
assert state["transport_calls"] == 1, (
|
|
f"permanent failure burned {state['transport_calls']} attempts — "
|
|
"should park immediately"
|
|
)
|
|
|
|
task._shutdown_event.set()
|
|
task._reconnect_event.set()
|
|
try:
|
|
await asyncio.wait_for(run_task, timeout=15)
|
|
except (asyncio.TimeoutError, asyncio.CancelledError, Exception):
|
|
run_task.cancel()
|
|
|
|
asyncio.run(_scenario())
|
|
|
|
park_warnings = [
|
|
r for r in caplog.records
|
|
if r.levelno == logging.WARNING and "permanent error" in r.getMessage()
|
|
]
|
|
assert len(park_warnings) == 1
|
|
assert "FileNotFoundError" in park_warnings[0].getMessage()
|
|
|
|
|
|
# ── An initial 401 must stay revivable ───────────────────────────────────────
|
|
|
|
@pytest.mark.no_isolate
|
|
def test_initial_auth_failure_parks_and_revives_after_relogin(
|
|
monkeypatch, tmp_path, caplog,
|
|
):
|
|
"""A 401 on the FIRST connect must park, not end the run task.
|
|
|
|
Ending the task drops the only listener on ``_reconnect_event``, so the
|
|
server stayed dead for the life of the process even after the user
|
|
re-authenticated. Parking keeps it revivable via the self-probe.
|
|
"""
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
|
|
httpx = pytest.importorskip("httpx")
|
|
|
|
from tools import mcp_tool
|
|
|
|
monkeypatch.setattr(mcp_tool, "_PARKED_RETRY_INTERVAL", 0.05)
|
|
|
|
from tools import mcp_tool_config as _config
|
|
monkeypatch.setattr(_config, "_load_mcp_config", lambda: {"figma": {"command": "x"}})
|
|
|
|
_real_sleep = asyncio.sleep
|
|
|
|
async def _fast_sleep(_delay, *a, **kw):
|
|
await _real_sleep(0)
|
|
|
|
monkeypatch.setattr(mcp_tool.asyncio, "sleep", _fast_sleep)
|
|
|
|
def _auth_error():
|
|
request = httpx.Request("POST", "https://mcp.example.test/mcp")
|
|
response = httpx.Response(401, request=request)
|
|
return httpx.HTTPStatusError("401", request=request, response=response)
|
|
|
|
state = {"transport_calls": 0, "parked": False, "authenticated": False}
|
|
|
|
async def _scenario():
|
|
class _Task(MCPServerTask):
|
|
def _is_http(self):
|
|
return False
|
|
|
|
def _deregister_tools(self):
|
|
state["parked"] = True
|
|
self._registered_tool_names = []
|
|
|
|
async def _run_stdio(self, config):
|
|
state["transport_calls"] += 1
|
|
if not state["authenticated"]:
|
|
raise _group(_auth_error())
|
|
self.session = object()
|
|
await self._wait_for_lifecycle_event()
|
|
|
|
task = _Task("figma")
|
|
|
|
with caplog.at_level(logging.DEBUG, logger="tools.mcp_tool"):
|
|
run_task = asyncio.ensure_future(task.run({"command": "x"}))
|
|
for _ in range(500):
|
|
await _real_sleep(0)
|
|
if state["parked"]:
|
|
break
|
|
|
|
assert state["parked"], "auth failure never parked"
|
|
assert state["transport_calls"] == 1, (
|
|
f"auth failure burned {state['transport_calls']} attempts"
|
|
)
|
|
assert not run_task.done(), (
|
|
"run task exited on a 401 — the server is now unrevivable"
|
|
)
|
|
|
|
# The user re-authenticates. Nothing sets _reconnect_event:
|
|
# revival must come from the timed self-probe alone.
|
|
state["authenticated"] = True
|
|
for _ in range(200):
|
|
await _real_sleep(0.01)
|
|
if task.session is not None:
|
|
break
|
|
|
|
assert task.session is not None, (
|
|
"parked server never recovered after re-authentication "
|
|
f"(transport_calls={state['transport_calls']})"
|
|
)
|
|
|
|
task._shutdown_event.set()
|
|
task._reconnect_event.set()
|
|
try:
|
|
await asyncio.wait_for(run_task, timeout=15)
|
|
except (asyncio.TimeoutError, asyncio.CancelledError, Exception):
|
|
run_task.cancel()
|
|
|
|
asyncio.run(_scenario())
|
|
|
|
auth_warnings = [
|
|
r for r in caplog.records
|
|
if r.levelno == logging.WARNING
|
|
and "failed initial authentication" in r.getMessage()
|
|
]
|
|
assert len(auth_warnings) == 1
|
|
assert "hermes mcp login figma" in auth_warnings[0].getMessage()
|