From 236aa6803c00092134e74e70b517e9c184698b92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro=20Brun?= Date: Wed, 1 Jul 2026 11:50:21 -0300 Subject: [PATCH 1/5] feat: add mcp tools filtering --- pyproject.toml | 2 +- src/sap_cloud_sdk/agentgateway/__init__.py | 2 + src/sap_cloud_sdk/agentgateway/_customer.py | 17 ++ src/sap_cloud_sdk/agentgateway/_lob.py | 25 +++ src/sap_cloud_sdk/agentgateway/_models.py | 34 ++++ src/sap_cloud_sdk/agentgateway/agw_client.py | 26 ++- tests/agentgateway/unit/test_agw_client.py | 119 +++++++++++- tests/agentgateway/unit/test_customer.py | 133 +++++++++++++ tests/agentgateway/unit/test_lob.py | 187 ++++++++++++++++++- uv.lock | 2 +- 10 files changed, 537 insertions(+), 10 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 440b6c07..aa778f3e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sap-cloud-sdk" -version = "0.31.0" +version = "0.32.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 e70b7275..4597462e 100644 --- a/src/sap_cloud_sdk/agentgateway/__init__.py +++ b/src/sap_cloud_sdk/agentgateway/__init__.py @@ -55,6 +55,7 @@ from sap_cloud_sdk.agentgateway._models import ( AuthResult, MCPTool, + MCPToolFilter, Agent, AgentCard, AgentCardFilter, @@ -77,6 +78,7 @@ # Data models "AuthResult", "MCPTool", + "MCPToolFilter", "Agent", "AgentCard", "AgentCardFilter", diff --git a/src/sap_cloud_sdk/agentgateway/_customer.py b/src/sap_cloud_sdk/agentgateway/_customer.py index be5019ea..5a9fe61f 100644 --- a/src/sap_cloud_sdk/agentgateway/_customer.py +++ b/src/sap_cloud_sdk/agentgateway/_customer.py @@ -480,6 +480,8 @@ async def get_mcp_tools_customer( credentials: CustomerCredentials, system_token: str, timeout: float, + names: list[str] | None = None, + ord_ids: list[str] | None = None, ) -> list[MCPTool]: """List all MCP tools from servers defined in credentials. @@ -490,6 +492,11 @@ async def get_mcp_tools_customer( credentials: Customer credentials with integrationDependencies. system_token: Pre-fetched raw system token for authentication. timeout: HTTP timeout in seconds for MCP server calls. + names: Optional list of tool names to include (matched against + MCPTool.name). Applied after fetching. If empty or None, all + are included. + ord_ids: Optional list of ORD IDs to include (extracted from URL). + Applied before fetching. If empty or None, all are included. Returns: List of MCPTool objects from all servers. @@ -504,6 +511,11 @@ async def get_mcp_tools_customer( "integrationDependencies is empty in credentials — no MCP servers configured." ) + # Pre-fetch filter: ORD ID is extractable from the URL without fetching tools + if ord_ids: + ord_ids_set = set(ord_ids) + dependencies = [d for d in dependencies if d.ord_id in ord_ids_set] + logger.info("Discovering tools from %d MCP server(s)", len(dependencies)) tools: list[MCPTool] = [] @@ -524,6 +536,11 @@ async def get_mcp_tools_customer( except Exception: logger.exception("Failed to load tools from %s — skipping", dep.ord_id) + # Post-fetch filter: tool names are only known after fetching + if names: + names_set = set(names) + tools = [t for t in tools if t.name in names_set] + logger.info( "Loaded %d MCP tool(s) from %d server(s)", len(tools), len(dependencies) ) diff --git a/src/sap_cloud_sdk/agentgateway/_lob.py b/src/sap_cloud_sdk/agentgateway/_lob.py index 0ada5b63..b566e864 100644 --- a/src/sap_cloud_sdk/agentgateway/_lob.py +++ b/src/sap_cloud_sdk/agentgateway/_lob.py @@ -308,6 +308,8 @@ async def get_mcp_tools_lob( tenant_subdomain: str, system_token: str, timeout: float, + names: list[str] | None = None, + ord_ids: list[str] | None = None, ) -> list[MCPTool]: """List all MCP tools using LoB flow (destination-based). @@ -317,6 +319,12 @@ 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. + names: Optional list of tool names to include (matched against + MCPTool.name). Applied after fetching. If empty or None, all + are included. + ord_ids: Optional list of ORD IDs to include (extracted from the + fragment URL). Applied before fetching. If empty or None, all + are included. Returns: List of MCPTool objects from all MCP servers. @@ -334,6 +342,18 @@ async def get_mcp_tools_lob( ) return tools + # Pre-fetch filter: ORD ID is extractable from the URL without fetching tools + if ord_ids: + ord_ids_set = set(ord_ids) + fragments = [ + f + for f in fragments + if _ord_id_from_url( + f.properties.get("URL") or f.properties.get("url") or "" + ) + in ord_ids_set + ] + for fragment in fragments: fragment_name = fragment.name mcp_url = fragment.properties.get("URL") or fragment.properties.get("url") @@ -360,6 +380,11 @@ async def get_mcp_tools_lob( fragment_name, ) + # Post-fetch filter: tool names are only known after fetching + if names: + names_set = set(names) + tools = [t for t in tools if t.name in names_set] + logger.info("Loaded %d MCP tool(s) from %d fragment(s)", len(tools), len(fragments)) return tools diff --git a/src/sap_cloud_sdk/agentgateway/_models.py b/src/sap_cloud_sdk/agentgateway/_models.py index 8e621bf0..c9426969 100644 --- a/src/sap_cloud_sdk/agentgateway/_models.py +++ b/src/sap_cloud_sdk/agentgateway/_models.py @@ -150,3 +150,37 @@ class AgentCardFilter: agent_names: list[str] = field(default_factory=list) ord_ids: list[str] = field(default_factory=list) + + +@dataclass +class MCPToolFilter: + """Filter options for list_mcp_tools. + + All fields are optional. When multiple fields are set they are applied + together (AND semantics). Empty lists are treated the same as None (no + filtering on that field). + + Attributes: + names: Tool names to include (matched against MCPTool.name). + Applied after fetching, since tool names are only known once + each MCP server has been queried. + ord_ids: ORD IDs to include. For LoB agents, extracted from the + fragment URL; for customer agents, taken from + credentials.integration_dependencies. Applied before fetching, + skipping non-matching fragments. + + Example: + ```python + from sap_cloud_sdk.agentgateway import MCPToolFilter + + tools = await agw_client.list_mcp_tools( + filter=MCPToolFilter( + names=["get-sales-order"], + ord_ids=["sap.s4:apiAccess:salesOrder:v1"], + ) + ) + ``` + """ + + names: list[str] = field(default_factory=list) + ord_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 411ff4a8..efccbc25 100644 --- a/src/sap_cloud_sdk/agentgateway/agw_client.py +++ b/src/sap_cloud_sdk/agentgateway/agw_client.py @@ -32,6 +32,7 @@ AgentCardFilter, AuthResult, MCPTool, + MCPToolFilter, ) from sap_cloud_sdk.agentgateway._token_cache import _GatewayUrlCache, _TokenCache from sap_cloud_sdk.agentgateway.exceptions import AgentGatewaySDKError @@ -300,6 +301,7 @@ async def list_mcp_tools( self, user_token: str | Callable[[], str] | None = None, app_tid: str | None = None, + filter: MCPToolFilter | None = None, ) -> list[MCPTool]: """List all MCP tools from MCP servers. @@ -320,6 +322,8 @@ async def list_mcp_tools( If provided, uses user-scoped auth instead of system auth. app_tid: BTP Application Tenant ID of the subscriber. Only used for customer agents. + filter: Optional filter to narrow results by tool name or ORD ID. + If None or empty, all tools are included. Returns: List of MCPTool objects from all MCP servers. @@ -335,9 +339,19 @@ async def list_mcp_tools( # With user token for principal propagation: tools = await agw_client.list_mcp_tools(user_token="user-jwt") + + # With filters + from sap_cloud_sdk.agentgateway import MCPToolFilter + tools = await agw_client.list_mcp_tools( + filter=MCPToolFilter( + names=["get-sales-order"], + ord_ids=["sap.s4:apiAccess:salesOrder:v1"], + ) + ) ``` """ try: + f = filter or MCPToolFilter() # Check for customer agent credentials credentials_path = detect_customer_agent_credentials() if credentials_path: @@ -350,7 +364,11 @@ async def list_mcp_tools( else: auth = await self.get_system_auth(app_tid=app_tid) return await get_mcp_tools_customer( - credentials, auth.access_token, self._config.timeout + credentials, + auth.access_token, + self._config.timeout, + names=f.names or None, + ord_ids=f.ord_ids or None, ) # LoB flow - requires tenant_subdomain @@ -363,7 +381,11 @@ async def list_mcp_tools( else: auth = await self.get_system_auth() return await get_mcp_tools_lob( - tenant, auth.access_token, self._config.timeout + tenant, + auth.access_token, + self._config.timeout, + names=f.names or None, + ord_ids=f.ord_ids or None, ) except AgentGatewaySDKError: diff --git a/tests/agentgateway/unit/test_agw_client.py b/tests/agentgateway/unit/test_agw_client.py index b1541fa5..db1a5a3a 100644 --- a/tests/agentgateway/unit/test_agw_client.py +++ b/tests/agentgateway/unit/test_agw_client.py @@ -12,6 +12,7 @@ AgentCardFilter, AuthResult, MCPTool, + MCPToolFilter, AgentGatewaySDKError, ) @@ -431,7 +432,9 @@ async def test_with_callable_tenant(self): await agw_client.list_mcp_tools() - mock_lob.assert_called_once_with("my-tenant", "system-token", 60.0) + mock_lob.assert_called_once_with( + "my-tenant", "system-token", 60.0, names=None, ord_ids=None + ) @pytest.mark.asyncio async def test_calls_lob_flow_with_system_token(self): @@ -456,7 +459,9 @@ async def test_calls_lob_flow_with_system_token(self): await agw_client.list_mcp_tools() - mock_lob.assert_called_once_with("my-tenant", "system-token-xyz", 60.0) + mock_lob.assert_called_once_with( + "my-tenant", "system-token-xyz", 60.0, names=None, ord_ids=None + ) @pytest.mark.asyncio async def test_returns_tools_from_lob_flow(self): @@ -521,7 +526,7 @@ async def test_customer_flow_passes_system_token(self): await agw_client.list_mcp_tools(app_tid="tid") mock_customer.assert_called_once_with( - mock_creds, "customer-system-token", 60.0 + mock_creds, "customer-system-token", 60.0, names=None, ord_ids=None ) @pytest.mark.asyncio @@ -548,7 +553,9 @@ async def test_lob_flow_with_user_token_uses_user_auth(self): await agw_client.list_mcp_tools(user_token="user-jwt") mock_user_auth.assert_called_once() - mock_lob.assert_called_once_with("my-tenant", "user-token-xyz", 60.0) + mock_lob.assert_called_once_with( + "my-tenant", "user-token-xyz", 60.0, names=None, ord_ids=None + ) @pytest.mark.asyncio async def test_customer_flow_with_user_token_uses_user_auth(self): @@ -575,9 +582,111 @@ async def test_customer_flow_with_user_token_uses_user_auth(self): await agw_client.list_mcp_tools(user_token="user-jwt", app_tid="tid") mock_customer.assert_called_once_with( - mock_creds, "exchanged-user-token", 60.0 + mock_creds, "exchanged-user-token", 60.0, names=None, ord_ids=None ) + @pytest.mark.asyncio + async def test_passes_filter_arguments_lob(self): + """Pass names and ord_ids from MCPToolFilter through to 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.fetch_system_auth", + new_callable=AsyncMock, + return_value=("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( + names=["get-sales-order"], + ord_ids=["sap.s4:apiAccess:salesOrder:v1"], + ) + ) + + mock_lob.assert_called_once_with( + "my-tenant", + "token", + 60.0, + names=["get-sales-order"], + ord_ids=["sap.s4:apiAccess:salesOrder:v1"], + ) + + @pytest.mark.asyncio + async def test_empty_filter_lists_pass_none_to_lob(self): + """MCPToolFilter() with empty lists should forward as None, None to the fetch helper.""" + with ( + patch( + "sap_cloud_sdk.agentgateway.agw_client.detect_customer_agent_credentials", + return_value=None, + ), + patch( + "sap_cloud_sdk.agentgateway.agw_client.fetch_system_auth", + new_callable=AsyncMock, + return_value=("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", "token", 60.0, names=None, ord_ids=None + ) + + @pytest.mark.asyncio + async def test_passes_filter_arguments_customer(self): + """Pass names and ord_ids from MCPToolFilter through to get_mcp_tools_customer.""" + with ( + patch( + "sap_cloud_sdk.agentgateway.agw_client.detect_customer_agent_credentials", + return_value="/path/to/credentials", + ), + patch( + "sap_cloud_sdk.agentgateway.agw_client.load_customer_credentials", + ) as mock_load, + patch( + "sap_cloud_sdk.agentgateway.agw_client.get_system_token_mtls", + return_value="customer-system-token", + ), + patch( + "sap_cloud_sdk.agentgateway.agw_client.get_mcp_tools_customer", + new_callable=AsyncMock, + return_value=[], + ) as mock_customer, + ): + mock_creds = MagicMock() + mock_creds.gateway_url = "https://agw.customer.com" + mock_load.return_value = mock_creds + + agw_client = create_client() + await agw_client.list_mcp_tools( + filter=MCPToolFilter( + names=["get-cost-center"], + ord_ids=["sap.s4:apiAccess:finance:v1"], + ) + ) + + mock_customer.assert_called_once_with( + mock_creds, + "customer-system-token", + 60.0, + names=["get-cost-center"], + ord_ids=["sap.s4:apiAccess:finance:v1"], + ) + # ============================================================ # Test: call_mcp_tool diff --git a/tests/agentgateway/unit/test_customer.py b/tests/agentgateway/unit/test_customer.py index 4469ab47..2890ed98 100644 --- a/tests/agentgateway/unit/test_customer.py +++ b/tests/agentgateway/unit/test_customer.py @@ -611,6 +611,139 @@ async def mock_list_tools(*args, **kwargs): assert len(result) == 1 assert result[0].name == "tool2" + @pytest.mark.asyncio + async def test_filters_dependencies_by_ord_id_pre_fetch(self): + """Skip non-matching integration dependencies BEFORE calling _list_server_tools.""" + credentials = CustomerCredentials( + token_service_url="https://ias.example.com/oauth2/token", + client_id="test-client", + certificate="cert", + private_key="key", + gateway_url="https://agw.example.com", + integration_dependencies=[ + IntegrationDependency( + ord_id="sap.mcpbuilder:apiResource:cost-center:v1", + global_tenant_id="gt-1", + ), + IntegrationDependency( + ord_id="sap.mcpbuilder:apiResource:finance:v1", + global_tenant_id="gt-1", + ), + ], + ) + + cost_tool = MCPTool( + name="list_cost_centers", + server_name="cost-center", + description="", + input_schema={}, + url="https://agw.example.com/v1/mcp/sap.mcpbuilder:apiResource:cost-center:v1/gt-1", + ) + + with ( + patch( + "sap_cloud_sdk.agentgateway._customer._list_server_tools", + new_callable=AsyncMock, + return_value=[cost_tool], + ) as mock_list, + ): + result = await get_mcp_tools_customer( + credentials, + "system-token", + 60.0, + ord_ids=["sap.mcpbuilder:apiResource:cost-center:v1"], + ) + + # Only the cost-center dependency was queried; finance was filtered out. + assert mock_list.call_count == 1 + assert [t.name for t in result] == ["list_cost_centers"] + + @pytest.mark.asyncio + async def test_filters_tools_by_name_post_fetch(self, credentials): + """Fetch from all dependencies, then filter the returned tools by name.""" + keep = MCPTool( + name="list_cost_centers", + server_name="s", + description="", + input_schema={}, + url="https://agw.example.com/v1/mcp/foo/bar", + ) + drop = MCPTool( + name="delete_cost_center", + server_name="s", + description="", + input_schema={}, + url="https://agw.example.com/v1/mcp/foo/bar", + ) + + with ( + patch( + "sap_cloud_sdk.agentgateway._customer._list_server_tools", + new_callable=AsyncMock, + return_value=[keep, drop], + ), + ): + result = await get_mcp_tools_customer( + credentials, + "system-token", + 60.0, + names=["list_cost_centers"], + ) + + assert [t.name for t in result] == ["list_cost_centers"] + + @pytest.mark.asyncio + async def test_empty_filter_lists_behave_like_none(self, credentials): + """names=[] and ord_ids=[] should behave the same as None (no filtering).""" + tool = MCPTool( + name="list_cost_centers", + server_name="s", + description="", + input_schema={}, + url="https://agw.example.com/v1/mcp/foo/bar", + ) + + with ( + patch( + "sap_cloud_sdk.agentgateway._customer._list_server_tools", + new_callable=AsyncMock, + return_value=[tool], + ), + ): + result = await get_mcp_tools_customer( + credentials, "system-token", 60.0, names=[], ord_ids=[] + ) + + assert [t.name for t in result] == ["list_cost_centers"] + + @pytest.mark.asyncio + async def test_filter_excluding_all_dependencies_returns_empty(self): + """Filter that excludes all dependencies returns empty list, not an error.""" + credentials = CustomerCredentials( + token_service_url="https://ias.example.com/oauth2/token", + client_id="test-client", + certificate="cert", + private_key="key", + gateway_url="https://agw.example.com", + integration_dependencies=[ + IntegrationDependency(ord_id="sap.x:v1", global_tenant_id="gt-1"), + ], + ) + + with ( + patch( + "sap_cloud_sdk.agentgateway._customer._list_server_tools", + new_callable=AsyncMock, + ) as mock_list, + ): + result = await get_mcp_tools_customer( + credentials, "system-token", 60.0, ord_ids=["sap.does-not-exist:v1"] + ) + + # Filter excluded everything -> empty list, no exception, no network call. + assert result == [] + assert mock_list.call_count == 0 + # ============================================================ # Test: call_mcp_tool_customer diff --git a/tests/agentgateway/unit/test_lob.py b/tests/agentgateway/unit/test_lob.py index 39c036e5..4a24a764 100644 --- a/tests/agentgateway/unit/test_lob.py +++ b/tests/agentgateway/unit/test_lob.py @@ -596,6 +596,185 @@ async def mock_list_tools_fn(*args, **kwargs): assert len(result) == 1 assert result[0].name == "tool2" + @pytest.mark.asyncio + async def test_filters_fragments_by_ord_id_pre_fetch(self): + """Skip non-matching fragments BEFORE calling list_server_tools.""" + sales = MagicMock() + sales.name = "frag-sales" + sales.properties = { + "URL": "https://agw.example.com/v1/mcp/sap.s4:apiAccess:salesOrder:v1/gt-1" + } + finance = MagicMock() + finance.name = "frag-finance" + finance.properties = { + "URL": "https://agw.example.com/v1/mcp/sap.s4:apiAccess:finance:v1/gt-1" + } + + sales_tool = MCPTool( + name="get-sales-order", + server_name="sales", + description="", + input_schema={}, + url=sales.properties["URL"], + fragment_name="frag-sales", + ) + + with ( + patch("sap_cloud_sdk.agentgateway._lob.list_mcp_fragments") as mock_list, + patch( + "sap_cloud_sdk.agentgateway._lob.list_server_tools", + new_callable=AsyncMock, + return_value=[sales_tool], + ) as mock_tools, + ): + mock_list.return_value = [sales, finance] + + result = await get_mcp_tools_lob( + "tenant-sub", + "system-token", + 60.0, + ord_ids=["sap.s4:apiAccess:salesOrder:v1"], + ) + + assert mock_tools.call_count == 1 + assert mock_tools.call_args.args[0] == sales.properties["URL"] + assert [t.name for t in result] == ["get-sales-order"] + + @pytest.mark.asyncio + async def test_filters_tools_by_name_post_fetch(self): + """Fetch from all fragments, then filter the returned tools by name.""" + frag = MagicMock() + frag.name = "frag" + frag.properties = { + "URL": "https://agw.example.com/v1/mcp/sap.s4:apiAccess:salesOrder:v1/gt-1" + } + + keep = MCPTool( + name="get-sales-order", + server_name="s", + description="", + input_schema={}, + url=frag.properties["URL"], + fragment_name="frag", + ) + drop = MCPTool( + name="cancel-sales-order", + server_name="s", + description="", + input_schema={}, + url=frag.properties["URL"], + fragment_name="frag", + ) + + with ( + patch("sap_cloud_sdk.agentgateway._lob.list_mcp_fragments") as mock_list, + patch( + "sap_cloud_sdk.agentgateway._lob.list_server_tools", + new_callable=AsyncMock, + return_value=[keep, drop], + ), + ): + mock_list.return_value = [frag] + + result = await get_mcp_tools_lob( + "tenant-sub", + "system-token", + 60.0, + names=["get-sales-order"], + ) + + assert [t.name for t in result] == ["get-sales-order"] + + @pytest.mark.asyncio + async def test_ord_id_and_name_filter_together_use_and_semantics(self): + """Both filters applied together — only tools matching both survive.""" + sales = MagicMock() + sales.name = "frag-sales" + sales.properties = { + "URL": "https://agw.example.com/v1/mcp/sap.s4:apiAccess:salesOrder:v1/gt-1" + } + finance = MagicMock() + finance.name = "frag-finance" + finance.properties = { + "URL": "https://agw.example.com/v1/mcp/sap.s4:apiAccess:finance:v1/gt-1" + } + + def per_fragment_tools(url, *_args, **_kwargs): + if "salesOrder" in url: + return [ + MCPTool( + name="get-sales-order", + server_name="s", + description="", + input_schema={}, + url=url, + fragment_name="frag-sales", + ) + ] + return [ + MCPTool( + name="get-cost-center", + server_name="f", + description="", + input_schema={}, + url=url, + fragment_name="frag-finance", + ) + ] + + with ( + patch("sap_cloud_sdk.agentgateway._lob.list_mcp_fragments") as mock_list, + patch( + "sap_cloud_sdk.agentgateway._lob.list_server_tools", + new_callable=AsyncMock, + side_effect=per_fragment_tools, + ), + ): + mock_list.return_value = [sales, finance] + + result = await get_mcp_tools_lob( + "tenant-sub", + "system-token", + 60.0, + names=["get-sales-order"], + ord_ids=["sap.s4:apiAccess:finance:v1"], + ) + + assert result == [] + + @pytest.mark.asyncio + async def test_empty_filter_lists_behave_like_none(self): + """names=[] and ord_ids=[] should behave the same as None (no filtering).""" + frag = MagicMock() + frag.name = "frag" + frag.properties = { + "URL": "https://agw.example.com/v1/mcp/sap.s4:apiAccess:salesOrder:v1/gt-1" + } + tool = MCPTool( + name="get-sales-order", + server_name="s", + description="", + input_schema={}, + url=frag.properties["URL"], + fragment_name="frag", + ) + + with ( + patch("sap_cloud_sdk.agentgateway._lob.list_mcp_fragments") as mock_list, + patch( + "sap_cloud_sdk.agentgateway._lob.list_server_tools", + new_callable=AsyncMock, + return_value=[tool], + ), + ): + mock_list.return_value = [frag] + + result = await get_mcp_tools_lob( + "tenant-sub", "system-token", 60.0, names=[], ord_ids=[] + ) + + assert [t.name for t in result] == ["get-sales-order"] + # ============================================================ # Test: call_mcp_tool_lob @@ -709,7 +888,7 @@ async def test_returns_empty_string_when_no_content(self): class TestOrdIdFromUrl: - """Tests for _ord_id_from_url helper.""" + """Tests for _ord_id_from_url helper (used for both A2A and MCP fragment URLs).""" def test_extracts_ord_id_from_standard_url(self): """Return the second-to-last path segment as ord_id.""" @@ -717,6 +896,12 @@ def test_extracts_ord_id_from_standard_url(self): "https://agw.example.com/v1/a2a/sap.s4:agent:v1/tenant-abc" ) == "sap.s4:agent:v1" + def test_extracts_ord_id_from_mcp_url(self): + """Same extraction logic works for MCP fragment URLs.""" + assert _ord_id_from_url( + "https://agw.example.com/v1/mcp/sap.s4:apiAccess:salesOrder:v1/global-tenant-1" + ) == "sap.s4:apiAccess:salesOrder:v1" + def test_strips_trailing_slash(self): """Handle trailing slash on URL.""" assert _ord_id_from_url( diff --git a/uv.lock b/uv.lock index 696dc52f..5ae7084d 100644 --- a/uv.lock +++ b/uv.lock @@ -3699,7 +3699,7 @@ wheels = [ [[package]] name = "sap-cloud-sdk" -version = "0.30.0" +version = "0.31.0" source = { editable = "." } dependencies = [ { name = "grpcio" }, From 010d87f7b076e29094a0d3be08e7871e2484238f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro=20Brun?= Date: Wed, 1 Jul 2026 12:03:32 -0300 Subject: [PATCH 2/5] bump version --- pyproject.toml | 2 +- src/sap_cloud_sdk/agentgateway/_models.py | 11 +++++------ uv.lock | 2 +- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index aa778f3e..3bbf3426 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sap-cloud-sdk" -version = "0.32.0" +version = "0.33.0" description = "SAP Cloud SDK for Python" readme = "README.md" license = "Apache-2.0" diff --git a/src/sap_cloud_sdk/agentgateway/_models.py b/src/sap_cloud_sdk/agentgateway/_models.py index c9426969..76696e6a 100644 --- a/src/sap_cloud_sdk/agentgateway/_models.py +++ b/src/sap_cloud_sdk/agentgateway/_models.py @@ -162,12 +162,11 @@ class MCPToolFilter: Attributes: names: Tool names to include (matched against MCPTool.name). - Applied after fetching, since tool names are only known once - each MCP server has been queried. - ord_ids: ORD IDs to include. For LoB agents, extracted from the - fragment URL; for customer agents, taken from - credentials.integration_dependencies. Applied before fetching, - skipping non-matching fragments. + Applied after fetching. + ord_ids: ORD IDs to include (extracted from the fragment URL for LoB + agents, or matched against IntegrationDependency.ord_id for + customer agents). Applied before fetching, skipping non-matching + fragments. Example: ```python diff --git a/uv.lock b/uv.lock index 5ae7084d..b93196f1 100644 --- a/uv.lock +++ b/uv.lock @@ -3699,7 +3699,7 @@ wheels = [ [[package]] name = "sap-cloud-sdk" -version = "0.31.0" +version = "0.33.0" source = { editable = "." } dependencies = [ { name = "grpcio" }, From ab5afba670b4910d976dde6b3f9878dc35294940 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro=20Brun?= Date: Wed, 1 Jul 2026 15:23:10 -0300 Subject: [PATCH 3/5] update user-guide --- src/sap_cloud_sdk/agentgateway/user-guide.md | 29 ++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/src/sap_cloud_sdk/agentgateway/user-guide.md b/src/sap_cloud_sdk/agentgateway/user-guide.md index 813ed2e0..a757c6e2 100644 --- a/src/sap_cloud_sdk/agentgateway/user-guide.md +++ b/src/sap_cloud_sdk/agentgateway/user-guide.md @@ -45,7 +45,7 @@ result = await agw_client.call_mcp_tool( LoB agents use BTP Destination Service for credential management. Tools and A2A agents are auto-discovered from destination fragments. ```python -from sap_cloud_sdk.agentgateway import ClientConfig, create_client +from sap_cloud_sdk.agentgateway import ClientConfig, MCPToolFilter, create_client config = ClientConfig(timeout=30.0) agw_client = create_client(tenant_subdomain="my-tenant", config=config) @@ -54,6 +54,14 @@ agw_client = create_client(tenant_subdomain="my-tenant", config=config) # Pass user_token to use principal propagation when listing tools tools = await agw_client.list_mcp_tools(user_token="user-jwt") +# Filter by tool name (post-fetch) or ORD ID (pre-fetch) +tools = await agw_client.list_mcp_tools( + filter=MCPToolFilter( + names=["get-sales-order"], + ord_ids=["sap.s4:apiAccess:salesOrder:v1"], + ) +) + # Invoke a tool (user_token required for principal propagation) result = await agw_client.call_mcp_tool( tool=tools[0], @@ -190,6 +198,7 @@ class AgentGatewayClient: self, user_token: str | Callable[[], str] | None = None, app_tid: str | None = None, + filter: MCPToolFilter | None = None, ) -> list[MCPTool] async def call_mcp_tool( @@ -217,7 +226,23 @@ AgentCardFilter( ) ``` -Both fields default to empty lists. Filters are applied with AND semantics: if both are set, an agent must match both to be included. `agent_names` is applied after fetching (requires reading the card); `ord_ids` is applied before fetching (extracted from the fragment URL, no card request needed). +Both fields default to empty lists. `agent_names` is applied after fetching; `ord_ids` is applied before fetching (extracted from the fragment URL, no card request needed). + +### MCPToolFilter + +```python +from sap_cloud_sdk.agentgateway import MCPToolFilter + +MCPToolFilter( + names=[], # tool names to include (matched against MCPTool.name); empty = no filter + ord_ids=[], # ORD IDs to include (extracted from fragment URL for LoB, or matched + # against IntegrationDependency.ord_id for customer agents); empty = no filter +) +``` + +Both fields default to empty lists. `names` is applied after fetching; `ord_ids` is applied before fetching, skipping non-matching fragments. + +> Both filter classes use AND semantics: if both fields are set, a result must match all of them to be included. ### Data Models From dd6cc20b6b05ff20b2ad8b3741ca2fb4420e3c63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro=20Brun?= Date: Thu, 2 Jul 2026 11:29:13 -0300 Subject: [PATCH 4/5] pass filter objetct directly --- src/sap_cloud_sdk/agentgateway/_customer.py | 20 ++++----- src/sap_cloud_sdk/agentgateway/_lob.py | 28 +++++------- src/sap_cloud_sdk/agentgateway/agw_client.py | 7 +-- tests/agentgateway/unit/test_agw_client.py | 47 +++++++++----------- tests/agentgateway/unit/test_customer.py | 16 ++++--- tests/agentgateway/unit/test_lob.py | 16 ++++--- 6 files changed, 65 insertions(+), 69 deletions(-) diff --git a/src/sap_cloud_sdk/agentgateway/_customer.py b/src/sap_cloud_sdk/agentgateway/_customer.py index 5a9fe61f..30099db2 100644 --- a/src/sap_cloud_sdk/agentgateway/_customer.py +++ b/src/sap_cloud_sdk/agentgateway/_customer.py @@ -23,6 +23,7 @@ CustomerCredentials, IntegrationDependency, MCPTool, + MCPToolFilter, ) from sap_cloud_sdk.agentgateway._token_cache import _TokenCache from sap_cloud_sdk.agentgateway.exceptions import AgentGatewaySDKError @@ -480,8 +481,7 @@ async def get_mcp_tools_customer( credentials: CustomerCredentials, system_token: str, timeout: float, - names: list[str] | None = None, - ord_ids: list[str] | None = None, + filter: MCPToolFilter | None = None, ) -> list[MCPTool]: """List all MCP tools from servers defined in credentials. @@ -492,11 +492,8 @@ async def get_mcp_tools_customer( credentials: Customer credentials with integrationDependencies. system_token: Pre-fetched raw system token for authentication. timeout: HTTP timeout in seconds for MCP server calls. - names: Optional list of tool names to include (matched against - MCPTool.name). Applied after fetching. If empty or None, all - are included. - ord_ids: Optional list of ORD IDs to include (extracted from URL). - Applied before fetching. If empty or None, all are included. + filter: Optional MCPToolFilter narrowing results by tool name or ORD ID. + If None or empty, all tools are included. Returns: List of MCPTool objects from all servers. @@ -504,6 +501,7 @@ async def get_mcp_tools_customer( Raises: AgentGatewaySDKError: If integrationDependencies is empty. """ + f = filter or MCPToolFilter() dependencies = credentials.integration_dependencies if not dependencies: @@ -512,8 +510,8 @@ async def get_mcp_tools_customer( ) # Pre-fetch filter: ORD ID is extractable from the URL without fetching tools - if ord_ids: - ord_ids_set = set(ord_ids) + if f.ord_ids: + ord_ids_set = set(f.ord_ids) dependencies = [d for d in dependencies if d.ord_id in ord_ids_set] logger.info("Discovering tools from %d MCP server(s)", len(dependencies)) @@ -537,8 +535,8 @@ async def get_mcp_tools_customer( logger.exception("Failed to load tools from %s — skipping", dep.ord_id) # Post-fetch filter: tool names are only known after fetching - if names: - names_set = set(names) + if f.names: + names_set = set(f.names) tools = [t for t in tools if t.name in names_set] logger.info( diff --git a/src/sap_cloud_sdk/agentgateway/_lob.py b/src/sap_cloud_sdk/agentgateway/_lob.py index b566e864..66950acd 100644 --- a/src/sap_cloud_sdk/agentgateway/_lob.py +++ b/src/sap_cloud_sdk/agentgateway/_lob.py @@ -27,7 +27,7 @@ list_mcp_fragments, list_a2a_fragments, ) -from sap_cloud_sdk.agentgateway._models import Agent, AgentCard, MCPTool +from sap_cloud_sdk.agentgateway._models import Agent, AgentCard, MCPTool, MCPToolFilter from sap_cloud_sdk.agentgateway._token_cache import _GatewayUrlCache, _TokenCache from sap_cloud_sdk.agentgateway.exceptions import ( AgentGatewaySDKError, @@ -308,8 +308,7 @@ async def get_mcp_tools_lob( tenant_subdomain: str, system_token: str, timeout: float, - names: list[str] | None = None, - ord_ids: list[str] | None = None, + filter: MCPToolFilter | None = None, ) -> list[MCPTool]: """List all MCP tools using LoB flow (destination-based). @@ -319,16 +318,13 @@ 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. - names: Optional list of tool names to include (matched against - MCPTool.name). Applied after fetching. If empty or None, all - are included. - ord_ids: Optional list of ORD IDs to include (extracted from the - fragment URL). Applied before fetching. If empty or None, all - are included. + filter: Optional MCPToolFilter narrowing results by tool name or ORD ID. + If None or empty, all tools are included. Returns: List of MCPTool objects from all MCP servers. """ + f = filter or MCPToolFilter() tools: list[MCPTool] = [] loop = asyncio.get_running_loop() @@ -343,13 +339,13 @@ async def get_mcp_tools_lob( return tools # Pre-fetch filter: ORD ID is extractable from the URL without fetching tools - if ord_ids: - ord_ids_set = set(ord_ids) + if f.ord_ids: + ord_ids_set = set(f.ord_ids) fragments = [ - f - for f in fragments + fr + for fr in fragments if _ord_id_from_url( - f.properties.get("URL") or f.properties.get("url") or "" + fr.properties.get("URL") or fr.properties.get("url") or "" ) in ord_ids_set ] @@ -381,8 +377,8 @@ async def get_mcp_tools_lob( ) # Post-fetch filter: tool names are only known after fetching - if names: - names_set = set(names) + if f.names: + names_set = set(f.names) tools = [t for t in tools if t.name in names_set] logger.info("Loaded %d MCP tool(s) from %d fragment(s)", len(tools), len(fragments)) diff --git a/src/sap_cloud_sdk/agentgateway/agw_client.py b/src/sap_cloud_sdk/agentgateway/agw_client.py index efccbc25..5706e385 100644 --- a/src/sap_cloud_sdk/agentgateway/agw_client.py +++ b/src/sap_cloud_sdk/agentgateway/agw_client.py @@ -351,7 +351,6 @@ async def list_mcp_tools( ``` """ try: - f = filter or MCPToolFilter() # Check for customer agent credentials credentials_path = detect_customer_agent_credentials() if credentials_path: @@ -367,8 +366,7 @@ async def list_mcp_tools( credentials, auth.access_token, self._config.timeout, - names=f.names or None, - ord_ids=f.ord_ids or None, + filter=filter, ) # LoB flow - requires tenant_subdomain @@ -384,8 +382,7 @@ async def list_mcp_tools( tenant, auth.access_token, self._config.timeout, - names=f.names or None, - ord_ids=f.ord_ids or None, + filter=filter, ) except AgentGatewaySDKError: diff --git a/tests/agentgateway/unit/test_agw_client.py b/tests/agentgateway/unit/test_agw_client.py index db1a5a3a..dbda628b 100644 --- a/tests/agentgateway/unit/test_agw_client.py +++ b/tests/agentgateway/unit/test_agw_client.py @@ -433,7 +433,7 @@ async def test_with_callable_tenant(self): await agw_client.list_mcp_tools() mock_lob.assert_called_once_with( - "my-tenant", "system-token", 60.0, names=None, ord_ids=None + "my-tenant", "system-token", 60.0, filter=None ) @pytest.mark.asyncio @@ -460,7 +460,7 @@ async def test_calls_lob_flow_with_system_token(self): await agw_client.list_mcp_tools() mock_lob.assert_called_once_with( - "my-tenant", "system-token-xyz", 60.0, names=None, ord_ids=None + "my-tenant", "system-token-xyz", 60.0, filter=None ) @pytest.mark.asyncio @@ -526,7 +526,7 @@ async def test_customer_flow_passes_system_token(self): await agw_client.list_mcp_tools(app_tid="tid") mock_customer.assert_called_once_with( - mock_creds, "customer-system-token", 60.0, names=None, ord_ids=None + mock_creds, "customer-system-token", 60.0, filter=None ) @pytest.mark.asyncio @@ -554,7 +554,7 @@ async def test_lob_flow_with_user_token_uses_user_auth(self): mock_user_auth.assert_called_once() mock_lob.assert_called_once_with( - "my-tenant", "user-token-xyz", 60.0, names=None, ord_ids=None + "my-tenant", "user-token-xyz", 60.0, filter=None ) @pytest.mark.asyncio @@ -582,12 +582,12 @@ async def test_customer_flow_with_user_token_uses_user_auth(self): await agw_client.list_mcp_tools(user_token="user-jwt", app_tid="tid") mock_customer.assert_called_once_with( - mock_creds, "exchanged-user-token", 60.0, names=None, ord_ids=None + mock_creds, "exchanged-user-token", 60.0, filter=None ) @pytest.mark.asyncio async def test_passes_filter_arguments_lob(self): - """Pass names and ord_ids from MCPToolFilter through to get_mcp_tools_lob.""" + """Pass the MCPToolFilter object through to get_mcp_tools_lob.""" with ( patch( "sap_cloud_sdk.agentgateway.agw_client.detect_customer_agent_credentials", @@ -605,24 +605,22 @@ async def test_passes_filter_arguments_lob(self): ) as mock_lob, ): agw_client = create_client(tenant_subdomain="my-tenant") - await agw_client.list_mcp_tools( - filter=MCPToolFilter( - names=["get-sales-order"], - ord_ids=["sap.s4:apiAccess:salesOrder:v1"], - ) + f = MCPToolFilter( + names=["get-sales-order"], + ord_ids=["sap.s4:apiAccess:salesOrder:v1"], ) + await agw_client.list_mcp_tools(filter=f) mock_lob.assert_called_once_with( "my-tenant", "token", 60.0, - names=["get-sales-order"], - ord_ids=["sap.s4:apiAccess:salesOrder:v1"], + filter=f, ) @pytest.mark.asyncio - async def test_empty_filter_lists_pass_none_to_lob(self): - """MCPToolFilter() with empty lists should forward as None, None to the fetch helper.""" + async def test_empty_filter_passes_through_to_lob(self): + """MCPToolFilter() with empty lists is still passed through as-is.""" with ( patch( "sap_cloud_sdk.agentgateway.agw_client.detect_customer_agent_credentials", @@ -640,15 +638,16 @@ async def test_empty_filter_lists_pass_none_to_lob(self): ) as mock_lob, ): agw_client = create_client(tenant_subdomain="my-tenant") - await agw_client.list_mcp_tools(filter=MCPToolFilter()) + f = MCPToolFilter() + await agw_client.list_mcp_tools(filter=f) mock_lob.assert_called_once_with( - "my-tenant", "token", 60.0, names=None, ord_ids=None + "my-tenant", "token", 60.0, filter=f ) @pytest.mark.asyncio async def test_passes_filter_arguments_customer(self): - """Pass names and ord_ids from MCPToolFilter through to get_mcp_tools_customer.""" + """Pass the MCPToolFilter object through to get_mcp_tools_customer.""" with ( patch( "sap_cloud_sdk.agentgateway.agw_client.detect_customer_agent_credentials", @@ -672,19 +671,17 @@ async def test_passes_filter_arguments_customer(self): mock_load.return_value = mock_creds agw_client = create_client() - await agw_client.list_mcp_tools( - filter=MCPToolFilter( - names=["get-cost-center"], - ord_ids=["sap.s4:apiAccess:finance:v1"], - ) + f = MCPToolFilter( + names=["get-cost-center"], + ord_ids=["sap.s4:apiAccess:finance:v1"], ) + await agw_client.list_mcp_tools(filter=f) mock_customer.assert_called_once_with( mock_creds, "customer-system-token", 60.0, - names=["get-cost-center"], - ord_ids=["sap.s4:apiAccess:finance:v1"], + filter=f, ) diff --git a/tests/agentgateway/unit/test_customer.py b/tests/agentgateway/unit/test_customer.py index 2890ed98..6e189001 100644 --- a/tests/agentgateway/unit/test_customer.py +++ b/tests/agentgateway/unit/test_customer.py @@ -20,6 +20,7 @@ CustomerCredentials, IntegrationDependency, MCPTool, + MCPToolFilter, ) from sap_cloud_sdk.agentgateway._token_cache import _TokenCache from sap_cloud_sdk.agentgateway.config import ClientConfig @@ -651,7 +652,9 @@ async def test_filters_dependencies_by_ord_id_pre_fetch(self): credentials, "system-token", 60.0, - ord_ids=["sap.mcpbuilder:apiResource:cost-center:v1"], + filter=MCPToolFilter( + ord_ids=["sap.mcpbuilder:apiResource:cost-center:v1"] + ), ) # Only the cost-center dependency was queried; finance was filtered out. @@ -687,14 +690,14 @@ async def test_filters_tools_by_name_post_fetch(self, credentials): credentials, "system-token", 60.0, - names=["list_cost_centers"], + filter=MCPToolFilter(names=["list_cost_centers"]), ) assert [t.name for t in result] == ["list_cost_centers"] @pytest.mark.asyncio async def test_empty_filter_lists_behave_like_none(self, credentials): - """names=[] and ord_ids=[] should behave the same as None (no filtering).""" + """MCPToolFilter() with empty lists behaves like no filter (returns everything).""" tool = MCPTool( name="list_cost_centers", server_name="s", @@ -711,7 +714,7 @@ async def test_empty_filter_lists_behave_like_none(self, credentials): ), ): result = await get_mcp_tools_customer( - credentials, "system-token", 60.0, names=[], ord_ids=[] + credentials, "system-token", 60.0, filter=MCPToolFilter() ) assert [t.name for t in result] == ["list_cost_centers"] @@ -737,7 +740,10 @@ async def test_filter_excluding_all_dependencies_returns_empty(self): ) as mock_list, ): result = await get_mcp_tools_customer( - credentials, "system-token", 60.0, ord_ids=["sap.does-not-exist:v1"] + credentials, + "system-token", + 60.0, + filter=MCPToolFilter(ord_ids=["sap.does-not-exist:v1"]), ) # Filter excluded everything -> empty list, no exception, no network call. diff --git a/tests/agentgateway/unit/test_lob.py b/tests/agentgateway/unit/test_lob.py index 4a24a764..55d59a64 100644 --- a/tests/agentgateway/unit/test_lob.py +++ b/tests/agentgateway/unit/test_lob.py @@ -24,7 +24,7 @@ _fetch_agent_card, call_mcp_tool_lob, ) -from sap_cloud_sdk.agentgateway._models import Agent, AgentCard, MCPTool +from sap_cloud_sdk.agentgateway._models import Agent, AgentCard, MCPTool, MCPToolFilter from sap_cloud_sdk.agentgateway._token_cache import _GatewayUrlCache, _TokenCache from sap_cloud_sdk.agentgateway.config import ClientConfig from sap_cloud_sdk.agentgateway.exceptions import AgentGatewaySDKError, MCPServerNotFoundError @@ -633,7 +633,7 @@ async def test_filters_fragments_by_ord_id_pre_fetch(self): "tenant-sub", "system-token", 60.0, - ord_ids=["sap.s4:apiAccess:salesOrder:v1"], + filter=MCPToolFilter(ord_ids=["sap.s4:apiAccess:salesOrder:v1"]), ) assert mock_tools.call_count == 1 @@ -680,7 +680,7 @@ async def test_filters_tools_by_name_post_fetch(self): "tenant-sub", "system-token", 60.0, - names=["get-sales-order"], + filter=MCPToolFilter(names=["get-sales-order"]), ) assert [t.name for t in result] == ["get-sales-order"] @@ -736,15 +736,17 @@ def per_fragment_tools(url, *_args, **_kwargs): "tenant-sub", "system-token", 60.0, - names=["get-sales-order"], - ord_ids=["sap.s4:apiAccess:finance:v1"], + filter=MCPToolFilter( + names=["get-sales-order"], + ord_ids=["sap.s4:apiAccess:finance:v1"], + ), ) assert result == [] @pytest.mark.asyncio async def test_empty_filter_lists_behave_like_none(self): - """names=[] and ord_ids=[] should behave the same as None (no filtering).""" + """MCPToolFilter() with empty lists behaves like no filter (returns everything).""" frag = MagicMock() frag.name = "frag" frag.properties = { @@ -770,7 +772,7 @@ async def test_empty_filter_lists_behave_like_none(self): mock_list.return_value = [frag] result = await get_mcp_tools_lob( - "tenant-sub", "system-token", 60.0, names=[], ord_ids=[] + "tenant-sub", "system-token", 60.0, filter=MCPToolFilter() ) assert [t.name for t in result] == ["get-sales-order"] From c43118c49492be2517b714f781d6f46fdc93f39a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro=20Brun?= Date: Mon, 6 Jul 2026 11:11:18 -0300 Subject: [PATCH 5/5] padronize get_agent to receive filter as an argument --- src/sap_cloud_sdk/agentgateway/_lob.py | 33 +++++++++++--------- src/sap_cloud_sdk/agentgateway/agw_client.py | 4 +-- tests/agentgateway/unit/test_agw_client.py | 8 ++--- tests/agentgateway/unit/test_lob.py | 18 +++++++++-- 4 files changed, 38 insertions(+), 25 deletions(-) diff --git a/src/sap_cloud_sdk/agentgateway/_lob.py b/src/sap_cloud_sdk/agentgateway/_lob.py index 66950acd..08846d03 100644 --- a/src/sap_cloud_sdk/agentgateway/_lob.py +++ b/src/sap_cloud_sdk/agentgateway/_lob.py @@ -27,7 +27,13 @@ list_mcp_fragments, list_a2a_fragments, ) -from sap_cloud_sdk.agentgateway._models import Agent, AgentCard, MCPTool, MCPToolFilter +from sap_cloud_sdk.agentgateway._models import ( + Agent, + AgentCard, + AgentCardFilter, + MCPTool, + MCPToolFilter, +) from sap_cloud_sdk.agentgateway._token_cache import _GatewayUrlCache, _TokenCache from sap_cloud_sdk.agentgateway.exceptions import ( AgentGatewaySDKError, @@ -499,8 +505,7 @@ async def get_agent_cards_lob( tenant_subdomain: str, system_token: str, timeout: float, - agent_names: list[str] | None = None, - ord_ids: list[str] | None = None, + filter: AgentCardFilter | None = None, ) -> list[Agent]: """List A2A agents and their agent cards using LoB flow. @@ -517,15 +522,13 @@ async def get_agent_cards_lob( tenant_subdomain: Tenant subdomain for multi-tenant lookup. system_token: Pre-fetched raw system token for authentication. timeout: HTTP timeout in seconds. - agent_names: Optional list of agent card names to include (matched - against the `name` field in the fetched agent card JSON). - Applied after fetching. If empty or None, all are included. - ord_ids: Optional list of ORD IDs to include (extracted from URL). - Applied before fetching. If empty or None, all are included. + filter: Optional AgentCardFilter narrowing results by agent card name + or ORD ID. If None or empty, all A2A fragments are included. Returns: List of Agent objects, each containing ORD ID and fetched AgentCard. """ + f = filter or AgentCardFilter() loop = asyncio.get_running_loop() logger.info("Listing A2A fragments for tenant '%s'", tenant_subdomain) @@ -538,13 +541,13 @@ async def get_agent_cards_lob( return [] # Pre-fetch filter: ORD ID is extractable from the URL without fetching the card - if ord_ids: - ord_ids_set = set(ord_ids) + if f.ord_ids: + ord_ids_set = set(f.ord_ids) fragments = [ - f - for f in fragments + fr + for fr in fragments if _ord_id_from_url( - {k.lower(): v for k, v in f.properties.items()}.get("url", "") + {k.lower(): v for k, v in fr.properties.items()}.get("url", "") ) in ord_ids_set ] @@ -583,8 +586,8 @@ async def get_agent_cards_lob( ) # Post-fetch filter: agent card name is only known after fetching - if agent_names: - agent_names_set = set(agent_names) + if f.agent_names: + agent_names_set = set(f.agent_names) agents = [a for a in agents if a.agent_card.raw.get("name") in agent_names_set] logger.info( diff --git a/src/sap_cloud_sdk/agentgateway/agw_client.py b/src/sap_cloud_sdk/agentgateway/agw_client.py index 5706e385..c886ad08 100644 --- a/src/sap_cloud_sdk/agentgateway/agw_client.py +++ b/src/sap_cloud_sdk/agentgateway/agw_client.py @@ -445,13 +445,11 @@ async def list_agent_cards( tenant = self._resolve_tenant_subdomain() auth = await self.get_system_auth() - f = filter or AgentCardFilter() return await get_agent_cards_lob( tenant, auth.access_token, self._config.timeout, - agent_names=f.agent_names or None, - ord_ids=f.ord_ids or None, + filter=filter, ) except AgentGatewaySDKError: raise diff --git a/tests/agentgateway/unit/test_agw_client.py b/tests/agentgateway/unit/test_agw_client.py index dbda628b..c444c6aa 100644 --- a/tests/agentgateway/unit/test_agw_client.py +++ b/tests/agentgateway/unit/test_agw_client.py @@ -983,8 +983,7 @@ async def test_returns_agents_from_lob_flow(self): "my-tenant", "system-token", 60.0, - agent_names=None, - ord_ids=None, + filter=None, ) @pytest.mark.asyncio @@ -1015,8 +1014,9 @@ async def test_passes_filter_arguments(self): "my-tenant", "token", 60.0, - agent_names=["Billing Agent"], - ord_ids=["sap.s4:agent:v1"], + filter=AgentCardFilter( + agent_names=["Billing Agent"], ord_ids=["sap.s4:agent:v1"] + ), ) @pytest.mark.asyncio diff --git a/tests/agentgateway/unit/test_lob.py b/tests/agentgateway/unit/test_lob.py index 55d59a64..906f9f75 100644 --- a/tests/agentgateway/unit/test_lob.py +++ b/tests/agentgateway/unit/test_lob.py @@ -24,7 +24,13 @@ _fetch_agent_card, call_mcp_tool_lob, ) -from sap_cloud_sdk.agentgateway._models import Agent, AgentCard, MCPTool, MCPToolFilter +from sap_cloud_sdk.agentgateway._models import ( + Agent, + AgentCard, + AgentCardFilter, + MCPTool, + MCPToolFilter, +) from sap_cloud_sdk.agentgateway._token_cache import _GatewayUrlCache, _TokenCache from sap_cloud_sdk.agentgateway.config import ClientConfig from sap_cloud_sdk.agentgateway.exceptions import AgentGatewaySDKError, MCPServerNotFoundError @@ -1107,7 +1113,10 @@ async def _cards_by_ord(fragment_url, token, timeout): ), ): result = await get_agent_cards_lob( - "tenant-sub", "token", 60.0, agent_names=["Billing Agent"] + "tenant-sub", + "token", + 60.0, + filter=AgentCardFilter(agent_names=["Billing Agent"]), ) assert len(result) == 1 @@ -1132,7 +1141,10 @@ async def test_filters_by_ord_ids(self): ) as mock_fetch, ): result = await get_agent_cards_lob( - "tenant-sub", "token", 60.0, ord_ids=["ord-2"] + "tenant-sub", + "token", + 60.0, + filter=AgentCardFilter(ord_ids=["ord-2"]), ) assert len(result) == 1