Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
19 changes: 11 additions & 8 deletions src/sap_cloud_sdk/agentgateway/_customer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 [
Expand Down
9 changes: 6 additions & 3 deletions src/sap_cloud_sdk/agentgateway/_lob.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
84 changes: 84 additions & 0 deletions tests/agentgateway/unit/test_customer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
# ============================================================
Expand Down
65 changes: 65 additions & 0 deletions tests/agentgateway/unit/test_lob.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading