diff --git a/pyproject.toml b/pyproject.toml index 440b6c07..aa778f3e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sap-cloud-sdk" -version = "0.31.0" +version = "0.32.0" description = "SAP Cloud SDK for Python" readme = "README.md" license = "Apache-2.0" diff --git a/src/sap_cloud_sdk/agentgateway/__init__.py b/src/sap_cloud_sdk/agentgateway/__init__.py index e70b7275..69c5f1e8 100644 --- a/src/sap_cloud_sdk/agentgateway/__init__.py +++ b/src/sap_cloud_sdk/agentgateway/__init__.py @@ -63,6 +63,7 @@ from sap_cloud_sdk.agentgateway.agw_client import create_client, AgentGatewayClient from sap_cloud_sdk.agentgateway.exceptions import ( AgentGatewaySDKError, + AgentGatewayServerError, MCPServerNotFoundError, ) @@ -82,5 +83,6 @@ "AgentCardFilter", # Exceptions "AgentGatewaySDKError", + "AgentGatewayServerError", "MCPServerNotFoundError", ] diff --git a/src/sap_cloud_sdk/agentgateway/_customer.py b/src/sap_cloud_sdk/agentgateway/_customer.py index be5019ea..dd846ced 100644 --- a/src/sap_cloud_sdk/agentgateway/_customer.py +++ b/src/sap_cloud_sdk/agentgateway/_customer.py @@ -26,14 +26,20 @@ ) from sap_cloud_sdk.agentgateway._token_cache import _TokenCache from sap_cloud_sdk.agentgateway.exceptions import AgentGatewaySDKError +from sap_cloud_sdk.core.secret_resolver import resolve_base_mount logger = logging.getLogger(__name__) -# Environment variable to override default credential path +# Environment variable to override default credential path (points directly to credentials file) _CREDENTIALS_PATH_ENV = "AGW_CREDENTIALS_PATH" -# Default credential path for Kyma production deployments -_CREDENTIALS_DEFAULT_PATH = "/etc/ums/credentials/credentials" +# servicebinding.io: scan $SERVICE_BINDING_ROOT for a binding whose 'type' file equals the expected type +_BINDING_TYPE = "integration-credentials" +_BINDING_TYPE_FILE = "type" +_CREDENTIALS_FILE = "credentials" + +# Kyma default when SERVICE_BINDING_ROOT is not set +_DEFAULT_BINDING_ROOT = "/bindings" # Resource URN for Agent Gateway token scope (hardcoded - production value) _AGW_RESOURCE_URN = "urn:sap:identity:application:provider:name:agent-gateway" @@ -66,24 +72,38 @@ def detect_customer_agent_credentials() -> str | None: """Check if customer agent credentials file exists. Checks for credential file in the following order: - 1. Path specified in AGW_CREDENTIALS_PATH env var - 2. Default mounted path: /etc/ums/credentials/credentials + 1. Path specified in AGW_CREDENTIALS_PATH env var (explicit override, points to file directly) + 2. $SERVICE_BINDING_ROOT (or /bindings if unset): scans all subdirectories for one whose + 'type' file contains 'integration-credentials', then reads 'credentials' from that directory Returns: Path to credentials file if found, None otherwise. """ - # Check env var first (path may be customized) + # 1. Explicit override via env var path_from_env = os.environ.get(_CREDENTIALS_PATH_ENV) if path_from_env and os.path.isfile(path_from_env): logger.debug("Customer credentials found at env var path: %s", path_from_env) return path_from_env - # Check default mounted path - if os.path.isfile(_CREDENTIALS_DEFAULT_PATH): - logger.debug( - "Customer credentials found at default path: %s", _CREDENTIALS_DEFAULT_PATH - ) - return _CREDENTIALS_DEFAULT_PATH + # 2. servicebinding.io: scan $SERVICE_BINDING_ROOT for a binding whose 'type' file equals _BINDING_TYPE + sbr = resolve_base_mount(_DEFAULT_BINDING_ROOT) + if sbr and os.path.isdir(sbr): + for entry in os.scandir(sbr): + if not entry.is_dir(): + continue + type_file = os.path.join(entry.path, _BINDING_TYPE_FILE) + if not os.path.isfile(type_file): + continue + with open(type_file) as f: + if f.read().strip() != _BINDING_TYPE: + continue + credentials_path = os.path.join(entry.path, _CREDENTIALS_FILE) + if os.path.isfile(credentials_path): + logger.debug( + "Customer credentials found via servicebinding.io type scan: %s", + credentials_path, + ) + return credentials_path return None @@ -128,16 +148,17 @@ def load_customer_credentials(path: str) -> CustomerCredentials: if _CredentialFields.INTEGRATION_DEPENDENCIES not in data: raise AgentGatewaySDKError( "Credentials file missing required field: integrationDependencies. " - 'Expected format: [{"ordId": "...", "data": {"globalTenantId": "..."}}]' + 'Expected format: [{"ordId": "...", "globalTenantId": "..."}]' ) try: integration_deps = [ IntegrationDependency( ord_id=dep[_CredentialFields.ORD_ID], - global_tenant_id=dep[_CredentialFields.DATA][ - _CredentialFields.GLOBAL_TENANT_ID - ], + global_tenant_id=( + dep.get(_CredentialFields.GLOBAL_TENANT_ID) + or dep[_CredentialFields.DATA][_CredentialFields.GLOBAL_TENANT_ID] + ), ) for dep in data[_CredentialFields.INTEGRATION_DEPENDENCIES] ] @@ -148,7 +169,7 @@ def load_customer_credentials(path: str) -> CustomerCredentials: except (KeyError, TypeError) as e: raise AgentGatewaySDKError( f"Failed to parse integrationDependencies: {e}. " - 'Expected format: [{"ordId": "...", "data": {"globalTenantId": "..."}}]' + 'Expected format: [{"ordId": "...", "globalTenantId": "..."}]' ) return CustomerCredentials( @@ -216,7 +237,6 @@ def _request_token_mtls( credentials: CustomerCredentials, grant_type: str, timeout: float, - app_tid: str | None = None, extra_data: dict | None = None, ) -> dict: """Make mTLS token request to IAS. @@ -225,7 +245,6 @@ def _request_token_mtls( credentials: Customer credentials with certificate and private key. grant_type: OAuth2 grant type. timeout: HTTP timeout in seconds. - app_tid: BTP Application Tenant ID of subscriber (optional). extra_data: Additional form data for the token request. Returns: @@ -242,11 +261,6 @@ def _request_token_mtls( "resource": _AGW_RESOURCE_URN, } - # TODO: app_tid requirement is still being clarified with the IBD team. - # This parameter may be removed if it turns out to be unnecessary. - if app_tid: - data["app_tid"] = app_tid - if extra_data: data.update(extra_data) @@ -326,7 +340,6 @@ def get_system_token_mtls( credentials, grant_type=_GRANT_TYPE_CLIENT_CREDENTIALS, timeout=timeout, - app_tid=app_tid, extra_data={"response_type": "token"}, ) access_token = token_data["access_token"] @@ -372,15 +385,17 @@ def exchange_user_token( return cached_token logger.info("Exchanging user token for AGW-scoped token via jwt-bearer grant") + extra: dict = { + "assertion": user_token, + "token_format": "jwt", + } + if app_tid: + extra["app_tid"] = app_tid token_data = _request_token_mtls( credentials, grant_type=_GRANT_TYPE_JWT_BEARER, timeout=timeout, - app_tid=app_tid, - extra_data={ - "assertion": user_token, - "token_format": "jwt", - }, + extra_data=extra, ) access_token = token_data["access_token"] @@ -476,26 +491,64 @@ async def _list_server_tools( ] +def _resolve_dependency( + credentials: CustomerCredentials, + ord_id: str, +) -> IntegrationDependency: + """Resolve a single IntegrationDependency by ORD ID. + + Args: + credentials: Customer credentials with integrationDependencies. + ord_id: ORD ID to look up. + + Returns: + The matching IntegrationDependency. + + Raises: + AgentGatewaySDKError: If no match is found or multiple entries share the same ORD ID. + """ + matches = [d for d in credentials.integration_dependencies if d.ord_id == ord_id] + if not matches: + available = ", ".join(d.ord_id for d in credentials.integration_dependencies) + raise AgentGatewaySDKError( + f"ORD ID '{ord_id}' not found in integrationDependencies. " + f"Available: {available or '(none)'}" + ) + if len(matches) > 1: + tids = ", ".join(m.global_tenant_id for m in matches) + raise AgentGatewaySDKError( + f"ORD ID '{ord_id}' matches multiple integrationDependencies with " + f"different tenant IDs ({tids}). Specify the tenant ID explicitly." + ) + return matches[0] + + async def get_mcp_tools_customer( credentials: CustomerCredentials, system_token: str, timeout: float, + ord_id: str | None = None, ) -> list[MCPTool]: - """List all MCP tools from servers defined in credentials. + """List MCP tools from servers defined in credentials. - Iterates over all integrationDependencies in the credentials file and - discovers tools from each MCP server using a pre-fetched system token. + When ord_id is given, only tools from that single dependency are returned + and the tenant ID is derived automatically from the credentials. + When ord_id is omitted, tools from all integrationDependencies are returned. Args: credentials: Customer credentials with integrationDependencies. system_token: Pre-fetched raw system token for authentication. timeout: HTTP timeout in seconds for MCP server calls. + ord_id: Optional ORD ID to filter to a single server. The tenant ID + is derived from the credentials — no need to pass it separately. + Raises AgentGatewaySDKError if the ORD ID matches multiple entries. Returns: - List of MCPTool objects from all servers. + List of MCPTool objects from the matching server(s). Raises: - AgentGatewaySDKError: If integrationDependencies is empty. + AgentGatewaySDKError: If integrationDependencies is empty, or if ord_id + is given but not found or matches multiple tenant IDs. """ dependencies = credentials.integration_dependencies @@ -504,6 +557,9 @@ async def get_mcp_tools_customer( "integrationDependencies is empty in credentials — no MCP servers configured." ) + if ord_id is not None: + dependencies = [_resolve_dependency(credentials, ord_id)] + logger.info("Discovering tools from %d MCP server(s)", len(dependencies)) tools: list[MCPTool] = [] diff --git a/src/sap_cloud_sdk/agentgateway/_mcp_session.py b/src/sap_cloud_sdk/agentgateway/_mcp_session.py new file mode 100644 index 00000000..c704648e --- /dev/null +++ b/src/sap_cloud_sdk/agentgateway/_mcp_session.py @@ -0,0 +1,93 @@ +"""Shared helpers for MCP session management.""" + +import logging +import uuid + +import httpx +from mcp import ClientSession, McpError +from mcp.client.streamable_http import streamable_http_client + +from sap_cloud_sdk.agentgateway._models import MCPTool +from sap_cloud_sdk.agentgateway.exceptions import AgentGatewayServerError + +logger = logging.getLogger(__name__) + + +async def invoke_mcp_tool( + tool: MCPTool, auth_token: str, timeout: float, **kwargs +) -> str: + """Open an MCP session, call a tool, and return its text result. + + Handles McpError from both initialize() and call_tool(), and checks + result.isError, raising AgentGatewayServerError in all three cases. + + Args: + tool: MCPTool to invoke. + auth_token: Raw bearer token for the Authorization header. + timeout: HTTP timeout in seconds. + **kwargs: Tool input parameters forwarded to call_tool(). + + Returns: + Tool result as a string, or "" if the response has no content. + + Raises: + AgentGatewayServerError: If the server returns any kind of error. + """ + async with httpx.AsyncClient( + headers={ + "Authorization": f"Bearer {auth_token}", + "x-correlation-id": str(uuid.uuid4()), + }, + timeout=timeout, + ) as http_client: + try: + async with streamable_http_client(tool.url, http_client=http_client) as ( + read, + write, + _, + ): + async with ClientSession(read, write) as session: + try: + await session.initialize() + except McpError as e: + raise AgentGatewayServerError( + f"Agent Gateway rejected MCP session for tool '{tool.name}': {e.error.message}", + error_code=e.error.code, + ) from e + try: + result = await session.call_tool(tool.name, kwargs) + except McpError as e: + raise AgentGatewayServerError( + f"Agent Gateway returned error for tool '{tool.name}': {e.error.message}", + error_code=e.error.code, + ) from e + if result is None: + logger.warning("Tool '%s' returned a null result", tool.name) + return "" + if result.isError: + raise AgentGatewayServerError( + f"Tool '{tool.name}' returned an error: {_error_text(result.content)}" + ) + if not result.content: + return "" + return str(getattr(result.content[0], "text", "")) + except BaseExceptionGroup as eg: + # anyio wraps task-group exceptions into ExceptionGroups. If the only + # leaf exception is AttributeError it means an older MCP library version + # crashed on a null result body inside call_tool. Re-raise anything else. + attr_errors, rest = eg.split(AttributeError) + if rest is not None: + raise + logger.warning( + "Tool '%s' returned a null result (MCP null-result bug: %s)", + tool.name, + attr_errors, + ) + return "" + + +def _error_text(content: list) -> str: + """Extract a human-readable message from MCP error content blocks.""" + texts = [getattr(block, "text", None) for block in content] + message = " ".join(t for t in texts if t) + return message or "unknown error" diff --git a/src/sap_cloud_sdk/agentgateway/agw_client.py b/src/sap_cloud_sdk/agentgateway/agw_client.py index 411ff4a8..2eabb540 100644 --- a/src/sap_cloud_sdk/agentgateway/agw_client.py +++ b/src/sap_cloud_sdk/agentgateway/agw_client.py @@ -300,6 +300,7 @@ async def list_mcp_tools( self, user_token: str | Callable[[], str] | None = None, app_tid: str | None = None, + ord_id: str | None = None, ) -> list[MCPTool]: """List all MCP tools from MCP servers. @@ -320,12 +321,17 @@ async def list_mcp_tools( If provided, uses user-scoped auth instead of system auth. app_tid: BTP Application Tenant ID of the subscriber. Only used for customer agents. + ord_id: ORD ID of the MCP server to filter to (customer agents only). + The tenant ID is derived automatically from the credentials. + When omitted, tools from all integrationDependencies are returned. + Raises AgentGatewaySDKError if the ORD ID matches multiple entries. Returns: List of MCPTool objects from all MCP servers. Raises: - AgentGatewaySDKError: If credential loading or token acquisition fails. + AgentGatewaySDKError: If credential loading or token acquisition fails, + or if ord_id does not match exactly one entry in integrationDependencies. Example: ```python @@ -333,6 +339,11 @@ async def list_mcp_tools( for tool in tools: print(f"{tool.name}: {tool.description}") + # Filter to a specific ORD (tenant ID resolved from credentials): + tools = await agw_client.list_mcp_tools( + ord_id="sap.s4:apiResource:API_PRODUCT_0002_MCP:v1" + ) + # With user token for principal propagation: tools = await agw_client.list_mcp_tools(user_token="user-jwt") ``` @@ -350,7 +361,7 @@ async def list_mcp_tools( else: auth = await self.get_system_auth(app_tid=app_tid) return await get_mcp_tools_customer( - credentials, auth.access_token, self._config.timeout + credentials, auth.access_token, self._config.timeout, ord_id=ord_id ) # LoB flow - requires tenant_subdomain @@ -367,7 +378,6 @@ async def list_mcp_tools( ) except AgentGatewaySDKError: - # Re-raise SDK errors as-is raise except Exception as e: logger.exception("Unexpected error during tool discovery") @@ -443,9 +453,10 @@ async def list_agent_cards( @record_metrics(Module.AGENTGATEWAY, Operation.AGENTGATEWAY_CALL_MCP_TOOL) async def call_mcp_tool( self, - tool: MCPTool, + tool: MCPTool | str, user_token: str | Callable[[], str] | None = None, app_tid: str | None = None, + ord_id: str | None = None, **kwargs, ) -> str: """Invoke an MCP tool. @@ -460,7 +471,9 @@ async def call_mcp_tool( provided, falls back to system token (no principal propagation). Args: - tool: MCPTool object (from list_mcp_tools). + tool: MCPTool object (from list_mcp_tools) or tool name as a string. + When a string is given, list_mcp_tools is called first to resolve + the MCPTool. For customer agents, ord_id must also be provided. user_token: User's JWT for principal propagation. Can be a string or a callable returning a string. Required for LoB agents. @@ -470,6 +483,8 @@ async def call_mcp_tool( for tenant-scoped token exchange. TODO: This parameter's requirement is still being clarified with the IBD team and may be removed if unnecessary. + ord_id: ORD ID used to resolve the MCPTool when tool is given as a string + (customer agents only). The tenant ID is derived from the credentials. **kwargs: Tool input parameters (passed directly to the tool). Returns: @@ -477,16 +492,23 @@ async def call_mcp_tool( Raises: AgentGatewaySDKError: If user_token or tenant_subdomain is required - but not provided (LoB flow), or if token exchange/tool invocation fails. + but not provided (LoB flow), if tool name cannot be resolved, or if + token exchange/tool invocation fails. Example: ```python - # Note: kwargs are tool-specific input parameters. - # Check tool.input_schema for expected parameters. + # Pass an MCPTool object directly: result = await agw_client.call_mcp_tool( tool=tools[0], user_token="user-jwt", - order_id="12345", # example tool-specific parameter + order_id="12345", + ) + + # Or pass the tool name as a string (ord_id required for customer agents): + result = await agw_client.call_mcp_tool( + tool="list_ProductPlantCosting_for_sap_self", + ord_id="sap.s4:apiResource:API_PRODUCT_0002_MCP:v1", + user_token="user-jwt", ) ``` """ @@ -498,6 +520,11 @@ async def call_mcp_tool( "Customer agent credentials detected at '%s'", credentials_path ) + if isinstance(tool, str): + tool = await self._resolve_tool_by_name( + tool, ord_id, user_token, app_tid + ) + # Resolve user_token if provided (optional for customer flow) if user_token: auth = await self.get_user_auth(user_token, app_tid) @@ -519,6 +546,11 @@ async def call_mcp_tool( if app_tid: logger.warning("app_tid parameter ignored for LoB agent flow") + if isinstance(tool, str): + tool = await self._resolve_tool_by_name( + tool, ord_id, user_token, app_tid + ) + auth = await self.get_user_auth(user_token, app_tid) return await call_mcp_tool_lob( tool, auth.access_token, self._config.timeout, **kwargs @@ -530,10 +562,44 @@ async def call_mcp_tool( except Exception as 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.name}': {cause}" + f"Tool invocation failed for '{tool_label}': {cause}" ) from e + async def _resolve_tool_by_name( + self, + tool_name: str, + ord_id: str | None, + user_token: str | Callable[[], str] | None, + app_tid: str | None, + ) -> MCPTool: + """Look up a tool by name via list_mcp_tools. + + Args: + tool_name: Name of the tool to resolve. + ord_id: ORD ID to narrow the search (customer agents). + user_token: Forwarded to list_mcp_tools for auth. + app_tid: Forwarded to list_mcp_tools for auth. + + Returns: + The matching MCPTool. + + Raises: + AgentGatewaySDKError: If no tool with that name is found. + """ + tools = await self.list_mcp_tools( + user_token=user_token, app_tid=app_tid, ord_id=ord_id + ) + match = next((t for t in tools if t.name == tool_name), None) + if match is None: + available = ", ".join(t.name for t in tools[:10]) + raise AgentGatewaySDKError( + f"Tool '{tool_name}' not found. " + f"Available tools{' (first 10)' if len(tools) > 10 else ''}: {available or '(none)'}" + ) + return match + def _unwrap_exception_group(exc: BaseException) -> BaseException: """Unwrap nested ExceptionGroups to present meaningful error messages.""" diff --git a/src/sap_cloud_sdk/agentgateway/exceptions.py b/src/sap_cloud_sdk/agentgateway/exceptions.py index 5d96e21f..b88a017c 100644 --- a/src/sap_cloud_sdk/agentgateway/exceptions.py +++ b/src/sap_cloud_sdk/agentgateway/exceptions.py @@ -22,3 +22,22 @@ class MCPServerNotFoundError(AgentGatewaySDKError): """ pass + + +class AgentGatewayServerError(AgentGatewaySDKError): + """Raised when the Agent Gateway server returns an error response. + + This error occurs when: + - The MCP server card is not found in the registry + - The server returns a JSON-RPC error (e.g. code -32600) + - A tool invocation returns an error result (isError=True) + + Attributes: + error_code: JSON-RPC error code, if available. + server_message: The raw error message from the server. + """ + + def __init__(self, message: str, error_code: int | None = None): + super().__init__(message) + self.error_code = error_code + self.server_message = message diff --git a/src/sap_cloud_sdk/agentgateway/user-guide.md b/src/sap_cloud_sdk/agentgateway/user-guide.md index 813ed2e0..245feda8 100644 --- a/src/sap_cloud_sdk/agentgateway/user-guide.md +++ b/src/sap_cloud_sdk/agentgateway/user-guide.md @@ -18,21 +18,48 @@ pip install sap-cloud-sdk[langchain] Customer agents use file-based credentials with mTLS authentication. MCP servers are read from `integrationDependencies` in the credentials file. +#### Credential Detection + +The SDK looks for credentials in the following order: + +1. **`AGW_CREDENTIALS_PATH`** env var — direct path to a JSON credentials file. +2. **`SERVICE_BINDING_ROOT`** env var — scans all subdirectories for one whose `type` file contains `integration-credentials`, then reads `credentials` from that directory (servicebinding.io format). +3. **`/bindings`** — same scan as above, used as the default when `SERVICE_BINDING_ROOT` is not set. + +**servicebinding.io layout** (used on BTP Kyma / Kubernetes): + +``` +$SERVICE_BINDING_ROOT/ +└── my-agw-binding/ + ├── type # must contain "integration-credentials" + ├── instance_name # optional, ignored by the SDK + └── credentials # JSON credentials object +``` + +**Flat file** (used with `AGW_CREDENTIALS_PATH`): + +``` +/path/to/credentials.json # JSON credentials object +``` + ```python from sap_cloud_sdk.agentgateway import create_client agw_client = create_client() -# Discover tools (reads all servers from credentials integrationDependencies) -tools = await agw_client.list_mcp_tools() +# Discover tools from all servers in integrationDependencies +tools = await agw_client.list_mcp_tools(user_token="user-jwt") for tool in tools: print(f"{tool.name}: {tool.description}") -# Discover tools with user principal propagation -tools = await agw_client.list_mcp_tools(user_token="user-jwt") +# Filter to a specific ORD ID — tenant ID is derived from credentials automatically +tools = await agw_client.list_mcp_tools( + user_token="user-jwt", + ord_id="sap.s4:apiResource:API_PRODUCT_0002_MCP:v1", +) -# Invoke a tool with user principal propagation +# Invoke a tool — pass the MCPTool object directly result = await agw_client.call_mcp_tool( tool=tools[0], user_token="user-jwt", @@ -40,6 +67,8 @@ result = await agw_client.call_mcp_tool( ) ``` +> **Note:** AGW currently requires a user token for all tool calls (principal propagation). Passing `user_token` is therefore required for customer agents. + ### LoB Agent Flow LoB agents use BTP Destination Service for credential management. Tools and A2A agents are auto-discovered from destination fragments. @@ -189,14 +218,14 @@ class AgentGatewayClient: async def list_mcp_tools( self, user_token: str | Callable[[], str] | None = None, - app_tid: str | None = None, + ord_id: str | None = None, ) -> list[MCPTool] async def call_mcp_tool( self, - tool: MCPTool, + tool: MCPTool | str, user_token: str | Callable[[], str] | None = None, - app_tid: str | None = None, + ord_id: str | None = None, **kwargs, ) -> str @@ -240,3 +269,19 @@ class MCPTool: url: str fragment_name: str | None ``` + +#### `list_mcp_tools` + +| Parameter | Type | Description | +|-----------|------|-------------| +| `user_token` | `str \| Callable[[], str] \| None` | User JWT for principal propagation. When provided, a jwt-bearer token exchange is performed instead of a system token request. | +| `ord_id` | `str \| None` | ORD ID to filter results to a single MCP server. The tenant ID is derived from the credentials — no need to pass it separately. Raises `AgentGatewaySDKError` if the ORD ID matches multiple `integrationDependencies` entries. | + +#### `call_mcp_tool` + +| Parameter | Type | Description | +|-----------|------|-------------| +| `tool` | `MCPTool \| str` | The tool to invoke. Pass an `MCPTool` object (from `list_mcp_tools`) or a tool name as a string. When a string is given, `list_mcp_tools` is called first to resolve the tool — provide `ord_id` to narrow the lookup. | +| `user_token` | `str \| Callable[[], str] \| None` | User JWT for principal propagation. Required for LoB agents; optional for customer agents (falls back to system token). | +| `ord_id` | `str \| None` | ORD ID used to resolve the tool when `tool` is given as a string. The tenant ID is derived from the credentials automatically. | +| `**kwargs` | | Tool input parameters forwarded directly to the MCP tool. See `tool.input_schema` for expected fields. | diff --git a/tests/agentgateway/unit/test_agw_client.py b/tests/agentgateway/unit/test_agw_client.py index b1541fa5..f7667c2d 100644 --- a/tests/agentgateway/unit/test_agw_client.py +++ b/tests/agentgateway/unit/test_agw_client.py @@ -521,7 +521,7 @@ async def test_customer_flow_passes_system_token(self): await agw_client.list_mcp_tools(app_tid="tid") mock_customer.assert_called_once_with( - mock_creds, "customer-system-token", 60.0 + mock_creds, "customer-system-token", 60.0, ord_id=None ) @pytest.mark.asyncio @@ -575,7 +575,7 @@ async def test_customer_flow_with_user_token_uses_user_auth(self): await agw_client.list_mcp_tools(user_token="user-jwt", app_tid="tid") mock_customer.assert_called_once_with( - mock_creds, "exchanged-user-token", 60.0 + mock_creds, "exchanged-user-token", 60.0, ord_id=None ) diff --git a/tests/agentgateway/unit/test_customer.py b/tests/agentgateway/unit/test_customer.py index 4469ab47..9069b9a5 100644 --- a/tests/agentgateway/unit/test_customer.py +++ b/tests/agentgateway/unit/test_customer.py @@ -11,11 +11,15 @@ get_system_token_mtls, exchange_user_token, get_mcp_tools_customer, - call_mcp_tool_customer, _build_mcp_url, + _resolve_dependency, _CREDENTIALS_PATH_ENV, - _CREDENTIALS_DEFAULT_PATH, + _BINDING_TYPE, + _BINDING_TYPE_FILE, + _CREDENTIALS_FILE, + _DEFAULT_BINDING_ROOT, ) +from sap_cloud_sdk.agentgateway._mcp_session import invoke_mcp_tool as call_mcp_tool_customer from sap_cloud_sdk.agentgateway._models import ( CustomerCredentials, IntegrationDependency, @@ -23,7 +27,7 @@ ) from sap_cloud_sdk.agentgateway._token_cache import _TokenCache from sap_cloud_sdk.agentgateway.config import ClientConfig -from sap_cloud_sdk.agentgateway.exceptions import AgentGatewaySDKError +from sap_cloud_sdk.agentgateway.exceptions import AgentGatewaySDKError, AgentGatewayServerError # ============================================================ @@ -34,6 +38,15 @@ class TestDetectCustomerAgentCredentials: """Tests for customer agent credential detection.""" + def _make_binding(self, parent, name="my-binding"): + """Create a servicebinding.io-compliant binding directory under parent.""" + binding_dir = parent / name + binding_dir.mkdir(parents=True, exist_ok=True) + (binding_dir / _BINDING_TYPE_FILE).write_text(_BINDING_TYPE) + creds_file = binding_dir / _CREDENTIALS_FILE + creds_file.write_text('{"clientid": "test"}') + return creds_file + def test_detect_from_env_var_path(self, tmp_path): """Detect credentials from path specified in environment variable.""" creds_file = tmp_path / "credentials.json" @@ -43,53 +56,82 @@ def test_detect_from_env_var_path(self, tmp_path): result = detect_customer_agent_credentials() assert result == str(creds_file) - def test_detect_from_env_var_path_file_not_exists(self): + def test_detect_from_env_var_path_file_not_exists(self, tmp_path): """Return None when env var path doesn't exist.""" - with patch.dict(os.environ, {_CREDENTIALS_PATH_ENV: "/nonexistent/path"}): + env = {_CREDENTIALS_PATH_ENV: "/nonexistent/path", "SERVICE_BINDING_ROOT": str(tmp_path)} + with patch.dict(os.environ, env, clear=False): result = detect_customer_agent_credentials() assert result is None - def test_detect_from_default_path(self): - """Detect credentials from default mounted path.""" - with patch.dict(os.environ, {}, clear=False): - # Remove env var if present + def test_detect_from_service_binding_root(self, tmp_path): + """Detect credentials by scanning SERVICE_BINDING_ROOT for a binding with matching type file.""" + creds_file = self._make_binding(tmp_path) + + env = {"SERVICE_BINDING_ROOT": str(tmp_path)} + with patch.dict(os.environ, env, clear=False): os.environ.pop(_CREDENTIALS_PATH_ENV, None) + result = detect_customer_agent_credentials() + assert result == str(creds_file) - with patch("os.path.isfile") as mock_isfile: - mock_isfile.side_effect = lambda p: p == _CREDENTIALS_DEFAULT_PATH + def test_detect_from_default_path(self, tmp_path): + """Detect credentials from default /bindings root when SERVICE_BINDING_ROOT is not set.""" + creds_file = self._make_binding(tmp_path) + with patch.dict(os.environ, {}, clear=False): + os.environ.pop(_CREDENTIALS_PATH_ENV, None) + os.environ.pop("SERVICE_BINDING_ROOT", None) + with patch("sap_cloud_sdk.agentgateway._customer._DEFAULT_BINDING_ROOT", str(tmp_path)): result = detect_customer_agent_credentials() - assert result == _CREDENTIALS_DEFAULT_PATH + assert result == str(creds_file) - def test_no_credentials_returns_none(self): - """Return None when no credentials are found.""" - with patch.dict(os.environ, {}, clear=False): + def test_skips_binding_with_wrong_type(self, tmp_path): + """Ignore binding directories whose type file does not match.""" + wrong_dir = tmp_path / "other-binding" + wrong_dir.mkdir() + (wrong_dir / _BINDING_TYPE_FILE).write_text("something-else") + (wrong_dir / _CREDENTIALS_FILE).write_text('{"clientid": "wrong"}') + + env = {"SERVICE_BINDING_ROOT": str(tmp_path)} + with patch.dict(os.environ, env, clear=False): os.environ.pop(_CREDENTIALS_PATH_ENV, None) + result = detect_customer_agent_credentials() + assert result is None - with patch("os.path.isfile", return_value=False): - result = detect_customer_agent_credentials() - assert result is None + def test_service_binding_root_takes_priority_over_default(self, tmp_path): + """SERVICE_BINDING_ROOT is checked before the hardcoded /bindings fallback.""" + sbr_dir = tmp_path / "sbr" + sbr_dir.mkdir() + creds_file = self._make_binding(sbr_dir) - def test_env_var_takes_priority_over_default(self, tmp_path): - """Env var path should take priority over default path.""" - creds_file = tmp_path / "custom_credentials.json" - creds_file.write_text('{"clientid": "custom"}') + with patch.dict(os.environ, {"SERVICE_BINDING_ROOT": str(sbr_dir)}, clear=False): + os.environ.pop(_CREDENTIALS_PATH_ENV, None) + result = detect_customer_agent_credentials() + assert result == str(creds_file) - with patch.dict(os.environ, {_CREDENTIALS_PATH_ENV: str(creds_file)}): - # Even if default path exists, env var should be used - with patch("os.path.isfile") as mock_isfile: + def test_no_credentials_returns_none(self, tmp_path): + """Return None when no binding with matching type is found.""" + env = {"SERVICE_BINDING_ROOT": str(tmp_path)} + with patch.dict(os.environ, env, clear=False): + os.environ.pop(_CREDENTIALS_PATH_ENV, None) + result = detect_customer_agent_credentials() + assert result is None - def isfile_side_effect(path): - if path == str(creds_file): - return True - if path == _CREDENTIALS_DEFAULT_PATH: - return True - return False + def test_env_var_takes_priority_over_service_binding_root(self, tmp_path): + """AGW_CREDENTIALS_PATH env var takes priority over SERVICE_BINDING_ROOT.""" + creds_file = tmp_path / "custom_credentials.json" + creds_file.write_text('{"clientid": "custom"}') - mock_isfile.side_effect = isfile_side_effect + sbr_dir = tmp_path / "sbr" + sbr_dir.mkdir() + self._make_binding(sbr_dir) - result = detect_customer_agent_credentials() - assert result == str(creds_file) + env = { + _CREDENTIALS_PATH_ENV: str(creds_file), + "SERVICE_BINDING_ROOT": str(sbr_dir), + } + with patch.dict(os.environ, env, clear=False): + result = detect_customer_agent_credentials() + assert result == str(creds_file) # ============================================================ @@ -112,7 +154,7 @@ def test_loads_valid_credentials(self, tmp_path): "integrationDependencies": [ { "ordId": "sap.test:apiResource:demo:v1", - "data": {"globalTenantId": "123"}, + "globalTenantId": "123", }, ], } @@ -172,11 +214,11 @@ def test_loads_integration_dependencies(self, tmp_path): "integrationDependencies": [ { "ordId": "sap.mcpbuilder:apiResource:cost-center:v1", - "data": {"globalTenantId": "250695"}, + "globalTenantId": "250695", }, { "ordId": "sap.flights:mcpServer:v1", - "data": {"globalTenantId": "892451733"}, + "globalTenantId": "892451733", }, ], } @@ -194,6 +236,35 @@ def test_loads_integration_dependencies(self, tmp_path): assert result.integration_dependencies[1].ord_id == "sap.flights:mcpServer:v1" assert result.integration_dependencies[1].global_tenant_id == "892451733" + def test_loads_nested_service_binding_integration_dependencies(self, tmp_path): + """Load service binding integrationDependencies when tenant id is nested under data.""" + creds_file = tmp_path / "credentials.json" + creds_data = { + "tokenServiceUrl": "https://ias.example.com/oauth2/token", + "clientid": "my-client-id", + "certificate": "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----", + "privateKey": "-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----", + "gatewayUrl": "https://agw.example.com", + "integrationDependencies": [ + { + "ordId": "sap.s4:apiResource:CE_COSTCENTER_0001_MCP:v1", + "data": { + "globalTenantId": "731473562", + }, + }, + ], + } + creds_file.write_text(json.dumps(creds_data)) + + result = load_customer_credentials(str(creds_file)) + + assert len(result.integration_dependencies) == 1 + assert ( + result.integration_dependencies[0].ord_id + == "sap.s4:apiResource:CE_COSTCENTER_0001_MCP:v1" + ) + assert result.integration_dependencies[0].global_tenant_id == "731473562" + def test_raises_when_integration_dependencies_missing(self, tmp_path): """Raise error when integrationDependencies is not in credentials file.""" creds_file = tmp_path / "credentials.json" @@ -222,7 +293,7 @@ def test_raises_on_invalid_integration_dependencies_format(self, tmp_path): "privateKey": "-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----", "gatewayUrl": "https://agw.example.com", "integrationDependencies": [ - {"ordId": "missing-data-field"}, # Missing 'data' key + {"ordId": "missing-global-tenant-id-field"}, # Missing 'globalTenantId' key ], } creds_file.write_text(json.dumps(creds_data)) @@ -493,6 +564,54 @@ def test_reuses_cached_user_token(self, credentials): mock_request.assert_called_once() +# ============================================================ +# Test: _resolve_dependency +# ============================================================ + + +class TestResolveDependency: + """Tests for ORD ID → tenant ID resolution.""" + + def _make_credentials(self, deps): + return CustomerCredentials( + token_service_url="https://ias.example.com/oauth2/token", + client_id="test-client", + certificate="cert", + private_key="key", + gateway_url="https://agw.example.com", + integration_dependencies=deps, + ) + + def test_resolves_matching_ord_id(self): + """Return the dependency whose ord_id matches.""" + dep = IntegrationDependency(ord_id="sap.s4:apiResource:FOO:v1", global_tenant_id="111") + creds = self._make_credentials([dep]) + assert _resolve_dependency(creds, "sap.s4:apiResource:FOO:v1") is dep + + def test_raises_when_not_found(self): + """Raise error when the ORD ID is not in the list.""" + creds = self._make_credentials([ + IntegrationDependency(ord_id="sap.s4:apiResource:FOO:v1", global_tenant_id="111"), + ]) + with pytest.raises(AgentGatewaySDKError, match="not found in integrationDependencies"): + _resolve_dependency(creds, "sap.s4:apiResource:BAR:v1") + + def test_raises_on_multiple_matches(self): + """Raise error when the same ORD ID appears with different tenant IDs.""" + creds = self._make_credentials([ + IntegrationDependency(ord_id="sap.s4:apiResource:FOO:v1", global_tenant_id="111"), + IntegrationDependency(ord_id="sap.s4:apiResource:FOO:v1", global_tenant_id="222"), + ]) + with pytest.raises(AgentGatewaySDKError, match="matches multiple integrationDependencies"): + _resolve_dependency(creds, "sap.s4:apiResource:FOO:v1") + + def test_raises_with_empty_dependencies(self): + """Raise error when integrationDependencies is empty.""" + creds = self._make_credentials([]) + with pytest.raises(AgentGatewaySDKError, match="not found in integrationDependencies"): + _resolve_dependency(creds, "sap.s4:apiResource:FOO:v1") + + # ============================================================ # Test: get_mcp_tools_customer # ============================================================ @@ -611,13 +730,84 @@ async def mock_list_tools(*args, **kwargs): assert len(result) == 1 assert result[0].name == "tool2" + @pytest.mark.asyncio + async def test_filters_to_single_server_when_ord_id_given(self): + """Only query the server whose ord_id matches when ord_id is provided.""" + credentials = CustomerCredentials( + token_service_url="https://ias.example.com/oauth2/token", + client_id="test-client", + certificate="cert", + private_key="key", + gateway_url="https://agw.example.com", + integration_dependencies=[ + IntegrationDependency(ord_id="sap.s4:apiResource:FOO:v1", global_tenant_id="111"), + IntegrationDependency(ord_id="sap.s4:apiResource:BAR:v1", global_tenant_id="222"), + ], + ) + mock_tool = MCPTool( + name="foo_tool", server_name="FOO", description="", input_schema={}, + url="https://agw.example.com/v1/mcp/sap.s4:apiResource:FOO:v1/111", + ) + + with patch( + "sap_cloud_sdk.agentgateway._customer._list_server_tools", + new_callable=AsyncMock, + return_value=[mock_tool], + ) as mock_list: + result = await get_mcp_tools_customer( + credentials, "token", 60.0, ord_id="sap.s4:apiResource:FOO:v1" + ) + + assert len(result) == 1 + assert result[0].name == "foo_tool" + assert mock_list.call_count == 1 + called_url = mock_list.call_args[0][0] + assert "FOO" in called_url and "111" in called_url + + @pytest.mark.asyncio + async def test_raises_when_ord_id_not_found(self): + """Raise error when the given ord_id is not in integrationDependencies.""" + credentials = CustomerCredentials( + token_service_url="https://ias.example.com/oauth2/token", + client_id="test-client", + certificate="cert", + private_key="key", + gateway_url="https://agw.example.com", + integration_dependencies=[ + IntegrationDependency(ord_id="sap.s4:apiResource:FOO:v1", global_tenant_id="111"), + ], + ) + with pytest.raises(AgentGatewaySDKError, match="not found in integrationDependencies"): + await get_mcp_tools_customer( + credentials, "token", 60.0, ord_id="sap.s4:apiResource:MISSING:v1" + ) + + @pytest.mark.asyncio + async def test_raises_when_ord_id_matches_multiple(self): + """Raise error when the given ord_id matches multiple entries.""" + credentials = CustomerCredentials( + token_service_url="https://ias.example.com/oauth2/token", + client_id="test-client", + certificate="cert", + private_key="key", + gateway_url="https://agw.example.com", + integration_dependencies=[ + IntegrationDependency(ord_id="sap.s4:apiResource:FOO:v1", global_tenant_id="111"), + IntegrationDependency(ord_id="sap.s4:apiResource:FOO:v1", global_tenant_id="222"), + ], + ) + with pytest.raises(AgentGatewaySDKError, match="matches multiple integrationDependencies"): + await get_mcp_tools_customer( + credentials, "token", 60.0, ord_id="sap.s4:apiResource:FOO:v1" + ) + # ============================================================ -# Test: call_mcp_tool_customer +# Test: invoke_mcp_tool (via customer flow) # ============================================================ -class TestCallMcpToolCustomer: +class TestInvokeMcpTool: """Tests for customer flow tool invocation.""" @pytest.fixture @@ -656,13 +846,13 @@ async def test_calls_tool_with_pre_fetched_token(self, credentials, mock_tool): """Call tool using pre-fetched auth token.""" with ( patch( - "httpx.AsyncClient", + "sap_cloud_sdk.agentgateway._mcp_session.httpx.AsyncClient", ) as mock_client_class, patch( - "sap_cloud_sdk.agentgateway._customer.streamable_http_client", + "sap_cloud_sdk.agentgateway._mcp_session.streamable_http_client", ) as mock_stream, patch( - "sap_cloud_sdk.agentgateway._customer.ClientSession", + "sap_cloud_sdk.agentgateway._mcp_session.ClientSession", ) as mock_session_class, ): # Set up mock chain @@ -681,6 +871,7 @@ async def test_calls_tool_with_pre_fetched_token(self, credentials, mock_tool): mock_session = AsyncMock() mock_session.initialize = AsyncMock() mock_result = MagicMock() + mock_result.isError = False mock_content = MagicMock() mock_content.text = "Order created successfully" mock_result.content = [mock_content] @@ -705,13 +896,13 @@ async def test_returns_empty_string_when_no_content(self, credentials, mock_tool """Return empty string when tool returns no content.""" with ( patch( - "httpx.AsyncClient", + "sap_cloud_sdk.agentgateway._mcp_session.httpx.AsyncClient", ) as mock_client_class, patch( - "sap_cloud_sdk.agentgateway._customer.streamable_http_client", + "sap_cloud_sdk.agentgateway._mcp_session.streamable_http_client", ) as mock_stream, patch( - "sap_cloud_sdk.agentgateway._customer.ClientSession", + "sap_cloud_sdk.agentgateway._mcp_session.ClientSession", ) as mock_session_class, ): mock_client = AsyncMock() @@ -729,6 +920,7 @@ async def test_returns_empty_string_when_no_content(self, credentials, mock_tool mock_session = AsyncMock() mock_session.initialize = AsyncMock() mock_result = MagicMock() + mock_result.isError = False mock_result.content = [] mock_session.call_tool = AsyncMock(return_value=mock_result) mock_session_ctx = AsyncMock() @@ -741,3 +933,195 @@ async def test_returns_empty_string_when_no_content(self, credentials, mock_tool ) assert result == "" + + @pytest.mark.asyncio + async def test_returns_empty_string_when_result_is_none(self, mock_tool): + """Return empty string when call_tool returns None (null MCP response).""" + with ( + patch("sap_cloud_sdk.agentgateway._mcp_session.httpx.AsyncClient") as mock_client_class, + patch("sap_cloud_sdk.agentgateway._mcp_session.streamable_http_client") as mock_stream, + patch("sap_cloud_sdk.agentgateway._mcp_session.ClientSession") as mock_session_class, + ): + mock_client = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=None) + mock_client_class.return_value = mock_client + + mock_stream_ctx = AsyncMock() + mock_stream_ctx.__aenter__ = AsyncMock(return_value=(AsyncMock(), AsyncMock(), None)) + mock_stream_ctx.__aexit__ = AsyncMock(return_value=None) + mock_stream.return_value = mock_stream_ctx + + mock_session = AsyncMock() + mock_session.initialize = AsyncMock() + mock_session.call_tool = AsyncMock(return_value=None) + mock_session_ctx = AsyncMock() + mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_session) + mock_session_ctx.__aexit__ = AsyncMock(return_value=None) + mock_session_class.return_value = mock_session_ctx + + result = await call_mcp_tool_customer(mock_tool, "auth-token", 60.0) + assert result == "" + + @pytest.mark.asyncio + async def test_returns_empty_string_when_exception_group_contains_only_attr_error(self, mock_tool): + """Return empty string when anyio wraps an AttributeError (older MCP null-result bug) in an ExceptionGroup.""" + with ( + patch("sap_cloud_sdk.agentgateway._mcp_session.httpx.AsyncClient") as mock_client_class, + patch("sap_cloud_sdk.agentgateway._mcp_session.streamable_http_client") as mock_stream, + patch("sap_cloud_sdk.agentgateway._mcp_session.ClientSession") as mock_session_class, + ): + mock_client = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=None) + mock_client_class.return_value = mock_client + + # Simulate anyio wrapping the AttributeError in a BaseExceptionGroup + nested = BaseExceptionGroup( + "unhandled errors in a TaskGroup", + [BaseExceptionGroup( + "unhandled errors in a TaskGroup", + [AttributeError("'NoneType' object has no attribute 'isError'")], + )], + ) + mock_stream_ctx = AsyncMock() + mock_stream_ctx.__aenter__ = AsyncMock(return_value=(AsyncMock(), AsyncMock(), None)) + mock_stream_ctx.__aexit__ = AsyncMock(side_effect=nested) + mock_stream.return_value = mock_stream_ctx + + mock_session_class.return_value = AsyncMock() + + result = await call_mcp_tool_customer(mock_tool, "auth-token", 60.0) + assert result == "" + + @pytest.mark.asyncio + async def test_raises_server_error_when_result_is_error(self, mock_tool): + """Raise AgentGatewayServerError when tool result has isError=True.""" + with ( + patch("sap_cloud_sdk.agentgateway._mcp_session.httpx.AsyncClient") as mock_client_class, + patch( + "sap_cloud_sdk.agentgateway._mcp_session.streamable_http_client" + ) as mock_stream, + patch( + "sap_cloud_sdk.agentgateway._mcp_session.ClientSession" + ) as mock_session_class, + ): + mock_client = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=None) + mock_client_class.return_value = mock_client + + mock_stream_ctx = AsyncMock() + mock_stream_ctx.__aenter__ = AsyncMock( + return_value=(AsyncMock(), AsyncMock(), None) + ) + mock_stream_ctx.__aexit__ = AsyncMock(return_value=None) + mock_stream.return_value = mock_stream_ctx + + mock_session = AsyncMock() + mock_session.initialize = AsyncMock() + mock_result = MagicMock() + mock_result.isError = True + error_content = MagicMock() + error_content.text = "Internal tool error" + mock_result.content = [error_content] + mock_session.call_tool = AsyncMock(return_value=mock_result) + mock_session_ctx = AsyncMock() + mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_session) + mock_session_ctx.__aexit__ = AsyncMock(return_value=None) + mock_session_class.return_value = mock_session_ctx + + with pytest.raises(AgentGatewayServerError, match="Internal tool error"): + await call_mcp_tool_customer(mock_tool, "auth-token", 60.0) + + @pytest.mark.asyncio + async def test_raises_server_error_on_mcp_error_during_call(self, mock_tool): + """Raise AgentGatewayServerError when MCP call_tool raises McpError.""" + from mcp import McpError + from mcp.types import ErrorData + + with ( + patch("sap_cloud_sdk.agentgateway._mcp_session.httpx.AsyncClient") as mock_client_class, + patch( + "sap_cloud_sdk.agentgateway._mcp_session.streamable_http_client" + ) as mock_stream, + patch( + "sap_cloud_sdk.agentgateway._mcp_session.ClientSession" + ) as mock_session_class, + ): + mock_client = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=None) + mock_client_class.return_value = mock_client + + mock_stream_ctx = AsyncMock() + mock_stream_ctx.__aenter__ = AsyncMock( + return_value=(AsyncMock(), AsyncMock(), None) + ) + mock_stream_ctx.__aexit__ = AsyncMock(return_value=None) + mock_stream.return_value = mock_stream_ctx + + mock_session = AsyncMock() + mock_session.initialize = AsyncMock() + mock_session.call_tool = AsyncMock( + side_effect=McpError( + ErrorData( + code=-32600, + message="MCP server card for ORD ID sap.mcpbuilder:apiResource:API_SALES_ORDE_SRV_MCP_1:v1 not found in registry", + ) + ) + ) + mock_session_ctx = AsyncMock() + mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_session) + mock_session_ctx.__aexit__ = AsyncMock(return_value=None) + mock_session_class.return_value = mock_session_ctx + + with pytest.raises( + AgentGatewayServerError, + match="not found in registry", + ) as exc_info: + await call_mcp_tool_customer(mock_tool, "auth-token", 60.0) + + assert exc_info.value.error_code == -32600 + + @pytest.mark.asyncio + async def test_raises_server_error_on_mcp_error_during_initialize(self, mock_tool): + """Raise AgentGatewayServerError when MCP initialize raises McpError.""" + from mcp import McpError + from mcp.types import ErrorData + + with ( + patch("sap_cloud_sdk.agentgateway._mcp_session.httpx.AsyncClient") as mock_client_class, + patch( + "sap_cloud_sdk.agentgateway._mcp_session.streamable_http_client" + ) as mock_stream, + patch( + "sap_cloud_sdk.agentgateway._mcp_session.ClientSession" + ) as mock_session_class, + ): + mock_client = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=None) + mock_client_class.return_value = mock_client + + mock_stream_ctx = AsyncMock() + mock_stream_ctx.__aenter__ = AsyncMock( + return_value=(AsyncMock(), AsyncMock(), None) + ) + mock_stream_ctx.__aexit__ = AsyncMock(return_value=None) + mock_stream.return_value = mock_stream_ctx + + mock_session = AsyncMock() + mock_session.initialize = AsyncMock( + side_effect=McpError(ErrorData(code=-32600, message="Unauthorized")) + ) + mock_session_ctx = AsyncMock() + mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_session) + mock_session_ctx.__aexit__ = AsyncMock(return_value=None) + mock_session_class.return_value = mock_session_ctx + + with pytest.raises( + AgentGatewayServerError, + match="rejected MCP session.*Unauthorized", + ): + await call_mcp_tool_customer(mock_tool, "auth-token", 60.0) diff --git a/tests/agentgateway/unit/test_lob.py b/tests/agentgateway/unit/test_lob.py index 39c036e5..2505e554 100644 --- a/tests/agentgateway/unit/test_lob.py +++ b/tests/agentgateway/unit/test_lob.py @@ -618,6 +618,7 @@ async def test_calls_tool_with_pre_fetched_token(self): ) mock_result = MagicMock() + mock_result.isError = False mock_result.content = [MagicMock()] mock_result.content[0].text = "Tool result" @@ -670,6 +671,7 @@ async def test_returns_empty_string_when_no_content(self): ) mock_result = MagicMock() + mock_result.isError = False mock_result.content = [] with (