@@ -225,6 +225,11 @@ async def _handle_list_tools( # pragma: no cover
225225 description = "Tool that closes standalone GET stream mid-operation" ,
226226 input_schema = {"type" : "object" , "properties" : {}},
227227 ),
228+ Tool (
229+ name = "tool_with_perpetual_stream_close" ,
230+ description = "Tool that always closes the stream without sending a response" ,
231+ input_schema = {"type" : "object" , "properties" : {}},
232+ ),
228233 ]
229234 )
230235
@@ -380,6 +385,16 @@ async def _handle_call_tool( # pragma: no cover
380385
381386 return CallToolResult (content = [TextContent (type = "text" , text = "Standalone stream close test done" )])
382387
388+ elif name == "tool_with_perpetual_stream_close" :
389+ # Repeatedly close the stream without ever sending a response.
390+ # Used to verify that _handle_reconnection gives up after MAX_RECONNECTION_ATTEMPTS.
391+ for _ in range (10 ):
392+ if ctx .close_sse_stream :
393+ await ctx .close_sse_stream ()
394+ await anyio .sleep (0.3 )
395+ # This response should never be reached by the client because reconnection gives up
396+ return CallToolResult (content = [TextContent (type = "text" , text = "Should not reach" )])
397+
383398 return CallToolResult (content = [TextContent (type = "text" , text = f"Called { name } " )])
384399
385400
@@ -1086,7 +1101,7 @@ async def test_streamable_http_client_tool_invocation(initialized_client_session
10861101 """Test client tool invocation."""
10871102 # First list tools
10881103 tools = await initialized_client_session .list_tools ()
1089- assert len (tools .tools ) == 10
1104+ assert len (tools .tools ) == 11
10901105 assert tools .tools [0 ].name == "test_tool"
10911106
10921107 # Call the tool
@@ -1116,7 +1131,7 @@ async def test_streamable_http_client_session_persistence(basic_server: None, ba
11161131
11171132 # Make multiple requests to verify session persistence
11181133 tools = await session .list_tools ()
1119- assert len (tools .tools ) == 10
1134+ assert len (tools .tools ) == 11
11201135
11211136 # Read a resource
11221137 resource = await session .read_resource (uri = "foobar://test-persist" )
@@ -1138,7 +1153,7 @@ async def test_streamable_http_client_json_response(json_response_server: None,
11381153
11391154 # Check tool listing
11401155 tools = await session .list_tools ()
1141- assert len (tools .tools ) == 10
1156+ assert len (tools .tools ) == 11
11421157
11431158 # Call a tool and verify JSON response handling
11441159 result = await session .call_tool ("test_tool" , {})
@@ -1220,7 +1235,7 @@ async def test_streamable_http_client_session_termination(basic_server: None, ba
12201235
12211236 # Make a request to confirm session is working
12221237 tools = await session .list_tools ()
1223- assert len (tools .tools ) == 10
1238+ assert len (tools .tools ) == 11
12241239
12251240 async with create_mcp_http_client (headers = headers ) as httpx_client2 :
12261241 async with streamable_http_client (f"{ basic_server_url } /mcp" , http_client = httpx_client2 ) as (
@@ -1281,7 +1296,7 @@ async def mock_delete(self: httpx.AsyncClient, *args: Any, **kwargs: Any) -> htt
12811296
12821297 # Make a request to confirm session is working
12831298 tools = await session .list_tools ()
1284- assert len (tools .tools ) == 10
1299+ assert len (tools .tools ) == 11
12851300
12861301 async with create_mcp_http_client (headers = headers ) as httpx_client2 :
12871302 async with streamable_http_client (f"{ basic_server_url } /mcp" , http_client = httpx_client2 ) as (
@@ -2318,3 +2333,23 @@ async def test_streamable_http_client_preserves_custom_with_mcp_headers(
23182333
23192334 assert "content-type" in headers_data
23202335 assert headers_data ["content-type" ] == "application/json"
2336+
2337+
2338+ @pytest .mark .anyio
2339+ async def test_reconnection_gives_up_after_max_attempts (
2340+ event_server : tuple [SimpleEventStore , str ],
2341+ ) -> None :
2342+ """Client should stop reconnecting after MAX_RECONNECTION_ATTEMPTS and return an error.
2343+
2344+ Regression test for https://github.com/modelcontextprotocol/python-sdk/issues/2393:
2345+ _handle_reconnection used to reset the attempt counter to 0 when the stream ended
2346+ without a response, causing an infinite retry loop.
2347+ """
2348+ _ , server_url = event_server
2349+
2350+ async with streamable_http_client (f"{ server_url } /mcp" ) as (read_stream , write_stream ):
2351+ async with ClientSession (read_stream , write_stream ) as session :
2352+ await session .initialize ()
2353+
2354+ with pytest .raises (MCPError ), anyio .fail_after (30 ): # pragma: no branch
2355+ await session .call_tool ("tool_with_perpetual_stream_close" , {})
0 commit comments