Skip to content

Commit 4519c75

Browse files
committed
feat: implement automated MCP protocol version negotiation for old MCP versions
1 parent 745727c commit 4519c75

4 files changed

Lines changed: 172 additions & 0 deletions

File tree

packages/toolbox-core/src/toolbox_core/mcp_transport/v20241105/mcp.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,21 @@ async def _send_request(
6767

6868
# Check for JSON-RPC Error
6969
if "error" in json_resp:
70+
err_val = json_resp["error"]
71+
if isinstance(err_val, dict) and err_val.get("code") == -32022:
72+
server_supported = err_val.get("data", {}).get("supported", [])
73+
client_supported = self._supported_protocols
74+
mutually_supported = [
75+
v for v in client_supported if v in server_supported
76+
]
77+
if mutually_supported:
78+
raise ProtocolNegotiationError(mutually_supported[0])
79+
else:
80+
raise RuntimeError(
81+
"No mutually supported protocol version. "
82+
f"Client supports: {client_supported}, "
83+
f"Server supports: {server_supported}"
84+
)
7085
try:
7186
err = types.JSONRPCError.model_validate(json_resp).error
7287
raise RuntimeError(

packages/toolbox-core/src/toolbox_core/mcp_transport/v20250326/mcp.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,21 @@ async def _send_request(
8181

8282
# Check for JSON-RPC Error
8383
if "error" in json_resp:
84+
err_val = json_resp["error"]
85+
if isinstance(err_val, dict) and err_val.get("code") == -32022:
86+
server_supported = err_val.get("data", {}).get("supported", [])
87+
client_supported = self._supported_protocols
88+
mutually_supported = [
89+
v for v in client_supported if v in server_supported
90+
]
91+
if mutually_supported:
92+
raise ProtocolNegotiationError(mutually_supported[0])
93+
else:
94+
raise RuntimeError(
95+
"No mutually supported protocol version. "
96+
f"Client supports: {client_supported}, "
97+
f"Server supports: {server_supported}"
98+
)
8499
try:
85100
err = types.JSONRPCError.model_validate(json_resp).error
86101
raise RuntimeError(

packages/toolbox-core/tests/mcp_transport/test_v20241105.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,77 @@ def get_result_model(self):
159159
with pytest.raises(RuntimeError, match="MCP request failed"):
160160
await transport._send_request("url", TestRequest())
161161

162+
async def test_version_negotiation_raises_fallback_200_ok(self, transport):
163+
"""Tests that the client raises ProtocolNegotiationError when the server returns 200 OK with -32022."""
164+
from toolbox_core.exceptions import ProtocolNegotiationError
165+
166+
mock_response_reject = AsyncMock()
167+
mock_response_reject.ok = True
168+
mock_response_reject.status = 200
169+
mock_response_reject.content.at_eof = MagicMock(return_value=False)
170+
mock_response_reject.json.return_value = {
171+
"jsonrpc": "2.0",
172+
"id": "1",
173+
"error": {
174+
"code": -32022,
175+
"message": "Unsupported protocol version",
176+
"data": {"supported": ["DRAFT-2026-v1"]},
177+
},
178+
}
179+
180+
transport._session.post.return_value.__aenter__.return_value = (
181+
mock_response_reject
182+
)
183+
184+
class TestResult(types.BaseModel):
185+
pass
186+
187+
class TestRequest(types.MCPRequest[TestResult]):
188+
method: str = "method"
189+
params: dict = {}
190+
191+
def get_result_model(self):
192+
return TestResult
193+
194+
with pytest.raises(ProtocolNegotiationError) as exc_info:
195+
await transport._send_request("url", TestRequest())
196+
197+
assert exc_info.value.negotiated_version == "DRAFT-2026-v1"
198+
assert transport._session.post.call_count == 1
199+
200+
async def test_version_negotiation_empty_intersection(self, transport):
201+
"""Tests that the client errors immediately without retrying when there is no mutual version."""
202+
mock_response_reject = AsyncMock()
203+
mock_response_reject.ok = True
204+
mock_response_reject.status = 200
205+
mock_response_reject.content.at_eof = MagicMock(return_value=False)
206+
mock_response_reject.json.return_value = {
207+
"jsonrpc": "2.0",
208+
"id": "1",
209+
"error": {
210+
"code": -32022,
211+
"message": "Unsupported protocol version",
212+
"data": {"supported": ["UNSUPPORTED-VERSION"]},
213+
},
214+
}
215+
216+
transport._session.post.return_value.__aenter__.return_value = (
217+
mock_response_reject
218+
)
219+
220+
class TestResult(types.BaseModel):
221+
pass
222+
223+
class TestRequest(types.MCPRequest[TestResult]):
224+
method: str = "method"
225+
params: dict = {}
226+
227+
def get_result_model(self):
228+
return TestResult
229+
230+
with pytest.raises(RuntimeError, match="No mutually supported protocol version"):
231+
await transport._send_request("url", TestRequest())
232+
162233
async def test_send_notification(self, transport):
163234
mock_response = AsyncMock()
164235
mock_response.ok = True

packages/toolbox-core/tests/mcp_transport/test_v20250326.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,77 @@ def get_result_model(self):
177177
with pytest.raises(RuntimeError, match="MCP request failed"):
178178
await transport._send_request("url", TestRequest())
179179

180+
async def test_version_negotiation_raises_fallback_200_ok(self, transport):
181+
"""Tests that the client raises ProtocolNegotiationError when the server returns 200 OK with -32022."""
182+
from toolbox_core.exceptions import ProtocolNegotiationError
183+
184+
mock_response_reject = AsyncMock()
185+
mock_response_reject.ok = True
186+
mock_response_reject.status = 200
187+
mock_response_reject.content.at_eof = MagicMock(return_value=False)
188+
mock_response_reject.json.return_value = {
189+
"jsonrpc": "2.0",
190+
"id": "1",
191+
"error": {
192+
"code": -32022,
193+
"message": "Unsupported protocol version",
194+
"data": {"supported": ["DRAFT-2026-v1"]},
195+
},
196+
}
197+
198+
transport._session.post.return_value.__aenter__.return_value = (
199+
mock_response_reject
200+
)
201+
202+
class TestResult(types.BaseModel):
203+
pass
204+
205+
class TestRequest(types.MCPRequest[TestResult]):
206+
method: str = "method"
207+
params: dict = {}
208+
209+
def get_result_model(self):
210+
return TestResult
211+
212+
with pytest.raises(ProtocolNegotiationError) as exc_info:
213+
await transport._send_request("url", TestRequest())
214+
215+
assert exc_info.value.negotiated_version == "DRAFT-2026-v1"
216+
assert transport._session.post.call_count == 1
217+
218+
async def test_version_negotiation_empty_intersection(self, transport):
219+
"""Tests that the client errors immediately without retrying when there is no mutual version."""
220+
mock_response_reject = AsyncMock()
221+
mock_response_reject.ok = True
222+
mock_response_reject.status = 200
223+
mock_response_reject.content.at_eof = MagicMock(return_value=False)
224+
mock_response_reject.json.return_value = {
225+
"jsonrpc": "2.0",
226+
"id": "1",
227+
"error": {
228+
"code": -32022,
229+
"message": "Unsupported protocol version",
230+
"data": {"supported": ["UNSUPPORTED-VERSION"]},
231+
},
232+
}
233+
234+
transport._session.post.return_value.__aenter__.return_value = (
235+
mock_response_reject
236+
)
237+
238+
class TestResult(types.BaseModel):
239+
pass
240+
241+
class TestRequest(types.MCPRequest[TestResult]):
242+
method: str = "method"
243+
params: dict = {}
244+
245+
def get_result_model(self):
246+
return TestResult
247+
248+
with pytest.raises(RuntimeError, match="No mutually supported protocol version"):
249+
await transport._send_request("url", TestRequest())
250+
180251
async def test_send_notification(self, transport):
181252
mock_response = AsyncMock()
182253
mock_response.ok = True

0 commit comments

Comments
 (0)