From 5e01c4c54bfd836a0d981889d2aeb4d57bf71d3a Mon Sep 17 00:00:00 2001 From: I542102 Date: Fri, 7 Aug 2026 16:29:19 -0300 Subject: [PATCH 1/4] add global id filter for lob agents --- src/sap_cloud_sdk/agentgateway/_fragments.py | 32 ++++++++-- src/sap_cloud_sdk/agentgateway/_lob.py | 11 +++- src/sap_cloud_sdk/agentgateway/_models.py | 9 +++ src/sap_cloud_sdk/agentgateway/agw_client.py | 6 +- tests/agentgateway/unit/test_agw_client.py | 67 ++++++++++++++++++++ tests/agentgateway/unit/test_lob.py | 59 +++++++++++++++++ 6 files changed, 174 insertions(+), 10 deletions(-) diff --git a/src/sap_cloud_sdk/agentgateway/_fragments.py b/src/sap_cloud_sdk/agentgateway/_fragments.py index 43a69cfd..e899a1f3 100644 --- a/src/sap_cloud_sdk/agentgateway/_fragments.py +++ b/src/sap_cloud_sdk/agentgateway/_fragments.py @@ -23,6 +23,11 @@ # Shared label key for all managed-runtime fragment types LABEL_KEY = "sap-managed-runtime-type" +# Label key carrying the global tenant id of the integrated system. +# Written by SPII at fragment creation time (see the internal SPII package +# ``sap_internal_sdk.spii.system.fragment._build_fragment``). +GTID_LABEL_KEY = "sap-managed-runtime-gtid" + _DESTINATION_INSTANCE = "default" @@ -35,28 +40,45 @@ class FragmentLabel(str, Enum): IAS_USER = "subscriber.ias.user" -def _list_fragments_by_label(label: FragmentLabel, tenant_subdomain: str) -> list: +def _list_fragments_by_label( + label: FragmentLabel, + tenant_subdomain: str, + global_tenant_ids: list[str] | None = None, +) -> list: + filter_labels = [Label(key=LABEL_KEY, values=[label.value])] + if global_tenant_ids: + filter_labels.append(Label(key=GTID_LABEL_KEY, values=global_tenant_ids)) client = create_fragment_client( instance=_DESTINATION_INSTANCE, _telemetry_source=Module.AGENTGATEWAY, ) return client.list_instance_fragments( - filter=ListOptions(filter_labels=[Label(key=LABEL_KEY, values=[label.value])]), + filter=ListOptions(filter_labels=filter_labels), tenant=tenant_subdomain, ) -def list_mcp_fragments(tenant_subdomain: str) -> list: +def list_mcp_fragments( + tenant_subdomain: str, + global_tenant_ids: list[str] | None = None, +) -> list: """List destination fragments with MCP server label. Args: tenant_subdomain: Tenant subdomain for multi-tenant lookup. + global_tenant_ids: Optional list of global tenant IDs of integrated + systems to filter by. When set, only fragments whose + ``sap-managed-runtime-gtid`` label matches one of these values are + returned (filter is applied server-side by the Destination Service). Returns: - List of fragments with sap-managed-runtime-type=agw.mcp.server label. + List of fragments with sap-managed-runtime-type=agw.mcp.server label + (and, if provided, matching one of the requested global tenant IDs). """ logger.debug("Fetching MCP fragments for tenant '%s'", tenant_subdomain) - return _list_fragments_by_label(FragmentLabel.MCP, tenant_subdomain) + return _list_fragments_by_label( + FragmentLabel.MCP, tenant_subdomain, global_tenant_ids + ) def list_a2a_fragments(tenant_subdomain: str) -> list: diff --git a/src/sap_cloud_sdk/agentgateway/_lob.py b/src/sap_cloud_sdk/agentgateway/_lob.py index 0c46124c..efc5d2a3 100644 --- a/src/sap_cloud_sdk/agentgateway/_lob.py +++ b/src/sap_cloud_sdk/agentgateway/_lob.py @@ -378,8 +378,11 @@ async def get_mcp_tools_lob( tenant_subdomain: Tenant subdomain for multi-tenant lookup. system_token: Pre-fetched raw system token (from get_system_auth). timeout: HTTP timeout in seconds for MCP server calls. - filter: Optional MCPToolFilter narrowing results by tool name or ORD ID. - If None or empty, all tools are included. + filter: Optional MCPToolFilter narrowing results by tool name, ORD ID, + or global tenant ID. If None or empty, all tools are included. + ``global_tenant_ids`` filters fragments server-side via the + Destination Service. ``ord_ids`` filters before fetching. + ``names`` filters after fetching. Returns: List of MCPTool objects from all MCP servers. @@ -390,7 +393,9 @@ async def get_mcp_tools_lob( logger.info("Listing MCP fragments for tenant '%s'", tenant_subdomain) - fragments = await loop.run_in_executor(None, list_mcp_fragments, tenant_subdomain) + fragments = await loop.run_in_executor( + None, list_mcp_fragments, tenant_subdomain, f.global_tenant_ids or None + ) if not fragments: logger.debug( diff --git a/src/sap_cloud_sdk/agentgateway/_models.py b/src/sap_cloud_sdk/agentgateway/_models.py index 2dd56e87..8af58412 100644 --- a/src/sap_cloud_sdk/agentgateway/_models.py +++ b/src/sap_cloud_sdk/agentgateway/_models.py @@ -169,6 +169,13 @@ class MCPToolFilter: agents, or matched against IntegrationDependency.ord_id for customer agents). Applied before fetching, skipping non-matching fragments. + global_tenant_ids: Global tenant IDs of the integrated systems whose + tools should be listed. Only supported in the LoB flow, where each + MCP fragment carries a ``sap-managed-runtime-gtid`` label written + by SPII at provisioning time. When set, the Destination Service + filters fragments server-side. Ignored by the customer flow (which + already scopes tools by the ``integrationDependencies`` in the + credentials file). Example: ```python @@ -178,6 +185,7 @@ class MCPToolFilter: filter=MCPToolFilter( names=["get-sales-order"], ord_ids=["sap.s4:apiAccess:salesOrder:v1"], + global_tenant_ids=["9e88a0c4-ab32-46d8-b1d3-07cbcac11831"], ) ) ``` @@ -185,3 +193,4 @@ class MCPToolFilter: names: list[str] = field(default_factory=list) ord_ids: list[str] = field(default_factory=list) + global_tenant_ids: list[str] = field(default_factory=list) diff --git a/src/sap_cloud_sdk/agentgateway/agw_client.py b/src/sap_cloud_sdk/agentgateway/agw_client.py index fb1d63f8..3dce95c9 100644 --- a/src/sap_cloud_sdk/agentgateway/agw_client.py +++ b/src/sap_cloud_sdk/agentgateway/agw_client.py @@ -376,8 +376,9 @@ async def list_mcp_tools( user_token: User's JWT for principal propagation. Can be a string or a callable returning a string. If provided, uses user-scoped auth instead of system auth. - filter: Optional filter to narrow results by tool name or ORD ID. - If None or empty, all tools are included. + filter: Optional filter to narrow results by tool name, ORD ID, or + global tenant ID. If None or empty, all tools are included. + See :class:`MCPToolFilter` for supported fields. Returns: List of MCPTool objects from all MCP servers. @@ -400,6 +401,7 @@ async def list_mcp_tools( filter=MCPToolFilter( names=["get-sales-order"], ord_ids=["sap.s4:apiAccess:salesOrder:v1"], + global_tenant_ids=[""], ) ) ``` diff --git a/tests/agentgateway/unit/test_agw_client.py b/tests/agentgateway/unit/test_agw_client.py index f950946a..7ea5ab59 100644 --- a/tests/agentgateway/unit/test_agw_client.py +++ b/tests/agentgateway/unit/test_agw_client.py @@ -447,6 +447,73 @@ async def test_with_callable_tenant(self): "my-tenant", "system-token", 60.0, filter=None ) + @pytest.mark.asyncio + async def test_forwards_global_tenant_ids_from_filter_to_lob(self): + """MCPToolFilter.global_tenant_ids should reach get_mcp_tools_lob.""" + with ( + patch( + "sap_cloud_sdk.agentgateway.agw_client.detect_customer_agent_credentials", + return_value=None, + ), + patch( + "sap_cloud_sdk.agentgateway.agw_client.detect_transparent_credentials", + return_value=False, + ), + patch( + "sap_cloud_sdk.agentgateway.agw_client.fetch_system_auth", + new_callable=AsyncMock, + return_value=("system-token", "https://agw.example.com"), + ), + patch( + "sap_cloud_sdk.agentgateway.agw_client.get_mcp_tools_lob", + new_callable=AsyncMock, + return_value=[], + ) as mock_lob, + ): + agw_client = create_client(tenant_subdomain="my-tenant") + + await agw_client.list_mcp_tools( + filter=MCPToolFilter(global_tenant_ids=["gtid-a", "gtid-b"]), + ) + + mock_lob.assert_called_once_with( + "my-tenant", + "system-token", + 60.0, + filter=MCPToolFilter(global_tenant_ids=["gtid-a", "gtid-b"]), + ) + + @pytest.mark.asyncio + async def test_empty_filter_is_equivalent_to_no_filter(self): + """MCPToolFilter() with no fields set should not restrict results.""" + with ( + patch( + "sap_cloud_sdk.agentgateway.agw_client.detect_customer_agent_credentials", + return_value=None, + ), + patch( + "sap_cloud_sdk.agentgateway.agw_client.detect_transparent_credentials", + return_value=False, + ), + patch( + "sap_cloud_sdk.agentgateway.agw_client.fetch_system_auth", + new_callable=AsyncMock, + return_value=("system-token", "https://agw.example.com"), + ), + patch( + "sap_cloud_sdk.agentgateway.agw_client.get_mcp_tools_lob", + new_callable=AsyncMock, + return_value=[], + ) as mock_lob, + ): + agw_client = create_client(tenant_subdomain="my-tenant") + + await agw_client.list_mcp_tools(filter=MCPToolFilter()) + + mock_lob.assert_called_once_with( + "my-tenant", "system-token", 60.0, filter=MCPToolFilter() + ) + @pytest.mark.asyncio async def test_calls_lob_flow_with_system_token(self): """list_mcp_tools should call LoB flow with system token.""" diff --git a/tests/agentgateway/unit/test_lob.py b/tests/agentgateway/unit/test_lob.py index 0f1b15e3..a5ff656d 100644 --- a/tests/agentgateway/unit/test_lob.py +++ b/tests/agentgateway/unit/test_lob.py @@ -6,6 +6,7 @@ import pytest from sap_cloud_sdk.agentgateway._fragments import ( + GTID_LABEL_KEY, LABEL_KEY, FragmentLabel, get_ias_fragment_name, @@ -230,6 +231,37 @@ def test_uses_correct_filter_labels(self): assert filter_opt.filter_labels[0].key == _LABEL_KEY assert filter_opt.filter_labels[0].values == [_MCP_LABEL_VALUE] + def test_adds_gtid_label_when_global_tenant_ids_provided(self): + """When global_tenant_ids is set, add a gtid label to the filter.""" + with patch( + "sap_cloud_sdk.agentgateway._fragments.create_fragment_client" + ) as mock_client: + mock_client.return_value.list_instance_fragments.return_value = [] + + list_mcp_fragments("tenant-sub", global_tenant_ids=["gtid-a", "gtid-b"]) + + call_args = mock_client.return_value.list_instance_fragments.call_args + filter_opt = call_args.kwargs.get("filter") + assert len(filter_opt.filter_labels) == 2 + gtid_label = next( + lb for lb in filter_opt.filter_labels if lb.key == GTID_LABEL_KEY + ) + assert gtid_label.values == ["gtid-a", "gtid-b"] + + def test_omits_gtid_label_when_global_tenant_ids_is_empty(self): + """Empty list is treated the same as None — no gtid label added.""" + with patch( + "sap_cloud_sdk.agentgateway._fragments.create_fragment_client" + ) as mock_client: + mock_client.return_value.list_instance_fragments.return_value = [] + + list_mcp_fragments("tenant-sub", global_tenant_ids=[]) + + call_args = mock_client.return_value.list_instance_fragments.call_args + filter_opt = call_args.kwargs.get("filter") + assert len(filter_opt.filter_labels) == 1 + assert filter_opt.filter_labels[0].key == _LABEL_KEY + # ============================================================ # Test: get_ias_fragment_name @@ -786,6 +818,33 @@ async def test_empty_filter_lists_behave_like_none(self): assert [t.name for t in result] == ["get-sales-order"] + @pytest.mark.asyncio + async def test_passes_global_tenant_ids_to_list_mcp_fragments(self): + """global_tenant_ids in MCPToolFilter should be forwarded to list_mcp_fragments.""" + with patch("sap_cloud_sdk.agentgateway._lob.list_mcp_fragments") as mock_list: + mock_list.return_value = [] + + await get_mcp_tools_lob( + "tenant-sub", + "system-token", + 60.0, + filter=MCPToolFilter(global_tenant_ids=["gtid-a", "gtid-b"]), + ) + + mock_list.assert_called_once_with( + "tenant-sub", ["gtid-a", "gtid-b"] + ) + + @pytest.mark.asyncio + async def test_default_global_tenant_ids_is_none(self): + """Without global_tenant_ids filter, list_mcp_fragments is called with None.""" + with patch("sap_cloud_sdk.agentgateway._lob.list_mcp_fragments") as mock_list: + mock_list.return_value = [] + + await get_mcp_tools_lob("tenant-sub", "system-token", 60.0) + + mock_list.assert_called_once_with("tenant-sub", None) + # ============================================================ # Test: call_mcp_tool_lob From a8193250597431832575e662f64203e13999217d Mon Sep 17 00:00:00 2001 From: "M. Girotto" Date: Fri, 7 Aug 2026 16:19:37 -0300 Subject: [PATCH 2/4] Add listing of sanatized integrations. --- src/sap_cloud_sdk/agentgateway/_fragments.py | 93 +++++++ src/sap_cloud_sdk/agentgateway/agw_client.py | 31 +++ src/sap_cloud_sdk/core/telemetry/operation.py | 1 + tests/agentgateway/unit/test_fragments.py | 244 ++++++++++++++++++ 4 files changed, 369 insertions(+) create mode 100644 tests/agentgateway/unit/test_fragments.py diff --git a/src/sap_cloud_sdk/agentgateway/_fragments.py b/src/sap_cloud_sdk/agentgateway/_fragments.py index 43a69cfd..1990543e 100644 --- a/src/sap_cloud_sdk/agentgateway/_fragments.py +++ b/src/sap_cloud_sdk/agentgateway/_fragments.py @@ -4,10 +4,12 @@ - Label constants for managed-runtime fragment types - Fragment listing by label (MCP, A2A, IAS) - IAS fragment name lookup for auth flows +- Active integration listing for tenant context """ import logging from enum import Enum +from typing import Optional from sap_cloud_sdk.destination import ( create_fragment_client, @@ -25,6 +27,9 @@ _DESTINATION_INSTANCE = "default" +# URL mode path segments used by system integration fragments +_INTEGRATION_URL_MODES = ("mcp", "a2a") + class FragmentLabel(str, Enum): """Label values for the sap-managed-runtime-type fragment label key.""" @@ -118,3 +123,91 @@ def get_ias_user_fragment_name(tenant_subdomain: str) -> str: f"for tenant '{tenant_subdomain}'" ) return fragments[0].name + + +def list_active_integrations(tenant_subdomain: str) -> list[dict]: + """List all active backend system integrations for the given tenant. + + Reads Destination Service instance fragments to discover active backend + system integrations for the given tenant. Each fragment represents a + connected backend system (e.g. SAP PCE, SAP S/4HANA). + + Extracts integration details from the fragment URL, which always has the form: + {agw_base_url}/v1/mcp/{ord_id}/{gtid} (MCP integrations) + {agw_base_url}/v1/a2a/{ord_id}/{gtid} (A2A integrations) + + Args: + tenant_subdomain: Subscriber tenant subdomain. + + Returns: + List of dicts, each with keys: + - global_tenant_id: GTID of the connected partner system. + - system_type: Application namespace of the partner (e.g. "sap.pce"). + - integration_dependency: ORD ID of the integration dependency fulfilled. + Returns empty list if no active integrations exist. + """ + client = create_fragment_client( + instance=_DESTINATION_INSTANCE, + _telemetry_source=Module.AGENTGATEWAY, + ) + fragments = client.list_instance_fragments( + filter=ListOptions( + filter_labels=[ + Label( + key=LABEL_KEY, + values=[FragmentLabel.MCP.value, FragmentLabel.A2A.value], + ) + ] + ), + tenant=tenant_subdomain, + ) + + result = [] + for fragment in fragments: + url = fragment.properties.get("URL", "") + entry = _parse_integration_from_url(url) + if entry is not None: + result.append(entry) + return result + + +def _parse_integration_from_url(url: str) -> Optional[dict]: + """Extract integration metadata from a system fragment URL. + + Fragment URLs have the form: + {base}/v1/{mode}/{ord_id}/{gtid} + where mode is "mcp" or "a2a", ord_id may contain colons and slashes, + and gtid is the last path segment. + + Args: + url: The fragment URL property value. + + Returns: + Dict with global_tenant_id, system_type, integration_dependency, + or None if the URL does not match the expected pattern. + """ + parts = url.rstrip("/").split("/") + + mode_idx = None + for i, part in enumerate(parts): + if i > 0 and parts[i - 1] == "v1" and part in _INTEGRATION_URL_MODES: + mode_idx = i + break + + if mode_idx is None or mode_idx + 2 > len(parts) - 1: + logger.debug("Skipping fragment with unexpected URL pattern: %s", url) + return None + + gtid = parts[-1] + ord_id = "/".join(parts[mode_idx + 1 : -1]) + system_type = ord_id.split(":")[0] + + if not gtid or not ord_id: + logger.debug("Skipping fragment with empty gtid or ord_id in URL: %s", url) + return None + + return { + "global_tenant_id": gtid, + "system_type": system_type, + "integration_dependency": ord_id, + } diff --git a/src/sap_cloud_sdk/agentgateway/agw_client.py b/src/sap_cloud_sdk/agentgateway/agw_client.py index 5aeb8078..272697f4 100644 --- a/src/sap_cloud_sdk/agentgateway/agw_client.py +++ b/src/sap_cloud_sdk/agentgateway/agw_client.py @@ -38,6 +38,7 @@ ) from sap_cloud_sdk.agentgateway._token_cache import _GatewayUrlCache, _TokenCache from sap_cloud_sdk.agentgateway.exceptions import AgentGatewaySDKError +from sap_cloud_sdk.agentgateway import _fragments from sap_cloud_sdk.core.telemetry import Module, Operation, record_metrics logger = logging.getLogger(__name__) @@ -498,6 +499,36 @@ async def list_agent_cards( logger.exception("Unexpected error during agent card discovery") raise AgentGatewaySDKError(f"Agent card discovery failed: {e}") from e + @record_metrics(Module.AGENTGATEWAY, Operation.AGENTGATEWAY_LIST_ACTIVE_INTEGRATIONS) + def list_active_integrations(self) -> list[dict]: + """List all active backend system integrations for the current tenant. + + Returns the connected backend systems (e.g. SAP PCE, SAP S/4HANA) that + are currently active for this tenant. Use this to determine which systems + are connected and which GTIDs to pass when loading MCP tools. + + Requires tenant_subdomain to be configured on the client. + + Returns: + List of dicts, each with: + - global_tenant_id: GTID of the connected partner system. + - system_type: Application namespace (e.g. "sap.pce", "sap.s4"). + - integration_dependency: ORD ID fulfilled by this integration. + Returns empty list if no active integrations exist. + + Raises: + AgentGatewaySDKError: If tenant_subdomain is not configured. + + Example: + ```python + integrations = agw_client.list_active_integrations() + for i in integrations: + print(i["system_type"], i["global_tenant_id"]) + ``` + """ + tenant = self._resolve_tenant_subdomain() + return _fragments.list_active_integrations(tenant) + @record_metrics(Module.AGENTGATEWAY, Operation.AGENTGATEWAY_CALL_MCP_TOOL) async def call_mcp_tool( self, diff --git a/src/sap_cloud_sdk/core/telemetry/operation.py b/src/sap_cloud_sdk/core/telemetry/operation.py index 22e3280c..15cba01d 100644 --- a/src/sap_cloud_sdk/core/telemetry/operation.py +++ b/src/sap_cloud_sdk/core/telemetry/operation.py @@ -193,6 +193,7 @@ class Operation(str, Enum): AGENTGATEWAY_GET_USER_AUTH = "get_user_auth" AGENTGATEWAY_LIST_AGENT_CARDS = "list_agent_cards" AGENTGATEWAY_GET_IAS_CLIENT_ID = "get_ias_client_id" + AGENTGATEWAY_LIST_ACTIVE_INTEGRATIONS = "list_active_integrations" # Agent Memory Operations AGENT_MEMORY_ADD_MEMORY = "add_memory" diff --git a/tests/agentgateway/unit/test_fragments.py b/tests/agentgateway/unit/test_fragments.py new file mode 100644 index 00000000..4075e9cb --- /dev/null +++ b/tests/agentgateway/unit/test_fragments.py @@ -0,0 +1,244 @@ +"""Unit tests for agentgateway._fragments — list_active_integrations and helpers.""" + +from unittest.mock import MagicMock, patch + +import pytest + +from sap_cloud_sdk.agentgateway._fragments import ( + _parse_integration_from_url, + list_active_integrations, +) +from sap_cloud_sdk.agentgateway import create_client, AgentGatewaySDKError +from sap_cloud_sdk.destination._models import Fragment + + +# ============================================================ +# Helpers +# ============================================================ + + +def _fragment(url: str, name: str = "sap-managed-runtime-agw-mcp-abc") -> Fragment: + return Fragment(name=name, properties={"URL": url}) + + +# ============================================================ +# Tests: _parse_integration_from_url +# ============================================================ + + +class TestParseIntegrationFromUrl: + def test_mcp_url_returns_correct_fields(self): + url = "https://agw.example.com/v1/mcp/sap.pce:apiResource:PA:v1/gtid-123" + result = _parse_integration_from_url(url) + assert result == { + "global_tenant_id": "gtid-123", + "system_type": "sap.pce", + "integration_dependency": "sap.pce:apiResource:PA:v1", + } + + def test_a2a_url_returns_correct_fields(self): + url = "https://agw.example.com/v1/a2a/sap.s4:apiResource:BP:v1/gtid-456" + result = _parse_integration_from_url(url) + assert result == { + "global_tenant_id": "gtid-456", + "system_type": "sap.s4", + "integration_dependency": "sap.s4:apiResource:BP:v1", + } + + def test_ord_id_with_slash_segments(self): + url = "https://agw.example.com/v1/mcp/sap.sf:apiResource:jobs/v1/gtid-789" + result = _parse_integration_from_url(url) + assert result == { + "global_tenant_id": "gtid-789", + "system_type": "sap.sf", + "integration_dependency": "sap.sf:apiResource:jobs/v1", + } + + def test_trailing_slash_is_ignored(self): + url = "https://agw.example.com/v1/mcp/sap.pce:apiResource:PA:v1/gtid-123/" + result = _parse_integration_from_url(url) + assert result is not None + assert result["global_tenant_id"] == "gtid-123" + + def test_returns_none_for_url_without_v1_mode(self): + url = "https://agw.example.com/some/other/path/gtid-123" + assert _parse_integration_from_url(url) is None + + def test_returns_none_for_empty_url(self): + assert _parse_integration_from_url("") is None + + def test_returns_none_when_nothing_after_mode(self): + url = "https://agw.example.com/v1/mcp/" + assert _parse_integration_from_url(url) is None + + def test_returns_none_when_only_gtid_after_mode(self): + # mode_idx + 2 > len(parts) - 1 → no ord_id between mode and gtid + url = "https://agw.example.com/v1/mcp/gtid-only" + assert _parse_integration_from_url(url) is None + + +# ============================================================ +# Tests: list_active_integrations (module-level function) +# ============================================================ + + +class TestListActiveIntegrations: + def test_returns_parsed_entries_for_matching_fragments(self): + fragments = [ + _fragment("https://agw.example.com/v1/mcp/sap.pce:apiResource:PA:v1/gtid-1"), + _fragment("https://agw.example.com/v1/a2a/sap.s4:apiResource:BP:v1/gtid-2"), + ] + mock_client = MagicMock() + mock_client.list_instance_fragments.return_value = fragments + + with patch( + "sap_cloud_sdk.agentgateway._fragments.create_fragment_client", + return_value=mock_client, + ): + result = list_active_integrations("my-tenant") + + assert len(result) == 2 + assert result[0] == { + "global_tenant_id": "gtid-1", + "system_type": "sap.pce", + "integration_dependency": "sap.pce:apiResource:PA:v1", + } + assert result[1] == { + "global_tenant_id": "gtid-2", + "system_type": "sap.s4", + "integration_dependency": "sap.s4:apiResource:BP:v1", + } + + def test_returns_empty_list_when_no_fragments(self): + mock_client = MagicMock() + mock_client.list_instance_fragments.return_value = [] + + with patch( + "sap_cloud_sdk.agentgateway._fragments.create_fragment_client", + return_value=mock_client, + ): + result = list_active_integrations("my-tenant") + + assert result == [] + + def test_skips_fragments_with_unparseable_url(self): + fragments = [ + _fragment("https://agw.example.com/some/unrelated/path"), + _fragment("https://agw.example.com/v1/mcp/sap.pce:apiResource:PA:v1/gtid-1"), + ] + mock_client = MagicMock() + mock_client.list_instance_fragments.return_value = fragments + + with patch( + "sap_cloud_sdk.agentgateway._fragments.create_fragment_client", + return_value=mock_client, + ): + result = list_active_integrations("my-tenant") + + assert len(result) == 1 + assert result[0]["global_tenant_id"] == "gtid-1" + + def test_skips_fragments_with_missing_url_property(self): + fragment = Fragment(name="sap-managed-runtime-agw-mcp-abc", properties={}) + mock_client = MagicMock() + mock_client.list_instance_fragments.return_value = [fragment] + + with patch( + "sap_cloud_sdk.agentgateway._fragments.create_fragment_client", + return_value=mock_client, + ): + result = list_active_integrations("my-tenant") + + assert result == [] + + def test_passes_tenant_subdomain_to_fragment_client(self): + mock_client = MagicMock() + mock_client.list_instance_fragments.return_value = [] + + with patch( + "sap_cloud_sdk.agentgateway._fragments.create_fragment_client", + return_value=mock_client, + ): + list_active_integrations("specific-tenant") + + call_kwargs = mock_client.list_instance_fragments.call_args.kwargs + assert call_kwargs["tenant"] == "specific-tenant" + + def test_filters_by_mcp_and_a2a_label_types(self): + from sap_cloud_sdk.destination._models import Label, ListOptions + + mock_client = MagicMock() + mock_client.list_instance_fragments.return_value = [] + + with patch( + "sap_cloud_sdk.agentgateway._fragments.create_fragment_client", + return_value=mock_client, + ): + list_active_integrations("my-tenant") + + call_kwargs = mock_client.list_instance_fragments.call_args.kwargs + filter_obj: ListOptions = call_kwargs["filter"] + assert filter_obj is not None + assert len(filter_obj.filter_labels) == 1 + label: Label = filter_obj.filter_labels[0] + assert label.key == "sap-managed-runtime-type" + assert "agw.mcp.server" in label.values + assert "agw.a2a.server" in label.values + + +# ============================================================ +# Tests: AgentGatewayClient.list_active_integrations +# ============================================================ + + +class TestAgentGatewayClientListActiveIntegrations: + def test_delegates_to_fragments_helper(self): + expected = [ + { + "global_tenant_id": "gtid-1", + "system_type": "sap.pce", + "integration_dependency": "sap.pce:apiResource:PA:v1", + } + ] + with ( + patch( + "sap_cloud_sdk.agentgateway.agw_client.detect_transparent_credentials", + return_value=False, + ), + patch.object( + __import__("sap_cloud_sdk.agentgateway._fragments", fromlist=["list_active_integrations"]), + "list_active_integrations", + return_value=expected, + ) as mock_fn, + ): + client = create_client(tenant_subdomain="my-tenant") + result = client.list_active_integrations() + + assert result == expected + mock_fn.assert_called_once_with("my-tenant") + + def test_returns_empty_list_when_no_integrations(self): + with ( + patch( + "sap_cloud_sdk.agentgateway.agw_client.detect_transparent_credentials", + return_value=False, + ), + patch.object( + __import__("sap_cloud_sdk.agentgateway._fragments", fromlist=["list_active_integrations"]), + "list_active_integrations", + return_value=[], + ), + ): + client = create_client(tenant_subdomain="my-tenant") + result = client.list_active_integrations() + + assert result == [] + + def test_raises_when_tenant_subdomain_not_configured(self): + with patch( + "sap_cloud_sdk.agentgateway.agw_client.detect_transparent_credentials", + return_value=False, + ): + client = create_client() + with pytest.raises(AgentGatewaySDKError): + client.list_active_integrations() From 57258d67423950184239fdedd465c8313a9a9ffd Mon Sep 17 00:00:00 2001 From: "M. Girotto" Date: Sun, 9 Aug 2026 19:00:02 -0300 Subject: [PATCH 3/4] feat(agentgateway): switch to label-based integration metadata and add typed return - Extract global_tenant_id, system_type, integration_dependency from fragment labels instead of parsing the fragment URL - Rename list_active_integrations to _list_active_integrations (private) - Add ActiveIntegration TypedDict for strong return type; export it from the agentgateway package - Skip fragments only when system_type label is absent (gtid and ord_id are guaranteed to be present by the writer) - Update unit tests to mock get_fragment_labels instead of URL parsing --- src/sap_cloud_sdk/agentgateway/__init__.py | 3 + src/sap_cloud_sdk/agentgateway/_fragments.py | 105 +++++---- src/sap_cloud_sdk/agentgateway/agw_client.py | 5 +- tests/agentgateway/unit/test_fragments.py | 212 ++++++++++--------- 4 files changed, 172 insertions(+), 153 deletions(-) diff --git a/src/sap_cloud_sdk/agentgateway/__init__.py b/src/sap_cloud_sdk/agentgateway/__init__.py index 69c5f1e8..3a82d184 100644 --- a/src/sap_cloud_sdk/agentgateway/__init__.py +++ b/src/sap_cloud_sdk/agentgateway/__init__.py @@ -59,6 +59,7 @@ AgentCard, AgentCardFilter, ) +from sap_cloud_sdk.agentgateway._fragments import ActiveIntegration from sap_cloud_sdk.agentgateway.config import ClientConfig from sap_cloud_sdk.agentgateway.agw_client import create_client, AgentGatewayClient from sap_cloud_sdk.agentgateway.exceptions import ( @@ -81,6 +82,8 @@ "Agent", "AgentCard", "AgentCardFilter", + # Integration metadata + "ActiveIntegration", # Exceptions "AgentGatewaySDKError", "AgentGatewayServerError", diff --git a/src/sap_cloud_sdk/agentgateway/_fragments.py b/src/sap_cloud_sdk/agentgateway/_fragments.py index 1990543e..2f7a3ef1 100644 --- a/src/sap_cloud_sdk/agentgateway/_fragments.py +++ b/src/sap_cloud_sdk/agentgateway/_fragments.py @@ -9,13 +9,14 @@ import logging from enum import Enum -from typing import Optional +from typing import TypedDict from sap_cloud_sdk.destination import ( create_fragment_client, Label, ListOptions, ) +from sap_cloud_sdk.destination._models import Level from sap_cloud_sdk.agentgateway.exceptions import MCPServerNotFoundError from sap_cloud_sdk.core.telemetry import Module @@ -25,10 +26,12 @@ # Shared label key for all managed-runtime fragment types LABEL_KEY = "sap-managed-runtime-type" -_DESTINATION_INSTANCE = "default" +# Label keys for integration metadata stored on system fragments +_LABEL_GTID = "sap-managed-runtime-gtid" +_LABEL_ORD_ID = "sap-managed-runtime-ordid" +_LABEL_SYSTEM_TYPE = "sap-managed-runtime-system-type" -# URL mode path segments used by system integration fragments -_INTEGRATION_URL_MODES = ("mcp", "a2a") +_DESTINATION_INSTANCE = "default" class FragmentLabel(str, Enum): @@ -125,22 +128,31 @@ def get_ias_user_fragment_name(tenant_subdomain: str) -> str: return fragments[0].name -def list_active_integrations(tenant_subdomain: str) -> list[dict]: +class ActiveIntegration(TypedDict): + """Metadata for a connected backend system integration.""" + + global_tenant_id: str + system_type: str + integration_dependency: str + + +def _list_active_integrations(tenant_subdomain: str) -> list[ActiveIntegration]: """List all active backend system integrations for the given tenant. Reads Destination Service instance fragments to discover active backend system integrations for the given tenant. Each fragment represents a connected backend system (e.g. SAP PCE, SAP S/4HANA). - Extracts integration details from the fragment URL, which always has the form: - {agw_base_url}/v1/mcp/{ord_id}/{gtid} (MCP integrations) - {agw_base_url}/v1/a2a/{ord_id}/{gtid} (A2A integrations) + Retrieves integration metadata from fragment labels: + - sap-managed-runtime-gtid: GTID of the connected partner system. + - sap-managed-runtime-system-type: Application namespace (e.g. "sap.pce"). + - sap-managed-runtime-ordid: Sanitized ORD ID of the integration dependency. Args: tenant_subdomain: Subscriber tenant subdomain. Returns: - List of dicts, each with keys: + List of ActiveIntegration dicts, each with keys: - global_tenant_id: GTID of the connected partner system. - system_type: Application namespace of the partner (e.g. "sap.pce"). - integration_dependency: ORD ID of the integration dependency fulfilled. @@ -162,52 +174,33 @@ def list_active_integrations(tenant_subdomain: str) -> list[dict]: tenant=tenant_subdomain, ) - result = [] + result: list[ActiveIntegration] = [] for fragment in fragments: - url = fragment.properties.get("URL", "") - entry = _parse_integration_from_url(url) - if entry is not None: - result.append(entry) - return result - - -def _parse_integration_from_url(url: str) -> Optional[dict]: - """Extract integration metadata from a system fragment URL. - - Fragment URLs have the form: - {base}/v1/{mode}/{ord_id}/{gtid} - where mode is "mcp" or "a2a", ord_id may contain colons and slashes, - and gtid is the last path segment. - - Args: - url: The fragment URL property value. + labels = { + lbl.key: lbl.values[0] if lbl.values else None + for lbl in client.get_fragment_labels( + name=fragment.name, + level=Level.SERVICE_INSTANCE, + tenant=tenant_subdomain, + ) + } + gtid = labels.get(_LABEL_GTID) + system_type = labels.get(_LABEL_SYSTEM_TYPE) + ord_id = labels.get(_LABEL_ORD_ID) + + if not system_type: + logger.debug( + "Skipping fragment '%s': missing system_type label (gtid=%s, ord_id=%s)", + fragment.name, gtid, ord_id, + ) + continue + + result.append( + ActiveIntegration( + global_tenant_id=gtid, + system_type=system_type, + integration_dependency=ord_id, + ) + ) - Returns: - Dict with global_tenant_id, system_type, integration_dependency, - or None if the URL does not match the expected pattern. - """ - parts = url.rstrip("/").split("/") - - mode_idx = None - for i, part in enumerate(parts): - if i > 0 and parts[i - 1] == "v1" and part in _INTEGRATION_URL_MODES: - mode_idx = i - break - - if mode_idx is None or mode_idx + 2 > len(parts) - 1: - logger.debug("Skipping fragment with unexpected URL pattern: %s", url) - return None - - gtid = parts[-1] - ord_id = "/".join(parts[mode_idx + 1 : -1]) - system_type = ord_id.split(":")[0] - - if not gtid or not ord_id: - logger.debug("Skipping fragment with empty gtid or ord_id in URL: %s", url) - return None - - return { - "global_tenant_id": gtid, - "system_type": system_type, - "integration_dependency": ord_id, - } + return result diff --git a/src/sap_cloud_sdk/agentgateway/agw_client.py b/src/sap_cloud_sdk/agentgateway/agw_client.py index 272697f4..e8c7a30e 100644 --- a/src/sap_cloud_sdk/agentgateway/agw_client.py +++ b/src/sap_cloud_sdk/agentgateway/agw_client.py @@ -39,6 +39,7 @@ from sap_cloud_sdk.agentgateway._token_cache import _GatewayUrlCache, _TokenCache from sap_cloud_sdk.agentgateway.exceptions import AgentGatewaySDKError from sap_cloud_sdk.agentgateway import _fragments +from sap_cloud_sdk.agentgateway._fragments import ActiveIntegration from sap_cloud_sdk.core.telemetry import Module, Operation, record_metrics logger = logging.getLogger(__name__) @@ -500,7 +501,7 @@ async def list_agent_cards( raise AgentGatewaySDKError(f"Agent card discovery failed: {e}") from e @record_metrics(Module.AGENTGATEWAY, Operation.AGENTGATEWAY_LIST_ACTIVE_INTEGRATIONS) - def list_active_integrations(self) -> list[dict]: + def list_active_integrations(self) -> list[ActiveIntegration]: """List all active backend system integrations for the current tenant. Returns the connected backend systems (e.g. SAP PCE, SAP S/4HANA) that @@ -527,7 +528,7 @@ def list_active_integrations(self) -> list[dict]: ``` """ tenant = self._resolve_tenant_subdomain() - return _fragments.list_active_integrations(tenant) + return _fragments._list_active_integrations(tenant) @record_metrics(Module.AGENTGATEWAY, Operation.AGENTGATEWAY_CALL_MCP_TOOL) async def call_mcp_tool( diff --git a/tests/agentgateway/unit/test_fragments.py b/tests/agentgateway/unit/test_fragments.py index 4075e9cb..7dfb8f1c 100644 --- a/tests/agentgateway/unit/test_fragments.py +++ b/tests/agentgateway/unit/test_fragments.py @@ -1,15 +1,12 @@ -"""Unit tests for agentgateway._fragments — list_active_integrations and helpers.""" +"""Unit tests for agentgateway._fragments — _list_active_integrations and helpers.""" from unittest.mock import MagicMock, patch import pytest -from sap_cloud_sdk.agentgateway._fragments import ( - _parse_integration_from_url, - list_active_integrations, -) +from sap_cloud_sdk.agentgateway._fragments import _list_active_integrations from sap_cloud_sdk.agentgateway import create_client, AgentGatewaySDKError -from sap_cloud_sdk.destination._models import Fragment +from sap_cloud_sdk.destination._models import Fragment, Label, Level # ============================================================ @@ -17,96 +14,55 @@ # ============================================================ -def _fragment(url: str, name: str = "sap-managed-runtime-agw-mcp-abc") -> Fragment: - return Fragment(name=name, properties={"URL": url}) +def _fragment(name: str = "sap-managed-runtime-agw-mcp-abc") -> Fragment: + return Fragment(name=name, properties={}) -# ============================================================ -# Tests: _parse_integration_from_url -# ============================================================ - - -class TestParseIntegrationFromUrl: - def test_mcp_url_returns_correct_fields(self): - url = "https://agw.example.com/v1/mcp/sap.pce:apiResource:PA:v1/gtid-123" - result = _parse_integration_from_url(url) - assert result == { - "global_tenant_id": "gtid-123", - "system_type": "sap.pce", - "integration_dependency": "sap.pce:apiResource:PA:v1", - } - - def test_a2a_url_returns_correct_fields(self): - url = "https://agw.example.com/v1/a2a/sap.s4:apiResource:BP:v1/gtid-456" - result = _parse_integration_from_url(url) - assert result == { - "global_tenant_id": "gtid-456", - "system_type": "sap.s4", - "integration_dependency": "sap.s4:apiResource:BP:v1", - } - - def test_ord_id_with_slash_segments(self): - url = "https://agw.example.com/v1/mcp/sap.sf:apiResource:jobs/v1/gtid-789" - result = _parse_integration_from_url(url) - assert result == { - "global_tenant_id": "gtid-789", - "system_type": "sap.sf", - "integration_dependency": "sap.sf:apiResource:jobs/v1", - } - - def test_trailing_slash_is_ignored(self): - url = "https://agw.example.com/v1/mcp/sap.pce:apiResource:PA:v1/gtid-123/" - result = _parse_integration_from_url(url) - assert result is not None - assert result["global_tenant_id"] == "gtid-123" +def _label(key: str, value: str) -> Label: + return Label(key=key, values=[value]) - def test_returns_none_for_url_without_v1_mode(self): - url = "https://agw.example.com/some/other/path/gtid-123" - assert _parse_integration_from_url(url) is None - def test_returns_none_for_empty_url(self): - assert _parse_integration_from_url("") is None - - def test_returns_none_when_nothing_after_mode(self): - url = "https://agw.example.com/v1/mcp/" - assert _parse_integration_from_url(url) is None - - def test_returns_none_when_only_gtid_after_mode(self): - # mode_idx + 2 > len(parts) - 1 → no ord_id between mode and gtid - url = "https://agw.example.com/v1/mcp/gtid-only" - assert _parse_integration_from_url(url) is None +def _full_labels(gtid: str, system_type: str, ord_id: str) -> list[Label]: + return [ + _label("sap-managed-runtime-gtid", gtid), + _label("sap-managed-runtime-system-type", system_type), + _label("sap-managed-runtime-ordid", ord_id), + _label("sap-managed-runtime-type", "agw.mcp.server"), + ] # ============================================================ -# Tests: list_active_integrations (module-level function) +# Tests: _list_active_integrations (module-level function) # ============================================================ class TestListActiveIntegrations: - def test_returns_parsed_entries_for_matching_fragments(self): - fragments = [ - _fragment("https://agw.example.com/v1/mcp/sap.pce:apiResource:PA:v1/gtid-1"), - _fragment("https://agw.example.com/v1/a2a/sap.s4:apiResource:BP:v1/gtid-2"), - ] + def test_returns_entries_from_fragment_labels(self): + frag1 = _fragment("frag-mcp-1") + frag2 = _fragment("frag-a2a-2") mock_client = MagicMock() - mock_client.list_instance_fragments.return_value = fragments + mock_client.list_instance_fragments.return_value = [frag1, frag2] + mock_client.get_fragment_labels.side_effect = [ + _full_labels("gtid-1", "sap.pce", "sap-pce-apiResource-PA-v1"), + _full_labels("gtid-2", "sap.s4", "sap-s4-apiResource-BP-v1"), + ] with patch( "sap_cloud_sdk.agentgateway._fragments.create_fragment_client", return_value=mock_client, ): - result = list_active_integrations("my-tenant") + result = _list_active_integrations("my-tenant") assert len(result) == 2 assert result[0] == { "global_tenant_id": "gtid-1", "system_type": "sap.pce", - "integration_dependency": "sap.pce:apiResource:PA:v1", + "integration_dependency": "sap-pce-apiResource-PA-v1", } assert result[1] == { "global_tenant_id": "gtid-2", "system_type": "sap.s4", - "integration_dependency": "sap.s4:apiResource:BP:v1", + "integration_dependency": "sap-s4-apiResource-BP-v1", } def test_returns_empty_list_when_no_fragments(self): @@ -117,55 +73,121 @@ def test_returns_empty_list_when_no_fragments(self): "sap_cloud_sdk.agentgateway._fragments.create_fragment_client", return_value=mock_client, ): - result = list_active_integrations("my-tenant") + result = _list_active_integrations("my-tenant") + + assert result == [] + mock_client.get_fragment_labels.assert_not_called() + + def test_skips_fragment_missing_system_type(self): + frag = _fragment("frag-no-systype") + mock_client = MagicMock() + mock_client.list_instance_fragments.return_value = [frag] + mock_client.get_fragment_labels.return_value = [ + _label("sap-managed-runtime-gtid", "gtid-1"), + _label("sap-managed-runtime-ordid", "sap-pce-apiResource-PA-v1"), + ] + + with patch( + "sap_cloud_sdk.agentgateway._fragments.create_fragment_client", + return_value=mock_client, + ): + result = _list_active_integrations("my-tenant") assert result == [] - def test_skips_fragments_with_unparseable_url(self): - fragments = [ - _fragment("https://agw.example.com/some/unrelated/path"), - _fragment("https://agw.example.com/v1/mcp/sap.pce:apiResource:PA:v1/gtid-1"), + def test_includes_fragment_missing_gtid(self): + """GTID and ORD ID are expected to always be present; missing system_type is the only skip condition.""" + frag = _fragment("frag-no-gtid") + mock_client = MagicMock() + mock_client.list_instance_fragments.return_value = [frag] + mock_client.get_fragment_labels.return_value = [ + _label("sap-managed-runtime-system-type", "sap.pce"), + _label("sap-managed-runtime-ordid", "sap-pce-apiResource-PA-v1"), ] + + with patch( + "sap_cloud_sdk.agentgateway._fragments.create_fragment_client", + return_value=mock_client, + ): + result = _list_active_integrations("my-tenant") + + assert len(result) == 1 + assert result[0]["global_tenant_id"] is None + + def test_partial_failure_returns_valid_entries_only(self): + frag_ok = _fragment("frag-ok") + frag_bad = _fragment("frag-bad") mock_client = MagicMock() - mock_client.list_instance_fragments.return_value = fragments + mock_client.list_instance_fragments.return_value = [frag_ok, frag_bad] + mock_client.get_fragment_labels.side_effect = [ + _full_labels("gtid-ok", "sap.pce", "sap-pce-apiResource-PA-v1"), + [_label("sap-managed-runtime-gtid", "gtid-bad")], # missing system_type + ] with patch( "sap_cloud_sdk.agentgateway._fragments.create_fragment_client", return_value=mock_client, ): - result = list_active_integrations("my-tenant") + result = _list_active_integrations("my-tenant") assert len(result) == 1 - assert result[0]["global_tenant_id"] == "gtid-1" + assert result[0]["global_tenant_id"] == "gtid-ok" - def test_skips_fragments_with_missing_url_property(self): - fragment = Fragment(name="sap-managed-runtime-agw-mcp-abc", properties={}) + def test_passes_tenant_subdomain_to_list_and_get_labels(self): + frag = _fragment("frag-abc") mock_client = MagicMock() - mock_client.list_instance_fragments.return_value = [fragment] + mock_client.list_instance_fragments.return_value = [frag] + mock_client.get_fragment_labels.return_value = _full_labels( + "gtid-1", "sap.pce", "sap-pce-apiResource-PA-v1" + ) with patch( "sap_cloud_sdk.agentgateway._fragments.create_fragment_client", return_value=mock_client, ): - result = list_active_integrations("my-tenant") + _list_active_integrations("specific-tenant") - assert result == [] + list_kwargs = mock_client.list_instance_fragments.call_args.kwargs + assert list_kwargs["tenant"] == "specific-tenant" + + get_kwargs = mock_client.get_fragment_labels.call_args.kwargs + assert get_kwargs["tenant"] == "specific-tenant" - def test_passes_tenant_subdomain_to_fragment_client(self): + def test_get_fragment_labels_called_with_service_instance_level(self): + frag = _fragment("frag-abc") mock_client = MagicMock() - mock_client.list_instance_fragments.return_value = [] + mock_client.list_instance_fragments.return_value = [frag] + mock_client.get_fragment_labels.return_value = _full_labels( + "gtid-1", "sap.pce", "sap-pce-apiResource-PA-v1" + ) with patch( "sap_cloud_sdk.agentgateway._fragments.create_fragment_client", return_value=mock_client, ): - list_active_integrations("specific-tenant") + _list_active_integrations("my-tenant") - call_kwargs = mock_client.list_instance_fragments.call_args.kwargs - assert call_kwargs["tenant"] == "specific-tenant" + get_kwargs = mock_client.get_fragment_labels.call_args.kwargs + assert get_kwargs["level"] == Level.SERVICE_INSTANCE + + def test_get_fragment_labels_called_once_per_fragment(self): + frags = [_fragment(f"frag-{i}") for i in range(3)] + mock_client = MagicMock() + mock_client.list_instance_fragments.return_value = frags + mock_client.get_fragment_labels.return_value = _full_labels( + "gtid-x", "sap.pce", "sap-pce-apiResource-PA-v1" + ) + + with patch( + "sap_cloud_sdk.agentgateway._fragments.create_fragment_client", + return_value=mock_client, + ): + _list_active_integrations("my-tenant") + + assert mock_client.get_fragment_labels.call_count == 3 def test_filters_by_mcp_and_a2a_label_types(self): - from sap_cloud_sdk.destination._models import Label, ListOptions + from sap_cloud_sdk.destination._models import ListOptions mock_client = MagicMock() mock_client.list_instance_fragments.return_value = [] @@ -174,7 +196,7 @@ def test_filters_by_mcp_and_a2a_label_types(self): "sap_cloud_sdk.agentgateway._fragments.create_fragment_client", return_value=mock_client, ): - list_active_integrations("my-tenant") + _list_active_integrations("my-tenant") call_kwargs = mock_client.list_instance_fragments.call_args.kwargs filter_obj: ListOptions = call_kwargs["filter"] @@ -197,7 +219,7 @@ def test_delegates_to_fragments_helper(self): { "global_tenant_id": "gtid-1", "system_type": "sap.pce", - "integration_dependency": "sap.pce:apiResource:PA:v1", + "integration_dependency": "sap-pce-apiResource-PA-v1", } ] with ( @@ -206,8 +228,8 @@ def test_delegates_to_fragments_helper(self): return_value=False, ), patch.object( - __import__("sap_cloud_sdk.agentgateway._fragments", fromlist=["list_active_integrations"]), - "list_active_integrations", + __import__("sap_cloud_sdk.agentgateway._fragments", fromlist=["_list_active_integrations"]), + "_list_active_integrations", return_value=expected, ) as mock_fn, ): @@ -224,8 +246,8 @@ def test_returns_empty_list_when_no_integrations(self): return_value=False, ), patch.object( - __import__("sap_cloud_sdk.agentgateway._fragments", fromlist=["list_active_integrations"]), - "list_active_integrations", + __import__("sap_cloud_sdk.agentgateway._fragments", fromlist=["_list_active_integrations"]), + "_list_active_integrations", return_value=[], ), ): From 4e84b80c1d2ef894ec72107c11508da42d281a49 Mon Sep 17 00:00:00 2001 From: "M. Girotto" Date: Sun, 9 Aug 2026 21:17:39 -0300 Subject: [PATCH 4/4] fix(agentgateway): return system_type as None instead of skipping fragment Fragments missing the system_type label are now included in the result with system_type set to None, and a debug log is emitted. Previously the fragment was silently dropped, causing incomplete results for older integrations. --- src/sap_cloud_sdk/agentgateway/_fragments.py | 9 ++-- tests/agentgateway/unit/test_fragments.py | 47 +++++++++----------- 2 files changed, 25 insertions(+), 31 deletions(-) diff --git a/src/sap_cloud_sdk/agentgateway/_fragments.py b/src/sap_cloud_sdk/agentgateway/_fragments.py index 2f7a3ef1..4db51012 100644 --- a/src/sap_cloud_sdk/agentgateway/_fragments.py +++ b/src/sap_cloud_sdk/agentgateway/_fragments.py @@ -9,7 +9,7 @@ import logging from enum import Enum -from typing import TypedDict +from typing import Optional, TypedDict from sap_cloud_sdk.destination import ( create_fragment_client, @@ -132,7 +132,7 @@ class ActiveIntegration(TypedDict): """Metadata for a connected backend system integration.""" global_tenant_id: str - system_type: str + system_type: Optional[str] integration_dependency: str @@ -190,10 +190,9 @@ def _list_active_integrations(tenant_subdomain: str) -> list[ActiveIntegration]: if not system_type: logger.debug( - "Skipping fragment '%s': missing system_type label (gtid=%s, ord_id=%s)", - fragment.name, gtid, ord_id, + "Fragment '%s' is missing system_type label; system_type will be None in result", + fragment.name, ) - continue result.append( ActiveIntegration( diff --git a/tests/agentgateway/unit/test_fragments.py b/tests/agentgateway/unit/test_fragments.py index 7dfb8f1c..183e6328 100644 --- a/tests/agentgateway/unit/test_fragments.py +++ b/tests/agentgateway/unit/test_fragments.py @@ -78,7 +78,7 @@ def test_returns_empty_list_when_no_fragments(self): assert result == [] mock_client.get_fragment_labels.assert_not_called() - def test_skips_fragment_missing_system_type(self): + def test_returns_none_system_type_when_label_absent(self): frag = _fragment("frag-no-systype") mock_client = MagicMock() mock_client.list_instance_fragments.return_value = [frag] @@ -87,24 +87,6 @@ def test_skips_fragment_missing_system_type(self): _label("sap-managed-runtime-ordid", "sap-pce-apiResource-PA-v1"), ] - with patch( - "sap_cloud_sdk.agentgateway._fragments.create_fragment_client", - return_value=mock_client, - ): - result = _list_active_integrations("my-tenant") - - assert result == [] - - def test_includes_fragment_missing_gtid(self): - """GTID and ORD ID are expected to always be present; missing system_type is the only skip condition.""" - frag = _fragment("frag-no-gtid") - mock_client = MagicMock() - mock_client.list_instance_fragments.return_value = [frag] - mock_client.get_fragment_labels.return_value = [ - _label("sap-managed-runtime-system-type", "sap.pce"), - _label("sap-managed-runtime-ordid", "sap-pce-apiResource-PA-v1"), - ] - with patch( "sap_cloud_sdk.agentgateway._fragments.create_fragment_client", return_value=mock_client, @@ -112,16 +94,20 @@ def test_includes_fragment_missing_gtid(self): result = _list_active_integrations("my-tenant") assert len(result) == 1 - assert result[0]["global_tenant_id"] is None + assert result[0] == { + "global_tenant_id": "gtid-1", + "system_type": None, + "integration_dependency": "sap-pce-apiResource-PA-v1", + } - def test_partial_failure_returns_valid_entries_only(self): + def test_fragment_with_missing_labels_gets_none_values(self): frag_ok = _fragment("frag-ok") - frag_bad = _fragment("frag-bad") + frag_partial = _fragment("frag-partial") mock_client = MagicMock() - mock_client.list_instance_fragments.return_value = [frag_ok, frag_bad] + mock_client.list_instance_fragments.return_value = [frag_ok, frag_partial] mock_client.get_fragment_labels.side_effect = [ _full_labels("gtid-ok", "sap.pce", "sap-pce-apiResource-PA-v1"), - [_label("sap-managed-runtime-gtid", "gtid-bad")], # missing system_type + [_label("sap-managed-runtime-gtid", "gtid-partial")], # missing system_type and ord_id ] with patch( @@ -130,8 +116,17 @@ def test_partial_failure_returns_valid_entries_only(self): ): result = _list_active_integrations("my-tenant") - assert len(result) == 1 - assert result[0]["global_tenant_id"] == "gtid-ok" + assert len(result) == 2 + assert result[0] == { + "global_tenant_id": "gtid-ok", + "system_type": "sap.pce", + "integration_dependency": "sap-pce-apiResource-PA-v1", + } + assert result[1] == { + "global_tenant_id": "gtid-partial", + "system_type": None, + "integration_dependency": None, + } def test_passes_tenant_subdomain_to_list_and_get_labels(self): frag = _fragment("frag-abc")