diff --git a/pyproject.toml b/pyproject.toml index 53e8ea16..aa406a5d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sap-cloud-sdk" -version = "0.56.0" +version = "0.56.1" description = "SAP Cloud SDK for Python" readme = "README.md" license = "Apache-2.0" diff --git a/src/sap_cloud_sdk/agentgateway/_customer.py b/src/sap_cloud_sdk/agentgateway/_customer.py index 25e2d90c..122fb99d 100644 --- a/src/sap_cloud_sdk/agentgateway/_customer.py +++ b/src/sap_cloud_sdk/agentgateway/_customer.py @@ -21,6 +21,8 @@ import tempfile import uuid +import anyio + import httpx from mcp import ClientSession from mcp.client.streamable_http import streamable_http_client @@ -664,16 +666,17 @@ async def _list_server_tools( *_, ): async with ClientSession(read, write) as session: - init_result = await session.initialize() + with anyio.fail_after(timeout): + init_result = await session.initialize() - server_name = mcp_server_name(init_result) - if not server_name: - raise AgentGatewaySDKError( - f"MCP server at '{url}' did not provide its server name " - "(serverInfo/server_info). This is required by the MCP protocol." - ) + server_name = mcp_server_name(init_result) + if not server_name: + raise AgentGatewaySDKError( + f"MCP server at '{url}' did not provide its server name " + "(serverInfo/server_info). This is required by the MCP protocol." + ) - result = await session.list_tools() + result = await session.list_tools() tools = result.tools or [] return [ diff --git a/src/sap_cloud_sdk/agentgateway/_lob.py b/src/sap_cloud_sdk/agentgateway/_lob.py index 46b1aad4..2241f897 100644 --- a/src/sap_cloud_sdk/agentgateway/_lob.py +++ b/src/sap_cloud_sdk/agentgateway/_lob.py @@ -10,6 +10,8 @@ import os import uuid +import anyio + import httpx from mcp import ClientSession from mcp.client.streamable_http import streamable_http_client @@ -386,9 +388,10 @@ async def list_server_tools( *_, ): async with ClientSession(read, write) as session: - init_result = await session.initialize() - server_name = mcp_server_name(init_result) or fragment_name - result = await session.list_tools() + with anyio.fail_after(timeout): + init_result = await session.initialize() + server_name = mcp_server_name(init_result) or fragment_name + result = await session.list_tools() tools = result.tools or [] if not tools: logger.info( diff --git a/tests/agentgateway/unit/test_customer.py b/tests/agentgateway/unit/test_customer.py index 72e03efe..f358cb9f 100644 --- a/tests/agentgateway/unit/test_customer.py +++ b/tests/agentgateway/unit/test_customer.py @@ -15,6 +15,7 @@ get_mcp_tools_customer, call_mcp_tool_customer, _build_mcp_url, + _list_server_tools, _INTEGRATION_CLIENT_ID_ENV, _INTEGRATION_AUTH_URL_ENV, _INTEGRATION_GATEWAY_URL_ENV, @@ -771,6 +772,89 @@ async def mock_list(url, token, timeout): assert result[0].name == "tool-ok" +# ============================================================ +# Test: _list_server_tools +# ============================================================ + + +class TestListServerTools: + """Tests for _list_server_tools async function.""" + + @pytest.mark.asyncio + async def test_raises_timeout_when_initialize_hangs(self): + """Raise TimeoutError when initialize() never returns (plain-text SSE body simulation).""" + import anyio + + async def _hang(*a, **kw): + await anyio.sleep(9999) + + with ( + patch( + "sap_cloud_sdk.agentgateway._customer.httpx.AsyncClient" + ) as mock_http, + patch( + "sap_cloud_sdk.agentgateway._customer.streamable_http_client" + ) as mock_stream, + patch( + "sap_cloud_sdk.agentgateway._customer.ClientSession" + ) as mock_session, + ): + mock_http.return_value.__aenter__.return_value = AsyncMock() + mock_stream.return_value.__aenter__.return_value = ( + AsyncMock(), + AsyncMock(), + None, + ) + + mock_session_instance = AsyncMock() + mock_session_instance.initialize = AsyncMock(side_effect=_hang) + mock_session.return_value.__aenter__.return_value = mock_session_instance + + with pytest.raises(TimeoutError): + await _list_server_tools( + "https://example.com/mcp", "token", timeout=0.05 + ) + + @pytest.mark.asyncio + async def test_raises_timeout_when_list_tools_hangs(self): + """Raise TimeoutError when list_tools() never returns (SSE stream stalled simulation).""" + import anyio + + async def _hang(*a, **kw): + await anyio.sleep(9999) + + with ( + patch( + "sap_cloud_sdk.agentgateway._customer.httpx.AsyncClient" + ) as mock_http, + patch( + "sap_cloud_sdk.agentgateway._customer.streamable_http_client" + ) as mock_stream, + patch( + "sap_cloud_sdk.agentgateway._customer.ClientSession" + ) as mock_session, + ): + mock_http.return_value.__aenter__.return_value = AsyncMock() + mock_stream.return_value.__aenter__.return_value = ( + AsyncMock(), + AsyncMock(), + None, + ) + + mock_init = MagicMock() + mock_init.server_info = MagicMock() + mock_init.server_info.name = "test-server" + mock_session_instance = AsyncMock() + mock_session_instance.initialize = AsyncMock(return_value=mock_init) + mock_session_instance.list_tools = AsyncMock(side_effect=_hang) + mock_session.return_value.__aenter__.return_value = mock_session_instance + + with pytest.raises(TimeoutError): + await _list_server_tools( + "https://example.com/mcp", "token", timeout=0.05 + ) + + # ============================================================ # Test: call_mcp_tool_customer # ============================================================ diff --git a/tests/agentgateway/unit/test_lob.py b/tests/agentgateway/unit/test_lob.py index bd33665a..4d88d434 100644 --- a/tests/agentgateway/unit/test_lob.py +++ b/tests/agentgateway/unit/test_lob.py @@ -1052,6 +1052,71 @@ async def test_falls_back_to_fragment_name_when_server_info_missing(self): assert result[0].server_name == "my-fragment" + @pytest.mark.asyncio + async def test_raises_timeout_when_initialize_hangs(self): + """Raise TimeoutError when initialize() never returns (plain-text SSE body simulation).""" + import anyio + + async def _hang(*a, **kw): + await anyio.sleep(9999) + + with ( + patch("sap_cloud_sdk.agentgateway._lob.httpx.AsyncClient") as mock_http, + patch( + "sap_cloud_sdk.agentgateway._lob.streamable_http_client" + ) as mock_stream, + patch("sap_cloud_sdk.agentgateway._lob.ClientSession") as mock_session, + ): + mock_http.return_value.__aenter__.return_value = AsyncMock() + mock_stream.return_value.__aenter__.return_value = ( + AsyncMock(), + AsyncMock(), + None, + ) + + mock_session_instance = AsyncMock() + mock_session_instance.initialize = AsyncMock(side_effect=_hang) + mock_session.return_value.__aenter__.return_value = mock_session_instance + + with pytest.raises(TimeoutError): + await list_server_tools( + "https://example.com/mcp", "token", "fragment", timeout=0.05 + ) + + @pytest.mark.asyncio + async def test_raises_timeout_when_list_tools_hangs(self): + """Raise TimeoutError when list_tools() never returns (SSE stream stalled simulation).""" + import anyio + + async def _hang(*a, **kw): + await anyio.sleep(9999) + + with ( + patch("sap_cloud_sdk.agentgateway._lob.httpx.AsyncClient") as mock_http, + patch( + "sap_cloud_sdk.agentgateway._lob.streamable_http_client" + ) as mock_stream, + patch("sap_cloud_sdk.agentgateway._lob.ClientSession") as mock_session, + ): + mock_http.return_value.__aenter__.return_value = AsyncMock() + mock_stream.return_value.__aenter__.return_value = ( + AsyncMock(), + AsyncMock(), + None, + ) + + mock_init = MagicMock(spec=[]) + mock_init.server_info = None + mock_session_instance = AsyncMock() + mock_session_instance.initialize = AsyncMock(return_value=mock_init) + mock_session_instance.list_tools = AsyncMock(side_effect=_hang) + mock_session.return_value.__aenter__.return_value = mock_session_instance + + with pytest.raises(TimeoutError): + await list_server_tools( + "https://example.com/mcp", "token", "fragment", timeout=0.05 + ) + # ============================================================ # Test: call_mcp_tool_lob diff --git a/uv.lock b/uv.lock index 19e2ef24..4e740820 100644 --- a/uv.lock +++ b/uv.lock @@ -4349,7 +4349,7 @@ wheels = [ [[package]] name = "sap-cloud-sdk" -version = "0.56.0" +version = "0.56.1" source = { editable = "." } dependencies = [ { name = "cryptography" },