From 5e01c4c54bfd836a0d981889d2aeb4d57bf71d3a Mon Sep 17 00:00:00 2001 From: I542102 Date: Fri, 7 Aug 2026 16:29:19 -0300 Subject: [PATCH] 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