diff --git a/pyproject.toml b/pyproject.toml index b61fb716..aea91d99 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sap-cloud-sdk" -version = "0.40.0" +version = "0.41.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 69c5f1e8..a8c0c850 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, @@ -78,6 +79,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 bf85299c..83f40ca1 100644 --- a/src/sap_cloud_sdk/agentgateway/_customer.py +++ b/src/sap_cloud_sdk/agentgateway/_customer.py @@ -32,6 +32,7 @@ CustomerCredentials, IntegrationDependency, MCPTool, + MCPToolFilter, ) from sap_cloud_sdk.agentgateway._token_cache import _TokenCache from sap_cloud_sdk.agentgateway.exceptions import AgentGatewaySDKError @@ -695,6 +696,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. @@ -705,10 +707,13 @@ 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. """ + f = filter or MCPToolFilter() dependencies = credentials.integration_dependencies if not dependencies: @@ -717,6 +722,10 @@ async def get_mcp_tools_customer( ) return [] + 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] = [] @@ -737,6 +746,11 @@ async def get_mcp_tools_customer( except Exception as exc: _log_mcp_server_error(dep.ord_id, exc) + # 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 b1d63e55..0c46124c 100644 --- a/src/sap_cloud_sdk/agentgateway/_lob.py +++ b/src/sap_cloud_sdk/agentgateway/_lob.py @@ -28,7 +28,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, @@ -362,6 +368,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). @@ -371,10 +378,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() @@ -388,6 +398,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") @@ -411,6 +433,11 @@ async def get_mcp_tools_lob( except Exception as exc: _log_mcp_server_error(fragment_name, exc) + # 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 @@ -534,8 +561,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. @@ -552,15 +578,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) @@ -573,13 +597,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 ] @@ -618,8 +642,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 7cfa3b74..2dd56e87 100644 --- a/src/sap_cloud_sdk/agentgateway/_models.py +++ b/src/sap_cloud_sdk/agentgateway/_models.py @@ -152,3 +152,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 5aeb8078..fb1d63f8 100644 --- a/src/sap_cloud_sdk/agentgateway/agw_client.py +++ b/src/sap_cloud_sdk/agentgateway/agw_client.py @@ -35,6 +35,7 @@ AgentCardFilter, AuthResult, MCPTool, + MCPToolFilter, ) from sap_cloud_sdk.agentgateway._token_cache import _GatewayUrlCache, _TokenCache from sap_cloud_sdk.agentgateway.exceptions import AgentGatewaySDKError @@ -354,7 +355,9 @@ def get_ias_client_id(self) -> str: @record_metrics(Module.AGENTGATEWAY, Operation.AGENTGATEWAY_LIST_MCP_TOOLS) async def list_mcp_tools( - self, user_token: str | Callable[[], str] | None = None + self, + user_token: str | Callable[[], str] | None = None, + filter: MCPToolFilter | None = None, ) -> list[MCPTool]: """List all MCP tools from MCP servers. @@ -373,6 +376,8 @@ 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. Returns: List of MCPTool objects from all MCP servers. @@ -388,6 +393,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: @@ -404,7 +418,10 @@ async def list_mcp_tools( ) credentials = load_customer_credentials(credentials_path) return await get_mcp_tools_customer( - credentials, auth.access_token, self._config.timeout + credentials, + auth.access_token, + self._config.timeout, + filter=filter, ) # Check for transparent mode @@ -412,7 +429,10 @@ async def list_mcp_tools( logger.info(_LOG_TRANSPARENT_MODE) credentials = load_customer_credentials_from_env() 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 @@ -422,7 +442,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: @@ -484,13 +507,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 0ff6ddbb..ab3cd3f5 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], @@ -212,6 +220,7 @@ class AgentGatewayClient: async def list_mcp_tools( self, user_token: str | Callable[[], str] | None = None, + filter: MCPToolFilter | None = None, ) -> list[MCPTool] async def call_mcp_tool( @@ -254,7 +263,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 55a6f86c..f950946a 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, ) from sap_cloud_sdk.core.telemetry import Module @@ -442,7 +443,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): @@ -467,7 +470,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): @@ -532,7 +537,7 @@ async def test_customer_flow_passes_system_token(self): await agw_client.list_mcp_tools() 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 @@ -559,7 +564,9 @@ async def test_lob_flow_with_user_token_uses_user_auth(self): await agw_client.list_mcp_tools(user_token="user-jwt") assert mock_user_auth.call_count == 2 - 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): @@ -586,8 +593,107 @@ async def test_customer_flow_with_user_token_uses_user_auth(self): await agw_client.list_mcp_tools(user_token="user-jwt") 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, + ) # ============================================================ @@ -896,8 +1002,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 @@ -928,8 +1033,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 e1db66f2..512875be 100644 --- a/tests/agentgateway/unit/test_customer.py +++ b/tests/agentgateway/unit/test_customer.py @@ -23,6 +23,7 @@ CustomerCredentials, IntegrationDependency, MCPTool, + MCPToolFilter, ) from sap_cloud_sdk.agentgateway._token_cache import _TokenCache from sap_cloud_sdk.agentgateway.config import ClientConfig @@ -554,6 +555,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 e2e66723..0f1b15e3 100644 --- a/tests/agentgateway/unit/test_lob.py +++ b/tests/agentgateway/unit/test_lob.py @@ -25,7 +25,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.destination import ConsumptionOptions, ConsumptionLevel @@ -599,6 +605,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 @@ -712,7 +899,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.""" @@ -720,6 +907,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( @@ -923,7 +1116,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 @@ -948,7 +1144,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 ef0a7cbc..2cd74460 100644 --- a/uv.lock +++ b/uv.lock @@ -161,9 +161,9 @@ name = "aiologic" version = "0.16.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "sniffio" }, - { name = "typing-extensions" }, - { name = "wrapt" }, + { name = "sniffio", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "wrapt", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a8/13/50b91a3ea6b030d280d2654be97c48b6ed81753a50286ee43c646ba36d3c/aiologic-0.16.0.tar.gz", hash = "sha256:c267ccbd3ff417ec93e78d28d4d577ccca115d5797cdbd16785a551d9658858f", size = 225952, upload-time = "2025-11-27T23:48:41.195Z" } wheels = [ @@ -615,8 +615,8 @@ name = "culsans" version = "0.11.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "aiologic" }, - { name = "typing-extensions" }, + { name = "aiologic", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d9/e3/49afa1bc180e0d28008ec6bcdf82a4072d1c7a41032b5b759b60814ca4b0/culsans-0.11.0.tar.gz", hash = "sha256:0b43d0d05dce6106293d114c86e3fb4bfc63088cfe8ff08ed3fe36891447fe33", size = 107546, upload-time = "2025-12-31T23:15:38.196Z" } wheels = [ @@ -665,9 +665,9 @@ resolution-markers = [ "python_full_version < '3.12'", ] dependencies = [ - { name = "asgiref" }, - { name = "sqlparse" }, - { name = "tzdata", marker = "sys_platform == 'win32'" }, + { name = "asgiref", marker = "python_full_version < '3.12'" }, + { name = "sqlparse", marker = "python_full_version < '3.12'" }, + { name = "tzdata", marker = "python_full_version < '3.12' and sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a9/26/889449d521ae508b26de715954faecd8bcf3f740affb81b2d146a83b42a5/django-5.2.16.tar.gz", hash = "sha256:59ea02020c3136fce14bef0bbece21a10a4febef5eed1c51c22ae468efa22200", size = 10890894, upload-time = "2026-07-07T13:52:17.005Z" } wheels = [ @@ -685,9 +685,9 @@ resolution-markers = [ "python_full_version == '3.12.*'", ] dependencies = [ - { name = "asgiref" }, - { name = "sqlparse" }, - { name = "tzdata", marker = "sys_platform == 'win32'" }, + { name = "asgiref", marker = "python_full_version >= '3.12'" }, + { name = "sqlparse", marker = "python_full_version >= '3.12'" }, + { name = "tzdata", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/89/55/664f24ff81c9ea19cb7dfc851afeae1f3c2390c7aee01d4ded68b5c1580d/django-6.0.7.tar.gz", hash = "sha256:2998503fc083124fb58037084bfa00de323c7c743f05f1b4284e77bff0ab8890", size = 10921299, upload-time = "2026-07-07T13:51:26.485Z" } wheels = [ @@ -3925,9 +3925,10 @@ wheels = [ [[package]] name = "sap-cloud-sdk" -version = "0.39.1" +version = "0.41.0" source = { editable = "." } dependencies = [ + { name = "cryptography" }, { name = "grpcio" }, { name = "hatchling" }, { name = "httpx" }, @@ -4016,6 +4017,7 @@ dev = [ requires-dist = [ { name = "a2a-sdk", marker = "extra == 'extensibility'", specifier = ">=0.2.0" }, { name = "aiohttp", marker = "extra == 'aiohttp'", specifier = ">=3.9.0" }, + { name = "cryptography", specifier = ">=46.0.3" }, { name = "django", marker = "extra == 'django'", specifier = ">=4.0" }, { name = "fastapi", marker = "extra == 'fastapi'", specifier = ">=0.100.0" }, { name = "flask", marker = "extra == 'flask'", specifier = ">=3.0" },