From 5e01c4c54bfd836a0d981889d2aeb4d57bf71d3a Mon Sep 17 00:00:00 2001 From: I542102 Date: Fri, 7 Aug 2026 16:29:19 -0300 Subject: [PATCH 1/8] 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/8] 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/8] 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/8] 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") From 804e3ec3557a0cc2d3048d7c36acce51dd42fd3a Mon Sep 17 00:00:00 2001 From: I542102 Date: Mon, 10 Aug 2026 16:15:38 -0300 Subject: [PATCH 5/8] feat(agentgateway): address PR review comments for connected systems and GTID filtering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move FragmentLabel enum to _models.py (single source of truth) - Rename ActiveIntegration → ConnectedSystem with all fragment metadata fields - Remove A2A from _list_active_integrations filter (MCP-only) - Rename MCPToolFilter.global_tenant_ids → gtids; simplify docstring - Guard list_active_integrations against customer agent flow - Export ConnectedSystem from __init__.py; remove ActiveIntegration - Add gtids filter test and customer agent guard tests - Add BDD integration tests for connected systems scenarios - Bump version to 0.43.0 --- pyproject.toml | 2 +- src/sap_cloud_sdk/agentgateway/__init__.py | 5 +- src/sap_cloud_sdk/agentgateway/_fragments.py | 31 +-- src/sap_cloud_sdk/agentgateway/_lob.py | 10 +- src/sap_cloud_sdk/agentgateway/_models.py | 26 ++- src/sap_cloud_sdk/agentgateway/agw_client.py | 27 ++- .../integration/connected_systems.feature | 32 ++++ .../integration/test_connected_systems_bdd.py | 176 ++++++++++++++++++ tests/agentgateway/unit/test_fragments.py | 55 +++++- tests/agentgateway/unit/test_lob.py | 28 +-- 10 files changed, 329 insertions(+), 63 deletions(-) create mode 100644 tests/agentgateway/integration/connected_systems.feature create mode 100644 tests/agentgateway/integration/test_connected_systems_bdd.py diff --git a/pyproject.toml b/pyproject.toml index 44a451a9..4f7042a1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sap-cloud-sdk" -version = "0.42.0" +version = "0.43.0" description = "SAP Cloud SDK for Python" readme = "README.md" license = "Apache-2.0" diff --git a/src/sap_cloud_sdk/agentgateway/__init__.py b/src/sap_cloud_sdk/agentgateway/__init__.py index 8e44d6d7..ee8b36d3 100644 --- a/src/sap_cloud_sdk/agentgateway/__init__.py +++ b/src/sap_cloud_sdk/agentgateway/__init__.py @@ -54,13 +54,13 @@ from sap_cloud_sdk.agentgateway._models import ( AuthResult, + ConnectedSystem, MCPTool, MCPToolFilter, Agent, 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 ( @@ -79,13 +79,12 @@ "ClientConfig", # Data models "AuthResult", + "ConnectedSystem", "MCPTool", "MCPToolFilter", "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 910f82de..2ae4d7c1 100644 --- a/src/sap_cloud_sdk/agentgateway/_fragments.py +++ b/src/sap_cloud_sdk/agentgateway/_fragments.py @@ -8,8 +8,7 @@ """ import logging -from enum import Enum -from typing import Optional, TypedDict +from typing import Optional from sap_cloud_sdk.destination import ( create_fragment_client, @@ -19,6 +18,7 @@ from sap_cloud_sdk.destination._models import Level from sap_cloud_sdk.agentgateway.exceptions import MCPServerNotFoundError +from sap_cloud_sdk.agentgateway._models import ConnectedSystem, FragmentLabel from sap_cloud_sdk.core.telemetry import Module logger = logging.getLogger(__name__) @@ -34,15 +34,6 @@ _DESTINATION_INSTANCE = "default" -class FragmentLabel(str, Enum): - """Label values for the sap-managed-runtime-type fragment label key.""" - - MCP = "agw.mcp.server" - A2A = "agw.a2a.server" - IAS = "subscriber.ias" - IAS_USER = "subscriber.ias.user" - - def _list_fragments_by_label( label: FragmentLabel, tenant_subdomain: str, @@ -145,15 +136,11 @@ def get_ias_user_fragment_name(tenant_subdomain: str) -> str: return fragments[0].name -class ActiveIntegration(TypedDict): - """Metadata for a connected backend system integration.""" - - global_tenant_id: str - system_type: Optional[str] - integration_dependency: str +# Backward-compatible alias +ActiveIntegration = ConnectedSystem -def _list_active_integrations(tenant_subdomain: str) -> list[ActiveIntegration]: +def _list_active_integrations(tenant_subdomain: str) -> list[ConnectedSystem]: """List all active backend system integrations for the given tenant. Reads Destination Service instance fragments to discover active backend @@ -169,7 +156,7 @@ def _list_active_integrations(tenant_subdomain: str) -> list[ActiveIntegration]: tenant_subdomain: Subscriber tenant subdomain. Returns: - List of ActiveIntegration dicts, each with keys: + List of ConnectedSystem 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. @@ -184,14 +171,14 @@ def _list_active_integrations(tenant_subdomain: str) -> list[ActiveIntegration]: filter_labels=[ Label( key=LABEL_KEY, - values=[FragmentLabel.MCP.value, FragmentLabel.A2A.value], + values=[FragmentLabel.MCP.value], ) ] ), tenant=tenant_subdomain, ) - result: list[ActiveIntegration] = [] + result: list[ConnectedSystem] = [] for fragment in fragments: labels = { lbl.key: lbl.values[0] if lbl.values else None @@ -212,7 +199,7 @@ def _list_active_integrations(tenant_subdomain: str) -> list[ActiveIntegration]: ) result.append( - ActiveIntegration( + ConnectedSystem( global_tenant_id=gtid, system_type=system_type, integration_dependency=ord_id, diff --git a/src/sap_cloud_sdk/agentgateway/_lob.py b/src/sap_cloud_sdk/agentgateway/_lob.py index efc5d2a3..338267fb 100644 --- a/src/sap_cloud_sdk/agentgateway/_lob.py +++ b/src/sap_cloud_sdk/agentgateway/_lob.py @@ -22,7 +22,6 @@ from sap_cloud_sdk.agentgateway._fragments import ( LABEL_KEY, - FragmentLabel, get_ias_fragment_name, get_ias_user_fragment_name, list_mcp_fragments, @@ -32,6 +31,7 @@ Agent, AgentCard, AgentCardFilter, + FragmentLabel, MCPTool, MCPToolFilter, ) @@ -379,9 +379,9 @@ async def get_mcp_tools_lob( 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, 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. + or GTID. If None or empty, all tools are included. + ``gtids`` filters fragments server-side via the Destination Service. + ``ord_ids`` filters before fetching. ``names`` filters after fetching. Returns: @@ -394,7 +394,7 @@ 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, f.global_tenant_ids or None + None, list_mcp_fragments, tenant_subdomain, f.gtids or None ) if not fragments: diff --git a/src/sap_cloud_sdk/agentgateway/_models.py b/src/sap_cloud_sdk/agentgateway/_models.py index 8af58412..45f2d8f5 100644 --- a/src/sap_cloud_sdk/agentgateway/_models.py +++ b/src/sap_cloud_sdk/agentgateway/_models.py @@ -1,7 +1,17 @@ """Data models for Agent Gateway MCP tools.""" from dataclasses import dataclass, field -from typing import Any +from enum import Enum +from typing import Any, Optional, TypedDict + + +class FragmentLabel(str, Enum): + """Label values for the sap-managed-runtime-type fragment label key.""" + + MCP = "agw.mcp.server" + A2A = "agw.a2a.server" + IAS = "subscriber.ias" + IAS_USER = "subscriber.ias.user" @dataclass @@ -169,13 +179,9 @@ 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). + gtids: Global tenant IDs of the connected systems whose tools should be + listed. Only supported in the LoB flow; the Destination Service + filters fragments server-side. Ignored by the customer flow. Example: ```python @@ -185,7 +191,7 @@ class MCPToolFilter: filter=MCPToolFilter( names=["get-sales-order"], ord_ids=["sap.s4:apiAccess:salesOrder:v1"], - global_tenant_ids=["9e88a0c4-ab32-46d8-b1d3-07cbcac11831"], + gtids=["9e88a0c4-ab32-46d8-b1d3-07cbcac11831"], ) ) ``` @@ -193,4 +199,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) + gtids: 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 db5a79a2..e062c73a 100644 --- a/src/sap_cloud_sdk/agentgateway/agw_client.py +++ b/src/sap_cloud_sdk/agentgateway/agw_client.py @@ -34,13 +34,13 @@ Agent, AgentCardFilter, AuthResult, + ConnectedSystem, MCPTool, MCPToolFilter, ) 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__) @@ -524,32 +524,47 @@ 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[ActiveIntegration]: + def list_active_integrations(self) -> list[ConnectedSystem]: """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. + Only available for LoB agents. Customer agents should use the + ``integrationDependencies`` field in their credentials file instead. + Requires tenant_subdomain to be configured on the client. Returns: - List of dicts, each with: + List of ConnectedSystem 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. + AgentGatewaySDKError: If tenant_subdomain is not configured, or + if called from a customer agent context. Example: ```python integrations = agw_client.list_active_integrations() - for i in integrations: - print(i["system_type"], i["global_tenant_id"]) + gtids = [i["global_tenant_id"] for i in integrations] + tools = await agw_client.list_mcp_tools( + filter=MCPToolFilter(gtids=gtids) + ) ``` """ + credentials_path = detect_customer_agent_credentials() + if credentials_path: + raise AgentGatewaySDKError( + "list_active_integrations is not supported for customer agents." + ) + if detect_transparent_credentials(): + raise AgentGatewaySDKError( + "list_active_integrations is not supported for customer agents." + ) tenant = self._resolve_tenant_subdomain() return _fragments._list_active_integrations(tenant) diff --git a/tests/agentgateway/integration/connected_systems.feature b/tests/agentgateway/integration/connected_systems.feature new file mode 100644 index 00000000..5d0edb3a --- /dev/null +++ b/tests/agentgateway/integration/connected_systems.feature @@ -0,0 +1,32 @@ +Feature: Agent Gateway Connected Systems Integration + As a LoB agent developer using the SDK + I want to discover which backend systems are connected for my tenant + So that I can scope MCP tool discovery to specific systems + + Background: + Given the Agent Gateway client is available + + Scenario: List connected systems returns a list + When I call list_active_integrations + Then the result should be a list of ConnectedSystem + + Scenario: Each connected system has required fields + When I call list_active_integrations + Then each connected system should have a non-empty global_tenant_id + And each connected system should have a non-null integration_dependency or system_type + + Scenario: Connected systems with missing labels are still returned + When I call list_active_integrations + Then fragments with missing labels should appear with None values + + Scenario: List MCP tools filtered by GTID from connected system + When I call list_active_integrations + And at least one connected system is present + When I call list_mcp_tools filtered by the first connected system gtid + Then the result should be a list of MCPTool + + Scenario: Customer agent cannot call list_active_integrations + Given a customer agent client is configured + When I call list_active_integrations with the customer agent client + Then the operation should fail with AgentGatewaySDKError + And the error message should mention "not supported for customer agents" diff --git a/tests/agentgateway/integration/test_connected_systems_bdd.py b/tests/agentgateway/integration/test_connected_systems_bdd.py new file mode 100644 index 00000000..fb9b0879 --- /dev/null +++ b/tests/agentgateway/integration/test_connected_systems_bdd.py @@ -0,0 +1,176 @@ +"""BDD step definitions for Agent Gateway connected-systems integration tests. + +Run against a live BTP tenant (same env vars as agw_auth.feature): + + CLOUD_SDK_CFG_AGW_DEFAULT_TENANT_SUBDOMAIN= \\ + CLOUD_SDK_CFG_AGW_DEFAULT_LANDSCAPE= \\ + CLOUD_SDK_CFG_DESTINATION_DEFAULT_CLIENTID=... \\ + CLOUD_SDK_CFG_DESTINATION_DEFAULT_CLIENTSECRET=... \\ + CLOUD_SDK_CFG_DESTINATION_DEFAULT_URL=... \\ + CLOUD_SDK_CFG_DESTINATION_DEFAULT_URI=... \\ + CLOUD_SDK_CFG_DESTINATION_DEFAULT_IDENTITYZONE=... \\ + pytest tests/agentgateway/integration/test_connected_systems_bdd.py -v +""" + +import os +from typing import Optional +from unittest.mock import patch + +import pytest +from pytest_bdd import scenarios, given, when, then + +from sap_cloud_sdk.agentgateway import ( + AgentGatewayClient, + AgentGatewaySDKError, + ConnectedSystem, + MCPTool, + MCPToolFilter, + create_client, +) + +scenarios("connected_systems.feature") + + +# ==================== CONTEXT ==================== + + +class ConnectedSystemsContext: + """State shared across steps within a scenario.""" + + def __init__(self): + self.integrations: Optional[list[ConnectedSystem]] = None + self.tools: Optional[list[MCPTool]] = None + self.operation_error: Optional[Exception] = None + self.customer_client: Optional[AgentGatewayClient] = None + + +@pytest.fixture +def context(): + return ConnectedSystemsContext() + + +# ==================== GIVEN ==================== + + +@given("the Agent Gateway client is available") +def agent_gateway_client_available(agw_client: AgentGatewayClient): + assert agw_client is not None + + +@given("a customer agent client is configured") +def customer_agent_client(context: ConnectedSystemsContext): + """Create a client that mimics the customer agent flow by faking credential detection.""" + context.customer_client = create_client(tenant_subdomain="irrelevant") + + +# ==================== WHEN ==================== + + +@when("I call list_active_integrations") +def call_list_active_integrations(context: ConnectedSystemsContext, agw_client: AgentGatewayClient): + context.integrations = agw_client.list_active_integrations() + + +@when("at least one connected system is present") +def at_least_one_connected_system(context: ConnectedSystemsContext): + if not context.integrations: + pytest.skip("No connected systems found for this tenant — skipping GTID filter scenario") + + +@when("I call list_mcp_tools filtered by the first connected system gtid") +def call_list_mcp_tools_filtered_by_gtid( + context: ConnectedSystemsContext, agw_client: AgentGatewayClient +): + import asyncio + + assert context.integrations + gtid = context.integrations[0].get("global_tenant_id") + if not gtid: + pytest.skip("First connected system has no global_tenant_id — skipping") + + loop = asyncio.new_event_loop() + try: + context.tools = loop.run_until_complete( + agw_client.list_mcp_tools(filter=MCPToolFilter(gtids=[gtid])) + ) + finally: + loop.close() + + +@when("I call list_active_integrations with the customer agent client") +def call_list_active_integrations_customer(context: ConnectedSystemsContext): + assert context.customer_client is not None + try: + with patch( + "sap_cloud_sdk.agentgateway.agw_client.detect_customer_agent_credentials", + return_value="/fake/credentials.json", + ): + context.customer_client.list_active_integrations() + except AgentGatewaySDKError as e: + context.operation_error = e + + +# ==================== THEN ==================== + + +@then("the result should be a list of ConnectedSystem") +def result_is_list_of_connected_system(context: ConnectedSystemsContext): + assert isinstance(context.integrations, list), ( + f"Expected list, got {type(context.integrations)}" + ) + for item in context.integrations: + assert isinstance(item, dict), f"Expected dict (TypedDict), got {type(item)}" + assert "global_tenant_id" in item + assert "system_type" in item + assert "integration_dependency" in item + + +@then("each connected system should have a non-empty global_tenant_id") +def each_system_has_gtid(context: ConnectedSystemsContext): + assert context.integrations is not None + for system in context.integrations: + assert system.get("global_tenant_id"), ( + f"Connected system missing global_tenant_id: {system}" + ) + + +@then("each connected system should have a non-null integration_dependency or system_type") +def each_system_has_at_least_one_label(context: ConnectedSystemsContext): + assert context.integrations is not None + for system in context.integrations: + has_data = system.get("integration_dependency") or system.get("system_type") + assert has_data, ( + f"Connected system has neither integration_dependency nor system_type: {system}" + ) + + +@then("fragments with missing labels should appear with None values") +def fragments_with_missing_labels_have_none(context: ConnectedSystemsContext): + """Verify the SDK does not drop fragments that are missing optional labels.""" + assert context.integrations is not None + # Every returned item must be a dict with the three expected keys, even if values are None. + for item in context.integrations: + assert "global_tenant_id" in item, f"Key 'global_tenant_id' missing from {item}" + assert "system_type" in item, f"Key 'system_type' missing from {item}" + assert "integration_dependency" in item, f"Key 'integration_dependency' missing from {item}" + + +@then("the result should be a list of MCPTool") +def result_is_list_of_mcp_tool(context: ConnectedSystemsContext): + assert isinstance(context.tools, list), f"Expected list, got {type(context.tools)}" + for tool in context.tools: + assert isinstance(tool, MCPTool), f"Expected MCPTool, got {type(tool)}" + + +@then("the operation should fail with AgentGatewaySDKError") +def operation_fails_with_sdk_error(context: ConnectedSystemsContext): + assert isinstance(context.operation_error, AgentGatewaySDKError), ( + f"Expected AgentGatewaySDKError, got: {context.operation_error}" + ) + + +@then('the error message should mention "not supported for customer agents"') +def error_mentions_customer_agents(context: ConnectedSystemsContext): + assert "not supported for customer agents" in str(context.operation_error), ( + f"Unexpected error message: {context.operation_error}" + ) diff --git a/tests/agentgateway/unit/test_fragments.py b/tests/agentgateway/unit/test_fragments.py index 183e6328..e638eb97 100644 --- a/tests/agentgateway/unit/test_fragments.py +++ b/tests/agentgateway/unit/test_fragments.py @@ -181,7 +181,7 @@ def test_get_fragment_labels_called_once_per_fragment(self): assert mock_client.get_fragment_labels.call_count == 3 - def test_filters_by_mcp_and_a2a_label_types(self): + def test_filters_by_mcp_label_type_only(self): from sap_cloud_sdk.destination._models import ListOptions mock_client = MagicMock() @@ -196,11 +196,12 @@ def test_filters_by_mcp_and_a2a_label_types(self): call_kwargs = mock_client.list_instance_fragments.call_args.kwargs filter_obj: ListOptions = call_kwargs["filter"] assert filter_obj is not None + assert filter_obj.filter_labels 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 + assert "agw.a2a.server" not in label.values # ============================================================ @@ -218,6 +219,10 @@ def test_delegates_to_fragments_helper(self): } ] 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, @@ -236,6 +241,10 @@ def test_delegates_to_fragments_helper(self): def test_returns_empty_list_when_no_integrations(self): 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, @@ -252,10 +261,46 @@ def test_returns_empty_list_when_no_integrations(self): 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, + 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, + ), ): client = create_client() with pytest.raises(AgentGatewaySDKError): client.list_active_integrations() + + def test_raises_for_standard_customer_agent(self): + with ( + patch( + "sap_cloud_sdk.agentgateway.agw_client.detect_customer_agent_credentials", + return_value="/etc/secrets/credentials.json", + ), + patch( + "sap_cloud_sdk.agentgateway.agw_client.detect_transparent_credentials", + return_value=False, + ), + ): + client = create_client(tenant_subdomain="my-tenant") + with pytest.raises(AgentGatewaySDKError, match="not supported for customer agents"): + client.list_active_integrations() + + def test_raises_for_transparent_customer_agent(self): + 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=True, + ), + ): + client = create_client(tenant_subdomain="my-tenant") + with pytest.raises(AgentGatewaySDKError, match="not supported for customer agents"): + client.list_active_integrations() diff --git a/tests/agentgateway/unit/test_lob.py b/tests/agentgateway/unit/test_lob.py index a5ff656d..cb6a0024 100644 --- a/tests/agentgateway/unit/test_lob.py +++ b/tests/agentgateway/unit/test_lob.py @@ -6,7 +6,6 @@ import pytest from sap_cloud_sdk.agentgateway._fragments import ( - GTID_LABEL_KEY, LABEL_KEY, FragmentLabel, get_ias_fragment_name, @@ -819,8 +818,8 @@ 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.""" + async def test_gtids_filter_passed_to_list_mcp_fragments(self): + """MCPToolFilter.gtids is forwarded to list_mcp_fragments for server-side filtering.""" with patch("sap_cloud_sdk.agentgateway._lob.list_mcp_fragments") as mock_list: mock_list.return_value = [] @@ -828,22 +827,29 @@ async def test_passes_global_tenant_ids_to_list_mcp_fragments(self): "tenant-sub", "system-token", 60.0, - filter=MCPToolFilter(global_tenant_ids=["gtid-a", "gtid-b"]), + filter=MCPToolFilter(gtids=["gtid-1", "gtid-2"]), ) - mock_list.assert_called_once_with( - "tenant-sub", ["gtid-a", "gtid-b"] - ) + mock_list.assert_called_once() + _, call_gtids = mock_list.call_args.args + assert call_gtids == ["gtid-1", "gtid-2"] @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.""" + async def test_empty_gtids_passes_none_to_list_mcp_fragments(self): + """Empty MCPToolFilter.gtids passes None (no filter) 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) + await get_mcp_tools_lob( + "tenant-sub", + "system-token", + 60.0, + filter=MCPToolFilter(gtids=[]), + ) - mock_list.assert_called_once_with("tenant-sub", None) + mock_list.assert_called_once() + _, call_gtids = mock_list.call_args.args + assert call_gtids is None # ============================================================ From 881538d1e647e89ec0023de7874b1e92600f5110 Mon Sep 17 00:00:00 2001 From: I542102 Date: Mon, 10 Aug 2026 16:35:30 -0300 Subject: [PATCH 6/8] fix(agentgateway): align gtids rename across fragments, tests and docstrings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix ConnectedSystem construction to use global_tenant_id (not gtids) - Rename list_mcp_fragments param global_tenant_ids → gtids in tests - Replace GTID_LABEL_KEY usage with string literal in test_lob.py - Update test names and MCPToolFilter usages to use gtids field - Fix agw_client.py docstring example to use gtids instead of global_tenant_ids --- src/sap_cloud_sdk/agentgateway/_fragments.py | 15 ++++++--------- src/sap_cloud_sdk/agentgateway/agw_client.py | 2 +- tests/agentgateway/unit/test_agw_client.py | 8 ++++---- tests/agentgateway/unit/test_fragments.py | 4 ++-- tests/agentgateway/unit/test_lob.py | 12 ++++++------ 5 files changed, 19 insertions(+), 22 deletions(-) diff --git a/src/sap_cloud_sdk/agentgateway/_fragments.py b/src/sap_cloud_sdk/agentgateway/_fragments.py index 2ae4d7c1..0f1c5eea 100644 --- a/src/sap_cloud_sdk/agentgateway/_fragments.py +++ b/src/sap_cloud_sdk/agentgateway/_fragments.py @@ -37,11 +37,11 @@ def _list_fragments_by_label( label: FragmentLabel, tenant_subdomain: str, - global_tenant_ids: list[str] | None = None, + gtids: list[str] | None = None, ) -> list: filter_labels = [Label(key=LABEL_KEY, values=[label.value])] - if global_tenant_ids: - filter_labels.append(Label(key=_LABEL_GTID, values=global_tenant_ids)) + if gtids: + filter_labels.append(Label(key=_LABEL_GTID, values=gtids)) client = create_fragment_client( instance=_DESTINATION_INSTANCE, _telemetry_source=Module.AGENTGATEWAY, @@ -54,13 +54,13 @@ def _list_fragments_by_label( def list_mcp_fragments( tenant_subdomain: str, - global_tenant_ids: list[str] | None = None, + gtids: 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 + gtids: 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). @@ -71,7 +71,7 @@ def list_mcp_fragments( """ logger.debug("Fetching MCP fragments for tenant '%s'", tenant_subdomain) return _list_fragments_by_label( - FragmentLabel.MCP, tenant_subdomain, global_tenant_ids + FragmentLabel.MCP, tenant_subdomain, gtids ) @@ -136,9 +136,6 @@ def get_ias_user_fragment_name(tenant_subdomain: str) -> str: return fragments[0].name -# Backward-compatible alias -ActiveIntegration = ConnectedSystem - def _list_active_integrations(tenant_subdomain: str) -> list[ConnectedSystem]: """List all active backend system integrations for the given tenant. diff --git a/src/sap_cloud_sdk/agentgateway/agw_client.py b/src/sap_cloud_sdk/agentgateway/agw_client.py index e062c73a..ed5df277 100644 --- a/src/sap_cloud_sdk/agentgateway/agw_client.py +++ b/src/sap_cloud_sdk/agentgateway/agw_client.py @@ -403,7 +403,7 @@ async def list_mcp_tools( filter=MCPToolFilter( names=["get-sales-order"], ord_ids=["sap.s4:apiAccess:salesOrder:v1"], - global_tenant_ids=[""], + gtids=[""], ) ) ``` diff --git a/tests/agentgateway/unit/test_agw_client.py b/tests/agentgateway/unit/test_agw_client.py index 7ea5ab59..5c11a736 100644 --- a/tests/agentgateway/unit/test_agw_client.py +++ b/tests/agentgateway/unit/test_agw_client.py @@ -448,8 +448,8 @@ async def test_with_callable_tenant(self): ) @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.""" + async def test_forwards_gtids_from_filter_to_lob(self): + """MCPToolFilter.gtids should reach get_mcp_tools_lob.""" with ( patch( "sap_cloud_sdk.agentgateway.agw_client.detect_customer_agent_credentials", @@ -473,14 +473,14 @@ async def test_forwards_global_tenant_ids_from_filter_to_lob(self): agw_client = create_client(tenant_subdomain="my-tenant") await agw_client.list_mcp_tools( - filter=MCPToolFilter(global_tenant_ids=["gtid-a", "gtid-b"]), + filter=MCPToolFilter(gtids=["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"]), + filter=MCPToolFilter(gtids=["gtid-a", "gtid-b"]), ) @pytest.mark.asyncio diff --git a/tests/agentgateway/unit/test_fragments.py b/tests/agentgateway/unit/test_fragments.py index e638eb97..c1a24971 100644 --- a/tests/agentgateway/unit/test_fragments.py +++ b/tests/agentgateway/unit/test_fragments.py @@ -36,7 +36,7 @@ def _full_labels(gtid: str, system_type: str, ord_id: str) -> list[Label]: # ============================================================ -class TestListActiveIntegrations: +class TestListConnectedSystems: def test_returns_entries_from_fragment_labels(self): frag1 = _fragment("frag-mcp-1") frag2 = _fragment("frag-a2a-2") @@ -209,7 +209,7 @@ def test_filters_by_mcp_label_type_only(self): # ============================================================ -class TestAgentGatewayClientListActiveIntegrations: +class TestAgentGatewayClientListConnectedSystems: def test_delegates_to_fragments_helper(self): expected = [ { diff --git a/tests/agentgateway/unit/test_lob.py b/tests/agentgateway/unit/test_lob.py index cb6a0024..3a08e2f0 100644 --- a/tests/agentgateway/unit/test_lob.py +++ b/tests/agentgateway/unit/test_lob.py @@ -230,31 +230,31 @@ 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.""" + def test_adds_gtid_label_when_gtids_provided(self): + """When gtids 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"]) + list_mcp_fragments("tenant-sub", gtids=["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 + lb for lb in filter_opt.filter_labels if lb.key == "sap-managed-runtime-gtid" ) assert gtid_label.values == ["gtid-a", "gtid-b"] - def test_omits_gtid_label_when_global_tenant_ids_is_empty(self): + def test_omits_gtid_label_when_gtids_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=[]) + list_mcp_fragments("tenant-sub", gtids=[]) call_args = mock_client.return_value.list_instance_fragments.call_args filter_opt = call_args.kwargs.get("filter") From 6c97a36021a1d7b675705962b02f34976feb26a5 Mon Sep 17 00:00:00 2001 From: "M. Girotto" Date: Mon, 10 Aug 2026 19:38:38 -0300 Subject: [PATCH 7/8] fix(agentgateway): add ConnectedSystem model and fix CI failures - Add ConnectedSystem TypedDict to _models.py (was referenced but never defined) - Fix ruff format violations in _fragments.py and agw_client.py - Update test_operation.py count for AGENTGATEWAY_LIST_ACTIVE_INTEGRATIONS - Sync uv.lock version to 0.43.0 --- src/sap_cloud_sdk/agentgateway/_fragments.py | 5 +---- src/sap_cloud_sdk/agentgateway/_models.py | 15 +++++++++++++++ src/sap_cloud_sdk/agentgateway/agw_client.py | 4 +++- tests/core/unit/telemetry/test_operation.py | 6 +++--- uv.lock | 2 +- 5 files changed, 23 insertions(+), 9 deletions(-) diff --git a/src/sap_cloud_sdk/agentgateway/_fragments.py b/src/sap_cloud_sdk/agentgateway/_fragments.py index 0f1c5eea..a5a6d040 100644 --- a/src/sap_cloud_sdk/agentgateway/_fragments.py +++ b/src/sap_cloud_sdk/agentgateway/_fragments.py @@ -70,9 +70,7 @@ def list_mcp_fragments( (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, gtids - ) + return _list_fragments_by_label(FragmentLabel.MCP, tenant_subdomain, gtids) def list_a2a_fragments(tenant_subdomain: str) -> list: @@ -136,7 +134,6 @@ def get_ias_user_fragment_name(tenant_subdomain: str) -> str: return fragments[0].name - def _list_active_integrations(tenant_subdomain: str) -> list[ConnectedSystem]: """List all active backend system integrations for the given tenant. diff --git a/src/sap_cloud_sdk/agentgateway/_models.py b/src/sap_cloud_sdk/agentgateway/_models.py index 45f2d8f5..cdaca980 100644 --- a/src/sap_cloud_sdk/agentgateway/_models.py +++ b/src/sap_cloud_sdk/agentgateway/_models.py @@ -14,6 +14,21 @@ class FragmentLabel(str, Enum): IAS_USER = "subscriber.ias.user" +class ConnectedSystem(TypedDict): + """Metadata for a connected backend system integration. + + Attributes: + global_tenant_id: GTID of the connected partner system. + system_type: Application namespace of the partner (e.g. ``"sap.pce"``). + May be ``None`` for older integrations missing the label. + integration_dependency: ORD ID of the integration dependency fulfilled. + """ + + global_tenant_id: Optional[str] + system_type: Optional[str] + integration_dependency: Optional[str] + + @dataclass class AuthResult: """Authentication result from Agent Gateway. diff --git a/src/sap_cloud_sdk/agentgateway/agw_client.py b/src/sap_cloud_sdk/agentgateway/agw_client.py index ed5df277..63c5eaf0 100644 --- a/src/sap_cloud_sdk/agentgateway/agw_client.py +++ b/src/sap_cloud_sdk/agentgateway/agw_client.py @@ -523,7 +523,9 @@ 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) + @record_metrics( + Module.AGENTGATEWAY, Operation.AGENTGATEWAY_LIST_ACTIVE_INTEGRATIONS + ) def list_active_integrations(self) -> list[ConnectedSystem]: """List all active backend system integrations for the current tenant. diff --git a/tests/core/unit/telemetry/test_operation.py b/tests/core/unit/telemetry/test_operation.py index 80a12025..17be2a21 100644 --- a/tests/core/unit/telemetry/test_operation.py +++ b/tests/core/unit/telemetry/test_operation.py @@ -215,6 +215,6 @@ def test_operation_count(self): """Test that we have the expected number of operations.""" all_operations = list(Operation) # 3 auditlog + 12 destination + 10 certificate + 10 fragment + 8 objectstore - # + 2 extensibility + 4 aicore + 23 dms + 6 agentgateway + 13 agent_memory - # + 5 data_anonymization + 52 adms + 6 print + 1 bootstrap + 3 output_management = 158 - assert len(all_operations) == 158 + # + 2 extensibility + 4 aicore + 23 dms + 7 agentgateway + 13 agent_memory + # + 5 data_anonymization + 52 adms + 6 print + 1 bootstrap + 3 output_management = 159 + assert len(all_operations) == 159 diff --git a/uv.lock b/uv.lock index f7b38236..42d31e17 100644 --- a/uv.lock +++ b/uv.lock @@ -3925,7 +3925,7 @@ wheels = [ [[package]] name = "sap-cloud-sdk" -version = "0.42.0" +version = "0.43.0" source = { editable = "." } dependencies = [ { name = "cryptography" }, From 789ddbe71337e16cec38ec3cc91b7ab5fd36783b Mon Sep 17 00:00:00 2001 From: "M. Girotto" Date: Mon, 10 Aug 2026 19:48:51 -0300 Subject: [PATCH 8/8] fix(agentgateway): remove unused Optional import from _fragments.py --- src/sap_cloud_sdk/agentgateway/_fragments.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/sap_cloud_sdk/agentgateway/_fragments.py b/src/sap_cloud_sdk/agentgateway/_fragments.py index a5a6d040..e5966ebe 100644 --- a/src/sap_cloud_sdk/agentgateway/_fragments.py +++ b/src/sap_cloud_sdk/agentgateway/_fragments.py @@ -8,7 +8,6 @@ """ import logging -from typing import Optional from sap_cloud_sdk.destination import ( create_fragment_client,