From bb614bda7e3e4cfe77e1971c1e75358984cf4a55 Mon Sep 17 00:00:00 2001 From: Soares Date: Mon, 6 Jul 2026 12:00:28 -0300 Subject: [PATCH 01/10] feat: add implicit audit log usage on agent gateway --- src/sap_cloud_sdk/agentgateway/agw_client.py | 71 +++- tests/agentgateway/unit/test_agw_client.py | 354 +++++++++++++++++++ 2 files changed, 420 insertions(+), 5 deletions(-) diff --git a/src/sap_cloud_sdk/agentgateway/agw_client.py b/src/sap_cloud_sdk/agentgateway/agw_client.py index 411ff4a8..ee88f1a6 100644 --- a/src/sap_cloud_sdk/agentgateway/agw_client.py +++ b/src/sap_cloud_sdk/agentgateway/agw_client.py @@ -9,6 +9,7 @@ import asyncio import logging +from datetime import datetime, timezone from typing import Callable from sap_cloud_sdk.agentgateway.config import ClientConfig @@ -35,6 +36,7 @@ ) from sap_cloud_sdk.agentgateway._token_cache import _GatewayUrlCache, _TokenCache from sap_cloud_sdk.agentgateway.exceptions import AgentGatewaySDKError +from sap_cloud_sdk.core.auditlog_ng import AuditClient from sap_cloud_sdk.core.telemetry import Module, Operation, record_metrics logger = logging.getLogger(__name__) @@ -106,6 +108,8 @@ def __init__( self, tenant_subdomain: str | Callable[[], str] | None = None, config: ClientConfig | None = None, + audit_client: AuditClient | None = None, + tenant_id: str | None = None, ): """Initialize the Agent Gateway client. @@ -114,11 +118,17 @@ def __init__( Can be a string or a callable returning a string. Required for LoB agents, ignored for Customer agents. config: Client configuration. Uses defaults if not provided. + audit_client: Optional audit log client. When provided, implicit + audit events are sent for list_mcp_tools and call_mcp_tool. + tenant_id: BTP tenant UUID (e.g. ``"9e0d89c9-..."``) used in audit + events. Required when audit_client is set. """ self._tenant_subdomain = tenant_subdomain self._config = config or ClientConfig() self._token_cache = _TokenCache(self._config) self._gateway_url_cache = _GatewayUrlCache() + self._audit_client = audit_client + self._tenant_id = tenant_id @staticmethod def _resolve_value( @@ -151,6 +161,32 @@ def _resolve_tenant_subdomain(self) -> str: "tenant_subdomain is required for LoB agent flow.", ) + def _send_audit_event( + self, + object_id: str, + user_id: str | None = None, + ) -> None: + """Send a DataAccess audit event. Errors are logged and suppressed.""" + if self._audit_client is None or self._tenant_id is None: + return + try: + from sap_cloud_sdk.core.auditlog_ng.gen.sap.auditlog.auditevent.v2 import ( + auditevent_pb2 as pb, + ) + + event = pb.DataAccess() + event.common.timestamp.FromDatetime(datetime.now(timezone.utc)) + event.common.tenant_id = self._tenant_id + if user_id: + event.common.user_initiator_id = user_id + event.channel_type = "MCP" + event.channel_id = "agent-gateway" + event.object_type = "mcp-tool" + event.object_id = object_id + self._audit_client.send(event) + except Exception: + logger.debug("Failed to send audit event", exc_info=True) + @record_metrics(Module.AGENTGATEWAY, Operation.AGENTGATEWAY_GET_SYSTEM_AUTH) async def get_system_auth(self, app_tid: str | None = None) -> AuthResult: """Get system-scoped authentication (client_credentials flow). @@ -300,6 +336,7 @@ async def list_mcp_tools( self, user_token: str | Callable[[], str] | None = None, app_tid: str | None = None, + user_id: str | None = None, ) -> list[MCPTool]: """List all MCP tools from MCP servers. @@ -320,6 +357,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. + user_id: User identifier recorded in the audit event when an + audit_client is configured on the client. Returns: List of MCPTool objects from all MCP servers. @@ -349,9 +388,11 @@ async def list_mcp_tools( auth = await self.get_user_auth(user_token, app_tid) else: auth = await self.get_system_auth(app_tid=app_tid) - return await get_mcp_tools_customer( + tools = await get_mcp_tools_customer( credentials, auth.access_token, self._config.timeout ) + self._send_audit_event("*", user_id) + return tools # LoB flow - requires tenant_subdomain if app_tid: @@ -362,9 +403,11 @@ async def list_mcp_tools( auth = await self.get_user_auth(user_token) else: auth = await self.get_system_auth() - return await get_mcp_tools_lob( + tools = await get_mcp_tools_lob( tenant, auth.access_token, self._config.timeout ) + self._send_audit_event("*", user_id) + return tools except AgentGatewaySDKError: # Re-raise SDK errors as-is @@ -446,6 +489,7 @@ async def call_mcp_tool( tool: MCPTool, user_token: str | Callable[[], str] | None = None, app_tid: str | None = None, + user_id: str | None = None, **kwargs, ) -> str: """Invoke an MCP tool. @@ -470,6 +514,8 @@ async def call_mcp_tool( for tenant-scoped token exchange. TODO: This parameter's requirement is still being clarified with the IBD team and may be removed if unnecessary. + user_id: User identifier recorded in the audit event when an + audit_client is configured on the client. **kwargs: Tool input parameters (passed directly to the tool). Returns: @@ -511,18 +557,22 @@ async def call_mcp_tool( ) auth = await self.get_system_auth(app_tid) - return await call_mcp_tool_customer( + result = await call_mcp_tool_customer( tool, auth.access_token, self._config.timeout, **kwargs ) + self._send_audit_event(tool.name, user_id) + return result # LoB flow - requires user_token and tenant_subdomain if app_tid: logger.warning("app_tid parameter ignored for LoB agent flow") auth = await self.get_user_auth(user_token, app_tid) - return await call_mcp_tool_lob( + result = await call_mcp_tool_lob( tool, auth.access_token, self._config.timeout, **kwargs ) + self._send_audit_event(tool.name, user_id) + return result except AgentGatewaySDKError: # Re-raise SDK errors as-is @@ -545,6 +595,8 @@ def _unwrap_exception_group(exc: BaseException) -> BaseException: def create_client( tenant_subdomain: str | Callable[[], str] | None = None, config: ClientConfig | None = None, + audit_client: AuditClient | None = None, + tenant_id: str | None = None, ) -> AgentGatewayClient: """Create an Agent Gateway client for discovering and invoking MCP tools. @@ -556,6 +608,10 @@ def create_client( Can be a string or a callable returning a string. Required for LoB agents, ignored for Customer agents. config: Client configuration. Uses defaults if not provided. + audit_client: Optional audit log client. When provided, implicit + audit events are sent for list_mcp_tools and call_mcp_tool. + tenant_id: BTP tenant UUID used in audit events. Required when + audit_client is set. Returns: AgentGatewayClient instance. @@ -609,4 +665,9 @@ def create_client( user_auth = await agw_client.get_user_auth(user_token="user-jwt") ``` """ - return AgentGatewayClient(tenant_subdomain=tenant_subdomain, config=config) + return AgentGatewayClient( + tenant_subdomain=tenant_subdomain, + config=config, + audit_client=audit_client, + tenant_id=tenant_id, + ) diff --git a/tests/agentgateway/unit/test_agw_client.py b/tests/agentgateway/unit/test_agw_client.py index b1541fa5..eaea6efc 100644 --- a/tests/agentgateway/unit/test_agw_client.py +++ b/tests/agentgateway/unit/test_agw_client.py @@ -15,6 +15,8 @@ AgentGatewaySDKError, ) +_TENANT_UUID = "9e0d89c9-17cd-439d-8a8b-9c44d3d272f0" + # ============================================================ # Fixtures @@ -980,3 +982,355 @@ async def test_wraps_unexpected_error(self): agw_client = create_client(tenant_subdomain="my-tenant") with pytest.raises(AgentGatewaySDKError, match="Agent card discovery failed"): await agw_client.list_agent_cards() + + +# ============================================================ +# Test: _send_audit_event +# ============================================================ + + +class TestSendAuditEvent: + """Tests for _send_audit_event helper.""" + + def test_no_op_without_audit_client(self): + """_send_audit_event is a no-op when audit_client is not set.""" + agw_client = create_client(tenant_subdomain="my-tenant") + # Should not raise + agw_client._send_audit_event("tool-name", "user@example.com") + + def test_no_op_without_tenant_id(self): + """_send_audit_event is a no-op when tenant_id is not set.""" + mock_audit = MagicMock() + agw_client = create_client( + tenant_subdomain="my-tenant", + audit_client=mock_audit, + tenant_id=None, + ) + agw_client._send_audit_event("tool-name") + mock_audit.send.assert_not_called() + + def test_sends_data_access_event(self): + """_send_audit_event builds and sends a DataAccess event.""" + mock_audit = MagicMock() + agw_client = create_client( + tenant_subdomain="my-tenant", + audit_client=mock_audit, + tenant_id=_TENANT_UUID, + ) + agw_client._send_audit_event("my-tool", "user@example.com") + mock_audit.send.assert_called_once() + event = mock_audit.send.call_args[0][0] + assert event.common.tenant_id == _TENANT_UUID + assert event.common.user_initiator_id == "user@example.com" + assert event.channel_type == "MCP" + assert event.channel_id == "agent-gateway" + assert event.object_type == "mcp-tool" + assert event.object_id == "my-tool" + + def test_sends_without_user_id(self): + """_send_audit_event omits user_initiator_id when user_id is None.""" + mock_audit = MagicMock() + agw_client = create_client( + tenant_subdomain="my-tenant", + audit_client=mock_audit, + tenant_id=_TENANT_UUID, + ) + agw_client._send_audit_event("my-tool") + mock_audit.send.assert_called_once() + event = mock_audit.send.call_args[0][0] + assert event.common.user_initiator_id == "" + + def test_suppresses_send_errors(self): + """_send_audit_event does not propagate exceptions from audit client.""" + mock_audit = MagicMock() + mock_audit.send.side_effect = RuntimeError("send failed") + agw_client = create_client( + tenant_subdomain="my-tenant", + audit_client=mock_audit, + tenant_id=_TENANT_UUID, + ) + # Should not raise + agw_client._send_audit_event("my-tool") + + +# ============================================================ +# Test: list_mcp_tools audit logging +# ============================================================ + + +class TestListMcpToolsAuditLog: + """Tests that list_mcp_tools emits an audit event on success.""" + + @pytest.mark.asyncio + async def test_lob_flow_sends_audit_event(self): + """list_mcp_tools sends audit event after LoB tool discovery.""" + mock_audit = MagicMock() + 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=[], + ), + ): + agw_client = create_client( + tenant_subdomain="my-tenant", + audit_client=mock_audit, + tenant_id=_TENANT_UUID, + ) + await agw_client.list_mcp_tools(user_id="user@example.com") + + mock_audit.send.assert_called_once() + event = mock_audit.send.call_args[0][0] + assert event.object_id == "*" + assert event.common.tenant_id == _TENANT_UUID + assert event.common.user_initiator_id == "user@example.com" + + @pytest.mark.asyncio + async def test_customer_flow_sends_audit_event(self): + """list_mcp_tools sends audit event after customer tool discovery.""" + mock_audit = MagicMock() + 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="system-token", + ), + patch( + "sap_cloud_sdk.agentgateway.agw_client.get_mcp_tools_customer", + new_callable=AsyncMock, + return_value=[], + ), + ): + mock_creds = MagicMock() + mock_creds.gateway_url = "https://agw.customer.com" + mock_load.return_value = mock_creds + + agw_client = create_client( + audit_client=mock_audit, + tenant_id=_TENANT_UUID, + ) + await agw_client.list_mcp_tools() + + mock_audit.send.assert_called_once() + event = mock_audit.send.call_args[0][0] + assert event.object_id == "*" + + @pytest.mark.asyncio + async def test_no_audit_event_without_audit_client(self): + """list_mcp_tools does not attempt audit logging when no audit_client.""" + 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=[], + ), + patch( + "sap_cloud_sdk.agentgateway.agw_client.AgentGatewayClient._send_audit_event" + ) as mock_send, + ): + agw_client = create_client(tenant_subdomain="my-tenant") + await agw_client.list_mcp_tools() + + mock_send.assert_called_once_with("*", None) + + @pytest.mark.asyncio + async def test_no_audit_event_on_failure(self): + """list_mcp_tools does not send audit event when tool discovery fails.""" + mock_audit = MagicMock() + 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, + side_effect=RuntimeError("network error"), + ), + ): + agw_client = create_client( + tenant_subdomain="my-tenant", + audit_client=mock_audit, + tenant_id=_TENANT_UUID, + ) + with pytest.raises(AgentGatewaySDKError): + await agw_client.list_mcp_tools() + + mock_audit.send.assert_not_called() + + +# ============================================================ +# Test: call_mcp_tool audit logging +# ============================================================ + + +class TestCallMcpToolAuditLog: + """Tests that call_mcp_tool emits an audit event on success.""" + + @pytest.mark.asyncio + async def test_lob_flow_sends_audit_event(self, mock_tool): + """call_mcp_tool sends audit event with tool name after LoB invocation.""" + mock_audit = MagicMock() + with ( + patch( + "sap_cloud_sdk.agentgateway.agw_client.detect_customer_agent_credentials", + return_value=None, + ), + patch( + "sap_cloud_sdk.agentgateway.agw_client.fetch_user_auth", + new_callable=AsyncMock, + return_value=("user-token", "https://agw.example.com"), + ), + patch( + "sap_cloud_sdk.agentgateway.agw_client.call_mcp_tool_lob", + new_callable=AsyncMock, + return_value="result", + ), + ): + agw_client = create_client( + tenant_subdomain="my-tenant", + audit_client=mock_audit, + tenant_id=_TENANT_UUID, + ) + await agw_client.call_mcp_tool( + tool=mock_tool, + user_token="user-jwt", + user_id="user@example.com", + ) + + mock_audit.send.assert_called_once() + event = mock_audit.send.call_args[0][0] + assert event.object_id == mock_tool.name + assert event.common.tenant_id == _TENANT_UUID + assert event.common.user_initiator_id == "user@example.com" + + @pytest.mark.asyncio + async def test_customer_flow_sends_audit_event(self, mock_tool): + """call_mcp_tool sends audit event after customer flow invocation.""" + mock_audit = MagicMock() + 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.exchange_user_token", + return_value="exchanged-token", + ), + patch( + "sap_cloud_sdk.agentgateway.agw_client.call_mcp_tool_customer", + new_callable=AsyncMock, + return_value="result", + ), + ): + mock_creds = MagicMock() + mock_creds.gateway_url = "https://agw.customer.com" + mock_load.return_value = mock_creds + + agw_client = create_client( + audit_client=mock_audit, + tenant_id=_TENANT_UUID, + ) + await agw_client.call_mcp_tool( + tool=mock_tool, + user_token="user-jwt", + ) + + mock_audit.send.assert_called_once() + event = mock_audit.send.call_args[0][0] + assert event.object_id == mock_tool.name + + @pytest.mark.asyncio + async def test_no_audit_event_on_failure(self, mock_tool): + """call_mcp_tool does not send audit event when tool invocation fails.""" + mock_audit = MagicMock() + with ( + patch( + "sap_cloud_sdk.agentgateway.agw_client.detect_customer_agent_credentials", + return_value=None, + ), + patch( + "sap_cloud_sdk.agentgateway.agw_client.fetch_user_auth", + new_callable=AsyncMock, + return_value=("user-token", "https://agw.example.com"), + ), + patch( + "sap_cloud_sdk.agentgateway.agw_client.call_mcp_tool_lob", + new_callable=AsyncMock, + side_effect=RuntimeError("invocation error"), + ), + ): + agw_client = create_client( + tenant_subdomain="my-tenant", + audit_client=mock_audit, + tenant_id=_TENANT_UUID, + ) + with pytest.raises(AgentGatewaySDKError): + await agw_client.call_mcp_tool( + tool=mock_tool, + user_token="user-jwt", + ) + + mock_audit.send.assert_not_called() + + @pytest.mark.asyncio + async def test_audit_event_uses_tool_name_as_object_id(self, mock_tool): + """call_mcp_tool records tool.name as the audit event object_id.""" + mock_audit = MagicMock() + with ( + patch( + "sap_cloud_sdk.agentgateway.agw_client.detect_customer_agent_credentials", + return_value=None, + ), + patch( + "sap_cloud_sdk.agentgateway.agw_client.fetch_user_auth", + new_callable=AsyncMock, + return_value=("user-token", "https://agw.example.com"), + ), + patch( + "sap_cloud_sdk.agentgateway.agw_client.call_mcp_tool_lob", + new_callable=AsyncMock, + return_value="ok", + ), + ): + agw_client = create_client( + tenant_subdomain="my-tenant", + audit_client=mock_audit, + tenant_id=_TENANT_UUID, + ) + await agw_client.call_mcp_tool(tool=mock_tool, user_token="jwt") + + event = mock_audit.send.call_args[0][0] + assert event.object_id == "test-tool" From fa3c7704f45cc72745cf8c1d9f0726edcbbe894d Mon Sep 17 00:00:00 2001 From: Soares Date: Mon, 6 Jul 2026 12:59:50 -0300 Subject: [PATCH 02/10] create audit client on initialization time of the agw client --- src/sap_cloud_sdk/agentgateway/agw_client.py | 43 +++++++++++--------- 1 file changed, 24 insertions(+), 19 deletions(-) diff --git a/src/sap_cloud_sdk/agentgateway/agw_client.py b/src/sap_cloud_sdk/agentgateway/agw_client.py index ee88f1a6..aa6d8288 100644 --- a/src/sap_cloud_sdk/agentgateway/agw_client.py +++ b/src/sap_cloud_sdk/agentgateway/agw_client.py @@ -36,8 +36,9 @@ ) from sap_cloud_sdk.agentgateway._token_cache import _GatewayUrlCache, _TokenCache from sap_cloud_sdk.agentgateway.exceptions import AgentGatewaySDKError +import sap_cloud_sdk.core.auditlog_ng as auditlog_ng from sap_cloud_sdk.core.auditlog_ng import AuditClient -from sap_cloud_sdk.core.telemetry import Module, Operation, record_metrics +from sap_cloud_sdk.core.telemetry import Module, Operation, record_metrics, get_tenant_id logger = logging.getLogger(__name__) @@ -108,8 +109,6 @@ def __init__( self, tenant_subdomain: str | Callable[[], str] | None = None, config: ClientConfig | None = None, - audit_client: AuditClient | None = None, - tenant_id: str | None = None, ): """Initialize the Agent Gateway client. @@ -118,17 +117,12 @@ def __init__( Can be a string or a callable returning a string. Required for LoB agents, ignored for Customer agents. config: Client configuration. Uses defaults if not provided. - audit_client: Optional audit log client. When provided, implicit - audit events are sent for list_mcp_tools and call_mcp_tool. - tenant_id: BTP tenant UUID (e.g. ``"9e0d89c9-..."``) used in audit - events. Required when audit_client is set. """ self._tenant_subdomain = tenant_subdomain self._config = config or ClientConfig() self._token_cache = _TokenCache(self._config) self._gateway_url_cache = _GatewayUrlCache() - self._audit_client = audit_client - self._tenant_id = tenant_id + self._audit_client: AuditClient | None = self._create_audit_client() @staticmethod def _resolve_value( @@ -161,13 +155,32 @@ def _resolve_tenant_subdomain(self) -> str: "tenant_subdomain is required for LoB agent flow.", ) + def _create_audit_client(self) -> AuditClient | None: + """Create an audit client from the LoB destination. Returns None for customer agents or on failure.""" + tenant_subdomain = ( + self._tenant_subdomain() + if callable(self._tenant_subdomain) + else self._tenant_subdomain + ) + if not tenant_subdomain: + return None + try: + return auditlog_ng.create_client( + tenant=tenant_subdomain, + _telemetry_source=Module.AGENTGATEWAY, + ) + except Exception: + logger.debug("Failed to create audit client", exc_info=True) + return None + def _send_audit_event( self, object_id: str, user_id: str | None = None, ) -> None: """Send a DataAccess audit event. Errors are logged and suppressed.""" - if self._audit_client is None or self._tenant_id is None: + tenant_id = get_tenant_id() + if self._audit_client is None or not tenant_id: return try: from sap_cloud_sdk.core.auditlog_ng.gen.sap.auditlog.auditevent.v2 import ( @@ -176,7 +189,7 @@ def _send_audit_event( event = pb.DataAccess() event.common.timestamp.FromDatetime(datetime.now(timezone.utc)) - event.common.tenant_id = self._tenant_id + event.common.tenant_id = tenant_id if user_id: event.common.user_initiator_id = user_id event.channel_type = "MCP" @@ -595,8 +608,6 @@ def _unwrap_exception_group(exc: BaseException) -> BaseException: def create_client( tenant_subdomain: str | Callable[[], str] | None = None, config: ClientConfig | None = None, - audit_client: AuditClient | None = None, - tenant_id: str | None = None, ) -> AgentGatewayClient: """Create an Agent Gateway client for discovering and invoking MCP tools. @@ -608,10 +619,6 @@ def create_client( Can be a string or a callable returning a string. Required for LoB agents, ignored for Customer agents. config: Client configuration. Uses defaults if not provided. - audit_client: Optional audit log client. When provided, implicit - audit events are sent for list_mcp_tools and call_mcp_tool. - tenant_id: BTP tenant UUID used in audit events. Required when - audit_client is set. Returns: AgentGatewayClient instance. @@ -668,6 +675,4 @@ def create_client( return AgentGatewayClient( tenant_subdomain=tenant_subdomain, config=config, - audit_client=audit_client, - tenant_id=tenant_id, ) From bd60cd30e4f63f5ea94426cd00f8bb710b736141 Mon Sep 17 00:00:00 2001 From: Soares Date: Mon, 6 Jul 2026 14:57:42 -0300 Subject: [PATCH 03/10] update unit tests --- tests/agentgateway/unit/test_agw_client.py | 194 +++++++++++++-------- 1 file changed, 122 insertions(+), 72 deletions(-) diff --git a/tests/agentgateway/unit/test_agw_client.py b/tests/agentgateway/unit/test_agw_client.py index eaea6efc..b6822533 100644 --- a/tests/agentgateway/unit/test_agw_client.py +++ b/tests/agentgateway/unit/test_agw_client.py @@ -993,31 +993,43 @@ class TestSendAuditEvent: """Tests for _send_audit_event helper.""" def test_no_op_without_audit_client(self): - """_send_audit_event is a no-op when audit_client is not set.""" - agw_client = create_client(tenant_subdomain="my-tenant") + """_send_audit_event is a no-op when audit client creation fails (no tenant_subdomain).""" + agw_client = create_client() # Should not raise agw_client._send_audit_event("tool-name", "user@example.com") def test_no_op_without_tenant_id(self): - """_send_audit_event is a no-op when tenant_id is not set.""" + """_send_audit_event is a no-op when get_tenant_id returns empty string.""" mock_audit = MagicMock() - agw_client = create_client( - tenant_subdomain="my-tenant", - audit_client=mock_audit, - tenant_id=None, - ) - agw_client._send_audit_event("tool-name") + with ( + patch( + "sap_cloud_sdk.agentgateway.agw_client.auditlog_ng.create_client", + return_value=mock_audit, + ), + patch( + "sap_cloud_sdk.agentgateway.agw_client.get_tenant_id", + return_value="", + ), + ): + agw_client = create_client(tenant_subdomain="my-tenant") + agw_client._send_audit_event("tool-name") mock_audit.send.assert_not_called() def test_sends_data_access_event(self): """_send_audit_event builds and sends a DataAccess event.""" mock_audit = MagicMock() - agw_client = create_client( - tenant_subdomain="my-tenant", - audit_client=mock_audit, - tenant_id=_TENANT_UUID, - ) - agw_client._send_audit_event("my-tool", "user@example.com") + with ( + patch( + "sap_cloud_sdk.agentgateway.agw_client.auditlog_ng.create_client", + return_value=mock_audit, + ), + patch( + "sap_cloud_sdk.agentgateway.agw_client.get_tenant_id", + return_value=_TENANT_UUID, + ), + ): + agw_client = create_client(tenant_subdomain="my-tenant") + agw_client._send_audit_event("my-tool", "user@example.com") mock_audit.send.assert_called_once() event = mock_audit.send.call_args[0][0] assert event.common.tenant_id == _TENANT_UUID @@ -1030,12 +1042,18 @@ def test_sends_data_access_event(self): def test_sends_without_user_id(self): """_send_audit_event omits user_initiator_id when user_id is None.""" mock_audit = MagicMock() - agw_client = create_client( - tenant_subdomain="my-tenant", - audit_client=mock_audit, - tenant_id=_TENANT_UUID, - ) - agw_client._send_audit_event("my-tool") + with ( + patch( + "sap_cloud_sdk.agentgateway.agw_client.auditlog_ng.create_client", + return_value=mock_audit, + ), + patch( + "sap_cloud_sdk.agentgateway.agw_client.get_tenant_id", + return_value=_TENANT_UUID, + ), + ): + agw_client = create_client(tenant_subdomain="my-tenant") + agw_client._send_audit_event("my-tool") mock_audit.send.assert_called_once() event = mock_audit.send.call_args[0][0] assert event.common.user_initiator_id == "" @@ -1044,13 +1062,19 @@ def test_suppresses_send_errors(self): """_send_audit_event does not propagate exceptions from audit client.""" mock_audit = MagicMock() mock_audit.send.side_effect = RuntimeError("send failed") - agw_client = create_client( - tenant_subdomain="my-tenant", - audit_client=mock_audit, - tenant_id=_TENANT_UUID, - ) - # Should not raise - agw_client._send_audit_event("my-tool") + with ( + patch( + "sap_cloud_sdk.agentgateway.agw_client.auditlog_ng.create_client", + return_value=mock_audit, + ), + patch( + "sap_cloud_sdk.agentgateway.agw_client.get_tenant_id", + return_value=_TENANT_UUID, + ), + ): + agw_client = create_client(tenant_subdomain="my-tenant") + # Should not raise + agw_client._send_audit_event("my-tool") # ============================================================ @@ -1066,6 +1090,14 @@ async def test_lob_flow_sends_audit_event(self): """list_mcp_tools sends audit event after LoB tool discovery.""" mock_audit = MagicMock() with ( + patch( + "sap_cloud_sdk.agentgateway.agw_client.auditlog_ng.create_client", + return_value=mock_audit, + ), + patch( + "sap_cloud_sdk.agentgateway.agw_client.get_tenant_id", + return_value=_TENANT_UUID, + ), patch( "sap_cloud_sdk.agentgateway.agw_client.detect_customer_agent_credentials", return_value=None, @@ -1081,11 +1113,7 @@ async def test_lob_flow_sends_audit_event(self): return_value=[], ), ): - agw_client = create_client( - tenant_subdomain="my-tenant", - audit_client=mock_audit, - tenant_id=_TENANT_UUID, - ) + agw_client = create_client(tenant_subdomain="my-tenant") await agw_client.list_mcp_tools(user_id="user@example.com") mock_audit.send.assert_called_once() @@ -1095,10 +1123,18 @@ async def test_lob_flow_sends_audit_event(self): assert event.common.user_initiator_id == "user@example.com" @pytest.mark.asyncio - async def test_customer_flow_sends_audit_event(self): - """list_mcp_tools sends audit event after customer tool discovery.""" + async def test_customer_flow_no_audit_event(self): + """list_mcp_tools does not send audit event for customer agents (no tenant_subdomain).""" mock_audit = MagicMock() with ( + patch( + "sap_cloud_sdk.agentgateway.agw_client.auditlog_ng.create_client", + return_value=mock_audit, + ), + patch( + "sap_cloud_sdk.agentgateway.agw_client.get_tenant_id", + return_value=_TENANT_UUID, + ), patch( "sap_cloud_sdk.agentgateway.agw_client.detect_customer_agent_credentials", return_value="/path/to/credentials", @@ -1120,19 +1156,14 @@ async def test_customer_flow_sends_audit_event(self): mock_creds.gateway_url = "https://agw.customer.com" mock_load.return_value = mock_creds - agw_client = create_client( - audit_client=mock_audit, - tenant_id=_TENANT_UUID, - ) + agw_client = create_client() await agw_client.list_mcp_tools() - mock_audit.send.assert_called_once() - event = mock_audit.send.call_args[0][0] - assert event.object_id == "*" + mock_audit.send.assert_not_called() @pytest.mark.asyncio async def test_no_audit_event_without_audit_client(self): - """list_mcp_tools does not attempt audit logging when no audit_client.""" + """list_mcp_tools still calls _send_audit_event (which silently skips when no audit client).""" with ( patch( "sap_cloud_sdk.agentgateway.agw_client.detect_customer_agent_credentials", @@ -1162,6 +1193,14 @@ async def test_no_audit_event_on_failure(self): """list_mcp_tools does not send audit event when tool discovery fails.""" mock_audit = MagicMock() with ( + patch( + "sap_cloud_sdk.agentgateway.agw_client.auditlog_ng.create_client", + return_value=mock_audit, + ), + patch( + "sap_cloud_sdk.agentgateway.agw_client.get_tenant_id", + return_value=_TENANT_UUID, + ), patch( "sap_cloud_sdk.agentgateway.agw_client.detect_customer_agent_credentials", return_value=None, @@ -1177,11 +1216,7 @@ async def test_no_audit_event_on_failure(self): side_effect=RuntimeError("network error"), ), ): - agw_client = create_client( - tenant_subdomain="my-tenant", - audit_client=mock_audit, - tenant_id=_TENANT_UUID, - ) + agw_client = create_client(tenant_subdomain="my-tenant") with pytest.raises(AgentGatewaySDKError): await agw_client.list_mcp_tools() @@ -1201,6 +1236,14 @@ async def test_lob_flow_sends_audit_event(self, mock_tool): """call_mcp_tool sends audit event with tool name after LoB invocation.""" mock_audit = MagicMock() with ( + patch( + "sap_cloud_sdk.agentgateway.agw_client.auditlog_ng.create_client", + return_value=mock_audit, + ), + patch( + "sap_cloud_sdk.agentgateway.agw_client.get_tenant_id", + return_value=_TENANT_UUID, + ), patch( "sap_cloud_sdk.agentgateway.agw_client.detect_customer_agent_credentials", return_value=None, @@ -1216,11 +1259,7 @@ async def test_lob_flow_sends_audit_event(self, mock_tool): return_value="result", ), ): - agw_client = create_client( - tenant_subdomain="my-tenant", - audit_client=mock_audit, - tenant_id=_TENANT_UUID, - ) + agw_client = create_client(tenant_subdomain="my-tenant") await agw_client.call_mcp_tool( tool=mock_tool, user_token="user-jwt", @@ -1234,10 +1273,18 @@ async def test_lob_flow_sends_audit_event(self, mock_tool): assert event.common.user_initiator_id == "user@example.com" @pytest.mark.asyncio - async def test_customer_flow_sends_audit_event(self, mock_tool): - """call_mcp_tool sends audit event after customer flow invocation.""" + async def test_customer_flow_no_audit_event(self, mock_tool): + """call_mcp_tool does not send audit event for customer agents (no tenant_subdomain).""" mock_audit = MagicMock() with ( + patch( + "sap_cloud_sdk.agentgateway.agw_client.auditlog_ng.create_client", + return_value=mock_audit, + ), + patch( + "sap_cloud_sdk.agentgateway.agw_client.get_tenant_id", + return_value=_TENANT_UUID, + ), patch( "sap_cloud_sdk.agentgateway.agw_client.detect_customer_agent_credentials", return_value="/path/to/credentials", @@ -1259,24 +1306,27 @@ async def test_customer_flow_sends_audit_event(self, mock_tool): mock_creds.gateway_url = "https://agw.customer.com" mock_load.return_value = mock_creds - agw_client = create_client( - audit_client=mock_audit, - tenant_id=_TENANT_UUID, - ) + agw_client = create_client() await agw_client.call_mcp_tool( tool=mock_tool, user_token="user-jwt", ) - mock_audit.send.assert_called_once() - event = mock_audit.send.call_args[0][0] - assert event.object_id == mock_tool.name + mock_audit.send.assert_not_called() @pytest.mark.asyncio async def test_no_audit_event_on_failure(self, mock_tool): """call_mcp_tool does not send audit event when tool invocation fails.""" mock_audit = MagicMock() with ( + patch( + "sap_cloud_sdk.agentgateway.agw_client.auditlog_ng.create_client", + return_value=mock_audit, + ), + patch( + "sap_cloud_sdk.agentgateway.agw_client.get_tenant_id", + return_value=_TENANT_UUID, + ), patch( "sap_cloud_sdk.agentgateway.agw_client.detect_customer_agent_credentials", return_value=None, @@ -1292,11 +1342,7 @@ async def test_no_audit_event_on_failure(self, mock_tool): side_effect=RuntimeError("invocation error"), ), ): - agw_client = create_client( - tenant_subdomain="my-tenant", - audit_client=mock_audit, - tenant_id=_TENANT_UUID, - ) + agw_client = create_client(tenant_subdomain="my-tenant") with pytest.raises(AgentGatewaySDKError): await agw_client.call_mcp_tool( tool=mock_tool, @@ -1310,6 +1356,14 @@ async def test_audit_event_uses_tool_name_as_object_id(self, mock_tool): """call_mcp_tool records tool.name as the audit event object_id.""" mock_audit = MagicMock() with ( + patch( + "sap_cloud_sdk.agentgateway.agw_client.auditlog_ng.create_client", + return_value=mock_audit, + ), + patch( + "sap_cloud_sdk.agentgateway.agw_client.get_tenant_id", + return_value=_TENANT_UUID, + ), patch( "sap_cloud_sdk.agentgateway.agw_client.detect_customer_agent_credentials", return_value=None, @@ -1325,11 +1379,7 @@ async def test_audit_event_uses_tool_name_as_object_id(self, mock_tool): return_value="ok", ), ): - agw_client = create_client( - tenant_subdomain="my-tenant", - audit_client=mock_audit, - tenant_id=_TENANT_UUID, - ) + agw_client = create_client(tenant_subdomain="my-tenant") await agw_client.call_mcp_tool(tool=mock_tool, user_token="jwt") event = mock_audit.send.call_args[0][0] From f99f6b454b8aeaedf9d480778bc3541710c1a26d Mon Sep 17 00:00:00 2001 From: Soares Date: Mon, 6 Jul 2026 15:46:42 -0300 Subject: [PATCH 04/10] version bump --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 3bbf3426..5f3c1189 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sap-cloud-sdk" -version = "0.33.0" +version = "0.34.0" description = "SAP Cloud SDK for Python" readme = "README.md" license = "Apache-2.0" From 5515beaf8d1cb37aa434b55a675503989f972b9a Mon Sep 17 00:00:00 2001 From: Soares Date: Mon, 6 Jul 2026 15:59:16 -0300 Subject: [PATCH 05/10] resolve pre-commit issue --- src/sap_cloud_sdk/agentgateway/agw_client.py | 15 +++++++++++---- tests/agentgateway/unit/test_agw_client.py | 6 +++--- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/sap_cloud_sdk/agentgateway/agw_client.py b/src/sap_cloud_sdk/agentgateway/agw_client.py index 9ef47e23..f18b862c 100644 --- a/src/sap_cloud_sdk/agentgateway/agw_client.py +++ b/src/sap_cloud_sdk/agentgateway/agw_client.py @@ -39,7 +39,12 @@ from sap_cloud_sdk.agentgateway.exceptions import AgentGatewaySDKError import sap_cloud_sdk.core.auditlog_ng as auditlog_ng from sap_cloud_sdk.core.auditlog_ng import AuditClient -from sap_cloud_sdk.core.telemetry import Module, Operation, record_metrics, get_tenant_id +from sap_cloud_sdk.core.telemetry import ( + Module, + Operation, + record_metrics, + get_tenant_id, +) logger = logging.getLogger(__name__) @@ -159,9 +164,11 @@ def _resolve_tenant_subdomain(self) -> str: def _create_audit_client(self) -> AuditClient | None: """Create an audit client from the LoB destination. Returns None for customer agents or on failure.""" tenant_subdomain = ( - self._tenant_subdomain() - if callable(self._tenant_subdomain) - else self._tenant_subdomain + self._tenant_subdomain + if isinstance(self._tenant_subdomain, str) + else self._tenant_subdomain() + if self._tenant_subdomain is not None + else None ) if not tenant_subdomain: return None diff --git a/tests/agentgateway/unit/test_agw_client.py b/tests/agentgateway/unit/test_agw_client.py index 9cb1f16a..e4e0cf79 100644 --- a/tests/agentgateway/unit/test_agw_client.py +++ b/tests/agentgateway/unit/test_agw_client.py @@ -983,8 +983,8 @@ async def test_wraps_unexpected_error(self): with pytest.raises(AgentGatewaySDKError, match="Agent card discovery failed"): await agw_client.list_agent_cards() - -# ============================================================ + +# ============================================================ # Test: get_ias_client_id # ============================================================ @@ -1051,7 +1051,7 @@ def test_lob_raises_on_exception(self, _mock_detect): with pytest.raises(AgentGatewaySDKError, match="Could not resolve IAS client ID"): create_client(tenant_subdomain="my-tenant").get_ias_client_id() - + # ============================================================ # Test: _send_audit_event # ============================================================ From 8d5b08e10cd23429e3cc0eb81430a30955555834 Mon Sep 17 00:00:00 2001 From: Soares Date: Tue, 7 Jul 2026 15:15:52 -0300 Subject: [PATCH 06/10] move import to the top --- src/sap_cloud_sdk/agentgateway/agw_client.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/sap_cloud_sdk/agentgateway/agw_client.py b/src/sap_cloud_sdk/agentgateway/agw_client.py index f18b862c..40ed60ab 100644 --- a/src/sap_cloud_sdk/agentgateway/agw_client.py +++ b/src/sap_cloud_sdk/agentgateway/agw_client.py @@ -45,6 +45,7 @@ record_metrics, get_tenant_id, ) +from sap_cloud_sdk.core.auditlog_ng.gen.sap.auditlog.auditevent.v2 import auditevent_pb2 as pb logger = logging.getLogger(__name__) @@ -191,10 +192,6 @@ def _send_audit_event( if self._audit_client is None or not tenant_id: return try: - from sap_cloud_sdk.core.auditlog_ng.gen.sap.auditlog.auditevent.v2 import ( - auditevent_pb2 as pb, - ) - event = pb.DataAccess() event.common.timestamp.FromDatetime(datetime.now(timezone.utc)) event.common.tenant_id = tenant_id From 02dc447d8f03e0a59a36adecd64cc1a9c7c5a7fc Mon Sep 17 00:00:00 2001 From: Soares Date: Wed, 8 Jul 2026 09:10:10 -0300 Subject: [PATCH 07/10] ruff format --- src/sap_cloud_sdk/agentgateway/agw_client.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/sap_cloud_sdk/agentgateway/agw_client.py b/src/sap_cloud_sdk/agentgateway/agw_client.py index 40ed60ab..4889a8b8 100644 --- a/src/sap_cloud_sdk/agentgateway/agw_client.py +++ b/src/sap_cloud_sdk/agentgateway/agw_client.py @@ -45,7 +45,9 @@ record_metrics, get_tenant_id, ) -from sap_cloud_sdk.core.auditlog_ng.gen.sap.auditlog.auditevent.v2 import auditevent_pb2 as pb +from sap_cloud_sdk.core.auditlog_ng.gen.sap.auditlog.auditevent.v2 import ( + auditevent_pb2 as pb, +) logger = logging.getLogger(__name__) From 2c7eb754ced94d26b8c89ed8842b38d66d9cfea8 Mon Sep 17 00:00:00 2001 From: Soares Date: Fri, 10 Jul 2026 11:06:35 -0300 Subject: [PATCH 08/10] move the auditlog helpers in agw to a separate .py file and update unit tests --- .../agentgateway/_auditlog_helper.py | 57 +++++++++ src/sap_cloud_sdk/agentgateway/agw_client.py | 62 ++-------- tests/agentgateway/unit/test_agw_client.py | 110 +++++++----------- 3 files changed, 106 insertions(+), 123 deletions(-) create mode 100644 src/sap_cloud_sdk/agentgateway/_auditlog_helper.py diff --git a/src/sap_cloud_sdk/agentgateway/_auditlog_helper.py b/src/sap_cloud_sdk/agentgateway/_auditlog_helper.py new file mode 100644 index 00000000..dc4cee6a --- /dev/null +++ b/src/sap_cloud_sdk/agentgateway/_auditlog_helper.py @@ -0,0 +1,57 @@ +"""Audit log helpers for the Agent Gateway client.""" + +import logging +from datetime import datetime, timezone +from typing import Callable + +import sap_cloud_sdk.core.auditlog_ng as auditlog_ng +from sap_cloud_sdk.core.auditlog_ng import AuditClient +from sap_cloud_sdk.core.auditlog_ng.gen.sap.auditlog.auditevent.v2 import ( + auditevent_pb2 as pb, +) +from sap_cloud_sdk.core.telemetry import Module, get_tenant_id + +logger = logging.getLogger(__name__) + + +def create_audit_client( + tenant_subdomain: str | Callable[[], str] | None, + module: Module, +) -> AuditClient | None: + """Create an audit client from a LoB destination. Returns None on failure.""" + resolved = tenant_subdomain() if callable(tenant_subdomain) else tenant_subdomain + if not resolved: + return None + tenant_subdomain = resolved + try: + return auditlog_ng.create_client( + tenant=tenant_subdomain, + _telemetry_source=module, + ) + except Exception: + logger.debug("Failed to create audit client", exc_info=True) + return None + + +def send_audit_event( + audit_client: AuditClient | None, + object_id: str, + user_id: str | None = None, +) -> None: + """Send a DataAccess audit event. Errors are logged and suppressed.""" + tenant_id = get_tenant_id() + if audit_client is None or not tenant_id: + return + try: + event = pb.DataAccess() + event.common.timestamp.FromDatetime(datetime.now(timezone.utc)) + event.common.tenant_id = tenant_id + if user_id: + event.common.user_initiator_id = user_id + event.channel_type = "MCP" + event.channel_id = "agent-gateway" + event.object_type = "mcp-tool" + event.object_id = object_id + audit_client.send(event) + except Exception: + logger.debug("Failed to send audit event", exc_info=True) diff --git a/src/sap_cloud_sdk/agentgateway/agw_client.py b/src/sap_cloud_sdk/agentgateway/agw_client.py index 4889a8b8..6831761b 100644 --- a/src/sap_cloud_sdk/agentgateway/agw_client.py +++ b/src/sap_cloud_sdk/agentgateway/agw_client.py @@ -9,7 +9,6 @@ import asyncio import logging -from datetime import datetime, timezone from typing import Callable from sap_cloud_sdk.agentgateway.config import ClientConfig @@ -35,18 +34,14 @@ AuthResult, MCPTool, ) +from sap_cloud_sdk.agentgateway._auditlog_helper import create_audit_client, send_audit_event from sap_cloud_sdk.agentgateway._token_cache import _GatewayUrlCache, _TokenCache from sap_cloud_sdk.agentgateway.exceptions import AgentGatewaySDKError -import sap_cloud_sdk.core.auditlog_ng as auditlog_ng from sap_cloud_sdk.core.auditlog_ng import AuditClient from sap_cloud_sdk.core.telemetry import ( Module, Operation, record_metrics, - get_tenant_id, -) -from sap_cloud_sdk.core.auditlog_ng.gen.sap.auditlog.auditevent.v2 import ( - auditevent_pb2 as pb, ) logger = logging.getLogger(__name__) @@ -131,7 +126,9 @@ def __init__( self._config = config or ClientConfig() self._token_cache = _TokenCache(self._config) self._gateway_url_cache = _GatewayUrlCache() - self._audit_client: AuditClient | None = self._create_audit_client() + self._audit_client: AuditClient | None = create_audit_client( + tenant_subdomain, Module.AGENTGATEWAY + ) @staticmethod def _resolve_value( @@ -164,49 +161,6 @@ def _resolve_tenant_subdomain(self) -> str: "tenant_subdomain is required for LoB agent flow.", ) - def _create_audit_client(self) -> AuditClient | None: - """Create an audit client from the LoB destination. Returns None for customer agents or on failure.""" - tenant_subdomain = ( - self._tenant_subdomain - if isinstance(self._tenant_subdomain, str) - else self._tenant_subdomain() - if self._tenant_subdomain is not None - else None - ) - if not tenant_subdomain: - return None - try: - return auditlog_ng.create_client( - tenant=tenant_subdomain, - _telemetry_source=Module.AGENTGATEWAY, - ) - except Exception: - logger.debug("Failed to create audit client", exc_info=True) - return None - - def _send_audit_event( - self, - object_id: str, - user_id: str | None = None, - ) -> None: - """Send a DataAccess audit event. Errors are logged and suppressed.""" - tenant_id = get_tenant_id() - if self._audit_client is None or not tenant_id: - return - try: - event = pb.DataAccess() - event.common.timestamp.FromDatetime(datetime.now(timezone.utc)) - event.common.tenant_id = tenant_id - if user_id: - event.common.user_initiator_id = user_id - event.channel_type = "MCP" - event.channel_id = "agent-gateway" - event.object_type = "mcp-tool" - event.object_id = object_id - self._audit_client.send(event) - except Exception: - logger.debug("Failed to send audit event", exc_info=True) - @record_metrics(Module.AGENTGATEWAY, Operation.AGENTGATEWAY_GET_SYSTEM_AUTH) async def get_system_auth(self, app_tid: str | None = None) -> AuthResult: """Get system-scoped authentication (client_credentials flow). @@ -445,7 +399,7 @@ async def list_mcp_tools( tools = await get_mcp_tools_customer( credentials, auth.access_token, self._config.timeout ) - self._send_audit_event("*", user_id) + send_audit_event(self._audit_client, "*", user_id) return tools # LoB flow - requires tenant_subdomain @@ -460,7 +414,7 @@ async def list_mcp_tools( tools = await get_mcp_tools_lob( tenant, auth.access_token, self._config.timeout ) - self._send_audit_event("*", user_id) + send_audit_event(self._audit_client, "*", user_id) return tools except AgentGatewaySDKError: @@ -614,7 +568,7 @@ async def call_mcp_tool( result = await call_mcp_tool_customer( tool, auth.access_token, self._config.timeout, **kwargs ) - self._send_audit_event(tool.name, user_id) + send_audit_event(self._audit_client, tool.name, user_id) return result # LoB flow - requires user_token and tenant_subdomain @@ -625,7 +579,7 @@ async def call_mcp_tool( result = await call_mcp_tool_lob( tool, auth.access_token, self._config.timeout, **kwargs ) - self._send_audit_event(tool.name, user_id) + send_audit_event(self._audit_client, tool.name, user_id) return result except AgentGatewaySDKError: diff --git a/tests/agentgateway/unit/test_agw_client.py b/tests/agentgateway/unit/test_agw_client.py index e4e0cf79..21f23ded 100644 --- a/tests/agentgateway/unit/test_agw_client.py +++ b/tests/agentgateway/unit/test_agw_client.py @@ -14,6 +14,7 @@ MCPTool, AgentGatewaySDKError, ) +from sap_cloud_sdk.agentgateway._auditlog_helper import send_audit_event _TENANT_UUID = "9e0d89c9-17cd-439d-8a8b-9c44d3d272f0" @@ -1058,46 +1059,31 @@ def test_lob_raises_on_exception(self, _mock_detect): class TestSendAuditEvent: - """Tests for _send_audit_event helper.""" + """Tests for send_audit_event helper.""" def test_no_op_without_audit_client(self): - """_send_audit_event is a no-op when audit client creation fails (no tenant_subdomain).""" - agw_client = create_client() + """send_audit_event is a no-op when audit_client is None.""" # Should not raise - agw_client._send_audit_event("tool-name", "user@example.com") + send_audit_event(None, "tool-name", "user@example.com") def test_no_op_without_tenant_id(self): - """_send_audit_event is a no-op when get_tenant_id returns empty string.""" + """send_audit_event is a no-op when get_tenant_id returns empty string.""" mock_audit = MagicMock() - with ( - patch( - "sap_cloud_sdk.agentgateway.agw_client.auditlog_ng.create_client", - return_value=mock_audit, - ), - patch( - "sap_cloud_sdk.agentgateway.agw_client.get_tenant_id", - return_value="", - ), + with patch( + "sap_cloud_sdk.agentgateway._auditlog_helper.get_tenant_id", + return_value="", ): - agw_client = create_client(tenant_subdomain="my-tenant") - agw_client._send_audit_event("tool-name") + send_audit_event(mock_audit, "tool-name") mock_audit.send.assert_not_called() def test_sends_data_access_event(self): - """_send_audit_event builds and sends a DataAccess event.""" + """send_audit_event builds and sends a DataAccess event.""" mock_audit = MagicMock() - with ( - patch( - "sap_cloud_sdk.agentgateway.agw_client.auditlog_ng.create_client", - return_value=mock_audit, - ), - patch( - "sap_cloud_sdk.agentgateway.agw_client.get_tenant_id", - return_value=_TENANT_UUID, - ), + with patch( + "sap_cloud_sdk.agentgateway._auditlog_helper.get_tenant_id", + return_value=_TENANT_UUID, ): - agw_client = create_client(tenant_subdomain="my-tenant") - agw_client._send_audit_event("my-tool", "user@example.com") + send_audit_event(mock_audit, "my-tool", "user@example.com") mock_audit.send.assert_called_once() event = mock_audit.send.call_args[0][0] assert event.common.tenant_id == _TENANT_UUID @@ -1108,41 +1094,27 @@ def test_sends_data_access_event(self): assert event.object_id == "my-tool" def test_sends_without_user_id(self): - """_send_audit_event omits user_initiator_id when user_id is None.""" + """send_audit_event omits user_initiator_id when user_id is None.""" mock_audit = MagicMock() - with ( - patch( - "sap_cloud_sdk.agentgateway.agw_client.auditlog_ng.create_client", - return_value=mock_audit, - ), - patch( - "sap_cloud_sdk.agentgateway.agw_client.get_tenant_id", - return_value=_TENANT_UUID, - ), + with patch( + "sap_cloud_sdk.agentgateway._auditlog_helper.get_tenant_id", + return_value=_TENANT_UUID, ): - agw_client = create_client(tenant_subdomain="my-tenant") - agw_client._send_audit_event("my-tool") + send_audit_event(mock_audit, "my-tool") mock_audit.send.assert_called_once() event = mock_audit.send.call_args[0][0] assert event.common.user_initiator_id == "" def test_suppresses_send_errors(self): - """_send_audit_event does not propagate exceptions from audit client.""" + """send_audit_event does not propagate exceptions from audit client.""" mock_audit = MagicMock() mock_audit.send.side_effect = RuntimeError("send failed") - with ( - patch( - "sap_cloud_sdk.agentgateway.agw_client.auditlog_ng.create_client", - return_value=mock_audit, - ), - patch( - "sap_cloud_sdk.agentgateway.agw_client.get_tenant_id", - return_value=_TENANT_UUID, - ), + with patch( + "sap_cloud_sdk.agentgateway._auditlog_helper.get_tenant_id", + return_value=_TENANT_UUID, ): - agw_client = create_client(tenant_subdomain="my-tenant") # Should not raise - agw_client._send_audit_event("my-tool") + send_audit_event(mock_audit, "my-tool") # ============================================================ @@ -1159,11 +1131,11 @@ async def test_lob_flow_sends_audit_event(self): mock_audit = MagicMock() with ( patch( - "sap_cloud_sdk.agentgateway.agw_client.auditlog_ng.create_client", + "sap_cloud_sdk.agentgateway._auditlog_helper.auditlog_ng.create_client", return_value=mock_audit, ), patch( - "sap_cloud_sdk.agentgateway.agw_client.get_tenant_id", + "sap_cloud_sdk.agentgateway._auditlog_helper.get_tenant_id", return_value=_TENANT_UUID, ), patch( @@ -1196,11 +1168,11 @@ async def test_customer_flow_no_audit_event(self): mock_audit = MagicMock() with ( patch( - "sap_cloud_sdk.agentgateway.agw_client.auditlog_ng.create_client", + "sap_cloud_sdk.agentgateway._auditlog_helper.auditlog_ng.create_client", return_value=mock_audit, ), patch( - "sap_cloud_sdk.agentgateway.agw_client.get_tenant_id", + "sap_cloud_sdk.agentgateway._auditlog_helper.get_tenant_id", return_value=_TENANT_UUID, ), patch( @@ -1231,7 +1203,7 @@ async def test_customer_flow_no_audit_event(self): @pytest.mark.asyncio async def test_no_audit_event_without_audit_client(self): - """list_mcp_tools still calls _send_audit_event (which silently skips when no audit client).""" + """list_mcp_tools still calls send_audit_event (which silently skips when no audit client).""" with ( patch( "sap_cloud_sdk.agentgateway.agw_client.detect_customer_agent_credentials", @@ -1248,13 +1220,13 @@ async def test_no_audit_event_without_audit_client(self): return_value=[], ), patch( - "sap_cloud_sdk.agentgateway.agw_client.AgentGatewayClient._send_audit_event" + "sap_cloud_sdk.agentgateway.agw_client.send_audit_event" ) as mock_send, ): agw_client = create_client(tenant_subdomain="my-tenant") await agw_client.list_mcp_tools() - mock_send.assert_called_once_with("*", None) + mock_send.assert_called_once_with(agw_client._audit_client, "*", None) @pytest.mark.asyncio async def test_no_audit_event_on_failure(self): @@ -1262,11 +1234,11 @@ async def test_no_audit_event_on_failure(self): mock_audit = MagicMock() with ( patch( - "sap_cloud_sdk.agentgateway.agw_client.auditlog_ng.create_client", + "sap_cloud_sdk.agentgateway._auditlog_helper.auditlog_ng.create_client", return_value=mock_audit, ), patch( - "sap_cloud_sdk.agentgateway.agw_client.get_tenant_id", + "sap_cloud_sdk.agentgateway._auditlog_helper.get_tenant_id", return_value=_TENANT_UUID, ), patch( @@ -1305,11 +1277,11 @@ async def test_lob_flow_sends_audit_event(self, mock_tool): mock_audit = MagicMock() with ( patch( - "sap_cloud_sdk.agentgateway.agw_client.auditlog_ng.create_client", + "sap_cloud_sdk.agentgateway._auditlog_helper.auditlog_ng.create_client", return_value=mock_audit, ), patch( - "sap_cloud_sdk.agentgateway.agw_client.get_tenant_id", + "sap_cloud_sdk.agentgateway._auditlog_helper.get_tenant_id", return_value=_TENANT_UUID, ), patch( @@ -1346,11 +1318,11 @@ async def test_customer_flow_no_audit_event(self, mock_tool): mock_audit = MagicMock() with ( patch( - "sap_cloud_sdk.agentgateway.agw_client.auditlog_ng.create_client", + "sap_cloud_sdk.agentgateway._auditlog_helper.auditlog_ng.create_client", return_value=mock_audit, ), patch( - "sap_cloud_sdk.agentgateway.agw_client.get_tenant_id", + "sap_cloud_sdk.agentgateway._auditlog_helper.get_tenant_id", return_value=_TENANT_UUID, ), patch( @@ -1388,11 +1360,11 @@ async def test_no_audit_event_on_failure(self, mock_tool): mock_audit = MagicMock() with ( patch( - "sap_cloud_sdk.agentgateway.agw_client.auditlog_ng.create_client", + "sap_cloud_sdk.agentgateway._auditlog_helper.auditlog_ng.create_client", return_value=mock_audit, ), patch( - "sap_cloud_sdk.agentgateway.agw_client.get_tenant_id", + "sap_cloud_sdk.agentgateway._auditlog_helper.get_tenant_id", return_value=_TENANT_UUID, ), patch( @@ -1425,11 +1397,11 @@ async def test_audit_event_uses_tool_name_as_object_id(self, mock_tool): mock_audit = MagicMock() with ( patch( - "sap_cloud_sdk.agentgateway.agw_client.auditlog_ng.create_client", + "sap_cloud_sdk.agentgateway._auditlog_helper.auditlog_ng.create_client", return_value=mock_audit, ), patch( - "sap_cloud_sdk.agentgateway.agw_client.get_tenant_id", + "sap_cloud_sdk.agentgateway._auditlog_helper.get_tenant_id", return_value=_TENANT_UUID, ), patch( From f7d74b81311e0e3d740eefbd19ba781ed55e0e08 Mon Sep 17 00:00:00 2001 From: Soares Date: Fri, 10 Jul 2026 11:41:27 -0300 Subject: [PATCH 09/10] created AuditLogMode config to control audit logging failure behavior --- .../agentgateway/_auditlog_helper.py | 20 ++++++++--- src/sap_cloud_sdk/agentgateway/agw_client.py | 6 ++-- src/sap_cloud_sdk/agentgateway/config.py | 19 +++++++++++ tests/agentgateway/unit/test_agw_client.py | 33 ++++++++++++++++--- 4 files changed, 65 insertions(+), 13 deletions(-) diff --git a/src/sap_cloud_sdk/agentgateway/_auditlog_helper.py b/src/sap_cloud_sdk/agentgateway/_auditlog_helper.py index dc4cee6a..462f9fb2 100644 --- a/src/sap_cloud_sdk/agentgateway/_auditlog_helper.py +++ b/src/sap_cloud_sdk/agentgateway/_auditlog_helper.py @@ -5,6 +5,7 @@ from typing import Callable import sap_cloud_sdk.core.auditlog_ng as auditlog_ng +from sap_cloud_sdk.agentgateway.config import AuditLogMode from sap_cloud_sdk.core.auditlog_ng import AuditClient from sap_cloud_sdk.core.auditlog_ng.gen.sap.auditlog.auditevent.v2 import ( auditevent_pb2 as pb, @@ -17,19 +18,23 @@ def create_audit_client( tenant_subdomain: str | Callable[[], str] | None, module: Module, + mode: AuditLogMode = AuditLogMode.BEST_EFFORT, ) -> AuditClient | None: """Create an audit client from a LoB destination. Returns None on failure.""" + if mode is AuditLogMode.DISABLED: + return None resolved = tenant_subdomain() if callable(tenant_subdomain) else tenant_subdomain if not resolved: return None - tenant_subdomain = resolved try: return auditlog_ng.create_client( - tenant=tenant_subdomain, + tenant=resolved, _telemetry_source=module, ) except Exception: - logger.debug("Failed to create audit client", exc_info=True) + if mode is AuditLogMode.STRICT: + raise + logger.warning("Failed to create audit client — audit events will not be recorded", exc_info=True) return None @@ -37,8 +42,11 @@ def send_audit_event( audit_client: AuditClient | None, object_id: str, user_id: str | None = None, + mode: AuditLogMode = AuditLogMode.BEST_EFFORT, ) -> None: - """Send a DataAccess audit event. Errors are logged and suppressed.""" + """Send a DataAccess audit event.""" + if mode is AuditLogMode.DISABLED: + return tenant_id = get_tenant_id() if audit_client is None or not tenant_id: return @@ -54,4 +62,6 @@ def send_audit_event( event.object_id = object_id audit_client.send(event) except Exception: - logger.debug("Failed to send audit event", exc_info=True) + if mode is AuditLogMode.STRICT: + raise + logger.warning("Failed to send audit event", exc_info=True) diff --git a/src/sap_cloud_sdk/agentgateway/agw_client.py b/src/sap_cloud_sdk/agentgateway/agw_client.py index 6831761b..31c2c829 100644 --- a/src/sap_cloud_sdk/agentgateway/agw_client.py +++ b/src/sap_cloud_sdk/agentgateway/agw_client.py @@ -127,7 +127,7 @@ def __init__( self._token_cache = _TokenCache(self._config) self._gateway_url_cache = _GatewayUrlCache() self._audit_client: AuditClient | None = create_audit_client( - tenant_subdomain, Module.AGENTGATEWAY + tenant_subdomain, Module.AGENTGATEWAY, self._config.audit_log_mode ) @staticmethod @@ -399,7 +399,7 @@ async def list_mcp_tools( tools = await get_mcp_tools_customer( credentials, auth.access_token, self._config.timeout ) - send_audit_event(self._audit_client, "*", user_id) + send_audit_event(self._audit_client, "*", user_id, self._config.audit_log_mode) return tools # LoB flow - requires tenant_subdomain @@ -568,7 +568,7 @@ async def call_mcp_tool( result = await call_mcp_tool_customer( tool, auth.access_token, self._config.timeout, **kwargs ) - send_audit_event(self._audit_client, tool.name, user_id) + send_audit_event(self._audit_client, tool.name, user_id, self._config.audit_log_mode) return result # LoB flow - requires user_token and tenant_subdomain diff --git a/src/sap_cloud_sdk/agentgateway/config.py b/src/sap_cloud_sdk/agentgateway/config.py index 17495dbd..3184e55c 100644 --- a/src/sap_cloud_sdk/agentgateway/config.py +++ b/src/sap_cloud_sdk/agentgateway/config.py @@ -1,6 +1,7 @@ """Configuration for Agent Gateway client.""" from dataclasses import dataclass +from enum import Enum DEFAULT_TIMEOUT_SECONDS = 60.0 DEFAULT_FALLBACK_TOKEN_TTL_SECONDS = 300.0 @@ -9,6 +10,21 @@ DEFAULT_MAX_USER_TOKEN_CACHE_SIZE = 256 +class AuditLogMode(Enum): + """Controls how audit logging failures are handled. + + Attributes: + DISABLED: Audit logging is skipped entirely. + BEST_EFFORT: Failures are logged at WARNING level but never raised. + This is the default. + STRICT: Failures raise an exception, blocking the operation. + """ + + DISABLED = "disabled" + BEST_EFFORT = "best_effort" + STRICT = "strict" + + @dataclass class ClientConfig: """Configuration options for the Agent Gateway client. @@ -22,6 +38,8 @@ class ClientConfig: token expiries before a cached token is considered stale. max_system_token_cache_size: Maximum number of cached system tokens. max_user_token_cache_size: Maximum number of cached user tokens. + audit_log_mode: Controls how audit logging failures are handled. + Defaults to BEST_EFFORT. """ timeout: float = DEFAULT_TIMEOUT_SECONDS @@ -29,6 +47,7 @@ class ClientConfig: token_expiry_buffer_seconds: float = DEFAULT_TOKEN_EXPIRY_BUFFER_SECONDS max_system_token_cache_size: int = DEFAULT_MAX_SYSTEM_TOKEN_CACHE_SIZE max_user_token_cache_size: int = DEFAULT_MAX_USER_TOKEN_CACHE_SIZE + audit_log_mode: AuditLogMode = AuditLogMode.BEST_EFFORT def __post_init__(self) -> None: if self.token_expiry_buffer_seconds >= self.fallback_token_ttl_seconds: diff --git a/tests/agentgateway/unit/test_agw_client.py b/tests/agentgateway/unit/test_agw_client.py index 21f23ded..e4db8169 100644 --- a/tests/agentgateway/unit/test_agw_client.py +++ b/tests/agentgateway/unit/test_agw_client.py @@ -15,6 +15,7 @@ AgentGatewaySDKError, ) from sap_cloud_sdk.agentgateway._auditlog_helper import send_audit_event +from sap_cloud_sdk.agentgateway.config import AuditLogMode, ClientConfig _TENANT_UUID = "9e0d89c9-17cd-439d-8a8b-9c44d3d272f0" @@ -1054,7 +1055,7 @@ def test_lob_raises_on_exception(self, _mock_detect): # ============================================================ -# Test: _send_audit_event +# Test: send_audit_event # ============================================================ @@ -1105,16 +1106,38 @@ def test_sends_without_user_id(self): event = mock_audit.send.call_args[0][0] assert event.common.user_initiator_id == "" - def test_suppresses_send_errors(self): - """send_audit_event does not propagate exceptions from audit client.""" + def test_best_effort_suppresses_send_errors(self): + """send_audit_event does not propagate exceptions in BEST_EFFORT mode.""" mock_audit = MagicMock() mock_audit.send.side_effect = RuntimeError("send failed") with patch( "sap_cloud_sdk.agentgateway._auditlog_helper.get_tenant_id", return_value=_TENANT_UUID, ): - # Should not raise - send_audit_event(mock_audit, "my-tool") + send_audit_event(mock_audit, "my-tool", mode=AuditLogMode.BEST_EFFORT) + + def test_strict_raises_on_send_error(self): + """send_audit_event raises in STRICT mode when send fails.""" + mock_audit = MagicMock() + mock_audit.send.side_effect = RuntimeError("send failed") + with ( + patch( + "sap_cloud_sdk.agentgateway._auditlog_helper.get_tenant_id", + return_value=_TENANT_UUID, + ), + pytest.raises(RuntimeError, match="send failed"), + ): + send_audit_event(mock_audit, "my-tool", mode=AuditLogMode.STRICT) + + def test_disabled_skips_send(self): + """send_audit_event does nothing in DISABLED mode.""" + mock_audit = MagicMock() + with patch( + "sap_cloud_sdk.agentgateway._auditlog_helper.get_tenant_id", + return_value=_TENANT_UUID, + ): + send_audit_event(mock_audit, "my-tool", mode=AuditLogMode.DISABLED) + mock_audit.send.assert_not_called() # ============================================================ From c16ac448bc566ef51c1d7cd50a38462ca77e7c53 Mon Sep 17 00:00:00 2001 From: Soares Date: Fri, 10 Jul 2026 14:10:11 -0300 Subject: [PATCH 10/10] keep auditlogging only on call_mcp_tool() --- src/sap_cloud_sdk/agentgateway/agw_client.py | 5 - tests/agentgateway/unit/test_agw_client.py | 145 ------------------- 2 files changed, 150 deletions(-) diff --git a/src/sap_cloud_sdk/agentgateway/agw_client.py b/src/sap_cloud_sdk/agentgateway/agw_client.py index 31c2c829..a6480355 100644 --- a/src/sap_cloud_sdk/agentgateway/agw_client.py +++ b/src/sap_cloud_sdk/agentgateway/agw_client.py @@ -344,7 +344,6 @@ async def list_mcp_tools( self, user_token: str | Callable[[], str] | None = None, app_tid: str | None = None, - user_id: str | None = None, ) -> list[MCPTool]: """List all MCP tools from MCP servers. @@ -365,8 +364,6 @@ 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. - user_id: User identifier recorded in the audit event when an - audit_client is configured on the client. Returns: List of MCPTool objects from all MCP servers. @@ -399,7 +396,6 @@ async def list_mcp_tools( tools = await get_mcp_tools_customer( credentials, auth.access_token, self._config.timeout ) - send_audit_event(self._audit_client, "*", user_id, self._config.audit_log_mode) return tools # LoB flow - requires tenant_subdomain @@ -414,7 +410,6 @@ async def list_mcp_tools( tools = await get_mcp_tools_lob( tenant, auth.access_token, self._config.timeout ) - send_audit_event(self._audit_client, "*", user_id) return tools except AgentGatewaySDKError: diff --git a/tests/agentgateway/unit/test_agw_client.py b/tests/agentgateway/unit/test_agw_client.py index e4db8169..9086ef46 100644 --- a/tests/agentgateway/unit/test_agw_client.py +++ b/tests/agentgateway/unit/test_agw_client.py @@ -1140,151 +1140,6 @@ def test_disabled_skips_send(self): mock_audit.send.assert_not_called() -# ============================================================ -# Test: list_mcp_tools audit logging -# ============================================================ - - -class TestListMcpToolsAuditLog: - """Tests that list_mcp_tools emits an audit event on success.""" - - @pytest.mark.asyncio - async def test_lob_flow_sends_audit_event(self): - """list_mcp_tools sends audit event after LoB tool discovery.""" - mock_audit = MagicMock() - with ( - patch( - "sap_cloud_sdk.agentgateway._auditlog_helper.auditlog_ng.create_client", - return_value=mock_audit, - ), - patch( - "sap_cloud_sdk.agentgateway._auditlog_helper.get_tenant_id", - return_value=_TENANT_UUID, - ), - 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=[], - ), - ): - agw_client = create_client(tenant_subdomain="my-tenant") - await agw_client.list_mcp_tools(user_id="user@example.com") - - mock_audit.send.assert_called_once() - event = mock_audit.send.call_args[0][0] - assert event.object_id == "*" - assert event.common.tenant_id == _TENANT_UUID - assert event.common.user_initiator_id == "user@example.com" - - @pytest.mark.asyncio - async def test_customer_flow_no_audit_event(self): - """list_mcp_tools does not send audit event for customer agents (no tenant_subdomain).""" - mock_audit = MagicMock() - with ( - patch( - "sap_cloud_sdk.agentgateway._auditlog_helper.auditlog_ng.create_client", - return_value=mock_audit, - ), - patch( - "sap_cloud_sdk.agentgateway._auditlog_helper.get_tenant_id", - return_value=_TENANT_UUID, - ), - 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="system-token", - ), - patch( - "sap_cloud_sdk.agentgateway.agw_client.get_mcp_tools_customer", - new_callable=AsyncMock, - return_value=[], - ), - ): - 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() - - mock_audit.send.assert_not_called() - - @pytest.mark.asyncio - async def test_no_audit_event_without_audit_client(self): - """list_mcp_tools still calls send_audit_event (which silently skips when no audit client).""" - 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=[], - ), - patch( - "sap_cloud_sdk.agentgateway.agw_client.send_audit_event" - ) as mock_send, - ): - agw_client = create_client(tenant_subdomain="my-tenant") - await agw_client.list_mcp_tools() - - mock_send.assert_called_once_with(agw_client._audit_client, "*", None) - - @pytest.mark.asyncio - async def test_no_audit_event_on_failure(self): - """list_mcp_tools does not send audit event when tool discovery fails.""" - mock_audit = MagicMock() - with ( - patch( - "sap_cloud_sdk.agentgateway._auditlog_helper.auditlog_ng.create_client", - return_value=mock_audit, - ), - patch( - "sap_cloud_sdk.agentgateway._auditlog_helper.get_tenant_id", - return_value=_TENANT_UUID, - ), - 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, - side_effect=RuntimeError("network error"), - ), - ): - agw_client = create_client(tenant_subdomain="my-tenant") - with pytest.raises(AgentGatewaySDKError): - await agw_client.list_mcp_tools() - - mock_audit.send.assert_not_called() - # ============================================================ # Test: call_mcp_tool audit logging