diff --git a/pyproject.toml b/pyproject.toml index e1e9a5a2..3c3f31c1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sap-cloud-sdk" -version = "0.39.0" +version = "0.39.1" 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 5a0ebd81..bf85299c 100644 --- a/src/sap_cloud_sdk/agentgateway/_customer.py +++ b/src/sap_cloud_sdk/agentgateway/_customer.py @@ -642,7 +642,7 @@ async def _list_server_tools( async with streamable_http_client(url, http_client=http_client) as ( read, write, - _, + *_, ): async with ClientSession(read, write) as session: init_result = await session.initialize() @@ -672,6 +672,25 @@ async def _list_server_tools( ] +def _log_mcp_server_error(ord_id: str, exc: BaseException) -> None: + # Unwrap ExceptionGroup from anyio to surface the real HTTP error body + if isinstance(exc, BaseExceptionGroup): + for inner in exc.exceptions: + _log_mcp_server_error(ord_id, inner) + return + if isinstance(exc, httpx.HTTPStatusError): + logger.error( + "Failed to load tools from %s (HTTP %d): %s", + ord_id, + exc.response.status_code, + exc.response.text[:500], + ) + else: + logger.exception( + "Failed to load tools from %s — skipping", ord_id, exc_info=exc + ) + + async def get_mcp_tools_customer( credentials: CustomerCredentials, system_token: str, @@ -689,16 +708,14 @@ async def get_mcp_tools_customer( Returns: List of MCPTool objects from all servers. - - Raises: - AgentGatewaySDKError: If integrationDependencies is empty. """ dependencies = credentials.integration_dependencies if not dependencies: - raise AgentGatewaySDKError( + logger.warning( "integrationDependencies is empty in credentials — no MCP servers configured." ) + return [] logger.info("Discovering tools from %d MCP server(s)", len(dependencies)) @@ -717,8 +734,8 @@ async def get_mcp_tools_customer( server_tools = await _list_server_tools(url, system_token, timeout) tools.extend(server_tools) logger.debug("Loaded %d tool(s) from %s", len(server_tools), dep.ord_id) - except Exception: - logger.exception("Failed to load tools from %s — skipping", dep.ord_id) + except Exception as exc: + _log_mcp_server_error(dep.ord_id, exc) logger.info( "Loaded %d MCP tool(s) from %d server(s)", len(tools), len(dependencies) @@ -758,7 +775,7 @@ async def call_mcp_tool_customer( async with streamable_http_client(tool.url, http_client=http_client) as ( read, write, - _, + *_, ): async with ClientSession(read, write) as session: await session.initialize() @@ -769,4 +786,9 @@ async def call_mcp_tool_customer( return "" first = result.content[0] - return str(getattr(first, "text", "")) + text = str(getattr(first, "text", "")) + + if result.isError: + logger.error("Tool '%s' returned an error: %s", tool.name, text) + + return text diff --git a/src/sap_cloud_sdk/agentgateway/_lob.py b/src/sap_cloud_sdk/agentgateway/_lob.py index 83ad4ebd..b1d63e55 100644 --- a/src/sap_cloud_sdk/agentgateway/_lob.py +++ b/src/sap_cloud_sdk/agentgateway/_lob.py @@ -290,6 +290,26 @@ def _fetch_user_auth_sync(): return token, gateway_url +def _log_mcp_server_error(fragment_name: str, exc: BaseException) -> None: + if isinstance(exc, BaseExceptionGroup): + for inner in exc.exceptions: + _log_mcp_server_error(fragment_name, inner) + return + if isinstance(exc, httpx.HTTPStatusError): + logger.error( + "Failed to load tools from fragment '%s' (HTTP %d): %s", + fragment_name, + exc.response.status_code, + exc.response.text[:500], + ) + else: + logger.exception( + "Failed to load tools from fragment '%s' — skipping", + fragment_name, + exc_info=exc, + ) + + async def list_server_tools( dest_url: str, auth_token: str, fragment_name: str, timeout: float ) -> list[MCPTool]: @@ -313,7 +333,7 @@ async def list_server_tools( async with streamable_http_client(dest_url, http_client=http_client) as ( read, write, - _, + *_, ): async with ClientSession(read, write) as session: init_result = await session.initialize() @@ -388,11 +408,8 @@ async def get_mcp_tools_lob( len(server_tools), fragment_name, ) - except Exception: - logger.exception( - "Failed to load tools from fragment '%s' — skipping", - fragment_name, - ) + except Exception as exc: + _log_mcp_server_error(fragment_name, exc) logger.info("Loaded %d MCP tool(s) from %d fragment(s)", len(tools), len(fragments)) return tools @@ -427,7 +444,7 @@ async def call_mcp_tool_lob( async with streamable_http_client(tool.url, http_client=http_client) as ( read, write, - _, + *_, ): async with ClientSession(read, write) as session: await session.initialize() @@ -436,7 +453,12 @@ async def call_mcp_tool_lob( logger.warning("Tool '%s' returned empty content", tool.name) return "" first = result.content[0] - return str(getattr(first, "text", "")) + text = str(getattr(first, "text", "")) + + if result.isError: + logger.error("Tool '%s' returned an error: %s", tool.name, text) + + return text async def _fetch_agent_card( diff --git a/tests/agentgateway/unit/test_customer.py b/tests/agentgateway/unit/test_customer.py index 795b0985..e1db66f2 100644 --- a/tests/agentgateway/unit/test_customer.py +++ b/tests/agentgateway/unit/test_customer.py @@ -464,8 +464,8 @@ def credentials(self): ) @pytest.mark.asyncio - async def test_raises_when_empty_dependencies(self): - """Raise error when integrationDependencies is empty.""" + async def test_returns_empty_when_empty_dependencies(self): + """Return empty list when integrationDependencies is empty.""" credentials = CustomerCredentials( token_service_url="https://ias.example.com/oauth2/token", client_id="test-client", @@ -474,10 +474,8 @@ async def test_raises_when_empty_dependencies(self): gateway_url="https://agw.example.com", integration_dependencies=[], ) - with pytest.raises( - AgentGatewaySDKError, match="integrationDependencies is empty" - ): - await get_mcp_tools_customer(credentials, "system-token", 60.0) + result = await get_mcp_tools_customer(credentials, "system-token", 60.0) + assert result == [] @pytest.mark.asyncio async def test_discovers_tools_from_credentials(self, credentials): diff --git a/uv.lock b/uv.lock index c9c42952..ef0a7cbc 100644 --- a/uv.lock +++ b/uv.lock @@ -161,9 +161,9 @@ name = "aiologic" version = "0.16.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "sniffio", marker = "python_full_version < '3.13'" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, - { name = "wrapt", marker = "python_full_version < '3.13'" }, + { name = "sniffio" }, + { name = "typing-extensions" }, + { name = "wrapt" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a8/13/50b91a3ea6b030d280d2654be97c48b6ed81753a50286ee43c646ba36d3c/aiologic-0.16.0.tar.gz", hash = "sha256:c267ccbd3ff417ec93e78d28d4d577ccca115d5797cdbd16785a551d9658858f", size = 225952, upload-time = "2025-11-27T23:48:41.195Z" } wheels = [ @@ -615,8 +615,8 @@ name = "culsans" version = "0.11.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "aiologic", marker = "python_full_version < '3.13'" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "aiologic" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d9/e3/49afa1bc180e0d28008ec6bcdf82a4072d1c7a41032b5b759b60814ca4b0/culsans-0.11.0.tar.gz", hash = "sha256:0b43d0d05dce6106293d114c86e3fb4bfc63088cfe8ff08ed3fe36891447fe33", size = 107546, upload-time = "2025-12-31T23:15:38.196Z" } wheels = [ @@ -665,9 +665,9 @@ resolution-markers = [ "python_full_version < '3.12'", ] dependencies = [ - { name = "asgiref", marker = "python_full_version < '3.12'" }, - { name = "sqlparse", marker = "python_full_version < '3.12'" }, - { name = "tzdata", marker = "python_full_version < '3.12' and sys_platform == 'win32'" }, + { name = "asgiref" }, + { name = "sqlparse" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a9/26/889449d521ae508b26de715954faecd8bcf3f740affb81b2d146a83b42a5/django-5.2.16.tar.gz", hash = "sha256:59ea02020c3136fce14bef0bbece21a10a4febef5eed1c51c22ae468efa22200", size = 10890894, upload-time = "2026-07-07T13:52:17.005Z" } wheels = [ @@ -685,9 +685,9 @@ resolution-markers = [ "python_full_version == '3.12.*'", ] dependencies = [ - { name = "asgiref", marker = "python_full_version >= '3.12'" }, - { name = "sqlparse", marker = "python_full_version >= '3.12'" }, - { name = "tzdata", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" }, + { name = "asgiref" }, + { name = "sqlparse" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/89/55/664f24ff81c9ea19cb7dfc851afeae1f3c2390c7aee01d4ded68b5c1580d/django-6.0.7.tar.gz", hash = "sha256:2998503fc083124fb58037084bfa00de323c7c743f05f1b4284e77bff0ab8890", size = 10921299, upload-time = "2026-07-07T13:51:26.485Z" } wheels = [ @@ -3925,7 +3925,7 @@ wheels = [ [[package]] name = "sap-cloud-sdk" -version = "0.39.0" +version = "0.39.1" source = { editable = "." } dependencies = [ { name = "grpcio" },