diff --git a/pyproject.toml b/pyproject.toml index 5aa3e57d..419e1792 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sap-cloud-sdk" -version = "0.46.0" +version = "0.46.1" description = "SAP Cloud SDK for Python" readme = "README.md" license = "Apache-2.0" diff --git a/src/sap_cloud_sdk/agentgateway/_customer.py b/src/sap_cloud_sdk/agentgateway/_customer.py index 83f40ca1..c69e4444 100644 --- a/src/sap_cloud_sdk/agentgateway/_customer.py +++ b/src/sap_cloud_sdk/agentgateway/_customer.py @@ -23,12 +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 ( + JsonRpcError, CustomerCredentials, IntegrationDependency, MCPTool, @@ -680,15 +682,42 @@ def _log_mcp_server_error(ord_id: str, exc: BaseException) -> None: _log_mcp_server_error(ord_id, inner) return if isinstance(exc, httpx.HTTPStatusError): + try: + body = exc.response.text + except httpx.ResponseNotRead: + body = 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", + 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)", + ) + elif isinstance(exc, McpError): logger.error( - "Failed to load tools from %s (HTTP %d): %s", + "Failed to load tools from %s — JSON-RPC %d: %s", ord_id, - exc.response.status_code, - exc.response.text[:500], + exc.error.code, + exc.error.message, ) else: - logger.exception( - "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, ) @@ -796,13 +825,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 6a199b82..d1d10811 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,6 +30,7 @@ list_a2a_fragments, ) from sap_cloud_sdk.agentgateway._models import ( + JsonRpcError, Agent, AgentCard, AgentCardFilter, @@ -307,16 +309,41 @@ def _log_mcp_server_error(fragment_name: str, exc: BaseException) -> None: _log_mcp_server_error(fragment_name, inner) return if isinstance(exc, httpx.HTTPStatusError): + try: + body = exc.response.text + except httpx.ResponseNotRead: + body = 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", + 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)", + ) + elif isinstance(exc, McpError): logger.error( - "Failed to load tools from fragment '%s' (HTTP %d): %s", + "Failed to load tools from fragment '%s' — JSON-RPC %d: %s", fragment_name, - exc.response.status_code, - exc.response.text[:500], + exc.error.code, + exc.error.message, ) else: - logger.exception( - "Failed to load tools from fragment '%s' — skipping", + logger.error( + "Failed to load tools from fragment '%s' — %s: %s", fragment_name, + type(exc).__name__, + exc, exc_info=exc, ) @@ -482,13 +509,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..2ce30c79 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 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. + + Example: {"jsonrpc": "2.0", "error": {"code": -32603, "message": "Internal Server Error"}} + + Attributes: + code: JSON-RPC error code. + message: Human-readable error message from AGW. + """ + + code: int + message: str + + @classmethod + def parse(cls, text: str) -> "JsonRpcError | 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. diff --git a/src/sap_cloud_sdk/agentgateway/agw_client.py b/src/sap_cloud_sdk/agentgateway/agw_client.py index 6f353de6..51203dd5 100644 --- a/src/sap_cloud_sdk/agentgateway/agw_client.py +++ b/src/sap_cloud_sdk/agentgateway/agw_client.py @@ -284,10 +284,14 @@ 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(): @@ -302,10 +306,14 @@ 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( @@ -314,7 +322,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 @@ -444,10 +454,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, @@ -597,7 +603,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 207deb9d..9fb5dcde 100644 --- a/tests/agentgateway/unit/test_agw_client.py +++ b/tests/agentgateway/unit/test_agw_client.py @@ -607,7 +607,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 ) diff --git a/uv.lock b/uv.lock index b0d52749..c55004dc 100644 --- a/uv.lock +++ b/uv.lock @@ -3925,7 +3925,7 @@ wheels = [ [[package]] name = "sap-cloud-sdk" -version = "0.45.4" +version = "0.46.1" source = { editable = "." } dependencies = [ { name = "cryptography" },