From c9a25c2eb197c59651070945f6fd9550c890304b Mon Sep 17 00:00:00 2001 From: Soares Date: Mon, 24 Aug 2026 17:09:45 -0300 Subject: [PATCH 1/4] improve agw logging --- src/sap_cloud_sdk/agentgateway/_customer.py | 44 +++++++++++++++------ src/sap_cloud_sdk/agentgateway/_lob.py | 42 +++++++++++++++----- src/sap_cloud_sdk/agentgateway/_models.py | 28 +++++++++++++ 3 files changed, 94 insertions(+), 20 deletions(-) diff --git a/src/sap_cloud_sdk/agentgateway/_customer.py b/src/sap_cloud_sdk/agentgateway/_customer.py index 83f40ca1..02a96fff 100644 --- a/src/sap_cloud_sdk/agentgateway/_customer.py +++ b/src/sap_cloud_sdk/agentgateway/_customer.py @@ -29,6 +29,7 @@ IntegrationDependenciesResolver, ) from sap_cloud_sdk.agentgateway._models import ( + AGWJsonRpcError, CustomerCredentials, IntegrationDependency, MCPTool, @@ -680,16 +681,30 @@ def _log_mcp_server_error(ord_id: str, exc: BaseException) -> None: _log_mcp_server_error(ord_id, inner) return if isinstance(exc, httpx.HTTPStatusError): - logger.error( - "Failed to load tools from %s (HTTP %d): %s", - ord_id, - exc.response.status_code, - exc.response.text[:500], - ) + try: + body = exc.response.text + except httpx.ResponseNotRead: + body = None + rpc_error = AGWJsonRpcError.parse(body) if body else None + if rpc_error: + logger.error( + "Failed to load tools from %s — %s returned HTTP %d [JSON-RPC %d]: %s", + ord_id, + exc.request.url, + exc.response.status_code, + rpc_error.code, + rpc_error.message, + ) + else: + logger.error( + "Failed to load tools from %s — %s returned HTTP %d: %s", + ord_id, + exc.request.url, + exc.response.status_code, + body[:500] if body else "(response body not available)", + ) else: - logger.exception( - "Failed to load tools from %s — skipping", ord_id, exc_info=exc - ) + logger.error("Failed to load tools from %s — skipping", ord_id, exc_info=exc) async def get_mcp_tools_customer( @@ -796,13 +811,20 @@ async def call_mcp_tool_customer( result = await session.call_tool(tool.name, kwargs) if not result.content: - logger.warning("Tool '%s' returned empty content", tool.name) + logger.warning( + "Tool '%s' on '%s' returned empty content", tool.name, tool.url + ) return "" first = result.content[0] text = str(getattr(first, "text", "")) if result.isError: - logger.error("Tool '%s' returned an error: %s", tool.name, text) + logger.error( + "Tool '%s' on '%s' returned an error: %s", + tool.name, + tool.url, + text, + ) return text diff --git a/src/sap_cloud_sdk/agentgateway/_lob.py b/src/sap_cloud_sdk/agentgateway/_lob.py index 0c46124c..416aad9f 100644 --- a/src/sap_cloud_sdk/agentgateway/_lob.py +++ b/src/sap_cloud_sdk/agentgateway/_lob.py @@ -29,6 +29,7 @@ list_a2a_fragments, ) from sap_cloud_sdk.agentgateway._models import ( + AGWJsonRpcError, Agent, AgentCard, AgentCardFilter, @@ -302,14 +303,30 @@ def _log_mcp_server_error(fragment_name: str, exc: BaseException) -> None: _log_mcp_server_error(fragment_name, inner) return if isinstance(exc, httpx.HTTPStatusError): - logger.error( - "Failed to load tools from fragment '%s' (HTTP %d): %s", - fragment_name, - exc.response.status_code, - exc.response.text[:500], - ) + try: + body = exc.response.text + except httpx.ResponseNotRead: + body = None + rpc_error = AGWJsonRpcError.parse(body) if body else None + if rpc_error: + logger.error( + "Failed to load tools from fragment '%s' — %s returned HTTP %d [JSON-RPC %d]: %s", + fragment_name, + exc.request.url, + exc.response.status_code, + rpc_error.code, + rpc_error.message, + ) + else: + logger.error( + "Failed to load tools from fragment '%s' — %s returned HTTP %d: %s", + fragment_name, + exc.request.url, + exc.response.status_code, + body[:500] if body else "(response body not available)", + ) else: - logger.exception( + logger.error( "Failed to load tools from fragment '%s' — skipping", fragment_name, exc_info=exc, @@ -477,13 +494,20 @@ async def call_mcp_tool_lob( await session.initialize() result = await session.call_tool(tool.name, kwargs) if not result.content: - logger.warning("Tool '%s' returned empty content", tool.name) + logger.warning( + "Tool '%s' on '%s' returned empty content", tool.name, tool.url + ) return "" first = result.content[0] text = str(getattr(first, "text", "")) if result.isError: - logger.error("Tool '%s' returned an error: %s", tool.name, text) + logger.error( + "Tool '%s' on '%s' returned an error: %s", + tool.name, + tool.url, + text, + ) return text diff --git a/src/sap_cloud_sdk/agentgateway/_models.py b/src/sap_cloud_sdk/agentgateway/_models.py index 2dd56e87..e194a9b5 100644 --- a/src/sap_cloud_sdk/agentgateway/_models.py +++ b/src/sap_cloud_sdk/agentgateway/_models.py @@ -1,5 +1,6 @@ """Data models for Agent Gateway MCP tools.""" +import json from dataclasses import dataclass, field from typing import Any @@ -96,6 +97,33 @@ class CustomerCredentials: private_key: str | None = None +@dataclass +class AGWJsonRpcError: + """Parsed JSON-RPC error from an Agent Gateway response. + + AGW returns HTTP 200 with a JSON-RPC error body when the request is + structurally valid but the server encountered an error, e.g.: + + {"jsonrpc": "2.0", "error": {"code": -32603, "message": "Internal Server Error"}} + + Attributes: + code: JSON-RPC error code (e.g. -32603 for internal error). + message: Human-readable error message from AGW. + """ + + code: int + message: str + + @classmethod + def parse(cls, text: str) -> "AGWJsonRpcError | None": + try: + data = json.loads(text) + error = data.get("error", {}) + return cls(code=error["code"], message=error["message"]) + except Exception: + return None + + @dataclass class AgentCard: """Agent Card as returned by the A2A well-known endpoint. From c81e4522e91d89399c009c56dc7075549d644efd Mon Sep 17 00:00:00 2001 From: Soares Date: Thu, 27 Aug 2026 15:11:20 -0300 Subject: [PATCH 2/4] improve MCP error logging and fix duplicate auth call --- src/sap_cloud_sdk/agentgateway/_customer.py | 20 ++++++++++++++++--- src/sap_cloud_sdk/agentgateway/_lob.py | 16 ++++++++++++--- src/sap_cloud_sdk/agentgateway/_models.py | 12 +++++------ src/sap_cloud_sdk/agentgateway/agw_client.py | 21 ++++++++++++-------- tests/agentgateway/unit/test_agw_client.py | 2 +- 5 files changed, 50 insertions(+), 21 deletions(-) diff --git a/src/sap_cloud_sdk/agentgateway/_customer.py b/src/sap_cloud_sdk/agentgateway/_customer.py index 02a96fff..c69e4444 100644 --- a/src/sap_cloud_sdk/agentgateway/_customer.py +++ b/src/sap_cloud_sdk/agentgateway/_customer.py @@ -23,13 +23,14 @@ import httpx from mcp import ClientSession from mcp.client.streamable_http import streamable_http_client +from mcp.shared.exceptions import McpError from sap_cloud_sdk.agentgateway._dependencies_resolver import ( EnvironmentDependenciesResolver, IntegrationDependenciesResolver, ) from sap_cloud_sdk.agentgateway._models import ( - AGWJsonRpcError, + JsonRpcError, CustomerCredentials, IntegrationDependency, MCPTool, @@ -685,7 +686,7 @@ def _log_mcp_server_error(ord_id: str, exc: BaseException) -> None: body = exc.response.text except httpx.ResponseNotRead: body = None - rpc_error = AGWJsonRpcError.parse(body) if body else None + rpc_error = JsonRpcError.parse(body) if body else None if rpc_error: logger.error( "Failed to load tools from %s — %s returned HTTP %d [JSON-RPC %d]: %s", @@ -703,8 +704,21 @@ def _log_mcp_server_error(ord_id: str, exc: BaseException) -> None: exc.response.status_code, body[:500] if body else "(response body not available)", ) + elif isinstance(exc, McpError): + logger.error( + "Failed to load tools from %s — JSON-RPC %d: %s", + ord_id, + exc.error.code, + exc.error.message, + ) else: - logger.error("Failed to load tools from %s — skipping", ord_id, exc_info=exc) + logger.error( + "Failed to load tools from %s — %s: %s", + ord_id, + type(exc).__name__, + exc, + exc_info=exc, + ) async def get_mcp_tools_customer( diff --git a/src/sap_cloud_sdk/agentgateway/_lob.py b/src/sap_cloud_sdk/agentgateway/_lob.py index 416aad9f..e11fe2d8 100644 --- a/src/sap_cloud_sdk/agentgateway/_lob.py +++ b/src/sap_cloud_sdk/agentgateway/_lob.py @@ -13,6 +13,7 @@ import httpx from mcp import ClientSession from mcp.client.streamable_http import streamable_http_client +from mcp.shared.exceptions import McpError from sap_cloud_sdk.destination import ( create_client as create_destination_client, ConsumptionLevel, @@ -29,7 +30,7 @@ list_a2a_fragments, ) from sap_cloud_sdk.agentgateway._models import ( - AGWJsonRpcError, + JsonRpcError, Agent, AgentCard, AgentCardFilter, @@ -307,7 +308,7 @@ def _log_mcp_server_error(fragment_name: str, exc: BaseException) -> None: body = exc.response.text except httpx.ResponseNotRead: body = None - rpc_error = AGWJsonRpcError.parse(body) if body else None + rpc_error = JsonRpcError.parse(body) if body else None if rpc_error: logger.error( "Failed to load tools from fragment '%s' — %s returned HTTP %d [JSON-RPC %d]: %s", @@ -325,10 +326,19 @@ def _log_mcp_server_error(fragment_name: str, exc: BaseException) -> None: exc.response.status_code, body[:500] if body else "(response body not available)", ) + elif isinstance(exc, McpError): + logger.error( + "Failed to load tools from fragment '%s' — JSON-RPC %d: %s", + fragment_name, + exc.error.code, + exc.error.message, + ) else: logger.error( - "Failed to load tools from fragment '%s' — skipping", + "Failed to load tools from fragment '%s' — %s: %s", fragment_name, + type(exc).__name__, + exc, exc_info=exc, ) diff --git a/src/sap_cloud_sdk/agentgateway/_models.py b/src/sap_cloud_sdk/agentgateway/_models.py index e194a9b5..3130cc16 100644 --- a/src/sap_cloud_sdk/agentgateway/_models.py +++ b/src/sap_cloud_sdk/agentgateway/_models.py @@ -98,16 +98,16 @@ class CustomerCredentials: @dataclass -class AGWJsonRpcError: +class JsonRpcError: """Parsed JSON-RPC error from an Agent Gateway response. AGW returns HTTP 200 with a JSON-RPC error body when the request is - structurally valid but the server encountered an error, e.g.: - - {"jsonrpc": "2.0", "error": {"code": -32603, "message": "Internal Server Error"}} + structurally valid but the server encountered an error. + + Example: {"jsonrpc": "2.0", "error": {"code": -32603, "message": "Internal Server Error"}} Attributes: - code: JSON-RPC error code (e.g. -32603 for internal error). + code: JSON-RPC error code. message: Human-readable error message from AGW. """ @@ -115,7 +115,7 @@ class AGWJsonRpcError: message: str @classmethod - def parse(cls, text: str) -> "AGWJsonRpcError | None": + def parse(cls, text: str) -> "JsonRpcError | None": try: data = json.loads(text) error = data.get("error", {}) diff --git a/src/sap_cloud_sdk/agentgateway/agw_client.py b/src/sap_cloud_sdk/agentgateway/agw_client.py index fb1d63f8..8c0aac06 100644 --- a/src/sap_cloud_sdk/agentgateway/agw_client.py +++ b/src/sap_cloud_sdk/agentgateway/agw_client.py @@ -281,10 +281,12 @@ async def get_user_auth( self._config.timeout, self._token_cache, ) - return AuthResult( + result = AuthResult( access_token=token, gateway_url=credentials.gateway_url, ) + logger.info("User auth token obtained — gateway: '%s'", result.gateway_url) + return result # Check for transparent mode if detect_transparent_credentials(): @@ -299,10 +301,12 @@ async def get_user_auth( self._config.timeout, self._token_cache, ) - return AuthResult( + result = AuthResult( access_token=token, gateway_url=credentials.gateway_url, ) + logger.info("User auth token obtained — gateway: '%s'", result.gateway_url) + return result tenant = self._resolve_tenant_subdomain() token, gateway_url = await fetch_user_auth( @@ -311,7 +315,9 @@ async def get_user_auth( token_cache=self._token_cache, gateway_url_cache=self._gateway_url_cache, ) - return AuthResult(access_token=token, gateway_url=gateway_url) + result = AuthResult(access_token=token, gateway_url=gateway_url) + logger.info("User auth token obtained — gateway: '%s'", result.gateway_url) + return result except AgentGatewaySDKError: raise @@ -437,10 +443,6 @@ async def list_mcp_tools( # LoB flow - requires tenant_subdomain tenant = self._resolve_tenant_subdomain() - if user_token: - auth = await self.get_user_auth(user_token) - else: - auth = await self.get_system_auth() return await get_mcp_tools_lob( tenant, auth.access_token, @@ -590,7 +592,10 @@ async def call_mcp_tool( tool, auth.access_token, self._config.timeout, **kwargs ) - auth = await self.get_user_auth(user_token) + if not user_token: + raise AgentGatewaySDKError( + "user_token is required for LoB tool invocation." + ) return await call_mcp_tool_lob( tool, auth.access_token, self._config.timeout, **kwargs ) diff --git a/tests/agentgateway/unit/test_agw_client.py b/tests/agentgateway/unit/test_agw_client.py index f950946a..05e756e0 100644 --- a/tests/agentgateway/unit/test_agw_client.py +++ b/tests/agentgateway/unit/test_agw_client.py @@ -563,7 +563,7 @@ 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 + assert mock_user_auth.call_count == 1 mock_lob.assert_called_once_with( "my-tenant", "user-token-xyz", 60.0, filter=None ) From b64991eb46efb8a6b5aaa1a53278fff032b1b970 Mon Sep 17 00:00:00 2001 From: Soares Date: Fri, 28 Aug 2026 15:13:58 -0300 Subject: [PATCH 3/4] apply ruff format --- src/sap_cloud_sdk/agentgateway/_models.py | 2 +- src/sap_cloud_sdk/agentgateway/agw_client.py | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/sap_cloud_sdk/agentgateway/_models.py b/src/sap_cloud_sdk/agentgateway/_models.py index 3130cc16..2ce30c79 100644 --- a/src/sap_cloud_sdk/agentgateway/_models.py +++ b/src/sap_cloud_sdk/agentgateway/_models.py @@ -103,7 +103,7 @@ class JsonRpcError: AGW returns HTTP 200 with a JSON-RPC error body when the request is structurally valid but the server encountered an error. - + Example: {"jsonrpc": "2.0", "error": {"code": -32603, "message": "Internal Server Error"}} Attributes: diff --git a/src/sap_cloud_sdk/agentgateway/agw_client.py b/src/sap_cloud_sdk/agentgateway/agw_client.py index 8c0aac06..5c355afb 100644 --- a/src/sap_cloud_sdk/agentgateway/agw_client.py +++ b/src/sap_cloud_sdk/agentgateway/agw_client.py @@ -285,7 +285,9 @@ async def get_user_auth( access_token=token, gateway_url=credentials.gateway_url, ) - logger.info("User auth token obtained — gateway: '%s'", result.gateway_url) + logger.info( + "User auth token obtained — gateway: '%s'", result.gateway_url + ) return result # Check for transparent mode @@ -305,7 +307,9 @@ async def get_user_auth( access_token=token, gateway_url=credentials.gateway_url, ) - logger.info("User auth token obtained — gateway: '%s'", result.gateway_url) + logger.info( + "User auth token obtained — gateway: '%s'", result.gateway_url + ) return result tenant = self._resolve_tenant_subdomain() From 8dc778ecaa286123880be834ea2becc86aa81dba Mon Sep 17 00:00:00 2001 From: Soares Date: Fri, 28 Aug 2026 16:55:31 -0300 Subject: [PATCH 4/4] chore: bump version to 0.46.1 --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c6b0bcde..c65bf810 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sap-cloud-sdk" -version = "0.45.1" +version = "0.46.1" description = "SAP Cloud SDK for Python" readme = "README.md" license = "Apache-2.0" diff --git a/uv.lock b/uv.lock index 2c98946e..24f42fd0 100644 --- a/uv.lock +++ b/uv.lock @@ -3925,7 +3925,7 @@ wheels = [ [[package]] name = "sap-cloud-sdk" -version = "0.45.0" +version = "0.46.1" source = { editable = "." } dependencies = [ { name = "cryptography" },