From 1c7d7994ea6d5c76902d128888be753ea1a004b3 Mon Sep 17 00:00:00 2001 From: Simon Kobler Date: Wed, 17 Jun 2026 17:17:36 +0200 Subject: [PATCH 1/8] feat(agentgateway): enhance credential detection logic for service binding and defaults --- src/sap_cloud_sdk/agentgateway/_customer.py | 37 ++++++---- tests/agentgateway/unit/test_customer.py | 80 +++++++++++++++------ 2 files changed, 84 insertions(+), 33 deletions(-) diff --git a/src/sap_cloud_sdk/agentgateway/_customer.py b/src/sap_cloud_sdk/agentgateway/_customer.py index be5019ea..b9d8f00c 100644 --- a/src/sap_cloud_sdk/agentgateway/_customer.py +++ b/src/sap_cloud_sdk/agentgateway/_customer.py @@ -29,11 +29,16 @@ 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: credentials are mounted at $SERVICE_BINDING_ROOT/integration-credentials/credentials +_SERVICE_BINDING_ROOT_ENV = "SERVICE_BINDING_ROOT" +_BINDING_NAME = "integration-credentials" +_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 +71,32 @@ 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/integration-credentials/credentials (servicebinding.io spec) + 3. /bindings/integration-credentials/credentials (Kyma default when SERVICE_BINDING_ROOT unset) 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: $SERVICE_BINDING_ROOT/integration-credentials/credentials + sbr = os.environ.get(_SERVICE_BINDING_ROOT_ENV) + if sbr: + path = os.path.join(sbr, _BINDING_NAME, _CREDENTIALS_FILE) + if os.path.isfile(path): + logger.debug("Customer credentials found at SERVICE_BINDING_ROOT path: %s", path) + return path + + # 3. Kyma default: /bindings/integration-credentials/credentials + default_path = os.path.join(_DEFAULT_BINDING_ROOT, _BINDING_NAME, _CREDENTIALS_FILE) + if os.path.isfile(default_path): + logger.debug("Customer credentials found at default path: %s", default_path) + return default_path return None diff --git a/tests/agentgateway/unit/test_customer.py b/tests/agentgateway/unit/test_customer.py index 4469ab47..916e944f 100644 --- a/tests/agentgateway/unit/test_customer.py +++ b/tests/agentgateway/unit/test_customer.py @@ -14,7 +14,10 @@ call_mcp_tool_customer, _build_mcp_url, _CREDENTIALS_PATH_ENV, - _CREDENTIALS_DEFAULT_PATH, + _SERVICE_BINDING_ROOT_ENV, + _BINDING_NAME, + _CREDENTIALS_FILE, + _DEFAULT_BINDING_ROOT, ) from sap_cloud_sdk.agentgateway._models import ( CustomerCredentials, @@ -45,48 +48,83 @@ def test_detect_from_env_var_path(self, tmp_path): def test_detect_from_env_var_path_file_not_exists(self): """Return None when env var path doesn't exist.""" - with patch.dict(os.environ, {_CREDENTIALS_PATH_ENV: "/nonexistent/path"}): + with patch.dict(os.environ, {_CREDENTIALS_PATH_ENV: "/nonexistent/path"}, clear=False): + os.environ.pop(_SERVICE_BINDING_ROOT_ENV, None) + with patch("os.path.isfile", return_value=False): + result = detect_customer_agent_credentials() + assert result is None + + def test_detect_from_service_binding_root(self, tmp_path): + """Detect credentials via SERVICE_BINDING_ROOT/integration-credentials/credentials.""" + binding_dir = tmp_path / _BINDING_NAME + binding_dir.mkdir() + creds_file = binding_dir / _CREDENTIALS_FILE + creds_file.write_text('{"clientid": "test"}') + + env = {_SERVICE_BINDING_ROOT_ENV: 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 + assert result == str(creds_file) + + def test_detect_from_default_path(self, tmp_path): + """Detect credentials from default Kyma path when SERVICE_BINDING_ROOT is not set.""" + default_creds = os.path.join(_DEFAULT_BINDING_ROOT, _BINDING_NAME, _CREDENTIALS_FILE) - 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 os.environ.pop(_CREDENTIALS_PATH_ENV, None) + os.environ.pop(_SERVICE_BINDING_ROOT_ENV, None) + + with patch("os.path.isfile") as mock_isfile: + mock_isfile.side_effect = lambda p: p == default_creds + result = detect_customer_agent_credentials() + assert result == default_creds + + def test_service_binding_root_takes_priority_over_default(self, tmp_path): + """SERVICE_BINDING_ROOT path is checked before the hardcoded /bindings fallback.""" + sbr_dir = tmp_path / "sbr" + binding_dir = sbr_dir / _BINDING_NAME + binding_dir.mkdir(parents=True) + creds_file = binding_dir / _CREDENTIALS_FILE + creds_file.write_text('{"clientid": "sbr"}') + + default_creds = os.path.join(_DEFAULT_BINDING_ROOT, _BINDING_NAME, _CREDENTIALS_FILE) + + env = {_SERVICE_BINDING_ROOT_ENV: str(sbr_dir)} + with patch.dict(os.environ, env, clear=False): + os.environ.pop(_CREDENTIALS_PATH_ENV, None) + # Both paths exist, SERVICE_BINDING_ROOT should win with patch("os.path.isfile") as mock_isfile: - mock_isfile.side_effect = lambda p: p == _CREDENTIALS_DEFAULT_PATH + mock_isfile.side_effect = lambda p: p in (str(creds_file), default_creds) 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): os.environ.pop(_CREDENTIALS_PATH_ENV, None) + os.environ.pop(_SERVICE_BINDING_ROOT_ENV, None) with patch("os.path.isfile", return_value=False): result = detect_customer_agent_credentials() assert result is None - def test_env_var_takes_priority_over_default(self, tmp_path): - """Env var path should take priority over default path.""" + 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"}') - 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 isfile_side_effect(path): - if path == str(creds_file): - return True - if path == _CREDENTIALS_DEFAULT_PATH: - return True - return False + sbr_creds = os.path.join(str(tmp_path), _BINDING_NAME, _CREDENTIALS_FILE) - mock_isfile.side_effect = isfile_side_effect + env = { + _CREDENTIALS_PATH_ENV: str(creds_file), + _SERVICE_BINDING_ROOT_ENV: str(tmp_path), + } + with patch.dict(os.environ, env, clear=False): + with patch("os.path.isfile") as mock_isfile: + mock_isfile.side_effect = lambda p: p in (str(creds_file), sbr_creds) result = detect_customer_agent_credentials() assert result == str(creds_file) From 9e029a6d1b7ce54cfae5809206c2c2313d5946e0 Mon Sep 17 00:00:00 2001 From: Simon Kobler Date: Thu, 18 Jun 2026 11:51:51 +0200 Subject: [PATCH 2/8] fix(agentgateway): update credential detection logic to scan for service bindings by type --- src/sap_cloud_sdk/agentgateway/_customer.py | 47 ++++----- tests/agentgateway/unit/test_customer.py | 108 ++++++++++---------- 2 files changed, 80 insertions(+), 75 deletions(-) diff --git a/src/sap_cloud_sdk/agentgateway/_customer.py b/src/sap_cloud_sdk/agentgateway/_customer.py index b9d8f00c..a4d7face 100644 --- a/src/sap_cloud_sdk/agentgateway/_customer.py +++ b/src/sap_cloud_sdk/agentgateway/_customer.py @@ -32,9 +32,10 @@ # Environment variable to override default credential path (points directly to credentials file) _CREDENTIALS_PATH_ENV = "AGW_CREDENTIALS_PATH" -# servicebinding.io: credentials are mounted at $SERVICE_BINDING_ROOT/integration-credentials/credentials +# servicebinding.io: scan $SERVICE_BINDING_ROOT for a binding whose 'type' file equals the expected type _SERVICE_BINDING_ROOT_ENV = "SERVICE_BINDING_ROOT" -_BINDING_NAME = "integration-credentials" +_BINDING_TYPE = "integration-credentials" +_BINDING_TYPE_FILE = "type" _CREDENTIALS_FILE = "credentials" # Kyma default when SERVICE_BINDING_ROOT is not set @@ -63,7 +64,6 @@ class _CredentialFields: GATEWAY_URL = "gatewayUrl" INTEGRATION_DEPENDENCIES = "integrationDependencies" ORD_ID = "ordId" - DATA = "data" GLOBAL_TENANT_ID = "globalTenantId" @@ -72,8 +72,8 @@ def detect_customer_agent_credentials() -> str | None: Checks for credential file in the following order: 1. Path specified in AGW_CREDENTIALS_PATH env var (explicit override, points to file directly) - 2. $SERVICE_BINDING_ROOT/integration-credentials/credentials (servicebinding.io spec) - 3. /bindings/integration-credentials/credentials (Kyma default when SERVICE_BINDING_ROOT unset) + 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. @@ -84,19 +84,22 @@ def detect_customer_agent_credentials() -> str | None: logger.debug("Customer credentials found at env var path: %s", path_from_env) return path_from_env - # 2. servicebinding.io: $SERVICE_BINDING_ROOT/integration-credentials/credentials - sbr = os.environ.get(_SERVICE_BINDING_ROOT_ENV) - if sbr: - path = os.path.join(sbr, _BINDING_NAME, _CREDENTIALS_FILE) - if os.path.isfile(path): - logger.debug("Customer credentials found at SERVICE_BINDING_ROOT path: %s", path) - return path - - # 3. Kyma default: /bindings/integration-credentials/credentials - default_path = os.path.join(_DEFAULT_BINDING_ROOT, _BINDING_NAME, _CREDENTIALS_FILE) - if os.path.isfile(default_path): - logger.debug("Customer credentials found at default path: %s", default_path) - return default_path + # 2. servicebinding.io: scan $SERVICE_BINDING_ROOT for a binding whose 'type' file equals _BINDING_TYPE + sbr = os.environ.get(_SERVICE_BINDING_ROOT_ENV, _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 @@ -141,16 +144,14 @@ 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[_CredentialFields.GLOBAL_TENANT_ID], ) for dep in data[_CredentialFields.INTEGRATION_DEPENDENCIES] ] @@ -161,7 +162,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( diff --git a/tests/agentgateway/unit/test_customer.py b/tests/agentgateway/unit/test_customer.py index 916e944f..aa44fec0 100644 --- a/tests/agentgateway/unit/test_customer.py +++ b/tests/agentgateway/unit/test_customer.py @@ -15,7 +15,8 @@ _build_mcp_url, _CREDENTIALS_PATH_ENV, _SERVICE_BINDING_ROOT_ENV, - _BINDING_NAME, + _BINDING_TYPE, + _BINDING_TYPE_FILE, _CREDENTIALS_FILE, _DEFAULT_BINDING_ROOT, ) @@ -37,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" @@ -46,20 +56,16 @@ 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"}, clear=False): - os.environ.pop(_SERVICE_BINDING_ROOT_ENV, None) - with patch("os.path.isfile", return_value=False): - result = detect_customer_agent_credentials() - assert result is None + env = {_CREDENTIALS_PATH_ENV: "/nonexistent/path", _SERVICE_BINDING_ROOT_ENV: str(tmp_path)} + with patch.dict(os.environ, env, clear=False): + result = detect_customer_agent_credentials() + assert result is None def test_detect_from_service_binding_root(self, tmp_path): - """Detect credentials via SERVICE_BINDING_ROOT/integration-credentials/credentials.""" - binding_dir = tmp_path / _BINDING_NAME - binding_dir.mkdir() - creds_file = binding_dir / _CREDENTIALS_FILE - creds_file.write_text('{"clientid": "test"}') + """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_ENV: str(tmp_path)} with patch.dict(os.environ, env, clear=False): @@ -68,66 +74,64 @@ def test_detect_from_service_binding_root(self, tmp_path): assert result == str(creds_file) def test_detect_from_default_path(self, tmp_path): - """Detect credentials from default Kyma path when SERVICE_BINDING_ROOT is not set.""" - default_creds = os.path.join(_DEFAULT_BINDING_ROOT, _BINDING_NAME, _CREDENTIALS_FILE) + """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_ENV, None) - - with patch("os.path.isfile") as mock_isfile: - mock_isfile.side_effect = lambda p: p == default_creds - + with patch("sap_cloud_sdk.agentgateway._customer._DEFAULT_BINDING_ROOT", str(tmp_path)): result = detect_customer_agent_credentials() - assert result == default_creds - - def test_service_binding_root_takes_priority_over_default(self, tmp_path): - """SERVICE_BINDING_ROOT path is checked before the hardcoded /bindings fallback.""" - sbr_dir = tmp_path / "sbr" - binding_dir = sbr_dir / _BINDING_NAME - binding_dir.mkdir(parents=True) - creds_file = binding_dir / _CREDENTIALS_FILE - creds_file.write_text('{"clientid": "sbr"}') + assert result == str(creds_file) - default_creds = os.path.join(_DEFAULT_BINDING_ROOT, _BINDING_NAME, _CREDENTIALS_FILE) + 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_ENV: str(sbr_dir)} + env = {_SERVICE_BINDING_ROOT_ENV: str(tmp_path)} with patch.dict(os.environ, env, clear=False): os.environ.pop(_CREDENTIALS_PATH_ENV, None) - # Both paths exist, SERVICE_BINDING_ROOT should win - with patch("os.path.isfile") as mock_isfile: - mock_isfile.side_effect = lambda p: p in (str(creds_file), default_creds) + result = detect_customer_agent_credentials() + assert result is None - result = detect_customer_agent_credentials() - assert result == str(creds_file) + 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_no_credentials_returns_none(self): - """Return None when no credentials are found.""" - with patch.dict(os.environ, {}, clear=False): + with patch.dict(os.environ, {_SERVICE_BINDING_ROOT_ENV: str(sbr_dir)}, clear=False): os.environ.pop(_CREDENTIALS_PATH_ENV, None) - os.environ.pop(_SERVICE_BINDING_ROOT_ENV, None) + result = detect_customer_agent_credentials() + assert result == str(creds_file) - with patch("os.path.isfile", return_value=False): - result = detect_customer_agent_credentials() - assert result is None + def test_no_credentials_returns_none(self, tmp_path): + """Return None when no binding with matching type is found.""" + env = {_SERVICE_BINDING_ROOT_ENV: 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 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"}') - sbr_creds = os.path.join(str(tmp_path), _BINDING_NAME, _CREDENTIALS_FILE) + sbr_dir = tmp_path / "sbr" + sbr_dir.mkdir() + self._make_binding(sbr_dir) env = { _CREDENTIALS_PATH_ENV: str(creds_file), - _SERVICE_BINDING_ROOT_ENV: str(tmp_path), + _SERVICE_BINDING_ROOT_ENV: str(sbr_dir), } with patch.dict(os.environ, env, clear=False): - with patch("os.path.isfile") as mock_isfile: - mock_isfile.side_effect = lambda p: p in (str(creds_file), sbr_creds) - - result = detect_customer_agent_credentials() - assert result == str(creds_file) + result = detect_customer_agent_credentials() + assert result == str(creds_file) # ============================================================ @@ -150,7 +154,7 @@ def test_loads_valid_credentials(self, tmp_path): "integrationDependencies": [ { "ordId": "sap.test:apiResource:demo:v1", - "data": {"globalTenantId": "123"}, + "globalTenantId": "123", }, ], } @@ -210,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", }, ], } @@ -260,7 +264,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)) From 3d4fc6bf06234548394dd7a8ddfb9a60dd2ff869 Mon Sep 17 00:00:00 2001 From: Simon Kobler Date: Fri, 26 Jun 2026 13:40:08 +0200 Subject: [PATCH 3/8] 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`. --- src/sap_cloud_sdk/agentgateway/__init__.py | 2 + src/sap_cloud_sdk/agentgateway/_customer.py | 36 +--- src/sap_cloud_sdk/agentgateway/_lob.py | 49 ++--- .../agentgateway/_mcp_session.py | 71 +++++++ src/sap_cloud_sdk/agentgateway/agw_client.py | 1 - src/sap_cloud_sdk/agentgateway/exceptions.py | 19 ++ tests/agentgateway/unit/test_customer.py | 177 +++++++++++++++++- tests/agentgateway/unit/test_lob.py | 18 +- uv.lock | 16 +- 9 files changed, 294 insertions(+), 95 deletions(-) create mode 100644 src/sap_cloud_sdk/agentgateway/_mcp_session.py diff --git a/src/sap_cloud_sdk/agentgateway/__init__.py b/src/sap_cloud_sdk/agentgateway/__init__.py index 216f3012..1ded7fda 100644 --- a/src/sap_cloud_sdk/agentgateway/__init__.py +++ b/src/sap_cloud_sdk/agentgateway/__init__.py @@ -57,6 +57,7 @@ from sap_cloud_sdk.agentgateway.agw_client import create_client, AgentGatewayClient from sap_cloud_sdk.agentgateway.exceptions import ( AgentGatewaySDKError, + AgentGatewayServerError, MCPServerNotFoundError, ) @@ -73,5 +74,6 @@ "MCPTool", # Exceptions "AgentGatewaySDKError", + "AgentGatewayServerError", "MCPServerNotFoundError", ] diff --git a/src/sap_cloud_sdk/agentgateway/_customer.py b/src/sap_cloud_sdk/agentgateway/_customer.py index a4d7face..e9ceca5c 100644 --- a/src/sap_cloud_sdk/agentgateway/_customer.py +++ b/src/sap_cloud_sdk/agentgateway/_customer.py @@ -24,6 +24,7 @@ IntegrationDependency, MCPTool, ) +from sap_cloud_sdk.agentgateway._mcp_session import invoke_mcp_tool from sap_cloud_sdk.agentgateway._token_cache import _TokenCache from sap_cloud_sdk.agentgateway.exceptions import AgentGatewaySDKError @@ -64,6 +65,7 @@ class _CredentialFields: GATEWAY_URL = "gatewayUrl" INTEGRATION_DEPENDENCIES = "integrationDependencies" ORD_ID = "ordId" + DATA = "data" GLOBAL_TENANT_ID = "globalTenantId" @@ -151,7 +153,10 @@ def load_customer_credentials(path: str) -> CustomerCredentials: integration_deps = [ IntegrationDependency( ord_id=dep[_CredentialFields.ORD_ID], - global_tenant_id=dep[_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] ] @@ -256,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) @@ -565,26 +565,4 @@ async def call_mcp_tool_customer( Tool execution result as string. """ logger.info("Calling tool '%s' on server '%s'", tool.name, tool.server_name) - - async with httpx.AsyncClient( - headers={ - "Authorization": f"Bearer {auth_token}", - "x-correlation-id": str(uuid.uuid4()), - }, - timeout=timeout, - ) as http_client: - async with streamable_http_client(tool.url, http_client=http_client) as ( - read, - write, - _, - ): - async with ClientSession(read, write) as session: - await session.initialize() - result = await session.call_tool(tool.name, kwargs) - - if not result.content: - logger.warning("Tool '%s' returned empty content", tool.name) - return "" - - first = result.content[0] - return str(getattr(first, "text", "")) + return await invoke_mcp_tool(tool, auth_token, timeout, **kwargs) diff --git a/src/sap_cloud_sdk/agentgateway/_lob.py b/src/sap_cloud_sdk/agentgateway/_lob.py index c4944b78..ee21bc6a 100644 --- a/src/sap_cloud_sdk/agentgateway/_lob.py +++ b/src/sap_cloud_sdk/agentgateway/_lob.py @@ -23,6 +23,7 @@ ) from sap_cloud_sdk.agentgateway._models import MCPTool +from sap_cloud_sdk.agentgateway._mcp_session import invoke_mcp_tool from sap_cloud_sdk.agentgateway._token_cache import _GatewayUrlCache, _TokenCache from sap_cloud_sdk.agentgateway.exceptions import MCPServerNotFoundError @@ -148,19 +149,7 @@ def get_ias_fragment_name(tenant_subdomain: str) -> str: Raises: MCPServerNotFoundError: If no IAS fragment is found. """ - client = create_fragment_client(instance=_DESTINATION_INSTANCE) - fragments = client.list_instance_fragments( - filter=ListOptions( - filter_labels=[Label(key=_LABEL_KEY, values=[_IAS_LABEL_VALUE])] - ), - tenant=tenant_subdomain, - ) - if not fragments: - raise MCPServerNotFoundError( - f"No IAS fragment found (label {_LABEL_KEY}={_IAS_LABEL_VALUE}) " - f"for tenant '{tenant_subdomain}'" - ) - return fragments[0].name + return _get_labeled_fragment_name(tenant_subdomain, _IAS_LABEL_VALUE) def get_ias_user_fragment_name(tenant_subdomain: str) -> str: @@ -178,16 +167,25 @@ def get_ias_user_fragment_name(tenant_subdomain: str) -> str: Raises: MCPServerNotFoundError: If no IAS user fragment is found. """ + return _get_labeled_fragment_name(tenant_subdomain, _IAS_USER_LABEL_VALUE) + + +def _get_labeled_fragment_name(tenant_subdomain: str, label_value: str) -> str: + """Return the name of the first fragment matching a managed-runtime label. + + Raises: + MCPServerNotFoundError: If no matching fragment is found. + """ client = create_fragment_client(instance=_DESTINATION_INSTANCE) fragments = client.list_instance_fragments( filter=ListOptions( - filter_labels=[Label(key=_LABEL_KEY, values=[_IAS_USER_LABEL_VALUE])] + filter_labels=[Label(key=_LABEL_KEY, values=[label_value])] ), tenant=tenant_subdomain, ) if not fragments: raise MCPServerNotFoundError( - f"No IAS user fragment found (label {_LABEL_KEY}={_IAS_USER_LABEL_VALUE}) " + f"No fragment found (label {_LABEL_KEY}={label_value}) " f"for tenant '{tenant_subdomain}'" ) return fragments[0].name @@ -462,23 +460,4 @@ async def call_mcp_tool_lob( Returns: Tool execution result as string. """ - async with httpx.AsyncClient( - headers={ - "Authorization": f"Bearer {user_auth_token}", - "x-correlation-id": str(uuid.uuid4()), - }, - timeout=timeout, - ) as http_client: - async with streamable_http_client(tool.url, http_client=http_client) as ( - read, - write, - _, - ): - async with ClientSession(read, write) as session: - await session.initialize() - result = await session.call_tool(tool.name, kwargs) - if not result.content: - logger.warning("Tool '%s' returned empty content", tool.name) - return "" - first = result.content[0] - return str(getattr(first, "text", "")) + return await invoke_mcp_tool(tool, user_auth_token, timeout, **kwargs) 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..cb5930cd --- /dev/null +++ b/src/sap_cloud_sdk/agentgateway/_mcp_session.py @@ -0,0 +1,71 @@ +"""Shared helpers for MCP session management.""" + +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 + + +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: + 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.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", "")) + + +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 b439ecd1..4dc7da0e 100644 --- a/src/sap_cloud_sdk/agentgateway/agw_client.py +++ b/src/sap_cloud_sdk/agentgateway/agw_client.py @@ -361,7 +361,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") 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/tests/agentgateway/unit/test_customer.py b/tests/agentgateway/unit/test_customer.py index aa44fec0..46927fd1 100644 --- a/tests/agentgateway/unit/test_customer.py +++ b/tests/agentgateway/unit/test_customer.py @@ -27,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 # ============================================================ @@ -236,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" @@ -698,13 +727,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 @@ -723,6 +752,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] @@ -747,13 +777,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() @@ -771,6 +801,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() @@ -783,3 +814,135 @@ async def test_returns_empty_string_when_no_content(self, credentials, mock_tool ) 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 6c605b96..3df645e3 100644 --- a/tests/agentgateway/unit/test_lob.py +++ b/tests/agentgateway/unit/test_lob.py @@ -259,7 +259,7 @@ def test_raises_when_no_fragment_found(self): ) as mock_client: mock_client.return_value.list_instance_fragments.return_value = [] - with pytest.raises(MCPServerNotFoundError, match="No IAS fragment found"): + with pytest.raises(MCPServerNotFoundError, match="No fragment found"): get_ias_fragment_name("tenant-sub") @@ -305,7 +305,7 @@ def test_raises_when_no_fragment_found(self): with patch("sap_cloud_sdk.agentgateway._lob.create_fragment_client") as mock_client: mock_client.return_value.list_instance_fragments.return_value = [] - with pytest.raises(MCPServerNotFoundError, match="No IAS user fragment found"): + with pytest.raises(MCPServerNotFoundError, match="No fragment found"): get_ias_user_fragment_name("tenant-sub") @@ -607,15 +607,16 @@ 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" with ( - patch("sap_cloud_sdk.agentgateway._lob.httpx.AsyncClient") as mock_http, + patch("sap_cloud_sdk.agentgateway._mcp_session.httpx.AsyncClient") as mock_http, patch( - "sap_cloud_sdk.agentgateway._lob.streamable_http_client" + "sap_cloud_sdk.agentgateway._mcp_session.streamable_http_client" ) as mock_stream, - patch("sap_cloud_sdk.agentgateway._lob.ClientSession") as mock_session, + patch("sap_cloud_sdk.agentgateway._mcp_session.ClientSession") as mock_session, ): # Setup async context managers mock_http_instance = AsyncMock() @@ -659,14 +660,15 @@ async def test_returns_empty_string_when_no_content(self): ) mock_result = MagicMock() + mock_result.isError = False mock_result.content = [] with ( - patch("sap_cloud_sdk.agentgateway._lob.httpx.AsyncClient") as mock_http, + patch("sap_cloud_sdk.agentgateway._mcp_session.httpx.AsyncClient") as mock_http, patch( - "sap_cloud_sdk.agentgateway._lob.streamable_http_client" + "sap_cloud_sdk.agentgateway._mcp_session.streamable_http_client" ) as mock_stream, - patch("sap_cloud_sdk.agentgateway._lob.ClientSession") as mock_session, + patch("sap_cloud_sdk.agentgateway._mcp_session.ClientSession") as mock_session, ): mock_http_instance = AsyncMock() mock_http.return_value.__aenter__.return_value = mock_http_instance diff --git a/uv.lock b/uv.lock index b46da9eb..302e3ff1 100644 --- a/uv.lock +++ b/uv.lock @@ -909,9 +909,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/42/3c/ff890b466eaba2b0f5e6bdfff025f8c75f41b8ffdc3dbc3d24ad261e764a/greenlet-3.5.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:73f78f9b9f0a5c06e5c946ba1e8e36f5114923b6be109ee618c54f079c3ea14f", size = 284764, upload-time = "2026-05-20T13:09:10.204Z" }, { url = "https://files.pythonhosted.org/packages/81/0e/5e5457be3d256918f6a4756f073548a3f0190836e2cc94aa6d0d617a940b/greenlet-3.5.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0cbed8bb44e23c5b199f888f4e4ce096b45ad9f25ff74a7ad0213875e936bb2", size = 603479, upload-time = "2026-05-20T14:00:04.757Z" }, { url = "https://files.pythonhosted.org/packages/6d/e1/f89a21d58d308298e6f275f13a1b472ed96c680b601a371b08be6a725989/greenlet-3.5.1-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a203a8bd0acb0701653d3bbb26e404854a68674139ed5cbb778830f42b09bb33", size = 615495, upload-time = "2026-05-20T14:05:40.87Z" }, - { url = "https://files.pythonhosted.org/packages/2c/f2/8fd452fd81adb9ec79c8275c1375702ab0fd6bee4952da12eaa09b9508d8/greenlet-3.5.1-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6ebeb75c81211f5c702576cf81f315e77e23cfdb2c7c6fcb9dd143e6de35c360", size = 623515, upload-time = "2026-05-20T14:09:07.853Z" }, { url = "https://files.pythonhosted.org/packages/75/de/af6cef182862d2ccd6975440d21c9058a77c3f9b469abf94e322dfd2e0e3/greenlet-3.5.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a271fcd66c74615cda6a964fda3f304267a12e50a084472218a39bb0376f563", size = 614754, upload-time = "2026-05-20T13:14:24.947Z" }, - { url = "https://files.pythonhosted.org/packages/ec/bc/c318aa9f3ffc77320fddcee3d892be957b42e2ff947198d9450b004f3a38/greenlet-3.5.1-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:017a544f0385d441e88714160d089d6900ef46c9eff9d99b6715a5ef2d127747", size = 418439, upload-time = "2026-05-20T14:01:38.446Z" }, { url = "https://files.pythonhosted.org/packages/1a/c6/50e520283a9f19388a7326b05f9e8637e566003475eacaadad04f558c68d/greenlet-3.5.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ded7b068c7c31c1a8657d4fd42d886b3e051ae29f88b80c5ff9d502257b0f071", size = 1574097, upload-time = "2026-05-20T14:02:24.003Z" }, { url = "https://files.pythonhosted.org/packages/21/1c/13abd1f4860d987fa5e1170a01930d6e6cd40d328de487a3c9fdaff0ffd0/greenlet-3.5.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d0932b81d72f552ded9d810d00021b64d89f2195a91ce115b893f943b7a4ab3c", size = 1641058, upload-time = "2026-05-20T13:14:31.83Z" }, { url = "https://files.pythonhosted.org/packages/f5/56/5f332b7705545eac2dc01b4e9254d24a793f2656d55d5cc6b94ee59d22ae/greenlet-3.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:88e300d136eac057b2397aa1cfd7328b4c87c7eb66a09c7bc6a1292234db474e", size = 238089, upload-time = "2026-05-20T13:14:03.229Z" }, @@ -919,9 +917,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c4/37/4549f149c9797c21b32c2683c33522af22522099de128b2406672526d005/greenlet-3.5.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:fa4f98af3a528f0c3fd592a26df7f376f93329c8f4d987f6bb979057af8bf5e2", size = 286220, upload-time = "2026-05-20T13:07:28.463Z" }, { url = "https://files.pythonhosted.org/packages/38/ff/a4f436709716965eaab9f36ea7b906c8a927fbe32fb1372a2071d964f6b1/greenlet-3.5.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ffea73584b216150eab159b6d12348fb253e68757974de1e2c40d8a318ac89ed", size = 601585, upload-time = "2026-05-20T14:00:06.141Z" }, { url = "https://files.pythonhosted.org/packages/65/ad/54bc3fcee3ad368a61b19b67d88117f7a8c29727bf71fffdeda81fbd946e/greenlet-3.5.1-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1072b4f9edcc1e192d9283a66a3e68d6b84c561de33a83d7858beb9ba1effe10", size = 614215, upload-time = "2026-05-20T14:05:42.675Z" }, - { url = "https://files.pythonhosted.org/packages/7c/6c/de5b1b388cd2d9fbdfeab324863daba37d54e6e233ddbefd70b385a8c591/greenlet-3.5.1-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:89101bfd5011e069be974903cb3a4e4523845e4ece2d62dcd8d358933c0ef249", size = 620094, upload-time = "2026-05-20T14:09:09.18Z" }, { url = "https://files.pythonhosted.org/packages/40/69/b91cda0647df839483201545913514c2827ebea5e5ccdf931842763bc127/greenlet-3.5.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:add5217d68b31130f0beca584d7fef4878327d2e31642b66618a14eef312b63b", size = 611358, upload-time = "2026-05-20T13:14:26.37Z" }, - { url = "https://files.pythonhosted.org/packages/4a/43/1204baffab8a6476464795a7ccf394a3248d4f22c9f87173a15b36b6d971/greenlet-3.5.1-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:e6cd99ea59dd5d89f0c956606571d79bfe6f68c9eb7f4a4083a41a7f1587edee", size = 422782, upload-time = "2026-05-20T14:01:39.597Z" }, { url = "https://files.pythonhosted.org/packages/59/90/3cf77e080350cd02fa307bb2abf05df48f4482c240275bbd2c203ba8bb1c/greenlet-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a5ea42a752d47a145eae922b605cd1634665ac3d5ec1e72402d5048e8d60d207", size = 1570475, upload-time = "2026-05-20T14:02:25.29Z" }, { url = "https://files.pythonhosted.org/packages/65/2c/18cece62045e74598c3c393f70dce4a63f56222015ba29a5d4eeb04f764c/greenlet-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c5551170cf4f5ff5623e9af81323751979fee2c731e2287b61f73cd27257b823", size = 1635625, upload-time = "2026-05-20T13:14:34.027Z" }, { url = "https://files.pythonhosted.org/packages/30/f5/310d104ddf41eb5a70f4c268d22508dfb0c3c8e86fec152be34d0d2ed819/greenlet-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:3c8bb982ad117d29478ef8f5533e97df21f1e2befd17a299257b0c96d1371c0b", size = 238791, upload-time = "2026-05-20T13:10:39.018Z" }, @@ -929,9 +925,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/27/69/7f7e5372d998b81001899b1c0823c957aa413ba0f2662e65821611cc31e4/greenlet-3.5.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:51518ff74664078fc51bffcc6fc529b0df5ae58da192691cee765d45ce944a2b", size = 285060, upload-time = "2026-05-20T13:08:51.899Z" }, { url = "https://files.pythonhosted.org/packages/b1/bf/387f9b6b865fd2ae0d0be09e0004827295a01b71be76ed350dd1e28a91a4/greenlet-3.5.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ffdb3c0bb002c99cd8f298957e046c3dbf6006b5b7cdf11a4e19194624a0a0a", size = 604370, upload-time = "2026-05-20T14:00:07.492Z" }, { url = "https://files.pythonhosted.org/packages/32/f5/169ce3d4e4c67291bd18f8cbe0299c9f3e45102c7f1fb3c14780c93e4532/greenlet-3.5.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7715a5a2c3378ba602c3a440558261e13a820bb53a82693aacd7b7f6d964e283", size = 616987, upload-time = "2026-05-20T14:05:44.237Z" }, - { url = "https://files.pythonhosted.org/packages/19/ba/c24110c55dffa55aa6e1d98b45310da33801aeba7686ff0190fe5d46fd32/greenlet-3.5.1-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d40a890035c0058cadbdc4af7569800fd28a0e527a0fdbb7b5f9418f176846ce", size = 622911, upload-time = "2026-05-20T14:09:10.598Z" }, { url = "https://files.pythonhosted.org/packages/ee/e5/7f2e41d5273be07e77560d61ea4e56485b4d6c316d2a84518c62d1364061/greenlet-3.5.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc71ff466927a201b08305acac451ebe1aedfcea002f62f1f2f2ac2ac1e6a135", size = 613911, upload-time = "2026-05-20T13:14:27.539Z" }, - { url = "https://files.pythonhosted.org/packages/ec/7b/d20db2e8a5ad6c038702f3179b136f93f0a3d1a21a0c0777f3e470cdf4b2/greenlet-3.5.1-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:67821bb03e4e98664490edb787ff6af501194c29bbee0f5c1dfdcf1dc3d9d436", size = 425228, upload-time = "2026-05-20T14:01:40.837Z" }, { url = "https://files.pythonhosted.org/packages/c5/a4/fbdc67579b73615a1f91615e814303cc71e06128f7baaba87be79b8fb90c/greenlet-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cd443683db272ebaaca03af98c0b063ab30db70ea8a31a1559f35e3f7b744ccd", size = 1570689, upload-time = "2026-05-20T14:02:27.225Z" }, { url = "https://files.pythonhosted.org/packages/e6/b4/77abbe35078be39718a46cd49caf16bceb35662f97a34101dca28aa98e47/greenlet-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:089fff7a6ce8d9316d1f65ebc00273a56be258c1725b32b94de90a3a979557e1", size = 1635602, upload-time = "2026-05-20T13:14:36.344Z" }, { url = "https://files.pythonhosted.org/packages/37/f7/129f27ca700845b8ee8ca88ce7f43435a1239c2eddb7677fc938822762cf/greenlet-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:110a1ca7b49b014b097f6078272c3f4ed31af45b254de5228b79adba879f6af9", size = 238683, upload-time = "2026-05-20T13:11:50.57Z" }, @@ -939,9 +933,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/cb/c62454606daf5640369c94d8a9dd540599b1bfc090e2d2180cb77f4038d2/greenlet-3.5.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d8ab31c9de8651a2facdd5c5bb0011f2380dd1a7af78ce2adf4b56095294fc07", size = 285579, upload-time = "2026-05-20T13:08:56.396Z" }, { url = "https://files.pythonhosted.org/packages/ec/71/c4270398c2eba968a6071af1dfbdcaeee6ec1c24bc8b435b8cc452700da6/greenlet-3.5.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e300185139abc337ade480c327183adf42a875ac7181bfe66d7d4efea31fbea", size = 651106, upload-time = "2026-05-20T14:00:09.448Z" }, { url = "https://files.pythonhosted.org/packages/1a/ab/71e34b78a44ec271fb5f550c17bc46d301ddc5953890d935f270b0dcdb5a/greenlet-3.5.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7ffdb990dcaa0234cf9845aead5df2e3c3a8b6507d409274dd87e0d5ab05ffc2", size = 663478, upload-time = "2026-05-20T14:05:45.88Z" }, - { url = "https://files.pythonhosted.org/packages/c6/2d/2d80842910da44f78c286532d084b8a5c3717c844ae80ceb3858738ae89a/greenlet-3.5.1-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c09df69dc1712d131332054a858a3e5cca400967fa3a672e2324fbb0971448c", size = 667767, upload-time = "2026-05-20T14:09:12.15Z" }, { url = "https://files.pythonhosted.org/packages/77/96/4efd6fa5c62c85426a0c19077a586258ebc3a2a146ff2493e4312a697a22/greenlet-3.5.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f82b3597e9d83b63408affed0b48fd0f54935edac4302237b9a837be0dae33c", size = 660800, upload-time = "2026-05-20T13:14:29.129Z" }, - { url = "https://files.pythonhosted.org/packages/e9/d3/dad2eecedfbb1ed7050a20dcfae40c1442b74bc7423608be2c7e03ee7133/greenlet-3.5.1-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:a4764e0bfc6a4d114c865b32520805c16a990ef5f286a514413b05d5ecd6a23d", size = 470786, upload-time = "2026-05-20T14:01:42.064Z" }, { url = "https://files.pythonhosted.org/packages/7a/e0/6c71401a25cac7000261304e866a2f2cc04dc74810d40e2f118aa4799495/greenlet-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c0141e37414c10164e702b8fb1473304221ad98f71600850c6ef7ff4880feba0", size = 1617518, upload-time = "2026-05-20T14:02:28.662Z" }, { url = "https://files.pythonhosted.org/packages/41/26/c5c06643e8c0af9e7bf18e16cb51d0ab7625155f0392e1c9015d66d556cd/greenlet-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:50ae25a67bea74ea41fb14b960bc532df73eb713417b2d61892dced82fe8d3bc", size = 1681593, upload-time = "2026-05-20T13:14:39.417Z" }, { url = "https://files.pythonhosted.org/packages/8a/bd/e11a108317485075e68af9d23039619b86b28130c3b50d227d42edece64b/greenlet-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:8a17c42330e261299766b75ac1ea32caa437a9453c8f65d16a13140db378ecd3", size = 239800, upload-time = "2026-05-20T13:09:30.128Z" }, @@ -949,18 +941,14 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/90/12/41bf27fde4d3605d3773ae57751eda182b8be2f5398011c041173b1d9534/greenlet-3.5.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:ea8da1e900d758d078810d4255d8c6aa572181896a31ec79d779eb79c3adc9ad", size = 293637, upload-time = "2026-05-20T13:12:35.529Z" }, { url = "https://files.pythonhosted.org/packages/44/44/ba14b23e9757707050c2f397d305bbcae62e5d7cad122f8b6baec5ae4a1f/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a19570c52a21420dcbc94e661994bc325c0b5b11304540fed514586da5dc8f2e", size = 650840, upload-time = "2026-05-20T14:00:11.079Z" }, { url = "https://files.pythonhosted.org/packages/a8/37/5ddc2b686a6844f91abecef43411842426da2e1573f60b49ecf2547f4ae1/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3d955c89b75eeca4723d7cc14135f393cd47c32e2a6cb4a8e4c6e760a26b0986", size = 656416, upload-time = "2026-05-20T14:05:47.118Z" }, - { url = "https://files.pythonhosted.org/packages/8c/46/5987dcd1a2570ba84f3b187536b2ca3ae97613387e57f5cfa99df068fe5e/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea37d5a157eb9493820d3792ac4ece28619a394391d2b9f2f78057d396ff0f0f", size = 656607, upload-time = "2026-05-20T14:09:13.949Z" }, { url = "https://files.pythonhosted.org/packages/e1/f0/d17510297c35a2992712f0bf84de3779749999f7d3d63aa1f09db7c62dbe/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2daaaebd1a5aa88c49045b6baf9310b3263796bd88db713edf37cf53e7bb4e", size = 654397, upload-time = "2026-05-20T13:14:30.696Z" }, - { url = "https://files.pythonhosted.org/packages/2c/c1/6da0a9ddcc29d7e51ef14883fa3dc1e53b3f4ffba00582106c7bf55da1d8/greenlet-3.5.1-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:8d8a23250ea3ec7b36de8fa4b541e9e2db3ee82915cc060ab0631609ad8b28de", size = 488287, upload-time = "2026-05-20T14:01:43.143Z" }, { url = "https://files.pythonhosted.org/packages/37/eb/147387705bb89092645b012586e7273cb5ed3c90ef7eaf3a69173eaf0209/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bfbd69cc349e43bf3a8ae1c85548ff0718efc887615c2db16c3833d7b0b072d", size = 1614469, upload-time = "2026-05-20T14:02:30.192Z" }, { url = "https://files.pythonhosted.org/packages/a6/4e/37ee0da7732b7aa9896f17e15579a9df34b9fcb9dd494f0adfa749af6623/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4378720dd888136c27215a0214d32a4d37c3852765d45bc37aad0623423cfd78", size = 1675115, upload-time = "2026-05-20T13:14:40.972Z" }, { url = "https://files.pythonhosted.org/packages/57/f3/97dfcf4a6eb5077f8a672234216fb5923eb89f2cab7081cb10b2cf75b605/greenlet-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:45718441607f9325d948db98cbc691276059316d0358c188c246da4e1d4d23d2", size = 245246, upload-time = "2026-05-20T13:12:22.646Z" }, { url = "https://files.pythonhosted.org/packages/5d/73/d7f72e34b582f694f4a9b248162db7b09cc458a259ba8f0c0bfa1a34ea7d/greenlet-3.5.1-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:2baee5ca02031757ffe8cc3d69f0cc0aec7065ce362622da74f32d3bcab1c541", size = 285575, upload-time = "2026-05-20T13:12:07.043Z" }, { url = "https://files.pythonhosted.org/packages/df/59/fa9c6e87dc8ad27a95dabe2f29f372b733d05a8a67470f6c901ed9975655/greenlet-3.5.1-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b1ec3274918a81d3ea778b9e75b56b72b33f300edb6cf7f3a7fe1dae56683de", size = 656428, upload-time = "2026-05-20T14:00:12.556Z" }, { url = "https://files.pythonhosted.org/packages/f6/f9/e753408871eaa61dfe35e619cfc67512b036fde99893685d50eea9e07146/greenlet-3.5.1-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:111e2390ffffc47d5840b01711dd7fac07d4c09283d0283e7f3264b14e284c64", size = 667064, upload-time = "2026-05-20T14:05:48.662Z" }, - { url = "https://files.pythonhosted.org/packages/dc/74/807a047255bf1e09303627c46dc043dca596b6958a354d904f32ab382005/greenlet-3.5.1-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:10a9a1c0bfbc93d41156ffcb90c75fbc05544054faf15dcc1fdf9765f8b607f0", size = 672962, upload-time = "2026-05-20T14:09:15.532Z" }, { url = "https://files.pythonhosted.org/packages/96/27/5565b5b40389f1c7753003a07e21892fda8660926787036d5bc0308b8113/greenlet-3.5.1-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e630136e905fe5ff43e86945ae41220b6d1470956a39220e708110ac48d01ea5", size = 665697, upload-time = "2026-05-20T13:14:32.943Z" }, - { url = "https://files.pythonhosted.org/packages/76/32/19d4e13225193c29b13e308015223f7d75fd3d8623d49dd19040d2ce8ec1/greenlet-3.5.1-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:ef08c1567c78074b22d1a200183d52d04a14df447bf70bcbb6a3507a48e776fc", size = 476047, upload-time = "2026-05-20T14:01:44.39Z" }, { url = "https://files.pythonhosted.org/packages/cf/82/e7de4178c0c2d1c9a5a3be3cc0b33e46a85b3ee4a77c071bf7ad8600e079/greenlet-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:975eac34b44a7077ca4d421348455b94f0f518246a7f14bc6d2fdcfe5b584368", size = 1621256, upload-time = "2026-05-20T14:02:31.91Z" }, { url = "https://files.pythonhosted.org/packages/00/10/f2dddcf7dacac17dfc68691809589adad06135eb28930429cf58a6467a2f/greenlet-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:9ab3c3a0b2ae6198e67c898dad5215a49f9ae0d0081b3c3ec59f333e39eeca26", size = 1685956, upload-time = "2026-05-20T13:14:42.55Z" }, { url = "https://files.pythonhosted.org/packages/22/17/4a232b32133230ada52f70e9d7f5b65b0caef8772f01849bd8d149e7e4ca/greenlet-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:cbfc69be86e10dcfef5b1e6269d1d6926552aa89ee39e1de3353360c1b6989ab", size = 239802, upload-time = "2026-05-20T13:13:15.481Z" }, @@ -968,9 +956,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7a/57/816d9cff29119da3505b3d6a5e14a8af89006ac36f47f891ff293ee05af1/greenlet-3.5.1-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:a6fdf2433a5441ef9a95464f7c3e674775da1c8c1177fff311cee1acad4626ed", size = 293877, upload-time = "2026-05-20T13:10:19.078Z" }, { url = "https://files.pythonhosted.org/packages/23/a1/59b0a7c7d140ff1a75626680b9a9899b79a9176cab298b394968fb023295/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7546556f0d649f99f6a361098a55f761181bb2ea12ff150bb16d26092ad88244", size = 655333, upload-time = "2026-05-20T14:00:14.758Z" }, { url = "https://files.pythonhosted.org/packages/72/1b/5efe127597625042218939d01855109f352779050768b670b52edcc16a6c/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d5ee3ea898009fa898f85f9982255d35278c477bebe185beca249cab42d4526c", size = 659443, upload-time = "2026-05-20T14:05:50.159Z" }, - { url = "https://files.pythonhosted.org/packages/c9/9d/1dcdf7b95ab3cf8c7b6d7277c18a5e167312f2b362ddfcc5d5e6d8d84b43/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a57b0d05a0448eed231d59c0ceb287dde984551e54cbc51ac2d4865712838e9c", size = 659998, upload-time = "2026-05-20T14:09:16.912Z" }, { url = "https://files.pythonhosted.org/packages/6c/6d/c404246ea4d22d097a7426d0efb5b781bd7eb67715f09e79001bd552ab18/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5c81f74d204d3edd136ebfd50dce53acbb776995d721a0fe801626cfc93b8cd", size = 658356, upload-time = "2026-05-20T13:14:35.091Z" }, - { url = "https://files.pythonhosted.org/packages/05/7e/c4959664fc231d587d66d8e81f2095e98056ba1954beafdcbe635e251052/greenlet-3.5.1-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:b0703c2cef53e01baec47f7a3868009913ad71ec678bbecb42a6f40895e4ce62", size = 494470, upload-time = "2026-05-20T14:01:45.611Z" }, { url = "https://files.pythonhosted.org/packages/51/02/f8ee37fb6d2219329f350af241c27fcf12df57e723d11f6fc6d3bacdadaa/greenlet-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:2c18ef16bf6d4dd410e4dd52996888ea1497be26892fe5bbc73580aba4287b8e", size = 1619216, upload-time = "2026-05-20T14:02:33.403Z" }, { url = "https://files.pythonhosted.org/packages/93/c5/3dc9475ace2c7a3680da12372cddd7f1ac874eb410a1ac48d3e9dab83782/greenlet-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:17d86354f0ae6b61bf9be5148d0dd34e06c3cb7c602c671f79f29ac3b150e659", size = 1678427, upload-time = "2026-05-20T13:14:43.71Z" }, { url = "https://files.pythonhosted.org/packages/df/4e/750c15c317a41ffb36f0bf40b933e3d744a7dede61889f74443ea69690cf/greenlet-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:e7516cf6ae6b8a582c2770a0caed47b8a48373ed732c33d69a72913ae6ac923e", size = 245225, upload-time = "2026-05-20T13:13:59.366Z" }, @@ -3696,7 +3682,7 @@ wheels = [ [[package]] name = "sap-cloud-sdk" -version = "0.26.0" +version = "0.26.1" source = { editable = "." } dependencies = [ { name = "grpcio" }, From 31b37cc536c8c0762baa4f96c002480e823e5190 Mon Sep 17 00:00:00 2001 From: Simon Kobler Date: Tue, 30 Jun 2026 08:59:51 +0200 Subject: [PATCH 4/8] fix(mcp_session): handle null results and log warnings for MCP tool calls --- src/sap_cloud_sdk/agentgateway/_customer.py | 15 ++-- .../agentgateway/_mcp_session.py | 74 ++++++++++++------- tests/agentgateway/unit/test_customer.py | 60 +++++++++++++++ 3 files changed, 114 insertions(+), 35 deletions(-) diff --git a/src/sap_cloud_sdk/agentgateway/_customer.py b/src/sap_cloud_sdk/agentgateway/_customer.py index e9ceca5c..c3390fd7 100644 --- a/src/sap_cloud_sdk/agentgateway/_customer.py +++ b/src/sap_cloud_sdk/agentgateway/_customer.py @@ -235,7 +235,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. @@ -244,7 +243,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: @@ -340,7 +338,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"] @@ -386,15 +383,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"] diff --git a/src/sap_cloud_sdk/agentgateway/_mcp_session.py b/src/sap_cloud_sdk/agentgateway/_mcp_session.py index cb5930cd..0daac279 100644 --- a/src/sap_cloud_sdk/agentgateway/_mcp_session.py +++ b/src/sap_cloud_sdk/agentgateway/_mcp_session.py @@ -1,5 +1,6 @@ """Shared helpers for MCP session management.""" +import logging import uuid import httpx @@ -9,6 +10,8 @@ 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. @@ -35,33 +38,50 @@ async def invoke_mcp_tool(tool: MCPTool, auth_token: str, timeout: float, **kwar }, timeout=timeout, ) as http_client: - 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.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", "")) + 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: diff --git a/tests/agentgateway/unit/test_customer.py b/tests/agentgateway/unit/test_customer.py index 46927fd1..1df7e55c 100644 --- a/tests/agentgateway/unit/test_customer.py +++ b/tests/agentgateway/unit/test_customer.py @@ -815,6 +815,66 @@ 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.""" From fcd1ebc12be81ebe628f5a23e651c03b03c37ab4 Mon Sep 17 00:00:00 2001 From: Simon Kobler Date: Tue, 30 Jun 2026 09:39:41 +0200 Subject: [PATCH 5/8] feat: enhance MCP tool handling with ORD ID filtering and improve documentation - Added `_resolve_dependency` function to resolve integration dependencies by ORD ID. - Updated `get_mcp_tools_customer` and `list_mcp_tools` to support filtering by ORD ID. - Refactored `call_mcp_tool` to handle tool name resolution with optional ORD ID. - Enhanced user guide with detailed credential detection and usage examples. - Added unit tests for new functionality and edge cases related to ORD ID resolution. --- src/sap_cloud_sdk/agentgateway/_customer.py | 76 ++++++----- src/sap_cloud_sdk/agentgateway/_lob.py | 23 ---- src/sap_cloud_sdk/agentgateway/agw_client.py | 86 +++++++++++-- src/sap_cloud_sdk/agentgateway/user-guide.md | 68 ++++++++-- tests/agentgateway/unit/test_customer.py | 126 ++++++++++++++++++- 5 files changed, 302 insertions(+), 77 deletions(-) diff --git a/src/sap_cloud_sdk/agentgateway/_customer.py b/src/sap_cloud_sdk/agentgateway/_customer.py index c3390fd7..ae72c934 100644 --- a/src/sap_cloud_sdk/agentgateway/_customer.py +++ b/src/sap_cloud_sdk/agentgateway/_customer.py @@ -24,7 +24,6 @@ IntegrationDependency, MCPTool, ) -from sap_cloud_sdk.agentgateway._mcp_session import invoke_mcp_tool from sap_cloud_sdk.agentgateway._token_cache import _TokenCache from sap_cloud_sdk.agentgateway.exceptions import AgentGatewaySDKError @@ -489,26 +488,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 @@ -517,6 +554,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] = [] @@ -541,27 +581,3 @@ async def get_mcp_tools_customer( "Loaded %d MCP tool(s) from %d server(s)", len(tools), len(dependencies) ) return tools - - -async def call_mcp_tool_customer( - tool: MCPTool, - auth_token: str, - timeout: float, - **kwargs, -) -> str: - """Invoke an MCP tool using customer flow. - - Uses a pre-fetched token (either user-scoped or system-scoped) for - authentication against the MCP server. - - Args: - tool: MCPTool to invoke. - auth_token: Pre-fetched raw access token for authentication. - timeout: HTTP timeout in seconds for the MCP server call. - **kwargs: Tool input parameters. - - Returns: - Tool execution result as string. - """ - logger.info("Calling tool '%s' on server '%s'", tool.name, tool.server_name) - return await invoke_mcp_tool(tool, auth_token, timeout, **kwargs) diff --git a/src/sap_cloud_sdk/agentgateway/_lob.py b/src/sap_cloud_sdk/agentgateway/_lob.py index ee21bc6a..50031973 100644 --- a/src/sap_cloud_sdk/agentgateway/_lob.py +++ b/src/sap_cloud_sdk/agentgateway/_lob.py @@ -23,7 +23,6 @@ ) from sap_cloud_sdk.agentgateway._models import MCPTool -from sap_cloud_sdk.agentgateway._mcp_session import invoke_mcp_tool from sap_cloud_sdk.agentgateway._token_cache import _GatewayUrlCache, _TokenCache from sap_cloud_sdk.agentgateway.exceptions import MCPServerNotFoundError @@ -439,25 +438,3 @@ async def get_mcp_tools_lob( logger.info("Loaded %d MCP tool(s) from %d fragment(s)", len(tools), len(fragments)) return tools - - -async def call_mcp_tool_lob( - tool: MCPTool, - user_auth_token: str, - timeout: float, - **kwargs, -) -> str: - """Invoke an MCP tool using LoB flow (destination-based). - - Uses a pre-fetched user token for principal propagation. - - Args: - tool: MCPTool object (from list_mcp_tools). - user_auth_token: Pre-fetched raw user token (from get_user_auth). - timeout: HTTP timeout in seconds for the MCP server call. - **kwargs: Tool input parameters. - - Returns: - Tool execution result as string. - """ - return await invoke_mcp_tool(tool, user_auth_token, timeout, **kwargs) diff --git a/src/sap_cloud_sdk/agentgateway/agw_client.py b/src/sap_cloud_sdk/agentgateway/agw_client.py index 4dc7da0e..e5877ca5 100644 --- a/src/sap_cloud_sdk/agentgateway/agw_client.py +++ b/src/sap_cloud_sdk/agentgateway/agw_client.py @@ -13,7 +13,6 @@ from sap_cloud_sdk.agentgateway.config import ClientConfig from sap_cloud_sdk.agentgateway._customer import ( - call_mcp_tool_customer, detect_customer_agent_credentials, exchange_user_token, get_mcp_tools_customer, @@ -21,11 +20,11 @@ load_customer_credentials, ) from sap_cloud_sdk.agentgateway._lob import ( - call_mcp_tool_lob, fetch_system_auth, fetch_user_auth, get_mcp_tools_lob, ) +from sap_cloud_sdk.agentgateway._mcp_session import invoke_mcp_tool from sap_cloud_sdk.agentgateway._models import AuthResult, MCPTool from sap_cloud_sdk.agentgateway._token_cache import _GatewayUrlCache, _TokenCache from sap_cloud_sdk.agentgateway.exceptions import AgentGatewaySDKError @@ -294,6 +293,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. @@ -314,12 +314,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 @@ -327,6 +332,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") ``` @@ -344,7 +354,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 @@ -370,9 +380,10 @@ async def list_mcp_tools( @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. @@ -387,7 +398,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. @@ -397,6 +410,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: @@ -404,16 +419,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", ) ``` """ @@ -425,6 +447,9 @@ 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) @@ -438,7 +463,7 @@ async def call_mcp_tool( ) auth = await self.get_system_auth(app_tid) - return await call_mcp_tool_customer( + return await invoke_mcp_tool( tool, auth.access_token, self._config.timeout, **kwargs ) @@ -446,8 +471,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( + return await invoke_mcp_tool( tool, auth.access_token, self._config.timeout, **kwargs ) @@ -457,10 +485,42 @@ 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/user-guide.md b/src/sap_cloud_sdk/agentgateway/user-guide.md index fd78a009..a30585a1 100644 --- a/src/sap_cloud_sdk/agentgateway/user-guide.md +++ b/src/sap_cloud_sdk/agentgateway/user-guide.md @@ -18,29 +18,65 @@ 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", cost_center="1000", ) +# Or invoke by tool name — the SDK resolves the MCPTool automatically. +# Provide ord_id to narrow the lookup to a single server (recommended). +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", +) ``` +> **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 are auto-discovered from destination fragments. @@ -155,14 +191,30 @@ 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 ``` + +#### `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_customer.py b/tests/agentgateway/unit/test_customer.py index 1df7e55c..b8584e51 100644 --- a/tests/agentgateway/unit/test_customer.py +++ b/tests/agentgateway/unit/test_customer.py @@ -11,8 +11,8 @@ get_system_token_mtls, exchange_user_token, get_mcp_tools_customer, - call_mcp_tool_customer, _build_mcp_url, + _resolve_dependency, _CREDENTIALS_PATH_ENV, _SERVICE_BINDING_ROOT_ENV, _BINDING_TYPE, @@ -20,6 +20,7 @@ _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, @@ -564,6 +565,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 # ============================================================ @@ -682,13 +731,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 From ad9a7fb4c850755e17ec435c1caaf1599273b4da Mon Sep 17 00:00:00 2001 From: Simon Kobler Date: Tue, 30 Jun 2026 15:12:31 +0200 Subject: [PATCH 6/8] fix: resolve base mount for SERVICE_BINDING_ROOT and clean up imports --- src/sap_cloud_sdk/agentgateway/_customer.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/sap_cloud_sdk/agentgateway/_customer.py b/src/sap_cloud_sdk/agentgateway/_customer.py index ae72c934..0135a799 100644 --- a/src/sap_cloud_sdk/agentgateway/_customer.py +++ b/src/sap_cloud_sdk/agentgateway/_customer.py @@ -26,6 +26,7 @@ ) 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__) @@ -33,7 +34,6 @@ _CREDENTIALS_PATH_ENV = "AGW_CREDENTIALS_PATH" # servicebinding.io: scan $SERVICE_BINDING_ROOT for a binding whose 'type' file equals the expected type -_SERVICE_BINDING_ROOT_ENV = "SERVICE_BINDING_ROOT" _BINDING_TYPE = "integration-credentials" _BINDING_TYPE_FILE = "type" _CREDENTIALS_FILE = "credentials" @@ -86,7 +86,7 @@ def detect_customer_agent_credentials() -> str | None: return path_from_env # 2. servicebinding.io: scan $SERVICE_BINDING_ROOT for a binding whose 'type' file equals _BINDING_TYPE - sbr = os.environ.get(_SERVICE_BINDING_ROOT_ENV, _DEFAULT_BINDING_ROOT) + 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(): From 6ef3f185974d2578f1435aadfc395b7cf8b32929 Mon Sep 17 00:00:00 2001 From: Simon Kobler Date: Tue, 30 Jun 2026 15:23:42 +0200 Subject: [PATCH 7/8] fix: resolve CI failures after upstream merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove _SERVICE_BINDING_ROOT_ENV from _customer.py (replaced by resolve_base_mount); update test_customer.py to use string literal - Apply ruff formatting to _customer.py, _mcp_session.py, agw_client.py - Bump version 0.31.0 → 0.32.0 (src/ modified) --- pyproject.toml | 2 +- src/sap_cloud_sdk/agentgateway/_customer.py | 5 ++++- src/sap_cloud_sdk/agentgateway/_mcp_session.py | 4 +++- src/sap_cloud_sdk/agentgateway/agw_client.py | 12 +++++++++--- tests/agentgateway/unit/test_customer.py | 15 +++++++-------- 5 files changed, 24 insertions(+), 14 deletions(-) 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/_customer.py b/src/sap_cloud_sdk/agentgateway/_customer.py index 34151293..dd846ced 100644 --- a/src/sap_cloud_sdk/agentgateway/_customer.py +++ b/src/sap_cloud_sdk/agentgateway/_customer.py @@ -99,7 +99,10 @@ def detect_customer_agent_credentials() -> str | None: 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) + logger.debug( + "Customer credentials found via servicebinding.io type scan: %s", + credentials_path, + ) return credentials_path return None diff --git a/src/sap_cloud_sdk/agentgateway/_mcp_session.py b/src/sap_cloud_sdk/agentgateway/_mcp_session.py index 0daac279..c704648e 100644 --- a/src/sap_cloud_sdk/agentgateway/_mcp_session.py +++ b/src/sap_cloud_sdk/agentgateway/_mcp_session.py @@ -13,7 +13,9 @@ logger = logging.getLogger(__name__) -async def invoke_mcp_tool(tool: MCPTool, auth_token: str, timeout: float, **kwargs) -> str: +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 diff --git a/src/sap_cloud_sdk/agentgateway/agw_client.py b/src/sap_cloud_sdk/agentgateway/agw_client.py index 93ece644..2eabb540 100644 --- a/src/sap_cloud_sdk/agentgateway/agw_client.py +++ b/src/sap_cloud_sdk/agentgateway/agw_client.py @@ -521,7 +521,9 @@ async def call_mcp_tool( ) if isinstance(tool, str): - tool = await self._resolve_tool_by_name(tool, ord_id, user_token, app_tid) + 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: @@ -545,7 +547,9 @@ async def call_mcp_tool( 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) + 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( @@ -584,7 +588,9 @@ async def _resolve_tool_by_name( 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) + 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]) diff --git a/tests/agentgateway/unit/test_customer.py b/tests/agentgateway/unit/test_customer.py index b8584e51..9069b9a5 100644 --- a/tests/agentgateway/unit/test_customer.py +++ b/tests/agentgateway/unit/test_customer.py @@ -14,7 +14,6 @@ _build_mcp_url, _resolve_dependency, _CREDENTIALS_PATH_ENV, - _SERVICE_BINDING_ROOT_ENV, _BINDING_TYPE, _BINDING_TYPE_FILE, _CREDENTIALS_FILE, @@ -59,7 +58,7 @@ def test_detect_from_env_var_path(self, tmp_path): def test_detect_from_env_var_path_file_not_exists(self, tmp_path): """Return None when env var path doesn't exist.""" - env = {_CREDENTIALS_PATH_ENV: "/nonexistent/path", _SERVICE_BINDING_ROOT_ENV: str(tmp_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 @@ -68,7 +67,7 @@ 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_ENV: str(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() @@ -80,7 +79,7 @@ def test_detect_from_default_path(self, tmp_path): with patch.dict(os.environ, {}, clear=False): os.environ.pop(_CREDENTIALS_PATH_ENV, None) - os.environ.pop(_SERVICE_BINDING_ROOT_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 == str(creds_file) @@ -92,7 +91,7 @@ def test_skips_binding_with_wrong_type(self, tmp_path): (wrong_dir / _BINDING_TYPE_FILE).write_text("something-else") (wrong_dir / _CREDENTIALS_FILE).write_text('{"clientid": "wrong"}') - env = {_SERVICE_BINDING_ROOT_ENV: str(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() @@ -104,14 +103,14 @@ def test_service_binding_root_takes_priority_over_default(self, tmp_path): sbr_dir.mkdir() creds_file = self._make_binding(sbr_dir) - with patch.dict(os.environ, {_SERVICE_BINDING_ROOT_ENV: str(sbr_dir)}, clear=False): + 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) def test_no_credentials_returns_none(self, tmp_path): """Return None when no binding with matching type is found.""" - env = {_SERVICE_BINDING_ROOT_ENV: str(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() @@ -128,7 +127,7 @@ def test_env_var_takes_priority_over_service_binding_root(self, tmp_path): env = { _CREDENTIALS_PATH_ENV: str(creds_file), - _SERVICE_BINDING_ROOT_ENV: str(sbr_dir), + "SERVICE_BINDING_ROOT": str(sbr_dir), } with patch.dict(os.environ, env, clear=False): result = detect_customer_agent_credentials() From a809fd132c373407e8fa7e493b2dda1bf24087d0 Mon Sep 17 00:00:00 2001 From: Simon Kobler Date: Tue, 30 Jun 2026 15:30:24 +0200 Subject: [PATCH 8/8] fix(tests): update agentgateway unit tests after upstream merge - Add ord_id=None to get_mcp_tools_customer assertions - Update IAS fragment error message regexes to match new specific messages - Redirect call_mcp_tool_lob mock patches from _mcp_session to _lob --- tests/agentgateway/unit/test_agw_client.py | 4 ++-- tests/agentgateway/unit/test_lob.py | 16 ++++++++-------- 2 files changed, 10 insertions(+), 10 deletions(-) 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_lob.py b/tests/agentgateway/unit/test_lob.py index 05eb8acb..2505e554 100644 --- a/tests/agentgateway/unit/test_lob.py +++ b/tests/agentgateway/unit/test_lob.py @@ -270,7 +270,7 @@ def test_raises_when_no_fragment_found(self): ) as mock_client: mock_client.return_value.list_instance_fragments.return_value = [] - with pytest.raises(MCPServerNotFoundError, match="No fragment found"): + with pytest.raises(MCPServerNotFoundError, match="No IAS fragment found"): get_ias_fragment_name("tenant-sub") @@ -316,7 +316,7 @@ def test_raises_when_no_fragment_found(self): with patch("sap_cloud_sdk.agentgateway._fragments.create_fragment_client") as mock_client: mock_client.return_value.list_instance_fragments.return_value = [] - with pytest.raises(MCPServerNotFoundError, match="No fragment found"): + with pytest.raises(MCPServerNotFoundError, match="No IAS user fragment found"): get_ias_user_fragment_name("tenant-sub") @@ -623,11 +623,11 @@ async def test_calls_tool_with_pre_fetched_token(self): mock_result.content[0].text = "Tool result" with ( - patch("sap_cloud_sdk.agentgateway._mcp_session.httpx.AsyncClient") as mock_http, + patch("sap_cloud_sdk.agentgateway._lob.httpx.AsyncClient") as mock_http, patch( - "sap_cloud_sdk.agentgateway._mcp_session.streamable_http_client" + "sap_cloud_sdk.agentgateway._lob.streamable_http_client" ) as mock_stream, - patch("sap_cloud_sdk.agentgateway._mcp_session.ClientSession") as mock_session, + patch("sap_cloud_sdk.agentgateway._lob.ClientSession") as mock_session, ): # Setup async context managers mock_http_instance = AsyncMock() @@ -675,11 +675,11 @@ async def test_returns_empty_string_when_no_content(self): mock_result.content = [] with ( - patch("sap_cloud_sdk.agentgateway._mcp_session.httpx.AsyncClient") as mock_http, + patch("sap_cloud_sdk.agentgateway._lob.httpx.AsyncClient") as mock_http, patch( - "sap_cloud_sdk.agentgateway._mcp_session.streamable_http_client" + "sap_cloud_sdk.agentgateway._lob.streamable_http_client" ) as mock_stream, - patch("sap_cloud_sdk.agentgateway._mcp_session.ClientSession") as mock_session, + patch("sap_cloud_sdk.agentgateway._lob.ClientSession") as mock_session, ): mock_http_instance = AsyncMock() mock_http.return_value.__aenter__.return_value = mock_http_instance