Skip to content
Merged
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.58.4"
version = "0.58.5"
description = "SAP Cloud SDK for Python"
readme = "README.md"
license = "Apache-2.0"
Expand Down
25 changes: 25 additions & 0 deletions src/sap_cloud_sdk/agentgateway/_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

from typing import Any

try:
from mcp.shared.exceptions import McpError
except ImportError:
from mcp.shared.exceptions import MCPError as McpError # ty: ignore[unresolved-import]


def mcp_server_name(init_result: Any) -> str | None:
"""Return ``serverInfo.name`` / ``server_info.name`` if present, else None."""
Expand Down Expand Up @@ -39,3 +44,23 @@ def mcp_is_error(result: Any) -> bool:
if flag is None:
flag = getattr(result, "isError", None)
return bool(flag)


def find_mcp_error(exc: BaseException) -> "McpError | None":
"""Return the first ``McpError`` carried by ``exc``, or None.
A protocol error (JSON-RPC ``error``) raised by ``session.call_tool`` surfaces
either as a bare ``McpError`` or, because the mcp client runs inside anyio task
groups, wrapped in a (possibly nested) ``BaseExceptionGroup``. This walks the
group tree and returns the first ``McpError`` leaf so callers can surface its
code+message. Returns None when no ``McpError`` is present, so the caller can
defer to its generic error handling.
"""
if isinstance(exc, McpError):
return exc
if isinstance(exc, BaseExceptionGroup):
for inner in exc.exceptions:
found = find_mcp_error(inner)
if found is not None:
return found
return None
37 changes: 28 additions & 9 deletions src/sap_cloud_sdk/agentgateway/_customer.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
from mcp.shared.exceptions import MCPError as McpError # type: ignore[no-redef] # ty: ignore[unresolved-import]

