@@ -201,3 +201,62 @@ async def test_invalid_json_response_sends_jsonrpc_error() -> None:
201201
202202 with pytest .raises (MCPError , match = "Failed to parse JSON response" ): # pragma: no branch
203203 await session .list_tools ()
204+
205+
206+ def _create_non_2xx_json_body_app (status : int , body : bytes ) -> Starlette :
207+ """Server that returns a fixed non-2xx status + ``application/json`` body for non-init requests."""
208+
209+ async def handle_mcp_request (request : Request ) -> Response :
210+ data = json .loads (await request .body ())
211+ if data .get ("method" ) == "initialize" :
212+ return _init_json_response (data )
213+ if "id" not in data :
214+ return Response (status_code = 202 )
215+ return Response (content = body , status_code = status , media_type = "application/json" )
216+
217+ return Starlette (debug = True , routes = [Route ("/mcp" , handle_mcp_request , methods = ["POST" ])])
218+
219+
220+ async def test_client_surfaces_jsonrpc_error_from_non_2xx_body_with_correlated_id () -> None :
221+ """SDK-defined: a JSON-RPC error in a non-2xx body is surfaced verbatim even when the
222+ server set ``id: null`` — the client rewraps it under the pending request's id, so
223+ the awaiting call resolves with the server's error code instead of the generic fallback."""
224+ body = json .dumps (
225+ {"jsonrpc" : "2.0" , "id" : None , "error" : {"code" : types .METHOD_NOT_FOUND , "message" : "nope" }}
226+ ).encode ()
227+ app = _create_non_2xx_json_body_app (400 , body )
228+ async with httpx .AsyncClient (transport = httpx .ASGITransport (app = app )) as client :
229+ async with streamable_http_client ("http://localhost/mcp" , http_client = client ) as (read_stream , write_stream ):
230+ async with ClientSession (read_stream , write_stream ) as session : # pragma: no branch
231+ await session .initialize ()
232+ with pytest .raises (MCPError ) as exc :
233+ await session .list_tools ()
234+ assert exc .value .error .code == types .METHOD_NOT_FOUND
235+
236+
237+ async def test_client_falls_back_to_generic_error_when_non_2xx_body_is_a_jsonrpc_result () -> None :
238+ """SDK-defined: a non-2xx response whose JSON body parses as a JSON-RPC *result* (not an
239+ error) falls through to the generic ``INTERNAL_ERROR`` fallback rather than being
240+ treated as the request's reply."""
241+ app = _create_non_2xx_json_body_app (400 , b'{"jsonrpc":"2.0","id":1,"result":{}}' )
242+ async with httpx .AsyncClient (transport = httpx .ASGITransport (app = app )) as client :
243+ async with streamable_http_client ("http://localhost/mcp" , http_client = client ) as (read_stream , write_stream ):
244+ async with ClientSession (read_stream , write_stream ) as session : # pragma: no branch
245+ await session .initialize ()
246+ with pytest .raises (MCPError ) as exc :
247+ await session .list_tools ()
248+ assert exc .value .error .code == types .INTERNAL_ERROR
249+
250+
251+ async def test_client_falls_back_to_session_terminated_when_404_body_is_malformed_json () -> None :
252+ """SDK-defined: an unparseable ``application/json`` body on a 404 response is swallowed
253+ and the status-derived ``INVALID_REQUEST`` (session-terminated) fallback resolves the
254+ pending request — the parse failure never propagates."""
255+ app = _create_non_2xx_json_body_app (404 , b"not valid json{{{" )
256+ async with httpx .AsyncClient (transport = httpx .ASGITransport (app = app )) as client :
257+ async with streamable_http_client ("http://localhost/mcp" , http_client = client ) as (read_stream , write_stream ):
258+ async with ClientSession (read_stream , write_stream ) as session : # pragma: no branch
259+ await session .initialize ()
260+ with pytest .raises (MCPError ) as exc :
261+ await session .list_tools ()
262+ assert exc .value .error .code == types .INVALID_REQUEST
0 commit comments