Skip to content

Commit 3d4fc6b

Browse files
committed
Refactor MCP session management and update error handling
- Introduced a new module `_mcp_session.py` to encapsulate MCP session management logic. - Added `invoke_mcp_tool` function to handle tool invocation with improved error handling. - Updated test cases in `test_lob.py` to reflect changes in error messages and patching for MCP session. - Updated package version to 0.26.1 in `uv.lock`.
1 parent 9e029a6 commit 3d4fc6b

9 files changed

Lines changed: 294 additions & 95 deletions

File tree

src/sap_cloud_sdk/agentgateway/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
from sap_cloud_sdk.agentgateway.agw_client import create_client, AgentGatewayClient
5858
from sap_cloud_sdk.agentgateway.exceptions import (
5959
AgentGatewaySDKError,
60+
AgentGatewayServerError,
6061
MCPServerNotFoundError,
6162
)
6263

@@ -73,5 +74,6 @@
7374
"MCPTool",
7475
# Exceptions
7576
"AgentGatewaySDKError",
77+
"AgentGatewayServerError",
7678
"MCPServerNotFoundError",
7779
]

src/sap_cloud_sdk/agentgateway/_customer.py

Lines changed: 7 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
IntegrationDependency,
2525
MCPTool,
2626
)
27+
from sap_cloud_sdk.agentgateway._mcp_session import invoke_mcp_tool
2728
from sap_cloud_sdk.agentgateway._token_cache import _TokenCache
2829
from sap_cloud_sdk.agentgateway.exceptions import AgentGatewaySDKError
2930

@@ -64,6 +65,7 @@ class _CredentialFields:
6465
GATEWAY_URL = "gatewayUrl"
6566
INTEGRATION_DEPENDENCIES = "integrationDependencies"
6667
ORD_ID = "ordId"
68+
DATA = "data"
6769
GLOBAL_TENANT_ID = "globalTenantId"
6870

6971

@@ -151,7 +153,10 @@ def load_customer_credentials(path: str) -> CustomerCredentials:
151153
integration_deps = [
152154
IntegrationDependency(
153155
ord_id=dep[_CredentialFields.ORD_ID],
154-
global_tenant_id=dep[_CredentialFields.GLOBAL_TENANT_ID],
156+
global_tenant_id=(
157+
dep.get(_CredentialFields.GLOBAL_TENANT_ID)
158+
or dep[_CredentialFields.DATA][_CredentialFields.GLOBAL_TENANT_ID]
159+
),
155160
)
156161
for dep in data[_CredentialFields.INTEGRATION_DEPENDENCIES]
157162
]
@@ -256,11 +261,6 @@ def _request_token_mtls(
256261
"resource": _AGW_RESOURCE_URN,
257262
}
258263

259-
# TODO: app_tid requirement is still being clarified with the IBD team.
260-
# This parameter may be removed if it turns out to be unnecessary.
261-
if app_tid:
262-
data["app_tid"] = app_tid
263-
264264
if extra_data:
265265
data.update(extra_data)
266266