from sap_cloud_sdk.agentgateway._compat import (
find_mcp_error,
mcp_input_schema,
mcp_is_error,
mcp_output_schema,
Expand Down Expand Up @@ -854,24 +855,42 @@ async def call_mcp_tool_customer(
):
async with ClientSession(read, write) as session:
await session.initialize()
result = await session.call_tool(tool.name, kwargs)
try:
result = await session.call_tool(tool.name, kwargs)
except (McpError, BaseExceptionGroup) as exc:
mcp_error = find_mcp_error(exc)
if mcp_error is None:
raise
raise AgentGatewayServerError(
mcp_error.error.message, error_code=mcp_error.error.code
) from exc

if result is None:
raise AgentGatewayServerError(
f"Tool '{tool.name}' on '{tool.url}' returned None"
)
if not result.content:
logger.warning(
"Tool '%s' on '%s' returned empty content", tool.name, tool.url
)
return ""

first = result.content[0]
text = str(getattr(first, "text", ""))
text = (
str(getattr(result.content[0], "text", ""))
if result.content
else ""
)

if mcp_is_error(result):
logger.warning(
"Tool '%s' on '%s' returned an error: %s",
tool.name,
tool.url,
text,
)
raise AgentGatewayServerError(
f"Tool '{tool.name}' on '{tool.url}' returned an error: {text}"
f"Tool '{tool.name}' returned an error: {text}"
)

if not result.content:
logger.warning(
"Tool '%s' on '%s' returned empty content", tool.name, tool.url
)
return ""

return text
36 changes: 28 additions & 8 deletions src/sap_cloud_sdk/agentgateway/_lob.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
list_mcp_fragments,
)
from sap_cloud_sdk.agentgateway._compat import (
find_mcp_error,
mcp_input_schema,
mcp_is_error,
mcp_output_schema,
Expand Down Expand Up @@ -542,23 +543,42 @@ async def call_mcp_tool_lob(
):
async with ClientSession(read, write) as session:
await session.initialize()
result = await session.call_tool(tool.name, kwargs)
try:
result = await session.call_tool(tool.name, kwargs)
except (McpError, BaseExceptionGroup) as exc:
mcp_error = find_mcp_error(exc)
if mcp_error is None:
raise
raise AgentGatewayServerError(
mcp_error.error.message, error_code=mcp_error.error.code
) from exc
if result is None:
raise AgentGatewayServerError(
f"Tool '{tool.name}' on '{tool.url}' returned None"
)

text = (
str(getattr(result.content[0], "text", ""))
if result.content
else ""
)

if mcp_is_error(result):
logger.warning(
"Tool '%s' on '%s' returned an error: %s",
tool.name,
tool.url,
text,
)
raise AgentGatewayServerError(
f"Tool '{tool.name}' returned an error: {text}"
)

if not result.content:
logger.warning(
"Tool '%s' on '%s' returned empty content", tool.name, tool.url
)
return ""
first = result.content[0]
text = str(getattr(first, "text", ""))

if mcp_is_error(result):
raise AgentGatewayServerError(
f"Tool '{tool.name}' on '{tool.url}' returned an error: {text}"
)

return text

Expand Down
23 changes: 22 additions & 1 deletion src/sap_cloud_sdk/agentgateway/agw_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -669,14 +669,35 @@ async def call_mcp_tool(
except AgentGatewaySDKError:
raise
except Exception as e:
tool_label = tool if isinstance(tool, str) else tool.name
sdk_error = _find_sdk_error(e)
if sdk_error is not None:
logger.info(
"Surfacing Agent Gateway error for tool '%s': %s",
tool_label,
sdk_error,
)
raise sdk_error from e
logger.exception("Unexpected error during tool invocation")
cause = _unwrap_exception_group(e)
tool_label = tool if isinstance(tool, str) else tool.name
raise AgentGatewaySDKError(
f"Tool invocation failed for '{tool_label}': {cause}"
) from e


def _find_sdk_error(exc: BaseException) -> AgentGatewaySDKError | None:
"""Return the first AgentGatewaySDKError carried by exc (bare or inside a
possibly-nested ExceptionGroup), or None."""
if isinstance(exc, AgentGatewaySDKError):
return exc
if isinstance(exc, BaseExceptionGroup):
for inner in exc.exceptions:
found = _find_sdk_error(inner)
if found is not None:
return found
return None


def _unwrap_exception_group(exc: BaseException) -> BaseException:
"""Unwrap nested ExceptionGroups to present meaningful error messages."""
while isinstance(exc, BaseExceptionGroup) and exc.exceptions:
Expand Down
38 changes: 32 additions & 6 deletions src/sap_cloud_sdk/agentgateway/converters.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from pydantic import Field, create_model

from sap_cloud_sdk.agentgateway._models import MCPTool
from sap_cloud_sdk.agentgateway.exceptions import AgentGatewaySDKError

if TYPE_CHECKING:
from langchain_core.tools import StructuredTool
Expand Down Expand Up @@ -101,6 +102,7 @@ def mcp_tool_to_langchain(
get_user_token: Callable[[], str],
*,
omit_none: bool = True,
handle_tool_error: bool | str | Callable[[Exception], str] | None = None,
) -> StructuredTool:
"""Convert MCPTool to LangChain StructuredTool.
Expand All @@ -113,6 +115,14 @@ def mcp_tool_to_langchain(
get_user_token: Callable that returns the user's JWT token.
omit_none: If True (default), optional parameters with a None value are not
forwarded to call_tool. Set to False to forward None values explicitly.
handle_tool_error: Opt-in LangChain error handling. When set, tool-call failures
Comment thread
NicoleMGomes marked this conversation as resolved.
are surfaced to the LLM as a ToolException and LangChain converts them to an
error ToolMessage the model can see and self-correct on. Default (None) keeps
the previous behavior: the raw SDK exception propagates.
Recommended value is ``True`` (use the error message as the tool result). You
may also pass a ``str`` to show the LLM a fixed message instead, or a callable
``(ToolException) -> str`` to compute the message. Passed through to
LangChain's ``StructuredTool.handle_tool_error``.
Returns:
LangChain StructuredTool that invokes the MCP tool.
Expand Down Expand Up @@ -140,7 +150,7 @@ def mcp_tool_to_langchain(
```
"""
try:
from langchain_core.tools import StructuredTool
from langchain_core.tools import StructuredTool, ToolException
except ImportError:
raise ImportError(
"langchain-core is required for mcp_tool_to_langchain. "
Expand All @@ -164,11 +174,22 @@ async def run(**kwargs) -> str:
if omit_none
else restored
)
return await call_tool(
mcp_tool,
user_token=get_user_token,
**resolved,
)
if handle_tool_error is None:
return await call_tool(
mcp_tool,
user_token=get_user_token,
**resolved,
)
# Opt-in: surface Agent Gateway failures as a ToolException so LangChain's
# handle_tool_error can convert them to an error ToolMessage for the LLM.
try:
return await call_tool(
mcp_tool,
user_token=get_user_token,
**resolved,
)
except AgentGatewaySDKError as exc:
Comment thread
NicoleMGomes marked this conversation as resolved.
raise ToolException(str(exc)) from exc

fields: dict[str, Any] = {}
for safe, orig in name_map.items():
Expand Down Expand Up @@ -201,4 +222,9 @@ async def run(**kwargs) -> str:
mcp_tool.description, mcp_tool.output_schema
),
args_schema=args_schema,
**(
{"handle_tool_error": handle_tool_error}
if handle_tool_error is not None
else {}
),
)
13 changes: 13 additions & 0 deletions src/sap_cloud_sdk/agentgateway/user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,19 @@ mcp_tool_to_langchain(
)
```

By default, a failed tool call raises the raw SDK exception, which propagates out of the agent. Set `handle_tool_error=True` to instead surface Agent Gateway failures to the LLM as a LangChain `ToolException`; LangChain converts it to an error `ToolMessage` the model can see and self-correct on, rather than crashing the agent loop:

```python
mcp_tool_to_langchain(
t,
agw_client.call_mcp_tool,
get_user_token=lambda: request.headers["Authorization"],
handle_tool_error=True,
)
```

With `handle_tool_error=True`, a tool error such as `Tool 'recalc' on '<url>' returned an error: backend unavailable` is delivered to the model as a tool result instead of being raised. You may also pass a fixed `str` to show the LLM a constant message, or a callable `(ToolException) -> str` to compute one; both are forwarded to LangChain's `StructuredTool.handle_tool_error`.

The converter maps each property's JSON Schema `"type"` to the corresponding Python type so Pydantic validates and forwards the correct native type to the MCP server:

| JSON Schema type | Python type |
Expand Down
27 changes: 27 additions & 0 deletions tests/agentgateway/unit/test_agw_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -1318,3 +1318,30 @@ def test_lob_raises_on_exception(self, _mock_detect):
with patch(_GET_IAS_CLIENT_ID_LOB_PATCH, side_effect=EnvironmentError("APPFND_CONHOS_LANDSCAPE not set")):
with pytest.raises(AgentGatewaySDKError, match="Could not resolve IAS client ID"):
create_client(tenant_subdomain="my-tenant").get_ias_client_id()


class TestFindSdkError:
"""Tests for _find_sdk_error (preserves a clean SDK error buried in a group)."""

def test_returns_bare_sdk_error(self):
from sap_cloud_sdk.agentgateway.agw_client import _find_sdk_error
from sap_cloud_sdk.agentgateway.exceptions import AgentGatewayServerError

err = AgentGatewayServerError("boom", error_code=-32602)
assert _find_sdk_error(err) is err

def test_finds_sdk_error_in_nested_group(self):
from sap_cloud_sdk.agentgateway.agw_client import _find_sdk_error
from sap_cloud_sdk.agentgateway.exceptions import AgentGatewayServerError

err = AgentGatewayServerError("boom", error_code=-32602)
group = BaseExceptionGroup("outer", [BaseExceptionGroup("inner", [err])])
found = _find_sdk_error(group)
assert found is err
assert found.error_code == -32602

def test_returns_none_without_sdk_error(self):
from sap_cloud_sdk.agentgateway.agw_client import _find_sdk_error

group = BaseExceptionGroup("eg", [ValueError("x"), RuntimeError("y")])
assert _find_sdk_error(group) is None
34 changes: 34 additions & 0 deletions tests/agentgateway/unit/test_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@

from types import SimpleNamespace

from mcp.shared.exceptions import McpError
from mcp.types import ErrorData

from sap_cloud_sdk.agentgateway._compat import (
find_mcp_error,
mcp_input_schema,
mcp_is_error,
mcp_server_name,
Expand Down Expand Up @@ -87,3 +91,33 @@ def test_false_when_flag_false(self):
def test_defaults_to_false_when_missing(self):
"""No error field on either name -> False."""
assert mcp_is_error(SimpleNamespace()) is False


class TestFindMcpError:
"""Tests for find_mcp_error unwrapping."""

def test_returns_bare_mcp_error(self):
"""A bare McpError is returned directly."""
err = McpError(ErrorData(code=-32602, message="Unknown tool: foo"))
assert find_mcp_error(err) is err

def test_finds_mcp_error_in_group(self):
"""An McpError wrapped in a BaseExceptionGroup is found."""
err = McpError(ErrorData(code=-32603, message="Internal Server Error"))
group = BaseExceptionGroup("eg", [err])
assert find_mcp_error(group) is err

def test_finds_mcp_error_in_nested_group(self):
"""An McpError nested in a group within a group is found."""
err = McpError(ErrorData(code=-32600, message="bad request"))
group = BaseExceptionGroup("outer", [BaseExceptionGroup("inner", [err])])
assert find_mcp_error(group) is err

def test_returns_none_without_mcp_error(self):
"""A group without any McpError leaf returns None (defer to generic handling)."""
group = BaseExceptionGroup("eg", [ValueError("x"), RuntimeError("y")])
assert find_mcp_error(group) is None

def test_returns_none_for_unrelated_exception(self):
"""A plain non-group, non-McpError exception returns None."""
assert find_mcp_error(ValueError("x")) is None
Loading
Loading