Skip to content

Commit d96906b

Browse files
feat(agentgateway): support mcp 2.x and guard the malformed-result crash (#418)
1 parent 193705c commit d96906b

9 files changed

Lines changed: 511 additions & 37 deletions

File tree

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ dependencies = [
3636
"opentelemetry-instrumentation-sqlalchemy~=0.65b0",
3737
"opentelemetry-instrumentation-django~=0.65b0",
3838
"opentelemetry-instrumentation-flask~=0.65b0",
39-
"mcp>=1.1.0,<2",
39+
"mcp>=1.1.0,<3",
4040
"python-odata>=0.7.0",
4141
"cryptography>=48.0.1,<51",
4242
]

‎src/sap_cloud_sdk/agentgateway/_compat.py‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,33 @@
88
from mcp.shared.exceptions import MCPError as McpError # ty: ignore[unresolved-import]
99

1010

11+
def make_mcp_error(code: int, message: str) -> "McpError":
12+
"""Construct an ``McpError`` with ``.error.code`` / ``.error.message`` set, across
13+
mcp 1.x and 2.x.
14+
15+
The constructor signature changed between majors:
16+
17+
- **mcp 1.x:** ``McpError(ErrorData(code=..., message=...))`` — a single ``ErrorData``.
18+
- **mcp 2.x:** ``MCPError(code, message, data=None)`` — positional ``code``/``message``
19+
(and the class was renamed ``McpError`` → ``MCPError``; see the import shim above).
20+
21+
Both result in an exception exposing ``exc.error.code`` and ``exc.error.message``,
22+
which is what the SDK reads when surfacing a protocol error as an
23+
``AgentGatewayServerError``. This helper hides the signature difference so callers
24+
(and tests) do not need to branch on the installed mcp major.
25+
"""
26+
try:
27+
# mcp 2.x: positional code + message. (ty type-checks against whichever mcp
28+
# major is installed; the 1.x ErrorData signature makes this look wrong even
29+
# though it is correct at runtime under v2, so suppress both shape diagnostics.)
30+
return McpError(code, message) # ty: ignore[invalid-argument-type, too-many-positional-arguments]
31+
except TypeError:
32+
# mcp 1.x: a single ErrorData payload.
33+
from mcp.types import ErrorData
34+
35+
return McpError(ErrorData(code=code, message=message))
36+
37+
1138
def mcp_server_name(init_result: Any) -> str | None:
1239
"""Return ``serverInfo.name`` / ``server_info.name`` if present, else None."""
1340
info = getattr(init_result, "server_info", None)
@@ -64,3 +91,32 @@ def find_mcp_error(exc: BaseException) -> "McpError | None":
6491
if found is not None:
6592
return found
6693
return None
94+
95+
96+
def find_malformed_result_error(exc: BaseException) -> "AttributeError | None":
97+
"""Return the first "result was None" ``AttributeError`` carried by ``exc``, else None.
98+
99+
The mcp client's ``ClientSession.call_tool`` does an unguarded ``if not
100+
result.isError:`` (mcp 1.x; renamed ``is_error`` in 2.x). When something upstream
101+
yields ``None`` instead of a ``CallToolResult``, that line raises
102+
``AttributeError: 'NoneType' object has no attribute 'isError'`` *inside*
103+
``call_tool`` — before the SDK ever gets a value back to None-check. This matches
104+
that AttributeError (by the ``isError`` / ``is_error`` attribute name) so the caller
105+
can surface a clean error instead of leaking ``'NoneType' object has no attribute
106+
'isError'``.
107+
108+
Walks ``BaseExceptionGroup`` trees (the mcp client runs inside anyio task groups),
109+
returning the first matching ``AttributeError`` leaf. Returns None for any other
110+
exception so the caller can defer to its generic handling.
111+
"""
112+
if isinstance(exc, AttributeError):
113+
message = str(exc)
114+
if "isError" in message or "is_error" in message:
115+
return exc
116+
return None
117+
if isinstance(exc, BaseExceptionGroup):
118+
for inner in exc.exceptions:
119+
found = find_malformed_result_error(inner)
120+
if found is not None:
121+
return found
122+
return None

‎src/sap_cloud_sdk/agentgateway/_customer.py‎

Lines changed: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
from mcp.shared.exceptions import MCPError as McpError # type: ignore[no-redef] # ty: ignore[unresolved-import]
3434

3535
from sap_cloud_sdk.agentgateway._compat import (
36+
find_malformed_result_error,
3637
find_mcp_error,
3738
mcp_input_schema,
3839
mcp_is_error,
@@ -857,13 +858,49 @@ async def call_mcp_tool_customer(
857858
await session.initialize()
858859
try:
859860
result = await session.call_tool(tool.name, kwargs)
860-
except (McpError, BaseExceptionGroup) as exc:
861+
except (McpError, BaseExceptionGroup, AttributeError) as exc:
861862
mcp_error = find_mcp_error(exc)
862-
if mcp_error is None:
863-
raise
864-
raise AgentGatewayServerError(
865-
mcp_error.error.message, error_code=mcp_error.error.code
866-
) from exc
863+
if mcp_error is not None:
864+
logger.warning(
865+
"Tool '%s' on '%s' (server '%s') failed with MCP protocol "
866+
"error: code=%s message=%s",
867+
tool.name,
868+
tool.url,
869+
tool.server_name,
870+
mcp_error.error.code,
871+
mcp_error.error.message,
872+
)
873+
raise AgentGatewayServerError(
874+
mcp_error.error.message, error_code=mcp_error.error.code
875+
) from exc
876+
877+
malformed = find_malformed_result_error(exc)
878+
if malformed is not None:
879+
logger.warning(
880+
"Tool '%s' on '%s' (server '%s') returned a malformed or "
881+
"empty result from the gateway; the MCP client received no "
882+
"parseable CallToolResult (raw cause: %s: %s)",
883+
tool.name,
884+
tool.url,
885+
tool.server_name,
886+
type(malformed).__name__,
887+
malformed,
888+
)
889+
raise AgentGatewayServerError(
890+
f"Tool '{tool.name}' on '{tool.url}' returned a malformed "
891+
f"or empty result from the gateway"
892+
) from exc
893+
894+
logger.error(
895+
"Tool '%s' on '%s' (server '%s') raised an unhandled error "
896+
"during invocation: %s: %s",
897+
tool.name,
898+
tool.url,
899+
tool.server_name,
900+
type(exc).__name__,
901+
exc,
902+
)
903+
raise
867904

868905
if result is None:
869906
raise AgentGatewayServerError(

‎src/sap_cloud_sdk/agentgateway/_lob.py‎

Lines changed: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
list_mcp_fragments,
3737
)
3838
from sap_cloud_sdk.agentgateway._compat import (
39+
find_malformed_result_error,
3940
find_mcp_error,
4041
mcp_input_schema,
4142
mcp_is_error,
@@ -545,13 +546,49 @@ async def call_mcp_tool_lob(
545546
await session.initialize()
546547
try:
547548
result = await session.call_tool(tool.name, kwargs)
548-
except (McpError, BaseExceptionGroup) as exc:
549+
except (McpError, BaseExceptionGroup, AttributeError) as exc:
549550
mcp_error = find_mcp_error(exc)
550-
if mcp_error is None:
551-
raise
552-
raise AgentGatewayServerError(
553-
mcp_error.error.message, error_code=mcp_error.error.code
554-
) from exc
551+
if mcp_error is not None:
552+
logger.warning(
553+
"Tool '%s' on '%s' (server '%s') failed with MCP protocol "
554+
"error: code=%s message=%s",
555+
tool.name,
556+
tool.url,
557+
tool.server_name,
558+
mcp_error.error.code,
559+
mcp_error.error.message,
560+
)
561+
raise AgentGatewayServerError(
562+
mcp_error.error.message, error_code=mcp_error.error.code
563+
) from exc
564+
565+
malformed = find_malformed_result_error(exc)
566+
if malformed is not None:
567+
logger.warning(
568+
"Tool '%s' on '%s' (server '%s') returned a malformed or "
569+
"empty result from the gateway; the MCP client received no "
570+
"parseable CallToolResult (raw cause: %s: %s)",
571+
tool.name,
572+
tool.url,
573+
tool.server_name,
574+
type(malformed).__name__,
575+
malformed,
576+
)
577+
raise AgentGatewayServerError(
578+
f"Tool '{tool.name}' on '{tool.url}' returned a malformed "
579+
f"or empty result from the gateway"
580+
) from exc
581+
582+
logger.error(
583+
"Tool '%s' on '%s' (server '%s') raised an unhandled error "
584+
"during invocation: %s: %s",
585+
tool.name,
586+
tool.url,
587+
tool.server_name,
588+
type(exc).__name__,
589+
exc,
590+
)
591+
raise
555592
if result is None:
556593
raise AgentGatewayServerError(
557594
f"Tool '{tool.name}' on '{tool.url}' returned None"

‎tests/agentgateway/unit/test_agw_client.py‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -980,6 +980,62 @@ async def test_customer_credentials_calls_customer_flow(self, mock_tool):
980980
mock_tool, "exchanged-token", 60.0
981981
)
982982

983+
@pytest.mark.asyncio
984+
async def test_grouped_server_error_is_surfaced_bare(self, mock_tool):
985+
"""Regression: an AgentGatewayServerError raised by the customer flow but
986+
re-wrapped in a BaseExceptionGroup (as anyio's task-group teardown does in
987+
the real streamable_http_client / ClientSession path) must reach the caller
988+
as a BARE AgentGatewayServerError — never as an ExceptionGroup, and never
989+
leaking the opaque 'NoneType' object has no attribute 'isError' crash string.
990+
991+
This pins the behavior of the outer `_find_sdk_error` unwrap in call_mcp_tool.
992+
Without that unwrap the deployed-IBD malformed-result crash would surface as an
993+
opaque ExceptionGroup to the agent again.
994+
"""
995+
from sap_cloud_sdk.agentgateway.exceptions import AgentGatewayServerError
996+
997+
clean_error = AgentGatewayServerError(
998+
"Tool 'test-tool' on 'https://example.com/mcp' returned a malformed "
999+
"or empty result from the gateway"
1000+
)
1001+
# mimic the guard raising the clean error, then anyio re-bundling it on the
1002+
# way out through the nested async-with task groups.
1003+
grouped = BaseExceptionGroup("unhandled errors in a TaskGroup", [clean_error])
1004+
1005+
with (
1006+
patch(
1007+
"sap_cloud_sdk.agentgateway.agw_client.detect_customer_agent_credentials",
1008+
return_value="/path/to/credentials",
1009+
),
1010+
patch(
1011+
"sap_cloud_sdk.agentgateway.agw_client.load_customer_credentials",
1012+
) as mock_load,
1013+
patch(
1014+
"sap_cloud_sdk.agentgateway.agw_client.exchange_user_token",
1015+
return_value="exchanged-token",
1016+
),
1017+
patch(
1018+
"sap_cloud_sdk.agentgateway.agw_client.call_mcp_tool_customer",
1019+
new_callable=AsyncMock,
1020+
side_effect=grouped,
1021+
),
1022+
):
1023+
mock_creds = MagicMock()
1024+
mock_creds.gateway_url = "https://agw.customer.com"
1025+
mock_load.return_value = mock_creds
1026+
1027+
agw_client = create_client(tenant_subdomain="my-tenant")
1028+
1029+
with pytest.raises(AgentGatewayServerError) as exc_info:
1030+
await agw_client.call_mcp_tool(tool=mock_tool, user_token="jwt-token")
1031+
1032+
# bare, clean error — not the opaque crash, not an ExceptionGroup.
1033+
raised = exc_info.value
1034+
assert raised is clean_error
1035+
assert "malformed or empty result" in str(raised)
1036+
assert "NoneType" not in str(raised)
1037+
assert "isError" not in str(raised)
1038+
9831039
@pytest.mark.asyncio
9841040
async def test_customer_flow_falls_back_to_system_token(self, mock_tool):
9851041
"""Customer flow falls back to system token when user_token is None."""

‎tests/agentgateway/unit/test_compat.py‎

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,10 @@
22

33
from types import SimpleNamespace
44

5-
from mcp.shared.exceptions import McpError
6-
from mcp.types import ErrorData
7-
85
from sap_cloud_sdk.agentgateway._compat import (
6+
find_malformed_result_error,
97
find_mcp_error,
8+
make_mcp_error,
109
mcp_input_schema,
1110
mcp_is_error,
1211
mcp_server_name,
@@ -98,18 +97,18 @@ class TestFindMcpError:
9897

9998
def test_returns_bare_mcp_error(self):
10099
"""A bare McpError is returned directly."""
101-
err = McpError(ErrorData(code=-32602, message="Unknown tool: foo"))
100+
err = make_mcp_error(-32602, "Unknown tool: foo")
102101
assert find_mcp_error(err) is err
103102

104103
def test_finds_mcp_error_in_group(self):
105104
"""An McpError wrapped in a BaseExceptionGroup is found."""
106-
err = McpError(ErrorData(code=-32603, message="Internal Server Error"))
105+
err = make_mcp_error(-32603, "Internal Server Error")
107106
group = BaseExceptionGroup("eg", [err])
108107
assert find_mcp_error(group) is err
109108

110109
def test_finds_mcp_error_in_nested_group(self):
111110
"""An McpError nested in a group within a group is found."""
112-
err = McpError(ErrorData(code=-32600, message="bad request"))
111+
err = make_mcp_error(-32600, "bad request")
113112
group = BaseExceptionGroup("outer", [BaseExceptionGroup("inner", [err])])
114113
assert find_mcp_error(group) is err
115114

@@ -121,3 +120,42 @@ def test_returns_none_without_mcp_error(self):
121120
def test_returns_none_for_unrelated_exception(self):
122121
"""A plain non-group, non-McpError exception returns None."""
123122
assert find_mcp_error(ValueError("x")) is None
123+
124+
125+
class TestFindMalformedResultError:
126+
"""Tests for find_malformed_result_error — the isError/is_error-on-None crash."""
127+
128+
def test_returns_bare_isError_attributeerror(self):
129+
"""The mcp 1.x deref: AttributeError mentioning 'isError' is recognised."""
130+
err = AttributeError("'NoneType' object has no attribute 'isError'")
131+
assert find_malformed_result_error(err) is err
132+
133+
def test_returns_bare_is_error_attributeerror(self):
134+
"""The mcp 2.x deref: AttributeError mentioning 'is_error' is recognised."""
135+
err = AttributeError("'NoneType' object has no attribute 'is_error'")
136+
assert find_malformed_result_error(err) is err
137+
138+
def test_finds_attributeerror_in_group(self):
139+
"""Wrapped in a BaseExceptionGroup (anyio task group), it is still found."""
140+
err = AttributeError("'NoneType' object has no attribute 'isError'")
141+
group = BaseExceptionGroup("eg", [err])
142+
assert find_malformed_result_error(group) is err
143+
144+
def test_finds_attributeerror_in_nested_group(self):
145+
"""Found through nested BaseExceptionGroups."""
146+
err = AttributeError("'NoneType' object has no attribute 'isError'")
147+
group = BaseExceptionGroup("outer", [BaseExceptionGroup("inner", [err])])
148+
assert find_malformed_result_error(group) is err
149+
150+
def test_returns_none_for_unrelated_attributeerror(self):
151+
"""An AttributeError not about isError/is_error is NOT matched (not masked)."""
152+
assert find_malformed_result_error(AttributeError("no attr 'bar'")) is None
153+
154+
def test_returns_none_for_unrelated_exception(self):
155+
"""A non-AttributeError exception returns None."""
156+
assert find_malformed_result_error(ValueError("x")) is None
157+
158+
def test_returns_none_without_match_in_group(self):
159+
"""A group with no matching AttributeError leaf returns None."""
160+
group = BaseExceptionGroup("eg", [ValueError("x"), RuntimeError("y")])
161+
assert find_malformed_result_error(group) is None

0 commit comments

Comments
 (0)