Files
hermes-agent/tests/tools/test_mcp_protocol_negotiation.py
teknium1 9bd8257c02 fix(mcp): complete the handshake when a stateless server names a modern protocolVersion and lacks server/discover
A server that answers the legacy initialize with HTTP 200 but reports
2026-07-28 (regardless of the version offered) makes the SDK raise
'Unsupported protocol version from the server'. auto mode then falls back
to server/discover, which such a server rejects with a non-JSON 4xx, and
the connect died with 'both Streamable HTTP and SSE transports failed
(Streamable HTTP: Server returned an error response; SSE: 405)' even
though the same initialize/tools/list POSTs succeed from curl (#113359).

When both fail for that reason, re-run initialize ourselves, adopt the
result pinned to the version we offered (keeps later requests
legacy-shaped, which the handshake just proved the server accepts), send
notifications/initialized and proceed to tools/list.
2026-09-18 12:48:42 -07:00

188 lines
7.3 KiB
Python

"""MCP 2026-07-28 protocol-era negotiation (_negotiate_session).
The negotiation helper decides between the legacy ``initialize`` handshake
and the stateless ``server/discover`` probe (SEP-2575) per the per-server
``protocol`` config key. These tests drive it with duck-typed sessions —
the live-path integration is covered by the real-server E2E in the PR.
"""
import asyncio
import pytest
from tools.mcp_tool import MCPServerTask
from tools.mcp_tool_errors import _handshake_rejected_as_modern, _JSONRPC_UNSUPPORTED_PROTOCOL_VERSION
class _Err(Exception):
def __init__(self, code, msg="err"):
super().__init__(msg)
self.error = type("E", (), {"code": code})()
class _Session:
def __init__(self, init=None, disc=None):
self._init = init
self._disc = disc
self.calls = []
async def initialize(self):
self.calls.append("initialize")
if isinstance(self._init, Exception):
raise self._init
return self._init
async def discover(self):
self.calls.append("discover")
if isinstance(self._disc, Exception):
raise self._disc
return self._disc
class _LegacySession:
"""mcp 1.x sessions have no discover() attribute at all."""
def __init__(self, init=None):
self._init = init
self.calls = []
async def initialize(self):
self.calls.append("initialize")
if isinstance(self._init, Exception):
raise self._init
return self._init
def _task(protocol=None):
t = MCPServerTask("negotest")
t._config = {} if protocol is None else {"protocol": protocol}
return t
def _run(coro):
return asyncio.new_event_loop().run_until_complete(coro)
class TestAutoMode:
def test_handshake_first_no_discover_on_success(self):
s = _Session(init="INIT_RESULT")
out = _run(_task()._negotiate_session(s, 5))
assert out == "INIT_RESULT"
assert s.calls == ["initialize"]
def test_falls_back_to_discover_on_unsupported_protocol_version(self):
s = _Session(init=_Err(_JSONRPC_UNSUPPORTED_PROTOCOL_VERSION), disc="DISC_RESULT")
out = _run(_task()._negotiate_session(s, 5))
assert out == "DISC_RESULT"
assert s.calls == ["initialize", "discover"]
def test_falls_back_on_method_not_found(self):
s = _Session(init=_Err(-32601, "Method not found: initialize"), disc="DISC_RESULT")
out = _run(_task()._negotiate_session(s, 5))
assert out == "DISC_RESULT"
def test_unrelated_error_propagates_without_discover(self):
s = _Session(init=_Err(-32000, "borked"))
with pytest.raises(_Err):
_run(_task()._negotiate_session(s, 5))
assert s.calls == ["initialize"]
def test_timeout_propagates_not_swallowed(self):
class _Hang(_Session):
async def initialize(self):
await asyncio.sleep(30)
with pytest.raises(asyncio.TimeoutError):
_run(_task()._negotiate_session(_Hang(), 0.05))
class TestExplicitModes:
def test_stateless_probes_discover_first(self):
s = _Session(init="INIT_RESULT", disc="DISC_RESULT")
out = _run(_task("stateless")._negotiate_session(s, 5))
assert out == "DISC_RESULT"
assert s.calls == ["discover"]
def test_stateless_falls_back_to_handshake(self):
s = _Session(init="INIT_RESULT", disc=_Err(-32601))
out = _run(_task("stateless")._negotiate_session(s, 5))
assert out == "INIT_RESULT"
assert s.calls == ["discover", "initialize"]
def test_legacy_never_discovers(self):
s = _Session(init=_Err(_JSONRPC_UNSUPPORTED_PROTOCOL_VERSION), disc="DISC_RESULT")
with pytest.raises(_Err):
_run(_task("legacy")._negotiate_session(s, 5))
assert s.calls == ["initialize"]
def test_unknown_mode_treated_as_auto(self):
s = _Session(init="INIT_RESULT")
out = _run(_task("bogus")._negotiate_session(s, 5))
assert out == "INIT_RESULT"
def test_legacy_sdk_session_without_discover_reraises(self):
# mcp 1.x ClientSession has no .discover(): the auto fallback must
# re-raise the original handshake error, not AttributeError.
s = _LegacySession(init=_Err(_JSONRPC_UNSUPPORTED_PROTOCOL_VERSION))
with pytest.raises(_Err):
_run(_task()._negotiate_session(s, 5))
assert s.calls == ["initialize"]
class TestModernRejectionClassifier:
def test_structural_codes(self):
assert _handshake_rejected_as_modern(_Err(-32022))
assert _handshake_rejected_as_modern(_Err(-32601))
assert not _handshake_rejected_as_modern(_Err(-32000))
def test_substring_fallbacks(self):
assert _handshake_rejected_as_modern(Exception("Unsupported protocol version"))
assert _handshake_rejected_as_modern(Exception("Unknown method: initialize"))
assert not _handshake_rejected_as_modern(Exception("connection reset by peer"))
class _EchoingStatelessSession(_Session):
"""A stateless server that answers the legacy ``initialize`` 200 but names a protocolVersion
outside the SDK's handshake set (#113359): the SDK raises RuntimeError AFTER the wire exchange
succeeded, and the server has no ``server/discover``. Exposes the SDK's manual-handshake
surface (``send_request`` / ``adopt`` / ``send_notification``)."""
def __init__(self, disc):
super().__init__(init=RuntimeError("Unsupported protocol version from the server: 2026-07-28"), disc=disc)
self.adopted = None
async def send_request(self, request, result_type):
self.calls.append(("send_request", request.method))
import mcp.types as types
return types.InitializeResult(
protocolVersion="2026-07-28", capabilities=types.ServerCapabilities(tools=types.ToolsCapability()),
serverInfo=types.Implementation(name="StatelessServer", version="ESF"))
def adopt(self, result):
self.calls.append("adopt")
self.adopted = result
async def send_notification(self, notification):
self.calls.append(("notify", notification.method))
class TestModernVersionEchoedToLegacyHandshake:
"""#113359: a stateless server that accepts ``initialize`` but reports 2026-07-28 and 400s
``server/discover`` must still connect — the handshake succeeded on the wire."""
def test_auto_completes_the_handshake_at_the_offered_version_when_discover_fails(self):
from tools import mcp_tool as _core
s = _EchoingStatelessSession(disc=_Err(-32603, "Server returned an error response"))
out = _run(_task()._negotiate_session(s, 5))
assert out.capabilities.tools is not None
assert s.calls == ["initialize", "discover", ("send_request", "initialize"), "adopt",
("notify", "notifications/initialized")]
assert s.adopted.protocol_version == _core.LATEST_HANDSHAKE_VERSION # later requests stay legacy-shaped
def test_genuine_unsupported_version_error_never_re_handshakes(self):
s = _Session(init=_Err(_JSONRPC_UNSUPPORTED_PROTOCOL_VERSION, "unsupported protocol version"),
disc=_Err(-32603, "Server returned an error response"))
with pytest.raises(_Err, match="Server returned an error response"):
_run(_task()._negotiate_session(s, 5))
assert s.calls == ["initialize", "discover"]