From 83c5daef985a127f793d2a91802cf6ba9683adc7 Mon Sep 17 00:00:00 2001 From: Tiago Kochenborger Date: Wed, 15 Jul 2026 10:28:13 +0200 Subject: [PATCH 01/14] fix(agentgateway): guard list_tools None response in LoB flow When MCP instrumentation returns None from list_tools(), skip the fragment with a clear warning instead of raising AttributeError on result.tools. --- src/sap_cloud_sdk/agentgateway/_lob.py | 9 +++ tests/agentgateway/unit/test_lob.py | 86 ++++++++++++++++++++++++++ 2 files changed, 95 insertions(+) diff --git a/src/sap_cloud_sdk/agentgateway/_lob.py b/src/sap_cloud_sdk/agentgateway/_lob.py index 83ad4ebd..5079cfb2 100644 --- a/src/sap_cloud_sdk/agentgateway/_lob.py +++ b/src/sap_cloud_sdk/agentgateway/_lob.py @@ -325,6 +325,15 @@ async def list_server_tools( else fragment_name ) result = await session.list_tools() + if result is None or result.tools is None: + logger.warning( + "MCP server '%s' at '%s' returned no tools from list_tools() " + "(response was None — often caused by OpenTelemetry MCP " + "instrumentation swallowing errors). Skipping fragment.", + fragment_name, + dest_url, + ) + return [] return [ MCPTool( name=t.name, diff --git a/tests/agentgateway/unit/test_lob.py b/tests/agentgateway/unit/test_lob.py index e2e66723..e46e50d6 100644 --- a/tests/agentgateway/unit/test_lob.py +++ b/tests/agentgateway/unit/test_lob.py @@ -21,6 +21,7 @@ fetch_user_auth, get_ias_client_id_lob, get_mcp_tools_lob, + list_server_tools, get_agent_cards_lob, _fetch_agent_card, call_mcp_tool_lob, @@ -600,6 +601,91 @@ async def mock_list_tools_fn(*args, **kwargs): assert result[0].name == "tool2" +# ============================================================ +# Test: list_server_tools +# ============================================================ + + +class TestListServerTools: + """Tests for list_server_tools async function.""" + + @pytest.mark.asyncio + async def test_returns_empty_when_list_tools_returns_none(self, caplog): + """Return empty list when MCP list_tools response is None (OTel instrumentation).""" + import logging + + caplog.set_level(logging.WARNING, logger="sap_cloud_sdk.agentgateway._lob") + + 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_instance = AsyncMock() + mock_http.return_value.__aenter__.return_value = mock_http_instance + + mock_stream.return_value.__aenter__.return_value = ( + AsyncMock(), + AsyncMock(), + None, + ) + + mock_session_instance = AsyncMock() + mock_init = MagicMock() + mock_init.serverInfo.name = "test-server" + mock_session_instance.initialize = AsyncMock(return_value=mock_init) + mock_session_instance.list_tools = AsyncMock(return_value=None) + mock_session.return_value.__aenter__.return_value = mock_session_instance + + result = await list_server_tools( + "https://example.com/mcp", "auth-token", "frag-a", 60.0 + ) + + assert result == [] + assert "returned no tools from list_tools()" in caplog.text + + @pytest.mark.asyncio + async def test_returns_empty_when_tools_attribute_is_none(self, caplog): + """Return empty list when list_tools result has tools=None.""" + import logging + + caplog.set_level(logging.WARNING, logger="sap_cloud_sdk.agentgateway._lob") + + 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_instance = AsyncMock() + mock_http.return_value.__aenter__.return_value = mock_http_instance + + mock_stream.return_value.__aenter__.return_value = ( + AsyncMock(), + AsyncMock(), + None, + ) + + mock_session_instance = AsyncMock() + mock_init = MagicMock() + mock_init.serverInfo.name = "test-server" + mock_session_instance.initialize = AsyncMock(return_value=mock_init) + mock_list_result = MagicMock() + mock_list_result.tools = None + mock_session_instance.list_tools = AsyncMock(return_value=mock_list_result) + mock_session.return_value.__aenter__.return_value = mock_session_instance + + result = await list_server_tools( + "https://example.com/mcp", "auth-token", "frag-a", 60.0 + ) + + assert result == [] + assert "returned no tools from list_tools()" in caplog.text + + # ============================================================ # Test: call_mcp_tool_lob # ============================================================ From 6d63e6530f6140fbf8622c1674d1db11f0d48d83 Mon Sep 17 00:00:00 2001 From: Tiago Kochenborger Date: Wed, 15 Jul 2026 10:35:37 +0200 Subject: [PATCH 02/14] chore: bump version to 0.35.1 and format test_lob.py Required for src/ change CI version check; ruff format on new tests. --- pyproject.toml | 2 +- tests/agentgateway/unit/test_lob.py | 138 ++++++++++++++++++++-------- 2 files changed, 102 insertions(+), 38 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a9e4a46a..5e9be9bd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sap-cloud-sdk" -version = "0.35.0" +version = "0.35.1" description = "SAP Cloud SDK for Python" readme = "README.md" license = "Apache-2.0" diff --git a/tests/agentgateway/unit/test_lob.py b/tests/agentgateway/unit/test_lob.py index e46e50d6..e59fed22 100644 --- a/tests/agentgateway/unit/test_lob.py +++ b/tests/agentgateway/unit/test_lob.py @@ -30,7 +30,10 @@ from sap_cloud_sdk.agentgateway._token_cache import _GatewayUrlCache, _TokenCache from sap_cloud_sdk.agentgateway.config import ClientConfig from sap_cloud_sdk.destination import ConsumptionOptions, ConsumptionLevel -from sap_cloud_sdk.agentgateway.exceptions import AgentGatewaySDKError, MCPServerNotFoundError +from sap_cloud_sdk.agentgateway.exceptions import ( + AgentGatewaySDKError, + MCPServerNotFoundError, +) from sap_cloud_sdk.destination import ConsumptionLevel # Aliases for use in existing test assertions @@ -110,7 +113,9 @@ def test_strips_trailing_slashes_from_url(self): mock_dest.auth_tokens[0].http_header = {"value": header_value} mock_dest.url = "https://agw.example.com/v1/mcp///" - with patch("sap_cloud_sdk.agentgateway._lob.create_destination_client") as mock_client: + with patch( + "sap_cloud_sdk.agentgateway._lob.create_destination_client" + ) as mock_client: mock_client.return_value.get_destination.return_value = mock_dest result = _fetch_auth_token("dest-name", "tenant-sub") @@ -291,7 +296,9 @@ def test_returns_fragment_name(self): fragment = MagicMock() fragment.name = "sap-managed-runtime-agw-subscriber-ias-user-abc123" - with patch("sap_cloud_sdk.agentgateway._fragments.create_fragment_client") as mock_client: + with patch( + "sap_cloud_sdk.agentgateway._fragments.create_fragment_client" + ) as mock_client: mock_client.return_value.list_instance_fragments.return_value = [fragment] result = get_ias_user_fragment_name("tenant-sub") @@ -303,7 +310,9 @@ def test_uses_correct_filter_labels(self): fragment = MagicMock() fragment.name = "ias-user-fragment" - with patch("sap_cloud_sdk.agentgateway._fragments.create_fragment_client") as mock_client: + with patch( + "sap_cloud_sdk.agentgateway._fragments.create_fragment_client" + ) as mock_client: mock_client.return_value.list_instance_fragments.return_value = [fragment] get_ias_user_fragment_name("tenant-sub") @@ -317,10 +326,14 @@ def test_uses_correct_filter_labels(self): def test_raises_when_no_fragment_found(self): """Raise MCPServerNotFoundError when no IAS user fragment exists.""" - with patch("sap_cloud_sdk.agentgateway._fragments.create_fragment_client") as mock_client: + with patch( + "sap_cloud_sdk.agentgateway._fragments.create_fragment_client" + ) as mock_client: mock_client.return_value.list_instance_fragments.return_value = [] - with pytest.raises(MCPServerNotFoundError, match="No IAS user fragment found"): + with pytest.raises( + MCPServerNotFoundError, match="No IAS user fragment found" + ): get_ias_user_fragment_name("tenant-sub") @@ -400,7 +413,9 @@ async def test_reuses_cached_system_auth(self): async def test_raises_when_only_token_cache_provided(self): """Raise ValueError when token_cache given without gateway_url_cache.""" with pytest.raises(ValueError, match="both be provided or both be None"): - await fetch_system_auth("tenant-sub", token_cache=_TokenCache(ClientConfig())) + await fetch_system_auth( + "tenant-sub", token_cache=_TokenCache(ClientConfig()) + ) @pytest.mark.asyncio async def test_raises_when_only_gateway_url_cache_provided(self): @@ -425,10 +440,16 @@ async def test_fetches_user_auth_with_ias_user_fragment(self): with patch.dict(os.environ, {"APPFND_CONHOS_LANDSCAPE": "eu10"}): with ( - patch("sap_cloud_sdk.agentgateway._lob.get_ias_user_fragment_name") as mock_ias_user, - patch("sap_cloud_sdk.agentgateway._lob._fetch_auth_token") as mock_fetch, + patch( + "sap_cloud_sdk.agentgateway._lob.get_ias_user_fragment_name" + ) as mock_ias_user, + patch( + "sap_cloud_sdk.agentgateway._lob._fetch_auth_token" + ) as mock_fetch, ): - mock_ias_user.return_value = "sap-managed-runtime-agw-subscriber-ias-user-abc" + mock_ias_user.return_value = ( + "sap-managed-runtime-agw-subscriber-ias-user-abc" + ) mock_fetch.return_value = (raw_token, gateway_url) result = await fetch_user_auth("user-jwt", "tenant-sub") @@ -441,7 +462,10 @@ async def test_fetches_user_auth_with_ias_user_fragment(self): assert call_args[0][1] == "tenant-sub" options = call_args[0][2] assert options.user_token == "user-jwt" - assert options.fragment_name == "sap-managed-runtime-agw-subscriber-ias-user-abc" + assert ( + options.fragment_name + == "sap-managed-runtime-agw-subscriber-ias-user-abc" + ) assert options.fragment_level == ConsumptionLevel.INSTANCE @pytest.mark.asyncio @@ -482,13 +506,17 @@ async def test_reuses_cached_user_auth(self): async def test_raises_when_only_token_cache_provided(self): """Raise ValueError when token_cache given without gateway_url_cache.""" with pytest.raises(ValueError, match="both be provided or both be None"): - await fetch_user_auth("user-jwt", "tenant-sub", token_cache=_TokenCache(ClientConfig())) + await fetch_user_auth( + "user-jwt", "tenant-sub", token_cache=_TokenCache(ClientConfig()) + ) @pytest.mark.asyncio async def test_raises_when_only_gateway_url_cache_provided(self): """Raise ValueError when gateway_url_cache given without token_cache.""" with pytest.raises(ValueError, match="both be provided or both be None"): - await fetch_user_auth("user-jwt", "tenant-sub", gateway_url_cache=_GatewayUrlCache()) + await fetch_user_auth( + "user-jwt", "tenant-sub", gateway_url_cache=_GatewayUrlCache() + ) # ============================================================ @@ -802,15 +830,16 @@ class TestOrdIdFromUrl: def test_extracts_ord_id_from_standard_url(self): """Return the second-to-last path segment as ord_id.""" - assert _ord_id_from_url( - "https://agw.example.com/v1/a2a/sap.s4:agent:v1/tenant-abc" - ) == "sap.s4:agent:v1" + assert ( + _ord_id_from_url( + "https://agw.example.com/v1/a2a/sap.s4:agent:v1/tenant-abc" + ) + == "sap.s4:agent:v1" + ) def test_strips_trailing_slash(self): """Handle trailing slash on URL.""" - assert _ord_id_from_url( - "https://agw.example.com/v1/a2a/ord-1/gt-1/" - ) == "ord-1" + assert _ord_id_from_url("https://agw.example.com/v1/a2a/ord-1/gt-1/") == "ord-1" def test_returns_empty_for_single_segment(self): """Return empty string when URL has only one path segment.""" @@ -832,7 +861,9 @@ def test_lists_fragments_with_a2a_label(self): with patch( "sap_cloud_sdk.agentgateway._fragments.create_fragment_client" ) as mock_client: - mock_client.return_value.list_instance_fragments.return_value = [mock_fragment] + mock_client.return_value.list_instance_fragments.return_value = [ + mock_fragment + ] result = list_a2a_fragments("tenant-sub") assert result == [mock_fragment] @@ -916,7 +947,9 @@ async def test_raises_on_non_200_status(self): mock_http.return_value.__aenter__.return_value = mock_http_instance with pytest.raises(AgentGatewaySDKError, match="404"): - await _fetch_agent_card("https://agw.example.com/base", "auth-token", 60.0) + await _fetch_agent_card( + "https://agw.example.com/base", "auth-token", 60.0 + ) @pytest.mark.asyncio async def test_raises_on_request_error(self): @@ -931,7 +964,9 @@ async def test_raises_on_request_error(self): mock_http.return_value.__aenter__.return_value = mock_http_instance with pytest.raises(AgentGatewaySDKError, match="Agent card request failed"): - await _fetch_agent_card("https://agw.example.com/base", "auth-token", 60.0) + await _fetch_agent_card( + "https://agw.example.com/base", "auth-token", 60.0 + ) # ============================================================ @@ -967,9 +1002,7 @@ async def test_returns_agents_for_all_fragments(self): return_value=AgentCard(raw=card_payload), ), ): - result = await get_agent_cards_lob( - "tenant-sub", "system-token", 60.0 - ) + result = await get_agent_cards_lob("tenant-sub", "system-token", 60.0) assert len(result) == 1 assert isinstance(result[0], Agent) @@ -990,8 +1023,12 @@ async def test_returns_empty_list_when_no_fragments(self): @pytest.mark.asyncio async def test_filters_by_agent_names(self): """Fetch all cards then keep only those whose agent card name matches.""" - frag_1 = self._make_fragment("frag-1", "https://agw.example.com/v1/a2a/ord-1/t1") - frag_2 = self._make_fragment("frag-2", "https://agw.example.com/v1/a2a/ord-2/t2") + frag_1 = self._make_fragment( + "frag-1", "https://agw.example.com/v1/a2a/ord-1/t1" + ) + frag_2 = self._make_fragment( + "frag-2", "https://agw.example.com/v1/a2a/ord-2/t2" + ) async def _cards_by_ord(fragment_url, token, timeout): if "ord-1" in fragment_url: @@ -1019,8 +1056,12 @@ async def _cards_by_ord(fragment_url, token, timeout): @pytest.mark.asyncio async def test_filters_by_ord_ids(self): """Only include fragments whose ordId (from URL) is in the ord_ids filter.""" - frag_1 = self._make_fragment("frag-1", "https://agw.example.com/v1/a2a/ord-1/t1") - frag_2 = self._make_fragment("frag-2", "https://agw.example.com/v1/a2a/ord-2/t2") + frag_1 = self._make_fragment( + "frag-1", "https://agw.example.com/v1/a2a/ord-1/t1" + ) + frag_2 = self._make_fragment( + "frag-2", "https://agw.example.com/v1/a2a/ord-2/t2" + ) with ( patch( @@ -1112,8 +1153,14 @@ def test_returns_client_id_from_destination_properties(self): mock_dest_client.get_destination.return_value = mock_dest with ( - patch("sap_cloud_sdk.agentgateway._lob._ias_dest_name", return_value="sap-managed-runtime-ias-eu10"), - patch("sap_cloud_sdk.agentgateway._lob.create_destination_client", return_value=mock_dest_client), + patch( + "sap_cloud_sdk.agentgateway._lob._ias_dest_name", + return_value="sap-managed-runtime-ias-eu10", + ), + patch( + "sap_cloud_sdk.agentgateway._lob.create_destination_client", + return_value=mock_dest_client, + ), ): result = get_ias_client_id_lob() @@ -1129,10 +1176,18 @@ def test_raises_when_destination_not_found(self): mock_dest_client.get_destination.return_value = None with ( - patch("sap_cloud_sdk.agentgateway._lob._ias_dest_name", return_value="sap-managed-runtime-ias-eu10"), - patch("sap_cloud_sdk.agentgateway._lob.create_destination_client", return_value=mock_dest_client), + patch( + "sap_cloud_sdk.agentgateway._lob._ias_dest_name", + return_value="sap-managed-runtime-ias-eu10", + ), + patch( + "sap_cloud_sdk.agentgateway._lob.create_destination_client", + return_value=mock_dest_client, + ), ): - with pytest.raises(AgentGatewaySDKError, match="sap-managed-runtime-ias-eu10"): + with pytest.raises( + AgentGatewaySDKError, match="sap-managed-runtime-ias-eu10" + ): get_ias_client_id_lob() def test_returns_empty_string_when_property_absent(self): @@ -1142,14 +1197,23 @@ def test_returns_empty_string_when_property_absent(self): mock_dest_client.get_destination.return_value = mock_dest with ( - patch("sap_cloud_sdk.agentgateway._lob._ias_dest_name", return_value="sap-managed-runtime-ias-eu10"), - patch("sap_cloud_sdk.agentgateway._lob.create_destination_client", return_value=mock_dest_client), + patch( + "sap_cloud_sdk.agentgateway._lob._ias_dest_name", + return_value="sap-managed-runtime-ias-eu10", + ), + patch( + "sap_cloud_sdk.agentgateway._lob.create_destination_client", + return_value=mock_dest_client, + ), ): result = get_ias_client_id_lob() assert result == "" def test_raises_when_landscape_env_not_set(self): - with patch("sap_cloud_sdk.agentgateway._lob._ias_dest_name", side_effect=EnvironmentError("APPFND_CONHOS_LANDSCAPE not set")): + with patch( + "sap_cloud_sdk.agentgateway._lob._ias_dest_name", + side_effect=EnvironmentError("APPFND_CONHOS_LANDSCAPE not set"), + ): with pytest.raises(EnvironmentError, match="APPFND_CONHOS_LANDSCAPE"): get_ias_client_id_lob() From 9977ebf3b92908b9dd011978326e1102ce69c36f Mon Sep 17 00:00:00 2001 From: Tiago Kochenborger Date: Wed, 15 Jul 2026 10:56:35 +0200 Subject: [PATCH 03/14] fix(agentgateway): extend None guard to customer flow and address review nits --- src/sap_cloud_sdk/agentgateway/_customer.py | 9 ++++ src/sap_cloud_sdk/agentgateway/_lob.py | 7 ++- tests/agentgateway/unit/test_lob.py | 49 +++++++++++++++++++-- 3 files changed, 58 insertions(+), 7 deletions(-) diff --git a/src/sap_cloud_sdk/agentgateway/_customer.py b/src/sap_cloud_sdk/agentgateway/_customer.py index be5019ea..13876a9b 100644 --- a/src/sap_cloud_sdk/agentgateway/_customer.py +++ b/src/sap_cloud_sdk/agentgateway/_customer.py @@ -464,6 +464,15 @@ async def _list_server_tools( server_name = init_result.serverInfo.name result = await session.list_tools() + if result is None or result.tools is None: + logger.warning( + "list_tools() returned no tools (response=%r); fragment %r skipped — " + "check MCP server health and OTel instrumentation", + result, + dependency.ord_id, + ) + return [] + return [ MCPTool( name=t.name, diff --git a/src/sap_cloud_sdk/agentgateway/_lob.py b/src/sap_cloud_sdk/agentgateway/_lob.py index 5079cfb2..b4877fbe 100644 --- a/src/sap_cloud_sdk/agentgateway/_lob.py +++ b/src/sap_cloud_sdk/agentgateway/_lob.py @@ -327,11 +327,10 @@ async def list_server_tools( result = await session.list_tools() if result is None or result.tools is None: logger.warning( - "MCP server '%s' at '%s' returned no tools from list_tools() " - "(response was None — often caused by OpenTelemetry MCP " - "instrumentation swallowing errors). Skipping fragment.", + "list_tools() returned no tools (response=%r); fragment %r skipped — " + "check MCP server health and OTel instrumentation", + result, fragment_name, - dest_url, ) return [] return [ diff --git a/tests/agentgateway/unit/test_lob.py b/tests/agentgateway/unit/test_lob.py index e59fed22..726b9bc8 100644 --- a/tests/agentgateway/unit/test_lob.py +++ b/tests/agentgateway/unit/test_lob.py @@ -34,7 +34,6 @@ AgentGatewaySDKError, MCPServerNotFoundError, ) -from sap_cloud_sdk.destination import ConsumptionLevel # Aliases for use in existing test assertions _LABEL_KEY = LABEL_KEY @@ -628,6 +627,50 @@ async def mock_list_tools_fn(*args, **kwargs): assert len(result) == 1 assert result[0].name == "tool2" + @pytest.mark.asyncio + async def test_continues_when_fragment_returns_empty_from_none_guard(self): + """Continue processing when one fragment hits the list_tools None guard.""" + fragment1 = MagicMock() + fragment1.name = "mcp-server1" + fragment1.properties = {"URL": "https://example1.com/mcp"} + + fragment2 = MagicMock() + fragment2.name = "mcp-server2" + fragment2.properties = {"URL": "https://example2.com/mcp"} + + mock_tool = MCPTool( + name="tool2", + server_name="server2", + description="Test", + input_schema={}, + url="https://example2.com/mcp", + fragment_name="mcp-server2", + ) + + call_count = 0 + + async def mock_list_tools_fn(*args, **kwargs): + nonlocal call_count + call_count += 1 + if call_count == 1: + return [] + return [mock_tool] + + with ( + patch("sap_cloud_sdk.agentgateway._lob.list_mcp_fragments") as mock_list, + patch( + "sap_cloud_sdk.agentgateway._lob.list_server_tools", + side_effect=mock_list_tools_fn, + ), + ): + mock_list.return_value = [fragment1, fragment2] + + result = await get_mcp_tools_lob("tenant-sub", "system-token", 60.0) + + assert len(result) == 1 + assert result[0].name == "tool2" + + # ============================================================ # Test: list_server_tools @@ -672,7 +715,7 @@ async def test_returns_empty_when_list_tools_returns_none(self, caplog): ) assert result == [] - assert "returned no tools from list_tools()" in caplog.text + assert "list_tools() returned no tools" in caplog.text @pytest.mark.asyncio async def test_returns_empty_when_tools_attribute_is_none(self, caplog): @@ -711,7 +754,7 @@ async def test_returns_empty_when_tools_attribute_is_none(self, caplog): ) assert result == [] - assert "returned no tools from list_tools()" in caplog.text + assert "list_tools() returned no tools" in caplog.text # ============================================================ From e26043126b25c6c9f6f63e31e9eefe2f273eed90 Mon Sep 17 00:00:00 2001 From: Tiago Kochenborger Date: Wed, 15 Jul 2026 11:07:47 +0200 Subject: [PATCH 04/14] fix(agentgateway,telemetry): MCP OTel unwrap and LoB 403 hint Unwrap opentelemetry-instrumentation-mcp at the end of auto_instrument() so list_tools is not silently broken by @dont_throw wrappers. Log a user_token hint when LoB system-token tool listing returns HTTP 403. Bump version to 0.35.2. --- pyproject.toml | 2 +- src/sap_cloud_sdk/agentgateway/_lob.py | 12 + .../core/telemetry/auto_instrument.py | 31 + tests/agentgateway/unit/test_lob.py | 37 +- .../unit/telemetry/test_auto_instrument.py | 649 ++++++++++++------ uv.lock | 16 +- 6 files changed, 536 insertions(+), 211 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 5e9be9bd..f6f4bda2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sap-cloud-sdk" -version = "0.35.1" +version = "0.35.2" description = "SAP Cloud SDK for Python" readme = "README.md" license = "Apache-2.0" diff --git a/src/sap_cloud_sdk/agentgateway/_lob.py b/src/sap_cloud_sdk/agentgateway/_lob.py index b4877fbe..33661098 100644 --- a/src/sap_cloud_sdk/agentgateway/_lob.py +++ b/src/sap_cloud_sdk/agentgateway/_lob.py @@ -396,6 +396,18 @@ async def get_mcp_tools_lob( len(server_tools), fragment_name, ) + except httpx.HTTPStatusError as exc: + if exc.response.status_code == 403: + logger.warning( + "HTTP 403 listing tools from fragment '%s' with system token — " + "MCP list_tools may require a user-scoped token; use Phase 2 flow " + "with user_token when calling list_mcp_tools with principal context", + fragment_name, + ) + logger.exception( + "Failed to load tools from fragment '%s' — skipping", + fragment_name, + ) except Exception: logger.exception( "Failed to load tools from fragment '%s' — skipping", diff --git a/src/sap_cloud_sdk/core/telemetry/auto_instrument.py b/src/sap_cloud_sdk/core/telemetry/auto_instrument.py index fdd3c34f..f887089a 100644 --- a/src/sap_cloud_sdk/core/telemetry/auto_instrument.py +++ b/src/sap_cloud_sdk/core/telemetry/auto_instrument.py @@ -90,9 +90,40 @@ def auto_instrument( if middlewares: _register_middleware_processors(middlewares) + _unwrap_mcp_otel_instrumentation() + logger.info("Cloud auto instrumentation initialized successfully") +def _unwrap_mcp_otel_instrumentation() -> None: + """Remove OTel MCP instrumentation wrappers that swallow protocol errors. + + opentelemetry-instrumentation-mcp wraps MCP transport with handlers that can + return None on tracing failures, which breaks Agent Gateway tool discovery. + """ + try: + from opentelemetry.instrumentation.utils import unwrap + + unwrap("mcp.shared.session.BaseSession", "send_request") + import mcp.client.streamable_http # noqa: F401 + + for _fn in ("streamablehttp_client", "streamable_http_client"): + try: + unwrap("mcp.client.streamable_http", _fn) + except Exception: + pass + logger.debug( + "Unwrapped MCP OpenTelemetry instrumentation for reliable tool discovery" + ) + except ImportError: + logger.debug("MCP OpenTelemetry instrumentation not available; skipping unwrap") + except Exception: + logger.warning( + "Failed to unwrap MCP OpenTelemetry instrumentation", + exc_info=True, + ) + + def _create_exporter() -> SpanExporter: if os.getenv(ENV_TRACES_EXPORTER, "").lower() == "console": logger.info("Initializing auto instrumentation with console exporter") diff --git a/tests/agentgateway/unit/test_lob.py b/tests/agentgateway/unit/test_lob.py index 726b9bc8..c63d9a50 100644 --- a/tests/agentgateway/unit/test_lob.py +++ b/tests/agentgateway/unit/test_lob.py @@ -3,6 +3,7 @@ import os from unittest.mock import patch, MagicMock, AsyncMock +import httpx import pytest from sap_cloud_sdk.agentgateway._fragments import ( @@ -627,6 +628,41 @@ async def mock_list_tools_fn(*args, **kwargs): assert len(result) == 1 assert result[0].name == "tool2" + @pytest.mark.asyncio + async def test_logs_hint_on_http_403_with_system_token(self, caplog): + """Log a user_token hint when system auth gets HTTP 403 on list_tools.""" + import logging + + caplog.set_level(logging.WARNING, logger="sap_cloud_sdk.agentgateway._lob") + + fragment = MagicMock() + fragment.name = "mcp-server-a" + fragment.properties = {"URL": "https://example.com/mcp"} + + response = MagicMock() + response.status_code = 403 + exc = httpx.HTTPStatusError( + "Forbidden", + request=MagicMock(), + response=response, + ) + + with ( + patch("sap_cloud_sdk.agentgateway._lob.list_mcp_fragments") as mock_list, + patch( + "sap_cloud_sdk.agentgateway._lob.list_server_tools", + new_callable=AsyncMock, + side_effect=exc, + ), + ): + mock_list.return_value = [fragment] + + result = await get_mcp_tools_lob("tenant-sub", "system-token", 60.0) + + assert result == [] + assert "HTTP 403" in caplog.text + assert "user_token" in caplog.text + @pytest.mark.asyncio async def test_continues_when_fragment_returns_empty_from_none_guard(self): """Continue processing when one fragment hits the list_tools None guard.""" @@ -671,7 +707,6 @@ async def mock_list_tools_fn(*args, **kwargs): assert result[0].name == "tool2" - # ============================================================ # Test: list_server_tools # ============================================================ diff --git a/tests/core/unit/telemetry/test_auto_instrument.py b/tests/core/unit/telemetry/test_auto_instrument.py index 98ed6328..b86f11ad 100644 --- a/tests/core/unit/telemetry/test_auto_instrument.py +++ b/tests/core/unit/telemetry/test_auto_instrument.py @@ -15,16 +15,49 @@ def mock_traceloop_components(): """Fixture that mocks Traceloop SDK components.""" with ExitStack() as stack: mocks = { - 'traceloop': stack.enter_context(patch('sap_cloud_sdk.core.telemetry.auto_instrument.Traceloop')), - 'grpc_exporter': stack.enter_context(patch('sap_cloud_sdk.core.telemetry.auto_instrument.GRPCSpanExporter')), - 'http_exporter': stack.enter_context(patch('sap_cloud_sdk.core.telemetry.auto_instrument.HTTPSpanExporter')), - 'console_exporter': stack.enter_context(patch('sap_cloud_sdk.core.telemetry.auto_instrument.ConsoleSpanExporter')), - 'transformer': stack.enter_context(patch('sap_cloud_sdk.core.telemetry.auto_instrument.GenAIAttributeTransformer')), - 'baggage_processor': stack.enter_context(patch('sap_cloud_sdk.core.telemetry.auto_instrument.BaggageSpanProcessor')), - 'propagated_processor': stack.enter_context(patch('sap_cloud_sdk.core.telemetry.auto_instrument.PropagatedAttributesSpanProcessor')), - 'get_tracer_provider': stack.enter_context(patch('sap_cloud_sdk.core.telemetry.auto_instrument.trace.get_tracer_provider', return_value=create_autospec(SDKTracerProvider))), - 'create_resource': stack.enter_context(patch('sap_cloud_sdk.core.telemetry.auto_instrument.create_resource_attributes_from_env')), - 'get_app_name': stack.enter_context(patch('sap_cloud_sdk.core.telemetry.auto_instrument._get_app_name')), + "traceloop": stack.enter_context( + patch("sap_cloud_sdk.core.telemetry.auto_instrument.Traceloop") + ), + "grpc_exporter": stack.enter_context( + patch("sap_cloud_sdk.core.telemetry.auto_instrument.GRPCSpanExporter") + ), + "http_exporter": stack.enter_context( + patch("sap_cloud_sdk.core.telemetry.auto_instrument.HTTPSpanExporter") + ), + "console_exporter": stack.enter_context( + patch( + "sap_cloud_sdk.core.telemetry.auto_instrument.ConsoleSpanExporter" + ) + ), + "transformer": stack.enter_context( + patch( + "sap_cloud_sdk.core.telemetry.auto_instrument.GenAIAttributeTransformer" + ) + ), + "baggage_processor": stack.enter_context( + patch( + "sap_cloud_sdk.core.telemetry.auto_instrument.BaggageSpanProcessor" + ) + ), + "propagated_processor": stack.enter_context( + patch( + "sap_cloud_sdk.core.telemetry.auto_instrument.PropagatedAttributesSpanProcessor" + ) + ), + "get_tracer_provider": stack.enter_context( + patch( + "sap_cloud_sdk.core.telemetry.auto_instrument.trace.get_tracer_provider", + return_value=create_autospec(SDKTracerProvider), + ) + ), + "create_resource": stack.enter_context( + patch( + "sap_cloud_sdk.core.telemetry.auto_instrument.create_resource_attributes_from_env" + ) + ), + "get_app_name": stack.enter_context( + patch("sap_cloud_sdk.core.telemetry.auto_instrument._get_app_name") + ), } yield mocks @@ -34,344 +67,522 @@ class TestAutoInstrument: def test_auto_instrument_with_endpoint_success(self, mock_traceloop_components): """Test successful auto-instrumentation with valid endpoint.""" - mock_traceloop_components['get_app_name'].return_value = 'test-app' - mock_traceloop_components['create_resource'].return_value = {'service.name': 'test-app'} + mock_traceloop_components["get_app_name"].return_value = "test-app" + mock_traceloop_components["create_resource"].return_value = { + "service.name": "test-app" + } - with patch.dict('os.environ', {'OTEL_EXPORTER_OTLP_ENDPOINT': 'http://localhost:4317'}, clear=True): + with patch.dict( + "os.environ", + {"OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:4317"}, + clear=True, + ): auto_instrument() # Verify Traceloop was initialized - mock_traceloop_components['traceloop'].init.assert_called_once() - call_kwargs = mock_traceloop_components['traceloop'].init.call_args[1] - assert call_kwargs['app_name'] == 'test-app' - assert call_kwargs['should_enrich_metrics'] is True - assert call_kwargs['disable_batch'] is False - - def test_auto_instrument_uses_grpc_exporter_by_default(self, mock_traceloop_components): + mock_traceloop_components["traceloop"].init.assert_called_once() + call_kwargs = mock_traceloop_components["traceloop"].init.call_args[1] + assert call_kwargs["app_name"] == "test-app" + assert call_kwargs["should_enrich_metrics"] is True + assert call_kwargs["disable_batch"] is False + + def test_auto_instrument_uses_grpc_exporter_by_default( + self, mock_traceloop_components + ): """Test that auto_instrument uses gRPC exporter by default, letting it read endpoint from env.""" - mock_traceloop_components['get_app_name'].return_value = 'test-app' - mock_traceloop_components['create_resource'].return_value = {} - - with patch.dict('os.environ', {'OTEL_EXPORTER_OTLP_ENDPOINT': 'http://localhost:4317'}, clear=True): + mock_traceloop_components["get_app_name"].return_value = "test-app" + mock_traceloop_components["create_resource"].return_value = {} + + with patch.dict( + "os.environ", + {"OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:4317"}, + clear=True, + ): auto_instrument() - mock_traceloop_components['grpc_exporter'].assert_called_once_with() - mock_traceloop_components['http_exporter'].assert_not_called() + mock_traceloop_components["grpc_exporter"].assert_called_once_with() + mock_traceloop_components["http_exporter"].assert_not_called() - def test_auto_instrument_creates_resource_with_attributes(self, mock_traceloop_components): + def test_auto_instrument_creates_resource_with_attributes( + self, mock_traceloop_components + ): """Test that auto_instrument creates resource with correct attributes.""" - mock_traceloop_components['get_app_name'].return_value = 'test-app' - mock_traceloop_components['create_resource'].return_value = { - 'service.name': 'test-app', - 'sap.cloud_sdk.language': 'python' + mock_traceloop_components["get_app_name"].return_value = "test-app" + mock_traceloop_components["create_resource"].return_value = { + "service.name": "test-app", + "sap.cloud_sdk.language": "python", } - with patch.dict('os.environ', { - 'OTEL_EXPORTER_OTLP_ENDPOINT': 'http://localhost:4317', - 'APPFND_CONHOS_APP_NAME': 'test-app', - 'HOSTNAME': 'test-host', - 'APPFND_CONHOS_REGION': 'us-east-1', - 'APPFND_CONHOS_ENVIRONMENT': 'production', - 'APPFND_CONHOS_SUBACCOUNTID': 'sub-123' - }, clear=True): + with patch.dict( + "os.environ", + { + "OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:4317", + "APPFND_CONHOS_APP_NAME": "test-app", + "HOSTNAME": "test-host", + "APPFND_CONHOS_REGION": "us-east-1", + "APPFND_CONHOS_ENVIRONMENT": "production", + "APPFND_CONHOS_SUBACCOUNTID": "sub-123", + }, + clear=True, + ): auto_instrument() # Verify resource was created - mock_traceloop_components['create_resource'].assert_called_once() + mock_traceloop_components["create_resource"].assert_called_once() def test_auto_instrument_logs_initialization(self, mock_traceloop_components): """Test that auto_instrument logs initialization messages.""" - mock_traceloop_components['get_app_name'].return_value = 'test-app' - mock_traceloop_components['create_resource'].return_value = {} - - with patch.dict('os.environ', {'OTEL_EXPORTER_OTLP_ENDPOINT': 'http://localhost:4317'}, clear=True): - with patch('sap_cloud_sdk.core.telemetry.auto_instrument.logger') as mock_logger: + mock_traceloop_components["get_app_name"].return_value = "test-app" + mock_traceloop_components["create_resource"].return_value = {} + + with patch.dict( + "os.environ", + {"OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:4317"}, + clear=True, + ): + with patch( + "sap_cloud_sdk.core.telemetry.auto_instrument.logger" + ) as mock_logger: auto_instrument() # Verify info logs were called assert mock_logger.info.call_count >= 1 # Check for initialization message info_calls = [call[0][0] for call in mock_logger.info.call_args_list] - assert any('initialized successfully' in msg.lower() for msg in info_calls) + assert any( + "initialized successfully" in msg.lower() for msg in info_calls + ) def test_auto_instrument_with_trailing_slash(self, mock_traceloop_components): """Test that auto_instrument works with a trailing slash endpoint (exporter reads from env).""" - mock_traceloop_components['get_app_name'].return_value = 'test-app' - mock_traceloop_components['create_resource'].return_value = {} - - with patch.dict('os.environ', {'OTEL_EXPORTER_OTLP_ENDPOINT': 'http://localhost:4317/'}, clear=True): + mock_traceloop_components["get_app_name"].return_value = "test-app" + mock_traceloop_components["create_resource"].return_value = {} + + with patch.dict( + "os.environ", + {"OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:4317/"}, + clear=True, + ): auto_instrument() - mock_traceloop_components['grpc_exporter'].assert_called_once_with() + mock_traceloop_components["grpc_exporter"].assert_called_once_with() - def test_auto_instrument_with_http_protobuf_protocol(self, mock_traceloop_components): + def test_auto_instrument_with_http_protobuf_protocol( + self, mock_traceloop_components + ): """Test that auto_instrument uses HTTP exporter when OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf.""" - mock_traceloop_components['get_app_name'].return_value = 'test-app' - mock_traceloop_components['create_resource'].return_value = {} - - with patch.dict('os.environ', { - 'OTEL_EXPORTER_OTLP_ENDPOINT': 'http://localhost:4318', - 'OTEL_EXPORTER_OTLP_PROTOCOL': 'http/protobuf' - }, clear=True): + mock_traceloop_components["get_app_name"].return_value = "test-app" + mock_traceloop_components["create_resource"].return_value = {} + + with patch.dict( + "os.environ", + { + "OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:4318", + "OTEL_EXPORTER_OTLP_PROTOCOL": "http/protobuf", + }, + clear=True, + ): auto_instrument() - mock_traceloop_components['http_exporter'].assert_called_once_with() - mock_traceloop_components['grpc_exporter'].assert_not_called() + mock_traceloop_components["http_exporter"].assert_called_once_with() + mock_traceloop_components["grpc_exporter"].assert_not_called() - def test_auto_instrument_passes_transformer_to_traceloop(self, mock_traceloop_components): + def test_auto_instrument_passes_transformer_to_traceloop( + self, mock_traceloop_components + ): """Test that auto_instrument passes the GenAIAttributeTransformer as exporter to Traceloop.""" - mock_traceloop_components['get_app_name'].return_value = 'test-app' - mock_traceloop_components['create_resource'].return_value = {} + mock_traceloop_components["get_app_name"].return_value = "test-app" + mock_traceloop_components["create_resource"].return_value = {} mock_transformer_instance = MagicMock() - mock_traceloop_components['transformer'].return_value = mock_transformer_instance - - with patch.dict('os.environ', {'OTEL_EXPORTER_OTLP_ENDPOINT': 'http://localhost:4317'}, clear=True): + mock_traceloop_components[ + "transformer" + ].return_value = mock_transformer_instance + + with patch.dict( + "os.environ", + {"OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:4317"}, + clear=True, + ): auto_instrument() # Verify transformer was created with base exporter - mock_traceloop_components['transformer'].assert_called_once() + mock_traceloop_components["transformer"].assert_called_once() # Verify Traceloop.init was called with the transformer as exporter - call_kwargs = mock_traceloop_components['traceloop'].init.call_args[1] - assert call_kwargs['exporter'] == mock_transformer_instance + call_kwargs = mock_traceloop_components["traceloop"].init.call_args[1] + assert call_kwargs["exporter"] == mock_transformer_instance - def test_auto_instrument_legacy_schema_parameter_ignored(self, mock_traceloop_components): + def test_auto_instrument_legacy_schema_parameter_ignored( + self, mock_traceloop_components + ): """Test that legacy_schema parameter is accepted but doesn't affect behavior.""" - mock_traceloop_components['get_app_name'].return_value = 'test-app' - mock_traceloop_components['create_resource'].return_value = {} - - with patch.dict('os.environ', {'OTEL_EXPORTER_OTLP_ENDPOINT': 'http://localhost:4317'}, clear=True): + mock_traceloop_components["get_app_name"].return_value = "test-app" + mock_traceloop_components["create_resource"].return_value = {} + + with patch.dict( + "os.environ", + {"OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:4317"}, + clear=True, + ): # Should not raise an error auto_instrument() auto_instrument() auto_instrument() # Verify Traceloop was initialized each time - assert mock_traceloop_components['traceloop'].init.call_count == 3 + assert mock_traceloop_components["traceloop"].init.call_count == 3 def test_auto_instrument_with_console_exporter(self, mock_traceloop_components): """Test that auto_instrument uses ConsoleSpanExporter when OTEL_TRACES_EXPORTER=console.""" - mock_traceloop_components['get_app_name'].return_value = 'test-app' - mock_traceloop_components['create_resource'].return_value = {} + mock_traceloop_components["get_app_name"].return_value = "test-app" + mock_traceloop_components["create_resource"].return_value = {} - with patch.dict('os.environ', {'OTEL_TRACES_EXPORTER': 'console'}, clear=True): + with patch.dict("os.environ", {"OTEL_TRACES_EXPORTER": "console"}, clear=True): auto_instrument() - mock_traceloop_components['console_exporter'].assert_called_once_with() - mock_traceloop_components['grpc_exporter'].assert_not_called() - mock_traceloop_components['http_exporter'].assert_not_called() - mock_traceloop_components['traceloop'].init.assert_called_once() + mock_traceloop_components["console_exporter"].assert_called_once_with() + mock_traceloop_components["grpc_exporter"].assert_not_called() + mock_traceloop_components["http_exporter"].assert_not_called() + mock_traceloop_components["traceloop"].init.assert_called_once() - def test_auto_instrument_console_exporter_case_insensitive(self, mock_traceloop_components): + def test_auto_instrument_console_exporter_case_insensitive( + self, mock_traceloop_components + ): """Test that OTEL_TRACES_EXPORTER=console matching is case insensitive.""" - mock_traceloop_components['get_app_name'].return_value = 'test-app' - mock_traceloop_components['create_resource'].return_value = {} + mock_traceloop_components["get_app_name"].return_value = "test-app" + mock_traceloop_components["create_resource"].return_value = {} - for value in ['CONSOLE', 'Console', 'CONSOLE']: - mock_traceloop_components['console_exporter'].reset_mock() - mock_traceloop_components['traceloop'].reset_mock() - with patch.dict('os.environ', {'OTEL_TRACES_EXPORTER': value}, clear=True): + for value in ["CONSOLE", "Console", "CONSOLE"]: + mock_traceloop_components["console_exporter"].reset_mock() + mock_traceloop_components["traceloop"].reset_mock() + with patch.dict("os.environ", {"OTEL_TRACES_EXPORTER": value}, clear=True): auto_instrument() - mock_traceloop_components['console_exporter'].assert_called_once_with() + mock_traceloop_components["console_exporter"].assert_called_once_with() - def test_auto_instrument_console_wins_when_both_set(self, mock_traceloop_components): + def test_auto_instrument_console_wins_when_both_set( + self, mock_traceloop_components + ): """Test that console exporter is used when OTEL_TRACES_EXPORTER=console, even if OTLP endpoint is also set.""" - mock_traceloop_components['get_app_name'].return_value = 'test-app' - mock_traceloop_components['create_resource'].return_value = {} - - with patch.dict('os.environ', { - 'OTEL_EXPORTER_OTLP_ENDPOINT': 'http://localhost:4317', - 'OTEL_TRACES_EXPORTER': 'console', - }, clear=True): + mock_traceloop_components["get_app_name"].return_value = "test-app" + mock_traceloop_components["create_resource"].return_value = {} + + with patch.dict( + "os.environ", + { + "OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:4317", + "OTEL_TRACES_EXPORTER": "console", + }, + clear=True, + ): auto_instrument() - mock_traceloop_components['console_exporter'].assert_called_once_with() - mock_traceloop_components['grpc_exporter'].assert_not_called() - mock_traceloop_components['http_exporter'].assert_not_called() + mock_traceloop_components["console_exporter"].assert_called_once_with() + mock_traceloop_components["grpc_exporter"].assert_not_called() + mock_traceloop_components["http_exporter"].assert_not_called() - def test_auto_instrument_console_wraps_with_transformer(self, mock_traceloop_components): + def test_auto_instrument_console_wraps_with_transformer( + self, mock_traceloop_components + ): """Test that ConsoleSpanExporter is wrapped with GenAIAttributeTransformer.""" - mock_traceloop_components['get_app_name'].return_value = 'test-app' - mock_traceloop_components['create_resource'].return_value = {} + mock_traceloop_components["get_app_name"].return_value = "test-app" + mock_traceloop_components["create_resource"].return_value = {} mock_console_instance = MagicMock() - mock_traceloop_components['console_exporter'].return_value = mock_console_instance + mock_traceloop_components[ + "console_exporter" + ].return_value = mock_console_instance - with patch.dict('os.environ', {'OTEL_TRACES_EXPORTER': 'console'}, clear=True): + with patch.dict("os.environ", {"OTEL_TRACES_EXPORTER": "console"}, clear=True): auto_instrument() - mock_traceloop_components['transformer'].assert_called_once_with(mock_console_instance) + mock_traceloop_components["transformer"].assert_called_once_with( + mock_console_instance + ) def test_auto_instrument_without_endpoint_or_console(self): """Test that auto_instrument warns when neither OTLP endpoint nor console exporter is configured.""" - with patch.dict('os.environ', {}, clear=True): - with patch('sap_cloud_sdk.core.telemetry.auto_instrument.logger') as mock_logger: + with patch.dict("os.environ", {}, clear=True): + with patch( + "sap_cloud_sdk.core.telemetry.auto_instrument.logger" + ) as mock_logger: auto_instrument() mock_logger.warning.assert_called_once() warning_message = mock_logger.warning.call_args[0][0] assert "OTEL_EXPORTER_OTLP_ENDPOINT not set" in warning_message - def test_auto_instrument_disable_batch_can_be_set_to_true(self, mock_traceloop_components): + def test_auto_instrument_disable_batch_can_be_set_to_true( + self, mock_traceloop_components + ): """Test that disable_batch=True can be explicitly passed to use SimpleSpanProcessor.""" - mock_traceloop_components['get_app_name'].return_value = 'test-app' - mock_traceloop_components['create_resource'].return_value = {} - - with patch.dict('os.environ', {'OTEL_EXPORTER_OTLP_ENDPOINT': 'http://localhost:4317'}, clear=True): + mock_traceloop_components["get_app_name"].return_value = "test-app" + mock_traceloop_components["create_resource"].return_value = {} + + with patch.dict( + "os.environ", + {"OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:4317"}, + clear=True, + ): auto_instrument(disable_batch=True) - call_kwargs = mock_traceloop_components['traceloop'].init.call_args[1] - assert call_kwargs['disable_batch'] is True + call_kwargs = mock_traceloop_components["traceloop"].init.call_args[1] + assert call_kwargs["disable_batch"] is True - def test_auto_instrument_passes_baggage_span_processor(self, mock_traceloop_components): + def test_auto_instrument_passes_baggage_span_processor( + self, mock_traceloop_components + ): """Test that auto_instrument registers a BaggageSpanProcessor on the tracer provider.""" - mock_traceloop_components['get_app_name'].return_value = 'test-app' - mock_traceloop_components['create_resource'].return_value = {} + mock_traceloop_components["get_app_name"].return_value = "test-app" + mock_traceloop_components["create_resource"].return_value = {} mock_processor_instance = MagicMock() - mock_traceloop_components['baggage_processor'].return_value = mock_processor_instance - - with patch.dict('os.environ', {'OTEL_EXPORTER_OTLP_ENDPOINT': 'http://localhost:4317'}, clear=True): + mock_traceloop_components[ + "baggage_processor" + ].return_value = mock_processor_instance + + with patch.dict( + "os.environ", + {"OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:4317"}, + clear=True, + ): auto_instrument() - mock_traceloop_components['baggage_processor'].assert_called_once() - mock_traceloop_components['get_tracer_provider'].return_value.add_span_processor.assert_any_call(mock_processor_instance) + mock_traceloop_components["baggage_processor"].assert_called_once() + mock_traceloop_components[ + "get_tracer_provider" + ].return_value.add_span_processor.assert_any_call(mock_processor_instance) - def test_auto_instrument_registers_propagated_attributes_processor(self, mock_traceloop_components): + def test_auto_instrument_registers_propagated_attributes_processor( + self, mock_traceloop_components + ): """Test that auto_instrument registers a PropagatedAttributesSpanProcessor on the tracer provider.""" - mock_traceloop_components['get_app_name'].return_value = 'test-app' - mock_traceloop_components['create_resource'].return_value = {} + mock_traceloop_components["get_app_name"].return_value = "test-app" + mock_traceloop_components["create_resource"].return_value = {} mock_processor_instance = MagicMock() - mock_traceloop_components['propagated_processor'].return_value = mock_processor_instance - - with patch.dict('os.environ', {'OTEL_EXPORTER_OTLP_ENDPOINT': 'http://localhost:4317'}, clear=True): + mock_traceloop_components[ + "propagated_processor" + ].return_value = mock_processor_instance + + with patch.dict( + "os.environ", + {"OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:4317"}, + clear=True, + ): auto_instrument() - mock_traceloop_components['propagated_processor'].assert_called_once() - mock_traceloop_components['get_tracer_provider'].return_value.add_span_processor.assert_any_call(mock_processor_instance) + mock_traceloop_components["propagated_processor"].assert_called_once() + mock_traceloop_components[ + "get_tracer_provider" + ].return_value.add_span_processor.assert_any_call(mock_processor_instance) - def test_auto_instrument_merges_resource_when_wrapper_installed(self, mock_traceloop_components): + def test_auto_instrument_merges_resource_when_wrapper_installed( + self, mock_traceloop_components + ): """When an OTel auto-instrumentation wrapper (e.g. an OpenTelemetry-Operator init-container injection) has pre-installed a TracerProvider whose Resource carries the standard `telemetry.auto.version` marker, auto_instrument merges sap-cloud-sdk attrs onto that provider's existing Resource — preserving the operator-supplied attrs and adding our SAP enrichment on top.""" - mock_traceloop_components['get_app_name'].return_value = 'cloud-sdk-app' + mock_traceloop_components["get_app_name"].return_value = "cloud-sdk-app" sap_attrs = { - 'service.name': 'cloud-sdk-app', - 'sap.cloud_sdk.name': 'SAP Cloud SDK for Python', - 'sap.cloud_sdk.language': 'python', - 'sap.solution_area': 'fina', - 'mlflow.experiment_id': '1635264705567712', + "service.name": "cloud-sdk-app", + "sap.cloud_sdk.name": "SAP Cloud SDK for Python", + "sap.cloud_sdk.language": "python", + "sap.solution_area": "fina", + "mlflow.experiment_id": "1635264705567712", } - mock_traceloop_components['create_resource'].return_value = sap_attrs + mock_traceloop_components["create_resource"].return_value = sap_attrs wrapper_provider = SDKTracerProvider( - resource=Resource.create({ - 'telemetry.auto.version': '0.62b1', - 'k8s.deployment.name': 'cloud-sdk-app-deployment', - 'service.name': 'cloud-sdk-app-deployment', - }) + resource=Resource.create( + { + "telemetry.auto.version": "0.62b1", + "k8s.deployment.name": "cloud-sdk-app-deployment", + "service.name": "cloud-sdk-app-deployment", + } + ) ) - mock_traceloop_components['get_tracer_provider'].return_value = wrapper_provider + mock_traceloop_components["get_tracer_provider"].return_value = wrapper_provider - with patch.dict('os.environ', {'OTEL_EXPORTER_OTLP_ENDPOINT': 'http://localhost:4317'}, clear=True): + with patch.dict( + "os.environ", + {"OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:4317"}, + clear=True, + ): auto_instrument() merged_attrs = wrapper_provider.resource.attributes # Operator-supplied attrs are preserved. - assert merged_attrs['telemetry.auto.version'] == '0.62b1' - assert merged_attrs['k8s.deployment.name'] == 'cloud-sdk-app-deployment' + assert merged_attrs["telemetry.auto.version"] == "0.62b1" + assert merged_attrs["k8s.deployment.name"] == "cloud-sdk-app-deployment" # sap-cloud-sdk attrs are added. - assert merged_attrs['sap.cloud_sdk.name'] == 'SAP Cloud SDK for Python' - assert merged_attrs['sap.cloud_sdk.language'] == 'python' - assert merged_attrs['sap.solution_area'] == 'fina' - assert merged_attrs['mlflow.experiment_id'] == '1635264705567712' - - def test_auto_instrument_skips_merge_when_no_wrapper_marker(self, mock_traceloop_components): + assert merged_attrs["sap.cloud_sdk.name"] == "SAP Cloud SDK for Python" + assert merged_attrs["sap.cloud_sdk.language"] == "python" + assert merged_attrs["sap.solution_area"] == "fina" + assert merged_attrs["mlflow.experiment_id"] == "1635264705567712" + + def test_auto_instrument_skips_merge_when_no_wrapper_marker( + self, mock_traceloop_components + ): """When the active TracerProvider's Resource lacks the `telemetry.auto.version` marker (e.g. a self-installed provider, or no wrapper at all), auto_instrument does not mutate the provider's Resource.""" - mock_traceloop_components['get_app_name'].return_value = 'cloud-sdk-app' - mock_traceloop_components['create_resource'].return_value = { - 'sap.cloud_sdk.name': 'SAP Cloud SDK for Python', - 'sap.solution_area': 'fina', + mock_traceloop_components["get_app_name"].return_value = "cloud-sdk-app" + mock_traceloop_components["create_resource"].return_value = { + "sap.cloud_sdk.name": "SAP Cloud SDK for Python", + "sap.solution_area": "fina", } - initial_resource = Resource.create({'service.name': 'self-installed'}) + initial_resource = Resource.create({"service.name": "self-installed"}) plain_provider = SDKTracerProvider(resource=initial_resource) - mock_traceloop_components['get_tracer_provider'].return_value = plain_provider + mock_traceloop_components["get_tracer_provider"].return_value = plain_provider - with patch.dict('os.environ', {'OTEL_EXPORTER_OTLP_ENDPOINT': 'http://localhost:4317'}, clear=True): + with patch.dict( + "os.environ", + {"OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:4317"}, + clear=True, + ): auto_instrument() # Resource is the original instance — no merge took place. assert plain_provider.resource is initial_resource - assert 'sap.cloud_sdk.name' not in plain_provider.resource.attributes - assert 'sap.solution_area' not in plain_provider.resource.attributes + assert "sap.cloud_sdk.name" not in plain_provider.resource.attributes + assert "sap.solution_area" not in plain_provider.resource.attributes - def test_auto_instrument_merge_overrides_colliding_service_name(self, mock_traceloop_components): + def test_auto_instrument_merge_overrides_colliding_service_name( + self, mock_traceloop_components + ): """On a wrapper-installed provider, sap-cloud-sdk attrs override colliding keys: service.name from APPFND_CONHOS_APP_NAME wins over the operator's k8s-deployment-derived service.name.""" - mock_traceloop_components['get_app_name'].return_value = 'cloud-sdk-app' - mock_traceloop_components['create_resource'].return_value = { - 'service.name': 'cloud-sdk-app', + mock_traceloop_components["get_app_name"].return_value = "cloud-sdk-app" + mock_traceloop_components["create_resource"].return_value = { + "service.name": "cloud-sdk-app", } wrapper_provider = SDKTracerProvider( - resource=Resource.create({ - 'telemetry.auto.version': '0.62b1', - 'service.name': 'operator-supplied-name', - }) + resource=Resource.create( + { + "telemetry.auto.version": "0.62b1", + "service.name": "operator-supplied-name", + } + ) ) - mock_traceloop_components['get_tracer_provider'].return_value = wrapper_provider + mock_traceloop_components["get_tracer_provider"].return_value = wrapper_provider - with patch.dict('os.environ', {'OTEL_EXPORTER_OTLP_ENDPOINT': 'http://localhost:4317'}, clear=True): + with patch.dict( + "os.environ", + {"OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:4317"}, + clear=True, + ): auto_instrument() - assert wrapper_provider.resource.attributes['service.name'] == 'cloud-sdk-app' + assert ( + wrapper_provider.resource.attributes["service.name"] == "cloud-sdk-app" + ) + + def test_auto_instrument_unwraps_mcp_otel_instrumentation( + self, mock_traceloop_components + ): + """Test that successful init unwraps MCP OTel instrumentation.""" + mock_traceloop_components["get_app_name"].return_value = "test-app" + mock_traceloop_components["create_resource"].return_value = {} + + with patch( + "sap_cloud_sdk.core.telemetry.auto_instrument._unwrap_mcp_otel_instrumentation" + ) as mock_unwrap: + with patch.dict( + "os.environ", + {"OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:4317"}, + clear=True, + ): + auto_instrument() + mock_unwrap.assert_called_once() + + def test_auto_instrument_skips_mcp_unwrap_when_disabled( + self, mock_traceloop_components + ): + """When instrumentation is disabled, MCP unwrap is not attempted.""" + with patch( + "sap_cloud_sdk.core.telemetry.auto_instrument._unwrap_mcp_otel_instrumentation" + ) as mock_unwrap: + with patch.dict("os.environ", {}, clear=True): + auto_instrument() + mock_unwrap.assert_not_called() class TestAutoInstrumentMiddlewares: """Tests for the middlewares parameter of auto_instrument.""" - def test_middlewares_register_called_and_processor_added(self, mock_traceloop_components): - mock_traceloop_components['get_app_name'].return_value = 'test-app' - mock_traceloop_components['create_resource'].return_value = {} + def test_middlewares_register_called_and_processor_added( + self, mock_traceloop_components + ): + mock_traceloop_components["get_app_name"].return_value = "test-app" + mock_traceloop_components["create_resource"].return_value = {} middleware = MagicMock() - with patch('sap_cloud_sdk.core.telemetry.auto_instrument._register_middleware_processors') as mock_reg: - with patch.dict('os.environ', {'OTEL_EXPORTER_OTLP_ENDPOINT': 'http://localhost:4317'}, clear=True): + with patch( + "sap_cloud_sdk.core.telemetry.auto_instrument._register_middleware_processors" + ) as mock_reg: + with patch.dict( + "os.environ", + {"OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:4317"}, + clear=True, + ): auto_instrument(middlewares=[middleware]) mock_reg.assert_called_once_with([middleware]) - def test_middlewares_none_does_not_register_processor(self, mock_traceloop_components): - mock_traceloop_components['get_app_name'].return_value = 'test-app' - mock_traceloop_components['create_resource'].return_value = {} - - with patch('sap_cloud_sdk.core.telemetry.auto_instrument._register_middleware_processors') as mock_reg: - with patch.dict('os.environ', {'OTEL_EXPORTER_OTLP_ENDPOINT': 'http://localhost:4317'}, clear=True): + def test_middlewares_none_does_not_register_processor( + self, mock_traceloop_components + ): + mock_traceloop_components["get_app_name"].return_value = "test-app" + mock_traceloop_components["create_resource"].return_value = {} + + with patch( + "sap_cloud_sdk.core.telemetry.auto_instrument._register_middleware_processors" + ) as mock_reg: + with patch.dict( + "os.environ", + {"OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:4317"}, + clear=True, + ): auto_instrument(middlewares=None) mock_reg.assert_not_called() - def test_empty_middlewares_list_does_not_register_processor(self, mock_traceloop_components): - mock_traceloop_components['get_app_name'].return_value = 'test-app' - mock_traceloop_components['create_resource'].return_value = {} - - with patch('sap_cloud_sdk.core.telemetry.auto_instrument._register_middleware_processors') as mock_reg: - with patch.dict('os.environ', {'OTEL_EXPORTER_OTLP_ENDPOINT': 'http://localhost:4317'}, clear=True): + def test_empty_middlewares_list_does_not_register_processor( + self, mock_traceloop_components + ): + mock_traceloop_components["get_app_name"].return_value = "test-app" + mock_traceloop_components["create_resource"].return_value = {} + + with patch( + "sap_cloud_sdk.core.telemetry.auto_instrument._register_middleware_processors" + ) as mock_reg: + with patch.dict( + "os.environ", + {"OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:4317"}, + clear=True, + ): auto_instrument(middlewares=[]) mock_reg.assert_not_called() - def test_register_middleware_processors_calls_register_and_adds_processor(self, mock_traceloop_components): - from sap_cloud_sdk.core.telemetry.auto_instrument import _register_middleware_processors - from sap_cloud_sdk.core.telemetry.middleware.span_processor import MiddlewareSpanProcessor + def test_register_middleware_processors_calls_register_and_adds_processor( + self, mock_traceloop_components + ): + from sap_cloud_sdk.core.telemetry.auto_instrument import ( + _register_middleware_processors, + ) + from sap_cloud_sdk.core.telemetry.middleware.span_processor import ( + MiddlewareSpanProcessor, + ) from opentelemetry.sdk.trace import TracerProvider as SDKTracerProvider from unittest.mock import create_autospec provider = create_autospec(SDKTracerProvider) middleware = MagicMock() - with patch('sap_cloud_sdk.core.telemetry.auto_instrument.trace.get_tracer_provider', return_value=provider): + with patch( + "sap_cloud_sdk.core.telemetry.auto_instrument.trace.get_tracer_provider", + return_value=provider, + ): _register_middleware_processors([middleware]) middleware.register.assert_called_once_with() @@ -379,29 +590,51 @@ def test_register_middleware_processors_calls_register_and_adds_processor(self, call_arg = provider.add_span_processor.call_args[0][0] assert isinstance(call_arg, MiddlewareSpanProcessor) - def test_register_middleware_processors_skips_when_unknown_provider(self, mock_traceloop_components): - from sap_cloud_sdk.core.telemetry.auto_instrument import _register_middleware_processors + def test_register_middleware_processors_skips_when_unknown_provider( + self, mock_traceloop_components + ): + from sap_cloud_sdk.core.telemetry.auto_instrument import ( + _register_middleware_processors, + ) middleware = MagicMock() unknown_provider = MagicMock() # not a TracerProvider instance - with patch('sap_cloud_sdk.core.telemetry.auto_instrument.trace.get_tracer_provider', return_value=unknown_provider): - with patch('sap_cloud_sdk.core.telemetry.auto_instrument.logger') as mock_logger: + with patch( + "sap_cloud_sdk.core.telemetry.auto_instrument.trace.get_tracer_provider", + return_value=unknown_provider, + ): + with patch( + "sap_cloud_sdk.core.telemetry.auto_instrument.logger" + ) as mock_logger: _register_middleware_processors([middleware]) mock_logger.warning.assert_called_once() middleware.register.assert_not_called() unknown_provider.add_span_processor.assert_not_called() - def test_baggage_and_middleware_processors_both_added(self, mock_traceloop_components): - mock_traceloop_components['get_app_name'].return_value = 'test-app' - mock_traceloop_components['create_resource'].return_value = {} + def test_baggage_and_middleware_processors_both_added( + self, mock_traceloop_components + ): + mock_traceloop_components["get_app_name"].return_value = "test-app" + mock_traceloop_components["create_resource"].return_value = {} middleware = MagicMock() mock_baggage_instance = MagicMock() - mock_traceloop_components['baggage_processor'].return_value = mock_baggage_instance - - with patch.dict('os.environ', {'OTEL_EXPORTER_OTLP_ENDPOINT': 'http://localhost:4317'}, clear=True): + mock_traceloop_components[ + "baggage_processor" + ].return_value = mock_baggage_instance + + with patch.dict( + "os.environ", + {"OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:4317"}, + clear=True, + ): auto_instrument(middlewares=[middleware]) # add_span_processor called 3 times: baggage, propagated attributes, middleware - assert mock_traceloop_components['get_tracer_provider'].return_value.add_span_processor.call_count == 3 + assert ( + mock_traceloop_components[ + "get_tracer_provider" + ].return_value.add_span_processor.call_count + == 3 + ) diff --git a/uv.lock b/uv.lock index 92e88dcb..4318f36f 100644 --- a/uv.lock +++ b/uv.lock @@ -925,7 +925,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/42/3c/ff890b466eaba2b0f5e6bdfff025f8c75f41b8ffdc3dbc3d24ad261e764a/greenlet-3.5.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:73f78f9b9f0a5c06e5c946ba1e8e36f5114923b6be109ee618c54f079c3ea14f", size = 284764, upload-time = "2026-05-20T13:09:10.204Z" }, { url = "https://files.pythonhosted.org/packages/81/0e/5e5457be3d256918f6a4756f073548a3f0190836e2cc94aa6d0d617a940b/greenlet-3.5.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0cbed8bb44e23c5b199f888f4e4ce096b45ad9f25ff74a7ad0213875e936bb2", size = 603479, upload-time = "2026-05-20T14:00:04.757Z" }, { url = "https://files.pythonhosted.org/packages/6d/e1/f89a21d58d308298e6f275f13a1b472ed96c680b601a371b08be6a725989/greenlet-3.5.1-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a203a8bd0acb0701653d3bbb26e404854a68674139ed5cbb778830f42b09bb33", size = 615495, upload-time = "2026-05-20T14:05:40.87Z" }, + { url = "https://files.pythonhosted.org/packages/2c/f2/8fd452fd81adb9ec79c8275c1375702ab0fd6bee4952da12eaa09b9508d8/greenlet-3.5.1-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6ebeb75c81211f5c702576cf81f315e77e23cfdb2c7c6fcb9dd143e6de35c360", size = 623515, upload-time = "2026-05-20T14:09:07.853Z" }, { url = "https://files.pythonhosted.org/packages/75/de/af6cef182862d2ccd6975440d21c9058a77c3f9b469abf94e322dfd2e0e3/greenlet-3.5.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a271fcd66c74615cda6a964fda3f304267a12e50a084472218a39bb0376f563", size = 614754, upload-time = "2026-05-20T13:14:24.947Z" }, + { url = "https://files.pythonhosted.org/packages/ec/bc/c318aa9f3ffc77320fddcee3d892be957b42e2ff947198d9450b004f3a38/greenlet-3.5.1-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:017a544f0385d441e88714160d089d6900ef46c9eff9d99b6715a5ef2d127747", size = 418439, upload-time = "2026-05-20T14:01:38.446Z" }, { url = "https://files.pythonhosted.org/packages/1a/c6/50e520283a9f19388a7326b05f9e8637e566003475eacaadad04f558c68d/greenlet-3.5.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ded7b068c7c31c1a8657d4fd42d886b3e051ae29f88b80c5ff9d502257b0f071", size = 1574097, upload-time = "2026-05-20T14:02:24.003Z" }, { url = "https://files.pythonhosted.org/packages/21/1c/13abd1f4860d987fa5e1170a01930d6e6cd40d328de487a3c9fdaff0ffd0/greenlet-3.5.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d0932b81d72f552ded9d810d00021b64d89f2195a91ce115b893f943b7a4ab3c", size = 1641058, upload-time = "2026-05-20T13:14:31.83Z" }, { url = "https://files.pythonhosted.org/packages/f5/56/5f332b7705545eac2dc01b4e9254d24a793f2656d55d5cc6b94ee59d22ae/greenlet-3.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:88e300d136eac057b2397aa1cfd7328b4c87c7eb66a09c7bc6a1292234db474e", size = 238089, upload-time = "2026-05-20T13:14:03.229Z" }, @@ -933,7 +935,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c4/37/4549f149c9797c21b32c2683c33522af22522099de128b2406672526d005/greenlet-3.5.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:fa4f98af3a528f0c3fd592a26df7f376f93329c8f4d987f6bb979057af8bf5e2", size = 286220, upload-time = "2026-05-20T13:07:28.463Z" }, { url = "https://files.pythonhosted.org/packages/38/ff/a4f436709716965eaab9f36ea7b906c8a927fbe32fb1372a2071d964f6b1/greenlet-3.5.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ffea73584b216150eab159b6d12348fb253e68757974de1e2c40d8a318ac89ed", size = 601585, upload-time = "2026-05-20T14:00:06.141Z" }, { url = "https://files.pythonhosted.org/packages/65/ad/54bc3fcee3ad368a61b19b67d88117f7a8c29727bf71fffdeda81fbd946e/greenlet-3.5.1-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1072b4f9edcc1e192d9283a66a3e68d6b84c561de33a83d7858beb9ba1effe10", size = 614215, upload-time = "2026-05-20T14:05:42.675Z" }, + { url = "https://files.pythonhosted.org/packages/7c/6c/de5b1b388cd2d9fbdfeab324863daba37d54e6e233ddbefd70b385a8c591/greenlet-3.5.1-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:89101bfd5011e069be974903cb3a4e4523845e4ece2d62dcd8d358933c0ef249", size = 620094, upload-time = "2026-05-20T14:09:09.18Z" }, { url = "https://files.pythonhosted.org/packages/40/69/b91cda0647df839483201545913514c2827ebea5e5ccdf931842763bc127/greenlet-3.5.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:add5217d68b31130f0beca584d7fef4878327d2e31642b66618a14eef312b63b", size = 611358, upload-time = "2026-05-20T13:14:26.37Z" }, + { url = "https://files.pythonhosted.org/packages/4a/43/1204baffab8a6476464795a7ccf394a3248d4f22c9f87173a15b36b6d971/greenlet-3.5.1-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:e6cd99ea59dd5d89f0c956606571d79bfe6f68c9eb7f4a4083a41a7f1587edee", size = 422782, upload-time = "2026-05-20T14:01:39.597Z" }, { url = "https://files.pythonhosted.org/packages/59/90/3cf77e080350cd02fa307bb2abf05df48f4482c240275bbd2c203ba8bb1c/greenlet-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a5ea42a752d47a145eae922b605cd1634665ac3d5ec1e72402d5048e8d60d207", size = 1570475, upload-time = "2026-05-20T14:02:25.29Z" }, { url = "https://files.pythonhosted.org/packages/65/2c/18cece62045e74598c3c393f70dce4a63f56222015ba29a5d4eeb04f764c/greenlet-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c5551170cf4f5ff5623e9af81323751979fee2c731e2287b61f73cd27257b823", size = 1635625, upload-time = "2026-05-20T13:14:34.027Z" }, { url = "https://files.pythonhosted.org/packages/30/f5/310d104ddf41eb5a70f4c268d22508dfb0c3c8e86fec152be34d0d2ed819/greenlet-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:3c8bb982ad117d29478ef8f5533e97df21f1e2befd17a299257b0c96d1371c0b", size = 238791, upload-time = "2026-05-20T13:10:39.018Z" }, @@ -941,7 +945,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/27/69/7f7e5372d998b81001899b1c0823c957aa413ba0f2662e65821611cc31e4/greenlet-3.5.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:51518ff74664078fc51bffcc6fc529b0df5ae58da192691cee765d45ce944a2b", size = 285060, upload-time = "2026-05-20T13:08:51.899Z" }, { url = "https://files.pythonhosted.org/packages/b1/bf/387f9b6b865fd2ae0d0be09e0004827295a01b71be76ed350dd1e28a91a4/greenlet-3.5.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ffdb3c0bb002c99cd8f298957e046c3dbf6006b5b7cdf11a4e19194624a0a0a", size = 604370, upload-time = "2026-05-20T14:00:07.492Z" }, { url = "https://files.pythonhosted.org/packages/32/f5/169ce3d4e4c67291bd18f8cbe0299c9f3e45102c7f1fb3c14780c93e4532/greenlet-3.5.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7715a5a2c3378ba602c3a440558261e13a820bb53a82693aacd7b7f6d964e283", size = 616987, upload-time = "2026-05-20T14:05:44.237Z" }, + { url = "https://files.pythonhosted.org/packages/19/ba/c24110c55dffa55aa6e1d98b45310da33801aeba7686ff0190fe5d46fd32/greenlet-3.5.1-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d40a890035c0058cadbdc4af7569800fd28a0e527a0fdbb7b5f9418f176846ce", size = 622911, upload-time = "2026-05-20T14:09:10.598Z" }, { url = "https://files.pythonhosted.org/packages/ee/e5/7f2e41d5273be07e77560d61ea4e56485b4d6c316d2a84518c62d1364061/greenlet-3.5.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc71ff466927a201b08305acac451ebe1aedfcea002f62f1f2f2ac2ac1e6a135", size = 613911, upload-time = "2026-05-20T13:14:27.539Z" }, + { url = "https://files.pythonhosted.org/packages/ec/7b/d20db2e8a5ad6c038702f3179b136f93f0a3d1a21a0c0777f3e470cdf4b2/greenlet-3.5.1-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:67821bb03e4e98664490edb787ff6af501194c29bbee0f5c1dfdcf1dc3d9d436", size = 425228, upload-time = "2026-05-20T14:01:40.837Z" }, { url = "https://files.pythonhosted.org/packages/c5/a4/fbdc67579b73615a1f91615e814303cc71e06128f7baaba87be79b8fb90c/greenlet-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cd443683db272ebaaca03af98c0b063ab30db70ea8a31a1559f35e3f7b744ccd", size = 1570689, upload-time = "2026-05-20T14:02:27.225Z" }, { url = "https://files.pythonhosted.org/packages/e6/b4/77abbe35078be39718a46cd49caf16bceb35662f97a34101dca28aa98e47/greenlet-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:089fff7a6ce8d9316d1f65ebc00273a56be258c1725b32b94de90a3a979557e1", size = 1635602, upload-time = "2026-05-20T13:14:36.344Z" }, { url = "https://files.pythonhosted.org/packages/37/f7/129f27ca700845b8ee8ca88ce7f43435a1239c2eddb7677fc938822762cf/greenlet-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:110a1ca7b49b014b097f6078272c3f4ed31af45b254de5228b79adba879f6af9", size = 238683, upload-time = "2026-05-20T13:11:50.57Z" }, @@ -949,7 +955,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/cb/c62454606daf5640369c94d8a9dd540599b1bfc090e2d2180cb77f4038d2/greenlet-3.5.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d8ab31c9de8651a2facdd5c5bb0011f2380dd1a7af78ce2adf4b56095294fc07", size = 285579, upload-time = "2026-05-20T13:08:56.396Z" }, { url = "https://files.pythonhosted.org/packages/ec/71/c4270398c2eba968a6071af1dfbdcaeee6ec1c24bc8b435b8cc452700da6/greenlet-3.5.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e300185139abc337ade480c327183adf42a875ac7181bfe66d7d4efea31fbea", size = 651106, upload-time = "2026-05-20T14:00:09.448Z" }, { url = "https://files.pythonhosted.org/packages/1a/ab/71e34b78a44ec271fb5f550c17bc46d301ddc5953890d935f270b0dcdb5a/greenlet-3.5.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7ffdb990dcaa0234cf9845aead5df2e3c3a8b6507d409274dd87e0d5ab05ffc2", size = 663478, upload-time = "2026-05-20T14:05:45.88Z" }, + { url = "https://files.pythonhosted.org/packages/c6/2d/2d80842910da44f78c286532d084b8a5c3717c844ae80ceb3858738ae89a/greenlet-3.5.1-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c09df69dc1712d131332054a858a3e5cca400967fa3a672e2324fbb0971448c", size = 667767, upload-time = "2026-05-20T14:09:12.15Z" }, { url = "https://files.pythonhosted.org/packages/77/96/4efd6fa5c62c85426a0c19077a586258ebc3a2a146ff2493e4312a697a22/greenlet-3.5.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f82b3597e9d83b63408affed0b48fd0f54935edac4302237b9a837be0dae33c", size = 660800, upload-time = "2026-05-20T13:14:29.129Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d3/dad2eecedfbb1ed7050a20dcfae40c1442b74bc7423608be2c7e03ee7133/greenlet-3.5.1-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:a4764e0bfc6a4d114c865b32520805c16a990ef5f286a514413b05d5ecd6a23d", size = 470786, upload-time = "2026-05-20T14:01:42.064Z" }, { url = "https://files.pythonhosted.org/packages/7a/e0/6c71401a25cac7000261304e866a2f2cc04dc74810d40e2f118aa4799495/greenlet-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c0141e37414c10164e702b8fb1473304221ad98f71600850c6ef7ff4880feba0", size = 1617518, upload-time = "2026-05-20T14:02:28.662Z" }, { url = "https://files.pythonhosted.org/packages/41/26/c5c06643e8c0af9e7bf18e16cb51d0ab7625155f0392e1c9015d66d556cd/greenlet-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:50ae25a67bea74ea41fb14b960bc532df73eb713417b2d61892dced82fe8d3bc", size = 1681593, upload-time = "2026-05-20T13:14:39.417Z" }, { url = "https://files.pythonhosted.org/packages/8a/bd/e11a108317485075e68af9d23039619b86b28130c3b50d227d42edece64b/greenlet-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:8a17c42330e261299766b75ac1ea32caa437a9453c8f65d16a13140db378ecd3", size = 239800, upload-time = "2026-05-20T13:09:30.128Z" }, @@ -957,14 +965,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/90/12/41bf27fde4d3605d3773ae57751eda182b8be2f5398011c041173b1d9534/greenlet-3.5.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:ea8da1e900d758d078810d4255d8c6aa572181896a31ec79d779eb79c3adc9ad", size = 293637, upload-time = "2026-05-20T13:12:35.529Z" }, { url = "https://files.pythonhosted.org/packages/44/44/ba14b23e9757707050c2f397d305bbcae62e5d7cad122f8b6baec5ae4a1f/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a19570c52a21420dcbc94e661994bc325c0b5b11304540fed514586da5dc8f2e", size = 650840, upload-time = "2026-05-20T14:00:11.079Z" }, { url = "https://files.pythonhosted.org/packages/a8/37/5ddc2b686a6844f91abecef43411842426da2e1573f60b49ecf2547f4ae1/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3d955c89b75eeca4723d7cc14135f393cd47c32e2a6cb4a8e4c6e760a26b0986", size = 656416, upload-time = "2026-05-20T14:05:47.118Z" }, + { url = "https://files.pythonhosted.org/packages/8c/46/5987dcd1a2570ba84f3b187536b2ca3ae97613387e57f5cfa99df068fe5e/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea37d5a157eb9493820d3792ac4ece28619a394391d2b9f2f78057d396ff0f0f", size = 656607, upload-time = "2026-05-20T14:09:13.949Z" }, { url = "https://files.pythonhosted.org/packages/e1/f0/d17510297c35a2992712f0bf84de3779749999f7d3d63aa1f09db7c62dbe/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2daaaebd1a5aa88c49045b6baf9310b3263796bd88db713edf37cf53e7bb4e", size = 654397, upload-time = "2026-05-20T13:14:30.696Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c1/6da0a9ddcc29d7e51ef14883fa3dc1e53b3f4ffba00582106c7bf55da1d8/greenlet-3.5.1-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:8d8a23250ea3ec7b36de8fa4b541e9e2db3ee82915cc060ab0631609ad8b28de", size = 488287, upload-time = "2026-05-20T14:01:43.143Z" }, { url = "https://files.pythonhosted.org/packages/37/eb/147387705bb89092645b012586e7273cb5ed3c90ef7eaf3a69173eaf0209/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bfbd69cc349e43bf3a8ae1c85548ff0718efc887615c2db16c3833d7b0b072d", size = 1614469, upload-time = "2026-05-20T14:02:30.192Z" }, { url = "https://files.pythonhosted.org/packages/a6/4e/37ee0da7732b7aa9896f17e15579a9df34b9fcb9dd494f0adfa749af6623/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4378720dd888136c27215a0214d32a4d37c3852765d45bc37aad0623423cfd78", size = 1675115, upload-time = "2026-05-20T13:14:40.972Z" }, { url = "https://files.pythonhosted.org/packages/57/f3/97dfcf4a6eb5077f8a672234216fb5923eb89f2cab7081cb10b2cf75b605/greenlet-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:45718441607f9325d948db98cbc691276059316d0358c188c246da4e1d4d23d2", size = 245246, upload-time = "2026-05-20T13:12:22.646Z" }, { url = "https://files.pythonhosted.org/packages/5d/73/d7f72e34b582f694f4a9b248162db7b09cc458a259ba8f0c0bfa1a34ea7d/greenlet-3.5.1-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:2baee5ca02031757ffe8cc3d69f0cc0aec7065ce362622da74f32d3bcab1c541", size = 285575, upload-time = "2026-05-20T13:12:07.043Z" }, { url = "https://files.pythonhosted.org/packages/df/59/fa9c6e87dc8ad27a95dabe2f29f372b733d05a8a67470f6c901ed9975655/greenlet-3.5.1-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b1ec3274918a81d3ea778b9e75b56b72b33f300edb6cf7f3a7fe1dae56683de", size = 656428, upload-time = "2026-05-20T14:00:12.556Z" }, { url = "https://files.pythonhosted.org/packages/f6/f9/e753408871eaa61dfe35e619cfc67512b036fde99893685d50eea9e07146/greenlet-3.5.1-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:111e2390ffffc47d5840b01711dd7fac07d4c09283d0283e7f3264b14e284c64", size = 667064, upload-time = "2026-05-20T14:05:48.662Z" }, + { url = "https://files.pythonhosted.org/packages/dc/74/807a047255bf1e09303627c46dc043dca596b6958a354d904f32ab382005/greenlet-3.5.1-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:10a9a1c0bfbc93d41156ffcb90c75fbc05544054faf15dcc1fdf9765f8b607f0", size = 672962, upload-time = "2026-05-20T14:09:15.532Z" }, { url = "https://files.pythonhosted.org/packages/96/27/5565b5b40389f1c7753003a07e21892fda8660926787036d5bc0308b8113/greenlet-3.5.1-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e630136e905fe5ff43e86945ae41220b6d1470956a39220e708110ac48d01ea5", size = 665697, upload-time = "2026-05-20T13:14:32.943Z" }, + { url = "https://files.pythonhosted.org/packages/76/32/19d4e13225193c29b13e308015223f7d75fd3d8623d49dd19040d2ce8ec1/greenlet-3.5.1-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:ef08c1567c78074b22d1a200183d52d04a14df447bf70bcbb6a3507a48e776fc", size = 476047, upload-time = "2026-05-20T14:01:44.39Z" }, { url = "https://files.pythonhosted.org/packages/cf/82/e7de4178c0c2d1c9a5a3be3cc0b33e46a85b3ee4a77c071bf7ad8600e079/greenlet-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:975eac34b44a7077ca4d421348455b94f0f518246a7f14bc6d2fdcfe5b584368", size = 1621256, upload-time = "2026-05-20T14:02:31.91Z" }, { url = "https://files.pythonhosted.org/packages/00/10/f2dddcf7dacac17dfc68691809589adad06135eb28930429cf58a6467a2f/greenlet-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:9ab3c3a0b2ae6198e67c898dad5215a49f9ae0d0081b3c3ec59f333e39eeca26", size = 1685956, upload-time = "2026-05-20T13:14:42.55Z" }, { url = "https://files.pythonhosted.org/packages/22/17/4a232b32133230ada52f70e9d7f5b65b0caef8772f01849bd8d149e7e4ca/greenlet-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:cbfc69be86e10dcfef5b1e6269d1d6926552aa89ee39e1de3353360c1b6989ab", size = 239802, upload-time = "2026-05-20T13:13:15.481Z" }, @@ -972,7 +984,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7a/57/816d9cff29119da3505b3d6a5e14a8af89006ac36f47f891ff293ee05af1/greenlet-3.5.1-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:a6fdf2433a5441ef9a95464f7c3e674775da1c8c1177fff311cee1acad4626ed", size = 293877, upload-time = "2026-05-20T13:10:19.078Z" }, { url = "https://files.pythonhosted.org/packages/23/a1/59b0a7c7d140ff1a75626680b9a9899b79a9176cab298b394968fb023295/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7546556f0d649f99f6a361098a55f761181bb2ea12ff150bb16d26092ad88244", size = 655333, upload-time = "2026-05-20T14:00:14.758Z" }, { url = "https://files.pythonhosted.org/packages/72/1b/5efe127597625042218939d01855109f352779050768b670b52edcc16a6c/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d5ee3ea898009fa898f85f9982255d35278c477bebe185beca249cab42d4526c", size = 659443, upload-time = "2026-05-20T14:05:50.159Z" }, + { url = "https://files.pythonhosted.org/packages/c9/9d/1dcdf7b95ab3cf8c7b6d7277c18a5e167312f2b362ddfcc5d5e6d8d84b43/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a57b0d05a0448eed231d59c0ceb287dde984551e54cbc51ac2d4865712838e9c", size = 659998, upload-time = "2026-05-20T14:09:16.912Z" }, { url = "https://files.pythonhosted.org/packages/6c/6d/c404246ea4d22d097a7426d0efb5b781bd7eb67715f09e79001bd552ab18/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5c81f74d204d3edd136ebfd50dce53acbb776995d721a0fe801626cfc93b8cd", size = 658356, upload-time = "2026-05-20T13:14:35.091Z" }, + { url = "https://files.pythonhosted.org/packages/05/7e/c4959664fc231d587d66d8e81f2095e98056ba1954beafdcbe635e251052/greenlet-3.5.1-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:b0703c2cef53e01baec47f7a3868009913ad71ec678bbecb42a6f40895e4ce62", size = 494470, upload-time = "2026-05-20T14:01:45.611Z" }, { url = "https://files.pythonhosted.org/packages/51/02/f8ee37fb6d2219329f350af241c27fcf12df57e723d11f6fc6d3bacdadaa/greenlet-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:2c18ef16bf6d4dd410e4dd52996888ea1497be26892fe5bbc73580aba4287b8e", size = 1619216, upload-time = "2026-05-20T14:02:33.403Z" }, { url = "https://files.pythonhosted.org/packages/93/c5/3dc9475ace2c7a3680da12372cddd7f1ac874eb410a1ac48d3e9dab83782/greenlet-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:17d86354f0ae6b61bf9be5148d0dd34e06c3cb7c602c671f79f29ac3b150e659", size = 1678427, upload-time = "2026-05-20T13:14:43.71Z" }, { url = "https://files.pythonhosted.org/packages/df/4e/750c15c317a41ffb36f0bf40b933e3d744a7dede61889f74443ea69690cf/greenlet-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:e7516cf6ae6b8a582c2770a0caed47b8a48373ed732c33d69a72913ae6ac923e", size = 245225, upload-time = "2026-05-20T13:13:59.366Z" }, @@ -3685,7 +3699,7 @@ wheels = [ [[package]] name = "sap-cloud-sdk" -version = "0.35.0" +version = "0.35.2" source = { editable = "." } dependencies = [ { name = "grpcio" }, From 0fc99d3ed81dd888108912b2f502f36548a7f01a Mon Sep 17 00:00:00 2001 From: Tiago Kochenborger Date: Wed, 15 Jul 2026 11:09:07 +0200 Subject: [PATCH 05/14] docs(agentgateway,telemetry): document MCP LoB troubleshooting and auto_instrument unwrap --- src/sap_cloud_sdk/agentgateway/user-guide.md | 15 +++++++++++++++ src/sap_cloud_sdk/core/telemetry/user-guide.md | 2 ++ 2 files changed, 17 insertions(+) diff --git a/src/sap_cloud_sdk/agentgateway/user-guide.md b/src/sap_cloud_sdk/agentgateway/user-guide.md index 686d9f37..ce746fa9 100644 --- a/src/sap_cloud_sdk/agentgateway/user-guide.md +++ b/src/sap_cloud_sdk/agentgateway/user-guide.md @@ -270,3 +270,18 @@ class MCPTool: url: str fragment_name: str | None ``` + +## Troubleshooting (LoB MCP) + +### Empty or null tool lists + +If the MCP session returns no tool list (`list_tools()` is `None` or `tools` is `None`), the SDK logs a warning and skips that destination fragment instead of raising an error. Discovery continues with remaining fragments. This often happens when OpenTelemetry MCP instrumentation swallows errors; see the telemetry user guide for `auto_instrument()` behavior on SDK 0.35.2+. + +### HTTP 403 during discovery + +Many LoB landscapes require a **user-scoped token** for MCP tool listing. Pass `user_token` on **every** `list_mcp_tools()` call that must see user-scoped tools. If one code path calls `list_mcp_tools()` without `user_token` while another passes it, you may get HTTP 403 on some fragments, zero tools from that path, and misleading errors such as “no tool for role” even when ordId mapping is correct. + +### OpenTelemetry and MCP + +Call `auto_instrument()` from `sap_cloud_sdk.core.telemetry` before importing MCP or AI libraries. On SDK **0.35.2+**, successful auto-instrumentation automatically unwraps OpenTelemetry MCP wrappers (`BaseSession.send_request` and streamable HTTP client entry points). Do not duplicate that unwrap in your application `main.py` once you depend on that release. + diff --git a/src/sap_cloud_sdk/core/telemetry/user-guide.md b/src/sap_cloud_sdk/core/telemetry/user-guide.md index 019bc1ac..5ce020ee 100644 --- a/src/sap_cloud_sdk/core/telemetry/user-guide.md +++ b/src/sap_cloud_sdk/core/telemetry/user-guide.md @@ -28,6 +28,8 @@ auto_instrument() from litellm import completion # LLM calls are now automatically traced ``` +When auto-instrumentation initializes successfully, the SDK also unwraps OpenTelemetry MCP instrumentation on `BaseSession.send_request` and the streamable HTTP client helpers so MCP `list_tools()` responses are not silently dropped. Agents should not add duplicate unwrap logic in application startup once they use SDK 0.35.2 or newer. + ### 2. Add business context with a parent span From 6b398fc245c4ab21cd9457c13de41d9e31f6e927 Mon Sep 17 00:00:00 2001 From: Tiago Kochenborger Date: Wed, 15 Jul 2026 11:20:23 +0200 Subject: [PATCH 06/14] fix(agentgateway,telemetry): fix user-guide eof and 403 log noise --- src/sap_cloud_sdk/agentgateway/_lob.py | 1 + src/sap_cloud_sdk/agentgateway/user-guide.md | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sap_cloud_sdk/agentgateway/_lob.py b/src/sap_cloud_sdk/agentgateway/_lob.py index 33661098..e1aaa039 100644 --- a/src/sap_cloud_sdk/agentgateway/_lob.py +++ b/src/sap_cloud_sdk/agentgateway/_lob.py @@ -404,6 +404,7 @@ async def get_mcp_tools_lob( "with user_token when calling list_mcp_tools with principal context", fragment_name, ) + continue logger.exception( "Failed to load tools from fragment '%s' — skipping", fragment_name, diff --git a/src/sap_cloud_sdk/agentgateway/user-guide.md b/src/sap_cloud_sdk/agentgateway/user-guide.md index ce746fa9..3de9b20c 100644 --- a/src/sap_cloud_sdk/agentgateway/user-guide.md +++ b/src/sap_cloud_sdk/agentgateway/user-guide.md @@ -284,4 +284,3 @@ Many LoB landscapes require a **user-scoped token** for MCP tool listing. Pass ` ### OpenTelemetry and MCP Call `auto_instrument()` from `sap_cloud_sdk.core.telemetry` before importing MCP or AI libraries. On SDK **0.35.2+**, successful auto-instrumentation automatically unwraps OpenTelemetry MCP wrappers (`BaseSession.send_request` and streamable HTTP client entry points). Do not duplicate that unwrap in your application `main.py` once you depend on that release. - From aa08f9c139ab9db40ca12db0258d92d138ffa65f Mon Sep 17 00:00:00 2001 From: Tiago Kochenborger Date: Wed, 15 Jul 2026 11:24:35 +0200 Subject: [PATCH 07/14] fix(agentgateway): justify fragment skip exception handling (py-el-02) --- src/sap_cloud_sdk/agentgateway/_lob.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/sap_cloud_sdk/agentgateway/_lob.py b/src/sap_cloud_sdk/agentgateway/_lob.py index e1aaa039..d19fb803 100644 --- a/src/sap_cloud_sdk/agentgateway/_lob.py +++ b/src/sap_cloud_sdk/agentgateway/_lob.py @@ -405,11 +405,14 @@ async def get_mcp_tools_lob( fragment_name, ) continue + # intentional: fragment failure must not abort remaining fragments logger.exception( "Failed to load tools from fragment '%s' — skipping", fragment_name, ) + # intentional: fragment failure must not abort remaining fragments except Exception: + # intentional: fragment failure must not abort remaining fragments logger.exception( "Failed to load tools from fragment '%s' — skipping", fragment_name, From bd58a2c8b4115575803056fc379f46c3d2628700 Mon Sep 17 00:00:00 2001 From: Tiago Kochenborger Date: Wed, 15 Jul 2026 11:25:52 +0200 Subject: [PATCH 08/14] fix(agentgateway): document intentional fragment skip (py-el-02) --- src/sap_cloud_sdk/agentgateway/_lob.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/sap_cloud_sdk/agentgateway/_lob.py b/src/sap_cloud_sdk/agentgateway/_lob.py index d19fb803..ea168452 100644 --- a/src/sap_cloud_sdk/agentgateway/_lob.py +++ b/src/sap_cloud_sdk/agentgateway/_lob.py @@ -405,14 +405,12 @@ async def get_mcp_tools_lob( fragment_name, ) continue - # intentional: fragment failure must not abort remaining fragments logger.exception( "Failed to load tools from fragment '%s' — skipping", fragment_name, ) # intentional: fragment failure must not abort remaining fragments except Exception: - # intentional: fragment failure must not abort remaining fragments logger.exception( "Failed to load tools from fragment '%s' — skipping", fragment_name, From 28c63d8703c1c2079084651be5da7daa20001ced Mon Sep 17 00:00:00 2001 From: Tiago Kochenborger Date: Wed, 15 Jul 2026 11:26:53 +0200 Subject: [PATCH 09/14] fix(agentgateway): comment non-403 fragment skip path (py-el-02) --- src/sap_cloud_sdk/agentgateway/_lob.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/sap_cloud_sdk/agentgateway/_lob.py b/src/sap_cloud_sdk/agentgateway/_lob.py index ea168452..384592a1 100644 --- a/src/sap_cloud_sdk/agentgateway/_lob.py +++ b/src/sap_cloud_sdk/agentgateway/_lob.py @@ -405,6 +405,7 @@ async def get_mcp_tools_lob( fragment_name, ) continue + # intentional: fragment failure must not abort remaining fragments logger.exception( "Failed to load tools from fragment '%s' — skipping", fragment_name, From 4eb89655a1b356c19eed5ed0f00cae92fd9587b7 Mon Sep 17 00:00:00 2001 From: Tiago Kochenborger Date: Wed, 15 Jul 2026 11:34:15 +0200 Subject: [PATCH 10/14] fix(agentgateway): document httpx fragment skip (py-el-02) --- src/sap_cloud_sdk/agentgateway/_lob.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/sap_cloud_sdk/agentgateway/_lob.py b/src/sap_cloud_sdk/agentgateway/_lob.py index 384592a1..c3ed5e8b 100644 --- a/src/sap_cloud_sdk/agentgateway/_lob.py +++ b/src/sap_cloud_sdk/agentgateway/_lob.py @@ -396,6 +396,8 @@ async def get_mcp_tools_lob( len(server_tools), fragment_name, ) + # intentional: fragment failure must not abort remaining fragments + # (HTTPStatusError: 403 → warning + continue; other status → exception log + skip) except httpx.HTTPStatusError as exc: if exc.response.status_code == 403: logger.warning( @@ -405,12 +407,12 @@ async def get_mcp_tools_lob( fragment_name, ) continue - # intentional: fragment failure must not abort remaining fragments logger.exception( "Failed to load tools from fragment '%s' — skipping", fragment_name, ) # intentional: fragment failure must not abort remaining fragments + # (unexpected errors → exception log + skip) except Exception: logger.exception( "Failed to load tools from fragment '%s' — skipping", From a473774354ae2767476f5f66feeceb49a9ebb5b7 Mon Sep 17 00:00:00 2001 From: Tiago Kochenborger Date: Wed, 15 Jul 2026 11:52:21 +0200 Subject: [PATCH 11/14] fix(telemetry): drop MCP unwrap from auto_instrument Remove reflection-based OpenTelemetry MCP unwrap from SDK init per review. Keep Agent Gateway None guards, HTTP 403 hints, and document agent-side unwrap as an optional workaround until upstream instrumentation fixes land. --- src/sap_cloud_sdk/agentgateway/user-guide.md | 6 ++-- .../core/telemetry/auto_instrument.py | 30 ------------------- .../core/telemetry/user-guide.md | 2 +- .../unit/telemetry/test_auto_instrument.py | 30 ------------------- 4 files changed, 5 insertions(+), 63 deletions(-) diff --git a/src/sap_cloud_sdk/agentgateway/user-guide.md b/src/sap_cloud_sdk/agentgateway/user-guide.md index 3de9b20c..1053792a 100644 --- a/src/sap_cloud_sdk/agentgateway/user-guide.md +++ b/src/sap_cloud_sdk/agentgateway/user-guide.md @@ -275,7 +275,7 @@ class MCPTool: ### Empty or null tool lists -If the MCP session returns no tool list (`list_tools()` is `None` or `tools` is `None`), the SDK logs a warning and skips that destination fragment instead of raising an error. Discovery continues with remaining fragments. This often happens when OpenTelemetry MCP instrumentation swallows errors; see the telemetry user guide for `auto_instrument()` behavior on SDK 0.35.2+. +If the MCP session returns no tool list (`list_tools()` is `None` or `tools` is `None`), the SDK logs a warning and skips that destination fragment instead of raising an error. Discovery continues with remaining fragments. This often happens when OpenTelemetry MCP instrumentation swallows errors. SDK 0.35.2+ treats null responses defensively; see the telemetry and OpenTelemetry sections below for optional agent-side workarounds. ### HTTP 403 during discovery @@ -283,4 +283,6 @@ Many LoB landscapes require a **user-scoped token** for MCP tool listing. Pass ` ### OpenTelemetry and MCP -Call `auto_instrument()` from `sap_cloud_sdk.core.telemetry` before importing MCP or AI libraries. On SDK **0.35.2+**, successful auto-instrumentation automatically unwraps OpenTelemetry MCP wrappers (`BaseSession.send_request` and streamable HTTP client entry points). Do not duplicate that unwrap in your application `main.py` once you depend on that release. +Call `auto_instrument()` from `sap_cloud_sdk.core.telemetry` before importing MCP or AI libraries. The SDK does **not** remove OpenTelemetry MCP wrappers during `auto_instrument()` — instrumentation should stay enabled. + +If you still see null `list_tools()` results with MCP OTel enabled, you can apply an agent-side unwrap after `auto_instrument()` and before MCP imports (for example `opentelemetry.instrumentation.utils.unwrap` on `BaseSession.send_request` and both `streamablehttp_client` / `streamable_http_client` in `mcp.client.streamable_http`) until `opentelemetry-instrumentation-mcp` is fixed upstream. Prefer the SDK None guard so discovery continues without disabling telemetry. diff --git a/src/sap_cloud_sdk/core/telemetry/auto_instrument.py b/src/sap_cloud_sdk/core/telemetry/auto_instrument.py index f887089a..1141c372 100644 --- a/src/sap_cloud_sdk/core/telemetry/auto_instrument.py +++ b/src/sap_cloud_sdk/core/telemetry/auto_instrument.py @@ -90,40 +90,10 @@ def auto_instrument( if middlewares: _register_middleware_processors(middlewares) - _unwrap_mcp_otel_instrumentation() logger.info("Cloud auto instrumentation initialized successfully") -def _unwrap_mcp_otel_instrumentation() -> None: - """Remove OTel MCP instrumentation wrappers that swallow protocol errors. - - opentelemetry-instrumentation-mcp wraps MCP transport with handlers that can - return None on tracing failures, which breaks Agent Gateway tool discovery. - """ - try: - from opentelemetry.instrumentation.utils import unwrap - - unwrap("mcp.shared.session.BaseSession", "send_request") - import mcp.client.streamable_http # noqa: F401 - - for _fn in ("streamablehttp_client", "streamable_http_client"): - try: - unwrap("mcp.client.streamable_http", _fn) - except Exception: - pass - logger.debug( - "Unwrapped MCP OpenTelemetry instrumentation for reliable tool discovery" - ) - except ImportError: - logger.debug("MCP OpenTelemetry instrumentation not available; skipping unwrap") - except Exception: - logger.warning( - "Failed to unwrap MCP OpenTelemetry instrumentation", - exc_info=True, - ) - - def _create_exporter() -> SpanExporter: if os.getenv(ENV_TRACES_EXPORTER, "").lower() == "console": logger.info("Initializing auto instrumentation with console exporter") diff --git a/src/sap_cloud_sdk/core/telemetry/user-guide.md b/src/sap_cloud_sdk/core/telemetry/user-guide.md index 5ce020ee..1720ce9f 100644 --- a/src/sap_cloud_sdk/core/telemetry/user-guide.md +++ b/src/sap_cloud_sdk/core/telemetry/user-guide.md @@ -28,7 +28,7 @@ auto_instrument() from litellm import completion # LLM calls are now automatically traced ``` -When auto-instrumentation initializes successfully, the SDK also unwraps OpenTelemetry MCP instrumentation on `BaseSession.send_request` and the streamable HTTP client helpers so MCP `list_tools()` responses are not silently dropped. Agents should not add duplicate unwrap logic in application startup once they use SDK 0.35.2 or newer. +If OpenTelemetry MCP instrumentation (`opentelemetry-instrumentation-mcp`) swallows protocol errors and `list_tools()` returns `None`, the Agent Gateway SDK logs a warning and skips that fragment instead of crashing (SDK 0.35.2+). Removing instrumentation is not recommended. Until upstream fixes land, agents may keep an application-side unwrap after `auto_instrument()` (see Agent Gateway user guide — OpenTelemetry and MCP). ### 2. Add business context with a parent span diff --git a/tests/core/unit/telemetry/test_auto_instrument.py b/tests/core/unit/telemetry/test_auto_instrument.py index b86f11ad..667834e9 100644 --- a/tests/core/unit/telemetry/test_auto_instrument.py +++ b/tests/core/unit/telemetry/test_auto_instrument.py @@ -479,36 +479,6 @@ def test_auto_instrument_merge_overrides_colliding_service_name( wrapper_provider.resource.attributes["service.name"] == "cloud-sdk-app" ) - def test_auto_instrument_unwraps_mcp_otel_instrumentation( - self, mock_traceloop_components - ): - """Test that successful init unwraps MCP OTel instrumentation.""" - mock_traceloop_components["get_app_name"].return_value = "test-app" - mock_traceloop_components["create_resource"].return_value = {} - - with patch( - "sap_cloud_sdk.core.telemetry.auto_instrument._unwrap_mcp_otel_instrumentation" - ) as mock_unwrap: - with patch.dict( - "os.environ", - {"OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:4317"}, - clear=True, - ): - auto_instrument() - mock_unwrap.assert_called_once() - - def test_auto_instrument_skips_mcp_unwrap_when_disabled( - self, mock_traceloop_components - ): - """When instrumentation is disabled, MCP unwrap is not attempted.""" - with patch( - "sap_cloud_sdk.core.telemetry.auto_instrument._unwrap_mcp_otel_instrumentation" - ) as mock_unwrap: - with patch.dict("os.environ", {}, clear=True): - auto_instrument() - mock_unwrap.assert_not_called() - - class TestAutoInstrumentMiddlewares: """Tests for the middlewares parameter of auto_instrument.""" From 9f837a90a58c7482959f7511938b35951aa50e0e Mon Sep 17 00:00:00 2001 From: Tiago Kochenborger Date: Wed, 15 Jul 2026 11:53:17 +0200 Subject: [PATCH 12/14] chore: ruff format auto_instrument after unwrap removal --- src/sap_cloud_sdk/core/telemetry/auto_instrument.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/sap_cloud_sdk/core/telemetry/auto_instrument.py b/src/sap_cloud_sdk/core/telemetry/auto_instrument.py index 1141c372..fdd3c34f 100644 --- a/src/sap_cloud_sdk/core/telemetry/auto_instrument.py +++ b/src/sap_cloud_sdk/core/telemetry/auto_instrument.py @@ -90,7 +90,6 @@ def auto_instrument( if middlewares: _register_middleware_processors(middlewares) - logger.info("Cloud auto instrumentation initialized successfully") From b71d7e2d4b948c95783021f6d90f0443d4fafdb6 Mon Sep 17 00:00:00 2001 From: Tiago Kochenborger Date: Wed, 15 Jul 2026 13:37:25 +0200 Subject: [PATCH 13/14] Add PY-EL-02 sdk-review suppression for fragment tool-list handler. Document intentional HTTPStatusError swallow when skipping a failed MCP fragment. --- src/sap_cloud_sdk/agentgateway/_lob.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sap_cloud_sdk/agentgateway/_lob.py b/src/sap_cloud_sdk/agentgateway/_lob.py index c3ed5e8b..f2226716 100644 --- a/src/sap_cloud_sdk/agentgateway/_lob.py +++ b/src/sap_cloud_sdk/agentgateway/_lob.py @@ -398,7 +398,7 @@ async def get_mcp_tools_lob( ) # intentional: fragment failure must not abort remaining fragments # (HTTPStatusError: 403 → warning + continue; other status → exception log + skip) - except httpx.HTTPStatusError as exc: + except httpx.HTTPStatusError as exc: # sdk-review: ignore[el-02] if exc.response.status_code == 403: logger.warning( "HTTP 403 listing tools from fragment '%s' with system token — " From d20b8dbe1e525cadf1374afc63d3c3f573a754d0 Mon Sep 17 00:00:00 2001 From: Tiago Kochenborger Date: Wed, 15 Jul 2026 16:54:21 +0200 Subject: [PATCH 14/14] fix(agentgateway): address review feedback on logs and docs Replace internal Phase 2 wording with user_token/list_mcp_tools hints, remove sdk-review inline suppression, warn when all fragments fail, soften customer None-guard log, and align user guides with public API naming. Bump version to 0.35.3. --- pyproject.toml | 2 +- src/sap_cloud_sdk/agentgateway/_customer.py | 4 +-- src/sap_cloud_sdk/agentgateway/_lob.py | 14 +++++--- src/sap_cloud_sdk/agentgateway/user-guide.md | 4 +-- .../core/telemetry/user-guide.md | 3 +- tests/agentgateway/unit/test_lob.py | 36 +++++++++++++++++++ uv.lock | 2 +- 7 files changed, 54 insertions(+), 11 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index f6f4bda2..80243ae8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sap-cloud-sdk" -version = "0.35.2" +version = "0.35.3" 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 13876a9b..f915f140 100644 --- a/src/sap_cloud_sdk/agentgateway/_customer.py +++ b/src/sap_cloud_sdk/agentgateway/_customer.py @@ -466,8 +466,8 @@ async def _list_server_tools( if result is None or result.tools is None: logger.warning( - "list_tools() returned no tools (response=%r); fragment %r skipped — " - "check MCP server health and OTel instrumentation", + "list_tools() returned no tools (response=%r); dependency %r skipped — " + "check MCP server health", result, dependency.ord_id, ) diff --git a/src/sap_cloud_sdk/agentgateway/_lob.py b/src/sap_cloud_sdk/agentgateway/_lob.py index f2226716..5c75d73c 100644 --- a/src/sap_cloud_sdk/agentgateway/_lob.py +++ b/src/sap_cloud_sdk/agentgateway/_lob.py @@ -328,7 +328,7 @@ async def list_server_tools( if result is None or result.tools is None: logger.warning( "list_tools() returned no tools (response=%r); fragment %r skipped — " - "check MCP server health and OTel instrumentation", + "check MCP server health and OpenTelemetry MCP instrumentation", result, fragment_name, ) @@ -398,12 +398,11 @@ async def get_mcp_tools_lob( ) # intentional: fragment failure must not abort remaining fragments # (HTTPStatusError: 403 → warning + continue; other status → exception log + skip) - except httpx.HTTPStatusError as exc: # sdk-review: ignore[el-02] + except httpx.HTTPStatusError as exc: if exc.response.status_code == 403: logger.warning( "HTTP 403 listing tools from fragment '%s' with system token — " - "MCP list_tools may require a user-scoped token; use Phase 2 flow " - "with user_token when calling list_mcp_tools with principal context", + "pass user_token to list_mcp_tools() for user-scoped tool discovery", fragment_name, ) continue @@ -419,6 +418,13 @@ async def get_mcp_tools_lob( fragment_name, ) + if fragments and not tools: + logger.warning( + "No MCP tools loaded from %d fragment(s) for tenant '%s'", + len(fragments), + tenant_subdomain, + ) + logger.info("Loaded %d MCP tool(s) from %d fragment(s)", len(tools), len(fragments)) return tools diff --git a/src/sap_cloud_sdk/agentgateway/user-guide.md b/src/sap_cloud_sdk/agentgateway/user-guide.md index 1053792a..ceecbf65 100644 --- a/src/sap_cloud_sdk/agentgateway/user-guide.md +++ b/src/sap_cloud_sdk/agentgateway/user-guide.md @@ -275,7 +275,7 @@ class MCPTool: ### Empty or null tool lists -If the MCP session returns no tool list (`list_tools()` is `None` or `tools` is `None`), the SDK logs a warning and skips that destination fragment instead of raising an error. Discovery continues with remaining fragments. This often happens when OpenTelemetry MCP instrumentation swallows errors. SDK 0.35.2+ treats null responses defensively; see the telemetry and OpenTelemetry sections below for optional agent-side workarounds. +If during `list_mcp_tools()` discovery the MCP session returns no tool list (`session.list_tools()` is `None` or `tools` is `None`), the SDK logs a warning and skips that destination fragment instead of raising an error. Discovery continues with remaining fragments. This often happens when OpenTelemetry MCP instrumentation swallows errors. SDK 0.35.3+ treats null responses defensively; see the telemetry and OpenTelemetry sections below for optional agent-side workarounds. ### HTTP 403 during discovery @@ -285,4 +285,4 @@ Many LoB landscapes require a **user-scoped token** for MCP tool listing. Pass ` Call `auto_instrument()` from `sap_cloud_sdk.core.telemetry` before importing MCP or AI libraries. The SDK does **not** remove OpenTelemetry MCP wrappers during `auto_instrument()` — instrumentation should stay enabled. -If you still see null `list_tools()` results with MCP OTel enabled, you can apply an agent-side unwrap after `auto_instrument()` and before MCP imports (for example `opentelemetry.instrumentation.utils.unwrap` on `BaseSession.send_request` and both `streamablehttp_client` / `streamable_http_client` in `mcp.client.streamable_http`) until `opentelemetry-instrumentation-mcp` is fixed upstream. Prefer the SDK None guard so discovery continues without disabling telemetry. +If you still see null tool lists during `list_mcp_tools()` with MCP OTel enabled, you can apply an agent-side unwrap after `auto_instrument()` and before MCP imports (for example `opentelemetry.instrumentation.utils.unwrap` on `BaseSession.send_request` and both `streamablehttp_client` / `streamable_http_client` in `mcp.client.streamable_http`) until `opentelemetry-instrumentation-mcp` is fixed upstream. Prefer the SDK None guard so discovery continues without disabling telemetry. diff --git a/src/sap_cloud_sdk/core/telemetry/user-guide.md b/src/sap_cloud_sdk/core/telemetry/user-guide.md index 1720ce9f..4b6f72e6 100644 --- a/src/sap_cloud_sdk/core/telemetry/user-guide.md +++ b/src/sap_cloud_sdk/core/telemetry/user-guide.md @@ -28,7 +28,8 @@ auto_instrument() from litellm import completion # LLM calls are now automatically traced ``` -If OpenTelemetry MCP instrumentation (`opentelemetry-instrumentation-mcp`) swallows protocol errors and `list_tools()` returns `None`, the Agent Gateway SDK logs a warning and skips that fragment instead of crashing (SDK 0.35.2+). Removing instrumentation is not recommended. Until upstream fixes land, agents may keep an application-side unwrap after `auto_instrument()` (see Agent Gateway user guide — OpenTelemetry and MCP). + +If OpenTelemetry MCP instrumentation (`opentelemetry-instrumentation-mcp`) swallows protocol errors during `list_mcp_tools()` discovery (the underlying MCP `session.list_tools()` may return `None`), the Agent Gateway SDK logs a warning and skips that fragment instead of crashing (SDK 0.35.3+). Removing instrumentation is not recommended. Until upstream fixes land, agents may keep an application-side unwrap after `auto_instrument()` (see Agent Gateway user guide — OpenTelemetry and MCP). ### 2. Add business context with a parent span diff --git a/tests/agentgateway/unit/test_lob.py b/tests/agentgateway/unit/test_lob.py index c63d9a50..0f309a7d 100644 --- a/tests/agentgateway/unit/test_lob.py +++ b/tests/agentgateway/unit/test_lob.py @@ -662,6 +662,42 @@ async def test_logs_hint_on_http_403_with_system_token(self, caplog): assert result == [] assert "HTTP 403" in caplog.text assert "user_token" in caplog.text + assert "list_mcp_tools" in caplog.text + assert "Phase 2" not in caplog.text + + @pytest.mark.asyncio + async def test_warns_when_all_fragments_fail_to_load(self, caplog): + """Log aggregate warning when every fragment fails tool discovery.""" + import logging + + caplog.set_level(logging.WARNING, logger="sap_cloud_sdk.agentgateway._lob") + + fragment = MagicMock() + fragment.name = "mcp-server-a" + fragment.properties = {"URL": "https://example.com/mcp"} + + response = MagicMock() + response.status_code = 403 + exc = httpx.HTTPStatusError( + "Forbidden", + request=MagicMock(), + response=response, + ) + + with ( + patch("sap_cloud_sdk.agentgateway._lob.list_mcp_fragments") as mock_list, + patch( + "sap_cloud_sdk.agentgateway._lob.list_server_tools", + new_callable=AsyncMock, + side_effect=exc, + ), + ): + mock_list.return_value = [fragment] + + result = await get_mcp_tools_lob("tenant-sub", "system-token", 60.0) + + assert result == [] + assert "No MCP tools loaded from 1 fragment(s)" in caplog.text @pytest.mark.asyncio async def test_continues_when_fragment_returns_empty_from_none_guard(self): diff --git a/uv.lock b/uv.lock index 4318f36f..347b1f25 100644 --- a/uv.lock +++ b/uv.lock @@ -3699,7 +3699,7 @@ wheels = [ [[package]] name = "sap-cloud-sdk" -version = "0.35.2" +version = "0.35.3" source = { editable = "." } dependencies = [ { name = "grpcio" },