@@ -565,26 +565,4 @@ async def call_mcp_tool_customer(
565565
Tool execution result as string.
566566
"""
567567
logger.info("Calling tool '%s' on server '%s'", tool.name, tool.server_name)
568-
569-
async with httpx.AsyncClient(
570-
headers={
571-
"Authorization": f"Bearer {auth_token}",
572-
"x-correlation-id": str(uuid.uuid4()),
573-
},
574-
timeout=timeout,
575-
) as http_client:
576-
async with streamable_http_client(tool.url, http_client=http_client) as (
577-
read,
578-
write,
579-
_,
580-
):
581-
async with ClientSession(read, write) as session:
582-
await session.initialize()
583-
result = await session.call_tool(tool.name, kwargs)
584-
585-
if not result.content:
586-
logger.warning("Tool '%s' returned empty content", tool.name)
587-
return ""
588-
589-
first = result.content[0]
590-
return str(getattr(first, "text", ""))
568+
return await invoke_mcp_tool(tool, auth_token, timeout, **kwargs)

src/sap_cloud_sdk/agentgateway/_lob.py

Lines changed: 14 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
)
2424

2525
from sap_cloud_sdk.agentgateway._models import MCPTool
26+
from sap_cloud_sdk.agentgateway._mcp_session import invoke_mcp_tool
2627
from sap_cloud_sdk.agentgateway._token_cache import _GatewayUrlCache, _TokenCache
2728
from sap_cloud_sdk.agentgateway.exceptions import MCPServerNotFoundError
2829

@@ -148,19 +149,7 @@ def get_ias_fragment_name(tenant_subdomain: str) -> str:
148149
Raises:
149150
MCPServerNotFoundError: If no IAS fragment is found.
150151
"""
151-
client = create_fragment_client(instance=_DESTINATION_INSTANCE)
152-
fragments = client.list_instance_fragments(
153-
filter=ListOptions(
154-
filter_labels=[Label(key=_LABEL_KEY, values=[_IAS_LABEL_VALUE])]
155-
),
156-
tenant=tenant_subdomain,
157-
)
158-
if not fragments:
159-
raise MCPServerNotFoundError(
160-
f"No IAS fragment found (label {_LABEL_KEY}={_IAS_LABEL_VALUE}) "
161-
f"for tenant '{tenant_subdomain}'"
162-
)
163-
return fragments[0].name
152+
return _get_labeled_fragment_name(tenant_subdomain, _IAS_LABEL_VALUE)
164153

165154

166155
def get_ias_user_fragment_name(tenant_subdomain: str) -> str:
@@ -178,16 +167,25 @@ def get_ias_user_fragment_name(tenant_subdomain: str) -> str:
178167
Raises:
179168
MCPServerNotFoundError: If no IAS user fragment is found.
180169
"""
170+
return _get_labeled_fragment_name(tenant_subdomain, _IAS_USER_LABEL_VALUE)
171+
172+
173+
def _get_labeled_fragment_name(tenant_subdomain: str, label_value: str) -> str:
174+
"""Return the name of the first fragment matching a managed-runtime label.
175+
176+
Raises:
177+
MCPServerNotFoundError: If no matching fragment is found.
178+
"""
181179
client = create_fragment_client(instance=_DESTINATION_INSTANCE)
182180
fragments = client.list_instance_fragments(
183181
filter=ListOptions(
184-
filter_labels=[Label(key=_LABEL_KEY, values=[_IAS_USER_LABEL_VALUE])]
182+
filter_labels=[Label(key=_LABEL_KEY, values=[label_value])]
185183
),
186184
tenant=tenant_subdomain,
187185
)
188186
if not fragments:
189187
raise MCPServerNotFoundError(
190-
f"No IAS user fragment found (label {_LABEL_KEY}={_IAS_USER_LABEL_VALUE}) "
188+
f"No fragment found (label {_LABEL_KEY}={label_value}) "
191189
f"for tenant '{tenant_subdomain}'"
192190
)
193191
return fragments[0].name
@@ -462,23 +460,4 @@ async def call_mcp_tool_lob(
462460
Returns:
463461
Tool execution result as string.
464462
"""
465-
async with httpx.AsyncClient(
466-
headers={
467-
"Authorization": f"Bearer {user_auth_token}",
468-
"x-correlation-id": str(uuid.uuid4()),
469-
},
470-
timeout=timeout,
471-
) as http_client:
472-
async with streamable_http_client(tool.url, http_client=http_client) as (
473-
read,
474-
write,
475-
_,
476-
):
477-
async with ClientSession(read, write) as session:
478-
await session.initialize()
479-
result = await session.call_tool(tool.name, kwargs)
480-
if not result.content:
481-
logger.warning("Tool '%s' returned empty content", tool.name)
482-
return ""
483-
first = result.content[0]
484-
return str(getattr(first, "text", ""))
463+
return await invoke_mcp_tool(tool, user_auth_token, timeout, **kwargs)
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
"""Shared helpers for MCP session management."""
2+
3+
import uuid
4+
5+
import httpx
6+
from mcp import ClientSession, McpError
7+
from mcp.client.streamable_http import streamable_http_client
8+
9+
from sap_cloud_sdk.agentgateway._models import MCPTool
10+
from sap_cloud_sdk.agentgateway.exceptions import AgentGatewayServerError
11+
12+
13+
async def invoke_mcp_tool(tool: MCPTool, auth_token: str, timeout: float, **kwargs) -> str:
14+
"""Open an MCP session, call a tool, and return its text result.
15+
16+
Handles McpError from both initialize() and call_tool(), and checks
17+
result.isError, raising AgentGatewayServerError in all three cases.
18+
19+
Args:
20+
tool: MCPTool to invoke.
21+
auth_token: Raw bearer token for the Authorization header.
22+
timeout: HTTP timeout in seconds.
23+
**kwargs: Tool input parameters forwarded to call_tool().
24+
25+
Returns:
26+
Tool result as a string, or "" if the response has no content.
27+
28+
Raises:
29+
AgentGatewayServerError: If the server returns any kind of error.
30+
"""
31+
async with httpx.AsyncClient(
32+
headers={
33+
"Authorization": f"Bearer {auth_token}",
34+
"x-correlation-id": str(uuid.uuid4()),
35+
},
36+
timeout=timeout,
37+
) as http_client:
38+
async with streamable_http_client(tool.url, http_client=http_client) as (
39+
read,
40+
write,
41+
_,
42+
):
43+
async with ClientSession(read, write) as session:
44+
try:
45+
await session.initialize()
46+
except McpError as e:
47+
raise AgentGatewayServerError(
48+
f"Agent Gateway rejected MCP session for tool '{tool.name}': {e.error.message}",
49+
error_code=e.error.code,
50+
) from e
51+
try:
52+
result = await session.call_tool(tool.name, kwargs)
53+
except McpError as e:
54+
raise AgentGatewayServerError(
55+
f"Agent Gateway returned error for tool '{tool.name}': {e.error.message}",
56+
error_code=e.error.code,
57+
) from e
58+
if result.isError:
59+
raise AgentGatewayServerError(
60+
f"Tool '{tool.name}' returned an error: {_error_text(result.content)}"
61+
)
62+
if not result.content:
63+
return ""
64+
return str(getattr(result.content[0], "text", ""))
65+
66+
67+
def _error_text(content: list) -> str:
68+
"""Extract a human-readable message from MCP error content blocks."""
69+
texts = [getattr(block, "text", None) for block in content]
70+
message = " ".join(t for t in texts if t)
71+
return message or "unknown error"

src/sap_cloud_sdk/agentgateway/agw_client.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -361,7 +361,6 @@ async def list_mcp_tools(
361361
)
362362

363363
except AgentGatewaySDKError:
364-
# Re-raise SDK errors as-is
365364
raise
366365
except Exception as e:
367366
logger.exception("Unexpected error during tool discovery")

src/sap_cloud_sdk/agentgateway/exceptions.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,22 @@ class MCPServerNotFoundError(AgentGatewaySDKError):
2222
"""
2323

2424
pass
25+
26+
27+
class AgentGatewayServerError(AgentGatewaySDKError):
28+
"""Raised when the Agent Gateway server returns an error response.
29+
30+
This error occurs when:
31+
- The MCP server card is not found in the registry
32+
- The server returns a JSON-RPC error (e.g. code -32600)
33+
- A tool invocation returns an error result (isError=True)
34+
35+
Attributes:
36+
error_code: JSON-RPC error code, if available.
37+
server_message: The raw error message from the server.
38+
"""
39+
40+
def __init__(self, message: str, error_code: int | None = None):
41+
super().__init__(message)
42+
self.error_code = error_code
43+
self.server_message = message

0 commit comments

Comments
 (0)