diff --git a/pyproject.toml b/pyproject.toml index a593aaa1..3bbf3426 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sap-cloud-sdk" -version = "0.32.1" +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/__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..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,6 +481,7 @@ async def get_mcp_tools_customer( credentials: CustomerCredentials, system_token: str, timeout: float, + filter: MCPToolFilter | None = None, ) -> list[MCPTool]: """List all MCP tools from servers defined in credentials. @@ -490,6 +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. + 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. @@ -497,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: @@ -504,6 +509,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 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)) tools: list[MCPTool] = [] @@ -524,6 +534,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 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 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..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 +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, @@ -308,6 +314,7 @@ async def get_mcp_tools_lob( tenant_subdomain: str, system_token: str, timeout: float, + filter: MCPToolFilter | None = None, ) -> list[MCPTool]: """List all MCP tools using LoB flow (destination-based). @@ -317,10 +324,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. + 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() @@ -334,6 +344,18 @@ async def get_mcp_tools_lob( ) return tools + # Pre-fetch filter: ORD ID is extractable from the URL without fetching tools + if f.ord_ids: + ord_ids_set = set(f.ord_ids) + fragments = [ + fr + for fr in fragments + if _ord_id_from_url( + fr.properties.get("URL") or fr.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 +382,11 @@ async def get_mcp_tools_lob( fragment_name, ) + # Post-fetch filter: tool names are only known after fetching + 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)) return tools @@ -478,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. @@ -496,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) @@ -517,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 ] @@ -562,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/_models.py b/src/sap_cloud_sdk/agentgateway/_models.py index 8e621bf0..76696e6a 100644 --- a/src/sap_cloud_sdk/agentgateway/_models.py +++ b/src/sap_cloud_sdk/agentgateway/_models.py @@ -150,3 +150,36 @@ 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. + 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 + 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..c886ad08 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,6 +339,15 @@ 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: @@ -350,7 +363,10 @@ 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, + filter=filter, ) # LoB flow - requires tenant_subdomain @@ -363,7 +379,10 @@ 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, + filter=filter, ) except AgentGatewaySDKError: @@ -426,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/src/sap_cloud_sdk/agentgateway/user-guide.md b/src/sap_cloud_sdk/agentgateway/user-guide.md index d31ace2c..cc7bb12f 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], @@ -204,6 +212,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( @@ -231,7 +240,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 diff --git a/tests/agentgateway/unit/test_agw_client.py b/tests/agentgateway/unit/test_agw_client.py index b1541fa5..c444c6aa 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, filter=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, filter=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, filter=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, filter=None + ) @pytest.mark.asyncio async def test_customer_flow_with_user_token_uses_user_auth(self): @@ -575,8 +582,107 @@ 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, filter=None + ) + + @pytest.mark.asyncio + async def test_passes_filter_arguments_lob(self): + """Pass the MCPToolFilter object 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") + 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, + filter=f, + ) + + @pytest.mark.asyncio + 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", + 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") + f = MCPToolFilter() + await agw_client.list_mcp_tools(filter=f) + + mock_lob.assert_called_once_with( + "my-tenant", "token", 60.0, filter=f + ) + + @pytest.mark.asyncio + async def test_passes_filter_arguments_customer(self): + """Pass the MCPToolFilter object 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() + 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, + filter=f, + ) # ============================================================ @@ -877,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 @@ -909,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_customer.py b/tests/agentgateway/unit/test_customer.py index 4469ab47..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 @@ -611,6 +612,144 @@ 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, + filter=MCPToolFilter( + 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, + 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): + """MCPToolFilter() with empty lists behaves like no filter (returns everything).""" + 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, filter=MCPToolFilter() + ) + + 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, + filter=MCPToolFilter(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..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 +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 @@ -596,6 +602,187 @@ 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, + filter=MCPToolFilter(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, + filter=MCPToolFilter(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, + 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): + """MCPToolFilter() with empty lists behaves like no filter (returns everything).""" + 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, filter=MCPToolFilter() + ) + + assert [t.name for t in result] == ["get-sales-order"] + # ============================================================ # Test: call_mcp_tool_lob @@ -709,7 +896,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 +904,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( @@ -920,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 @@ -945,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 diff --git a/uv.lock b/uv.lock index be9e3cfa..b6dd1330 100644 --- a/uv.lock +++ b/uv.lock @@ -3713,7 +3713,7 @@ wheels = [ [[package]] name = "sap-cloud-sdk" -version = "0.32.0" +version = "0.33.0" source = { editable = "." } dependencies = [ { name = "grpcio" },