Skip to content

Commit 91de13e

Browse files
committed
fix: parse standard json-rpc errors on http 400
1 parent 24dbc97 commit 91de13e

4 files changed

Lines changed: 127 additions & 69 deletions

File tree

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

Lines changed: 47 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -78,77 +78,25 @@ async def _send_request(
7878
async with self._session.post(
7979
url, json=payload, headers=req_headers
8080
) as response:
81-
if response.status == 400:
81+
json_resp = None
82+
if not response.ok:
8283
try:
8384
json_resp = await response.json()
84-
if "error" in json_resp:
85-
err_val = json_resp["error"]
86-
if isinstance(err_val, dict) and err_val.get("code") == -32022:
87-
server_supported = err_val.get("data", {}).get(
88-
"supported", []
89-
)
90-
91-
client_supported = (
92-
self._supported_protocols
93-
or Protocol.get_supported_mcp_versions()
94-
)
95-
mutually_supported = [
96-
v for v in client_supported if v in server_supported
97-
]
98-
99-
if mutually_supported:
100-
raise ProtocolNegotiationError(mutually_supported[0])
101-
else:
102-
raise RuntimeError(
103-
"No mutually supported protocol version. "
104-
f"Client supports: {client_supported}, "
105-
f"Server supports: {server_supported}"
106-
)
107-
elif (
108-
isinstance(err_val, str)
109-
and "invalid protocol version" in err_val.lower()
110-
):
111-
# Cascading Fallback: Legacy servers throw this string error.
112-
# We pick the next version from the user's supported list.
113-
client_supported = (
114-
self._supported_protocols
115-
or Protocol.get_supported_mcp_versions()
116-
)
117-
try:
118-
current_idx = client_supported.index(
119-
self._protocol_version
120-
)
121-
if current_idx + 1 < len(client_supported):
122-
raise ProtocolNegotiationError(
123-
client_supported[current_idx + 1]
124-
)
125-
else:
126-
raise RuntimeError(
127-
"Server threw 'invalid protocol version' but no fallback versions "
128-
"remain in the user's supported protocols array."
129-
)
130-
except ValueError:
131-
# Current version not in list somehow, just fallback to highest stateful
132-
raise ProtocolNegotiationError(Protocol.MCP_v20251125)
133-
except Exception as e:
134-
if isinstance(e, (RuntimeError, ProtocolNegotiationError)):
135-
raise e
136-
137-
if not response.ok:
138-
error_text = await response.text()
139-
raise RuntimeError(
140-
"API request failed with status"
141-
f" {response.status} ({response.reason}). Server response:"
142-
f" {error_text}"
143-
)
144-
145-
if response.status == 204 or response.content.at_eof():
146-
return None
147-
148-
json_resp = await response.json()
85+
except Exception:
86+
# Not JSON, fallback to raw text
87+
error_text = await response.text()
88+
raise RuntimeError(
89+
"API request failed with status"
90+
f" {response.status} ({response.reason}). Server response:"
91+
f" {error_text}"
92+
)
93+
else:
94+
if response.status == 204 or response.content.at_eof():
95+
return None
96+
json_resp = await response.json()
14997

15098
# Check for JSON-RPC Error
151-
if "error" in json_resp:
99+
if json_resp and isinstance(json_resp, dict) and "error" in json_resp:
152100
err_val = json_resp["error"]
153101
if isinstance(err_val, dict) and err_val.get("code") == -32022:
154102
server_supported = err_val.get("data", {}).get("supported", [])
@@ -167,6 +115,31 @@ async def _send_request(
167115
f"Client supports: {client_supported}, "
168116
f"Server supports: {server_supported}"
169117
)
118+
elif (
119+
isinstance(err_val, str)
120+
and "invalid protocol version" in err_val.lower()
121+
):
122+
# Cascading Fallback: Legacy servers throw this string error.
123+
# We pick the next version from the user's supported list.
124+
client_supported = (
125+
self._supported_protocols
126+
or Protocol.get_supported_mcp_versions()
127+
)
128+
try:
129+
current_idx = client_supported.index(self._protocol_version)
130+
if current_idx + 1 < len(client_supported):
131+
raise ProtocolNegotiationError(
132+
client_supported[current_idx + 1]
133+
)
134+
else:
135+
raise RuntimeError(
136+
"Server threw 'invalid protocol version' but no fallback versions "
137+
"remain in the user's supported protocols array."
138+
)
139+
except ValueError:
140+
# Current version not in list somehow, just fallback to highest stateful
141+
raise ProtocolNegotiationError(Protocol.MCP_v20251125)
142+
170143
try:
171144
err = types.JSONRPCError.model_validate(json_resp).error
172145
raise RuntimeError(
@@ -177,6 +150,13 @@ async def _send_request(
177150
raw_error = json_resp.get("error", {})
178151
raise RuntimeError(f"MCP request failed: {raw_error}")
179152

153+
# If response.ok was False, but there was no JSON-RPC "error" field
154+
if not response.ok:
155+
raise RuntimeError(
156+
f"API request failed with status {response.status} ({response.reason}). "
157+
f"Server response: {json_resp}"
158+
)
159+
180160
# Parse Result
181161
if isinstance(request, types.MCPRequest):
182162
try:

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -227,7 +227,9 @@ class TestRequest(types.MCPRequest[TestResult]):
227227
def get_result_model(self):
228228
return TestResult
229229

230-
with pytest.raises(RuntimeError, match="No mutually supported protocol version"):
230+
with pytest.raises(
231+
RuntimeError, match="No mutually supported protocol version"
232+
):
231233
await transport._send_request("url", TestRequest())
232234

233235
async def test_send_notification(self, transport):

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,9 @@ class TestRequest(types.MCPRequest[TestResult]):
245245
def get_result_model(self):
246246
return TestResult
247247

248-
with pytest.raises(RuntimeError, match="No mutually supported protocol version"):
248+
with pytest.raises(
249+
RuntimeError, match="No mutually supported protocol version"
250+
):
249251
await transport._send_request("url", TestRequest())
250252

251253
async def test_send_notification(self, transport):

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

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import pytest
1818
import pytest_asyncio
1919
from aiohttp import ClientSession
20+
from aioresponses import aioresponses
2021

2122
from toolbox_core.mcp_transport.v20260618 import types
2223
from toolbox_core.mcp_transport.v20260618.mcp import McpHttpTransportV20260618
@@ -365,3 +366,76 @@ async def test_tool_invoke_success(self, transport, mocker):
365366
)
366367
result = await transport.tool_invoke("tool", {}, {})
367368
assert result == "Result"
369+
370+
async def test_send_request_400_with_json_rpc_error(self, transport):
371+
# Test that an HTTP 400 with a non-negotiation JSON-RPC error is parsed properly.
372+
with aioresponses() as m:
373+
m.post(
374+
"http://test.com/mcp",
375+
status=400,
376+
payload={
377+
"jsonrpc": "2.0",
378+
"id": 1,
379+
"error": {"code": -32602, "message": "missing _meta"},
380+
},
381+
)
382+
with pytest.raises(RuntimeError) as exc_info:
383+
await transport._send_request(
384+
"http://test.com/mcp",
385+
types.JSONRPCRequest(method="test", params={}),
386+
)
387+
388+
assert "MCP request failed with code -32602" in str(exc_info.value)
389+
assert "missing _meta" in str(exc_info.value)
390+
391+
async def test_send_request_400_with_raw_text(self, transport):
392+
# Test that an HTTP 400 with non-JSON text is raised with the raw string payload.
393+
with aioresponses() as m:
394+
m.post(
395+
"http://test.com/mcp",
396+
status=400,
397+
body="<html/>",
398+
headers={"Content-Type": "text/html"},
399+
)
400+
with pytest.raises(RuntimeError) as exc_info:
401+
await transport._send_request(
402+
"http://test.com/mcp",
403+
types.JSONRPCRequest(method="test", params={}),
404+
)
405+
406+
assert "API request failed with status 400" in str(exc_info.value)
407+
assert "<html/>" in str(exc_info.value)
408+
409+
async def test_version_negotiation_legacy_string_fallback(self, transport):
410+
"""Tests that the client raises ProtocolNegotiationError when the server returns a string 'invalid protocol version' error."""
411+
from toolbox_core.exceptions import ProtocolNegotiationError
412+
413+
mock_response_reject = AsyncMock()
414+
mock_response_reject.ok = False
415+
mock_response_reject.status = 400
416+
mock_response_reject.json.return_value = {
417+
"jsonrpc": "2.0",
418+
"id": "1",
419+
"error": "invalid protocol version",
420+
}
421+
422+
transport._session.post.return_value.__aenter__.return_value = (
423+
mock_response_reject
424+
)
425+
426+
class TestResult(types.BaseModel):
427+
pass
428+
429+
class TestRequest(types.MCPRequest[TestResult]):
430+
method: str = "method"
431+
params: dict = {}
432+
433+
def get_result_model(self):
434+
return TestResult
435+
436+
# The fallback defaults to picking the next version in the supported list, or 2025-11-25.
437+
with pytest.raises(ProtocolNegotiationError) as exc_info:
438+
await transport._send_request("url", TestRequest())
439+
440+
assert exc_info.value.negotiated_version == Protocol.MCP_v20251125
441+
assert transport._session.post.call_count == 1

0 commit comments

Comments
 (0)