Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
50 changes: 43 additions & 7 deletions src/sap_cloud_sdk/agentgateway/_customer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just to confirm, is this the logger from core that Jean implemented?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is plain logger from the Python standard library

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should use the one from our telemetry module, can you check?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you referring to _provider.py? That's only needed when you want these logs to flow through OTel rather than just printing to the console. What's the expected logging behavior here?

"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,
)


Expand Down Expand Up @@ -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
48 changes: 41 additions & 7 deletions src/sap_cloud_sdk/agentgateway/_lob.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -29,6 +30,7 @@
list_a2a_fragments,
)
from sap_cloud_sdk.agentgateway._models import (
JsonRpcError,
Agent,
AgentCard,
AgentCardFilter,
Expand Down Expand Up @@ -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,
)

Expand Down Expand Up @@ -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

Expand Down
28 changes: 28 additions & 0 deletions src/sap_cloud_sdk/agentgateway/_models.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Data models for Agent Gateway MCP tools."""

import json
from dataclasses import dataclass, field
from typing import Any

Expand Down Expand Up @@ -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.
Expand Down
25 changes: 17 additions & 8 deletions src/sap_cloud_sdk/agentgateway/agw_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand All @@ -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(
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
)
Expand Down
2 changes: 1 addition & 1 deletion tests/agentgateway/unit/test_agw_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading