@@ -2353,3 +2353,193 @@ async def test_streamablehttp_client_deprecation_warning(basic_server: None, bas
23532353 await session .initialize ()
23542354 tools = await session .list_tools ()
23552355 assert len (tools .tools ) > 0
2356+
2357+
2358+ @pytest .mark .anyio
2359+ @pytest .mark .parametrize ("status" , [401 , 403 , 500 , 503 ])
2360+ async def test_non_2xx_status_reaches_the_waiting_caller (status : int ) -> None :
2361+ """A 4xx/5xx to a request is answered as a JSON-RPC error rather than raised into the void.
2362+
2363+ Regression test for #3091: the status used to escape `raise_for_status()` inside the POST's
2364+ background task, where nothing caught it and nothing resolved the caller, so the request hung
2365+ until its read timeout — a 401 was indistinguishable from a slow server. The `fail_after` below
2366+ is what pins that: without the fix this test hangs rather than failing an assertion.
2367+ """
2368+
2369+ def handler (request : httpx .Request ) -> httpx .Response :
2370+ return httpx .Response (status )
2371+
2372+ with anyio .fail_after (5 ):
2373+ async with (
2374+ httpx .AsyncClient (transport = httpx .MockTransport (handler )) as http_client ,
2375+ streamable_http_client ("http://test/mcp" , http_client = http_client ) as (read , write , _ ),
2376+ read ,
2377+ write ,
2378+ ):
2379+ request = types .JSONRPCRequest (jsonrpc = "2.0" , id = 1 , method = "tools/list" , params = {})
2380+ await write .send (SessionMessage (types .JSONRPCMessage (request )))
2381+ reply = await read .receive ()
2382+
2383+ assert isinstance (reply , SessionMessage )
2384+ assert isinstance (reply .message .root , types .JSONRPCError )
2385+ assert reply .message .root .id == 1
2386+ assert reply .message .root .error == types .ErrorData (
2387+ code = types .INTERNAL_ERROR , message = "Server returned an error response"
2388+ )
2389+
2390+
2391+ @pytest .mark .anyio
2392+ async def test_404_still_reports_session_terminated () -> None :
2393+ """The 404 spelling is unchanged by #3091: same message, same (positive) code callers match on."""
2394+
2395+ def handler (request : httpx .Request ) -> httpx .Response :
2396+ return httpx .Response (404 )
2397+
2398+ with anyio .fail_after (5 ):
2399+ async with (
2400+ httpx .AsyncClient (transport = httpx .MockTransport (handler )) as http_client ,
2401+ streamable_http_client ("http://test/mcp" , http_client = http_client ) as (read , write , _ ),
2402+ read ,
2403+ write ,
2404+ ):
2405+ request = types .JSONRPCRequest (jsonrpc = "2.0" , id = 1 , method = "tools/list" , params = {})
2406+ await write .send (SessionMessage (types .JSONRPCMessage (request )))
2407+ reply = await read .receive ()
2408+
2409+ assert isinstance (reply , SessionMessage )
2410+ assert isinstance (reply .message .root , types .JSONRPCError )
2411+ assert reply .message .root .error == types .ErrorData (code = 32600 , message = "Session terminated" )
2412+
2413+
2414+ @pytest .mark .anyio
2415+ async def test_non_2xx_prefers_the_servers_own_jsonrpc_error_body () -> None :
2416+ """When the server explains itself in the body, the caller gets that, not our stand-in.
2417+
2418+ The reply is re-stamped with the request's id rather than trusting the body's, so a server that
2419+ echoes a different id cannot resolve the wrong caller (or, echoing none, no caller at all).
2420+ """
2421+
2422+ def handler (request : httpx .Request ) -> httpx .Response :
2423+ error = {"code" : types .INVALID_PARAMS , "message" : "Unknown tool: nope" }
2424+ return httpx .Response (400 , json = {"jsonrpc" : "2.0" , "id" : 99 , "error" : error })
2425+
2426+ with anyio .fail_after (5 ):
2427+ async with (
2428+ httpx .AsyncClient (transport = httpx .MockTransport (handler )) as http_client ,
2429+ streamable_http_client ("http://test/mcp" , http_client = http_client ) as (read , write , _ ),
2430+ read ,
2431+ write ,
2432+ ):
2433+ request = types .JSONRPCRequest (jsonrpc = "2.0" , id = 7 , method = "tools/call" , params = {})
2434+ await write .send (SessionMessage (types .JSONRPCMessage (request )))
2435+ reply = await read .receive ()
2436+
2437+ assert isinstance (reply , SessionMessage )
2438+ assert isinstance (reply .message .root , types .JSONRPCError )
2439+ assert reply .message .root .id == 7
2440+ assert reply .message .root .error == types .ErrorData (code = types .INVALID_PARAMS , message = "Unknown tool: nope" )
2441+
2442+
2443+ @pytest .mark .anyio
2444+ async def test_a_failed_request_leaves_the_session_usable () -> None :
2445+ """The connection survives a 500: the error resolves one call, it does not tear down the transport.
2446+
2447+ In v1 the escaping `HTTPStatusError` cancelled the transport's task group, so a single 500 took
2448+ the whole session with it.
2449+ """
2450+
2451+ def handler (request : httpx .Request ) -> httpx .Response :
2452+ request_id = json .loads (request .content )["id" ]
2453+ if request_id == 1 :
2454+ return httpx .Response (500 )
2455+ return httpx .Response (200 , json = {"jsonrpc" : "2.0" , "id" : request_id , "result" : {}})
2456+
2457+ with anyio .fail_after (5 ):
2458+ async with (
2459+ httpx .AsyncClient (transport = httpx .MockTransport (handler )) as http_client ,
2460+ streamable_http_client ("http://test/mcp" , http_client = http_client ) as (read , write , _ ),
2461+ read ,
2462+ write ,
2463+ ):
2464+ failing = types .JSONRPCRequest (jsonrpc = "2.0" , id = 1 , method = "tools/list" , params = {})
2465+ await write .send (SessionMessage (types .JSONRPCMessage (failing )))
2466+ first = await read .receive ()
2467+
2468+ following = types .JSONRPCRequest (jsonrpc = "2.0" , id = 2 , method = "tools/list" , params = {})
2469+ await write .send (SessionMessage (types .JSONRPCMessage (following )))
2470+ second = await read .receive ()
2471+
2472+ assert isinstance (first , SessionMessage )
2473+ assert isinstance (first .message .root , types .JSONRPCError )
2474+ assert isinstance (second , SessionMessage )
2475+ assert isinstance (second .message .root , types .JSONRPCResponse )
2476+
2477+
2478+ @pytest .mark .anyio
2479+ async def test_non_2xx_to_a_notification_resolves_nobody_and_keeps_the_session_alive () -> None :
2480+ """A notification has no id and no waiter, so a 4xx to one cannot be surfaced — only survived.
2481+
2482+ The following request still gets its answer: the rejected notification must not take the
2483+ transport down with it.
2484+ """
2485+
2486+ def handler (request : httpx .Request ) -> httpx .Response :
2487+ body = json .loads (request .content )
2488+ if "id" not in body :
2489+ return httpx .Response (500 )
2490+ return httpx .Response (200 , json = {"jsonrpc" : "2.0" , "id" : body ["id" ], "result" : {}})
2491+
2492+ with anyio .fail_after (5 ):
2493+ async with (
2494+ httpx .AsyncClient (transport = httpx .MockTransport (handler )) as http_client ,
2495+ streamable_http_client ("http://test/mcp" , http_client = http_client ) as (read , write , _ ),
2496+ read ,
2497+ write ,
2498+ ):
2499+ notification = types .JSONRPCNotification (jsonrpc = "2.0" , method = "notifications/progress" )
2500+ await write .send (SessionMessage (types .JSONRPCMessage (notification )))
2501+
2502+ request = types .JSONRPCRequest (jsonrpc = "2.0" , id = 1 , method = "tools/list" , params = {})
2503+ await write .send (SessionMessage (types .JSONRPCMessage (request )))
2504+ reply = await read .receive ()
2505+
2506+ assert isinstance (reply , SessionMessage )
2507+ assert isinstance (reply .message .root , types .JSONRPCResponse )
2508+
2509+
2510+ @pytest .mark .anyio
2511+ @pytest .mark .parametrize (
2512+ ("body" , "case" ),
2513+ [
2514+ ({"jsonrpc" : "2.0" , "id" : 1 , "result" : {}}, "valid JSON-RPC, but not an error" ),
2515+ ({"detail" : "gateway rejected the request" }, "JSON, but not JSON-RPC at all" ),
2516+ ],
2517+ )
2518+ async def test_a_json_body_that_is_not_a_jsonrpc_error_falls_back_to_the_stand_in (
2519+ body : dict [str , Any ], case : str
2520+ ) -> None :
2521+ """`Content-Type: application/json` is not a promise of a JSON-RPC error body.
2522+
2523+ Proxies and gateways send JSON that is nothing of the sort. Neither shape may be handed to the
2524+ caller as their answer, and neither may be allowed to escape: both fall back to the stand-in.
2525+ """
2526+
2527+ def handler (request : httpx .Request ) -> httpx .Response :
2528+ return httpx .Response (500 , json = body )
2529+
2530+ with anyio .fail_after (5 ):
2531+ async with (
2532+ httpx .AsyncClient (transport = httpx .MockTransport (handler )) as http_client ,
2533+ streamable_http_client ("http://test/mcp" , http_client = http_client ) as (read , write , _ ),
2534+ read ,
2535+ write ,
2536+ ):
2537+ request = types .JSONRPCRequest (jsonrpc = "2.0" , id = 1 , method = "tools/list" , params = {})
2538+ await write .send (SessionMessage (types .JSONRPCMessage (request )))
2539+ reply = await read .receive ()
2540+
2541+ assert isinstance (reply , SessionMessage )
2542+ assert isinstance (reply .message .root , types .JSONRPCError )
2543+ assert reply .message .root .error == types .ErrorData (
2544+ code = types .INTERNAL_ERROR , message = "Server returned an error response"
2545+ )
0 commit comments