From ec96f3f4bd761c0e270e16f7088ec2dfb05a3ae0 Mon Sep 17 00:00:00 2001 From: Cassio Farias Machado Date: Fri, 25 Sep 2026 13:58:02 -0300 Subject: [PATCH 1/2] fix(agentgateway): guard MCP session handshake with anyio.fail_after --- src/sap_cloud_sdk/agentgateway/_customer.py | 19 +++-- src/sap_cloud_sdk/agentgateway/_lob.py | 9 ++- tests/agentgateway/unit/test_customer.py | 84 +++++++++++++++++++++ tests/agentgateway/unit/test_lob.py | 65 ++++++++++++++++ 4 files changed, 166 insertions(+), 11 deletions(-) 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 From 6643e9c46f2c321adc0d384fef473b2cf8021cd3 Mon Sep 17 00:00:00 2001 From: Cassio Farias Machado Date: Fri, 25 Sep 2026 14:37:54 -0300 Subject: [PATCH 2/2] chore: bump version to 0.56.1 --- pyproject.toml | 2 +- uv.lock | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 82987cf3..aa406a5d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sap-cloud-sdk" -version = "0.55.2" +version = "0.56.1" description = "SAP Cloud SDK for Python" readme = "README.md" license = "Apache-2.0" diff --git a/uv.lock b/uv.lock index 56f9d3a1..4e740820 100644 --- a/uv.lock +++ b/uv.lock @@ -166,9 +166,9 @@ name = "aiologic" version = "0.17.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "sniffio", marker = "python_full_version < '3.13'" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, - { name = "wrapt", marker = "python_full_version < '3.13'" }, + { name = "sniffio" }, + { name = "typing-extensions" }, + { name = "wrapt" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f1/7a/d51f2fde1e8ae8a83431f8e97b7a71e9358cdb1d4d2ce6be387fa44d68de/aiologic-0.17.1.tar.gz", hash = "sha256:2e1b93b9e88ced318c2a63ad7b382688f40cbfe40e3d42258d49dc9c5aea179d", size = 252354, upload-time = "2026-06-27T20:41:33.25Z" } wheels = [ @@ -816,8 +816,8 @@ name = "culsans" version = "0.11.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "aiologic", marker = "python_full_version < '3.13'" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "aiologic" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d9/e3/49afa1bc180e0d28008ec6bcdf82a4072d1c7a41032b5b759b60814ca4b0/culsans-0.11.0.tar.gz", hash = "sha256:0b43d0d05dce6106293d114c86e3fb4bfc63088cfe8ff08ed3fe36891447fe33", size = 107546, upload-time = "2025-12-31T23:15:38.196Z" } wheels = [ @@ -4349,7 +4349,7 @@ wheels = [ [[package]] name = "sap-cloud-sdk" -version = "0.55.2" +version = "0.56.1" source = { editable = "." } dependencies = [ { name = "cryptography" },