66
77import json
88
9- import httpx
9+ import httpx2
10+ import mcp_types as types
1011import pytest
12+ from mcp_types import RootsListChangedNotification
1113from starlette .applications import Starlette
1214from starlette .requests import Request
1315from starlette .responses import JSONResponse , Response
1416from starlette .routing import Route
1517
16- from mcp import ClientSession , MCPError , types
18+ from mcp import ClientSession , MCPError
1719from mcp .client .streamable_http import streamable_http_client
1820from mcp .shared .session import RequestResponder
19- from mcp .types import RootsListChangedNotification
2021
2122pytestmark = pytest .mark .anyio
2223
@@ -103,7 +104,7 @@ async def message_handler( # pragma: no cover
103104 if isinstance (message , Exception ):
104105 returned_exception = message
105106
106- async with httpx .AsyncClient (transport = httpx .ASGITransport (app = _create_non_sdk_server_app ())) as client :
107+ async with httpx2 .AsyncClient (transport = httpx2 .ASGITransport (app = _create_non_sdk_server_app ())) as client :
107108 async with streamable_http_client ("http://localhost/mcp" , http_client = client ) as (read_stream , write_stream ):
108109 async with ClientSession (read_stream , write_stream , message_handler = message_handler ) as session :
109110 await session .initialize ()
@@ -122,7 +123,7 @@ async def test_unexpected_content_type_sends_jsonrpc_error() -> None:
122123 the client should send a JSONRPCError so the pending request resolves immediately
123124 instead of hanging until timeout.
124125 """
125- async with httpx .AsyncClient (transport = httpx .ASGITransport (app = _create_unexpected_content_type_app ())) as client :
126+ async with httpx2 .AsyncClient (transport = httpx2 .ASGITransport (app = _create_unexpected_content_type_app ())) as client :
126127 async with streamable_http_client ("http://localhost/mcp" , http_client = client ) as (read_stream , write_stream ):
127128 async with ClientSession (read_stream , write_stream ) as session : # pragma: no branch
128129 await session .initialize ()
@@ -138,7 +139,7 @@ async def test_initialize_does_not_hang_on_unexpected_content_type() -> None:
138139 other than application/json or text/event-stream in response to the initialize request,
139140 the client must raise MCPError right away instead of hanging forever.
140141 """
141- async with httpx .AsyncClient (transport = httpx .ASGITransport (app = _create_plain_text_server_app ())) as client :
142+ async with httpx2 .AsyncClient (transport = httpx2 .ASGITransport (app = _create_plain_text_server_app ())) as client :
142143 async with streamable_http_client ("http://localhost/mcp" , http_client = client ) as (read_stream , write_stream ):
143144 async with ClientSession (read_stream , write_stream ) as session : # pragma: no branch
144145 with pytest .raises (MCPError , match = "Unexpected content type: text/plain" ): # pragma: no branch
@@ -170,9 +171,9 @@ async def test_http_error_status_sends_jsonrpc_error() -> None:
170171
171172 When a server returns a non-2xx status code (e.g. 500), the client should
172173 send a JSONRPCError so the pending request resolves immediately instead of
173- raising an unhandled httpx .HTTPStatusError that causes the caller to hang.
174+ raising an unhandled httpx2 .HTTPStatusError that causes the caller to hang.
174175 """
175- async with httpx .AsyncClient (transport = httpx .ASGITransport (app = _create_http_error_app (500 ))) as client :
176+ async with httpx2 .AsyncClient (transport = httpx2 .ASGITransport (app = _create_http_error_app (500 ))) as client :
176177 async with streamable_http_client ("http://localhost/mcp" , http_client = client ) as (read_stream , write_stream ):
177178 async with ClientSession (read_stream , write_stream ) as session : # pragma: no branch
178179 await session .initialize ()
@@ -188,7 +189,7 @@ async def test_http_error_on_notification_does_not_hang() -> None:
188189 unblock, so the client should just return without sending a JSONRPCError.
189190 """
190191 app = _create_http_error_app (500 , error_on_notifications = True )
191- async with httpx .AsyncClient (transport = httpx .ASGITransport (app = app )) as client :
192+ async with httpx2 .AsyncClient (transport = httpx2 .ASGITransport (app = app )) as client :
192193 async with streamable_http_client ("http://localhost/mcp" , http_client = client ) as (read_stream , write_stream ):
193194 async with ClientSession (read_stream , write_stream ) as session : # pragma: no branch
194195 await session .initialize ()
@@ -223,10 +224,76 @@ async def test_invalid_json_response_sends_jsonrpc_error() -> None:
223224 should send a JSONRPCError so the pending request resolves immediately
224225 instead of hanging until timeout.
225226 """
226- async with httpx .AsyncClient (transport = httpx .ASGITransport (app = _create_invalid_json_response_app ())) as client :
227+ async with httpx2 .AsyncClient (transport = httpx2 .ASGITransport (app = _create_invalid_json_response_app ())) as client :
227228 async with streamable_http_client ("http://localhost/mcp" , http_client = client ) as (read_stream , write_stream ):
228229 async with ClientSession (read_stream , write_stream ) as session : # pragma: no branch
229230 await session .initialize ()
230231
231232 with pytest .raises (MCPError , match = "Failed to parse JSON response" ): # pragma: no branch
232233 await session .list_tools ()
234+
235+
236+ def _create_non_2xx_json_body_app (status : int , body : bytes ) -> Starlette :
237+ """Server that returns a fixed non-2xx status + ``application/json`` body for non-init requests.
238+
239+ The initialize response carries an ``mcp-session-id`` so the client treats subsequent
240+ requests as part of an established session (needed for the 404 → session-terminated mapping).
241+ """
242+
243+ async def handle_mcp_request (request : Request ) -> Response :
244+ data = json .loads (await request .body ())
245+ if data .get ("method" ) == "initialize" :
246+ return JSONResponse (
247+ {"jsonrpc" : "2.0" , "id" : data ["id" ], "result" : INIT_RESPONSE },
248+ headers = {"mcp-session-id" : "test-session" },
249+ )
250+ if "id" not in data :
251+ return Response (status_code = 202 )
252+ return Response (content = body , status_code = status , media_type = "application/json" )
253+
254+ return Starlette (debug = True , routes = [Route ("/mcp" , handle_mcp_request , methods = ["POST" ])])
255+
256+
257+ async def test_client_surfaces_jsonrpc_error_from_non_2xx_body_with_correlated_id () -> None :
258+ """SDK-defined: a JSON-RPC error in a non-2xx body is surfaced verbatim even when the
259+ server set ``id: null`` — the client rewraps it under the pending request's id, so
260+ the awaiting call resolves with the server's error code instead of the generic fallback."""
261+ body = json .dumps (
262+ {"jsonrpc" : "2.0" , "id" : None , "error" : {"code" : types .METHOD_NOT_FOUND , "message" : "nope" }}
263+ ).encode ()
264+ app = _create_non_2xx_json_body_app (400 , body )
265+ async with httpx2 .AsyncClient (transport = httpx2 .ASGITransport (app = app )) as client :
266+ async with streamable_http_client ("http://localhost/mcp" , http_client = client ) as (read_stream , write_stream ):
267+ async with ClientSession (read_stream , write_stream ) as session : # pragma: no branch
268+ await session .initialize ()
269+ with pytest .raises (MCPError ) as exc :
270+ await session .list_tools ()
271+ assert exc .value .error .code == types .METHOD_NOT_FOUND
272+
273+
274+ async def test_client_falls_back_to_generic_error_when_non_2xx_body_is_a_jsonrpc_result () -> None :
275+ """SDK-defined: a non-2xx response whose JSON body parses as a JSON-RPC *result* (not an
276+ error) falls through to the generic ``INTERNAL_ERROR`` fallback rather than being
277+ treated as the request's reply."""
278+ app = _create_non_2xx_json_body_app (400 , b'{"jsonrpc":"2.0","id":1,"result":{}}' )
279+ async with httpx2 .AsyncClient (transport = httpx2 .ASGITransport (app = app )) as client :
280+ async with streamable_http_client ("http://localhost/mcp" , http_client = client ) as (read_stream , write_stream ):
281+ async with ClientSession (read_stream , write_stream ) as session : # pragma: no branch
282+ await session .initialize ()
283+ with pytest .raises (MCPError ) as exc :
284+ await session .list_tools ()
285+ assert exc .value .error .code == types .INTERNAL_ERROR
286+
287+
288+ async def test_client_falls_back_to_session_terminated_when_404_body_is_malformed_json () -> None :
289+ """SDK-defined: an unparseable ``application/json`` body on a 404 response is swallowed
290+ and the status-derived ``INVALID_REQUEST`` (session-terminated) fallback resolves the
291+ pending request — the parse failure never propagates."""
292+ app = _create_non_2xx_json_body_app (404 , b"not valid json{{{" )
293+ async with httpx2 .AsyncClient (transport = httpx2 .ASGITransport (app = app )) as client :
294+ async with streamable_http_client ("http://localhost/mcp" , http_client = client ) as (read_stream , write_stream ):
295+ async with ClientSession (read_stream , write_stream ) as session : # pragma: no branch
296+ await session .initialize ()
297+ with pytest .raises (MCPError ) as exc :
298+ await session .list_tools ()
299+ assert exc .value .error .code == types .INVALID_REQUEST
0 commit comments