From 0d45813ffac367fc40add968530dc2a91ace6595 Mon Sep 17 00:00:00 2001 From: Ionut Mihalache <67947900+ionut-mihalache-uipath@users.noreply.github.com> Date: Thu, 2 Jul 2026 16:48:21 +0300 Subject: [PATCH] fix: categorize tool transport errors as SYSTEM with actionable messages Integration tool calls that hit an httpx timeout and MCP tool calls that fail with a protocol-level McpError (e.g. "Session terminated") currently propagate raw and get mapped to AGENT_RUNTIME.UNEXPECTED_ERROR with category Unknown - and for timeouts, an empty detail, since str(httpx.ReadTimeout) is "". - integration_tool: catch httpx.TimeoutException and raise AgentRuntimeError(HTTP_ERROR, SYSTEM) with a retry hint naming the tool - mcp_tool: catch McpError and raise AgentRuntimeError(HTTP_ERROR, SYSTEM); session errors (32600/-32000) get a "connection terminated, retry later" message, other codes surface the server's own error message - mcp_client: expose the configured server slug as a public property Both raises use should_wrap=False so the message is not buried under the generic unexpected-error prefix, and chain the original exception. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01N4bvjRA16sKYhsW3mHHpiZ --- .../agent/tools/integration_tool.py | 15 ++++ .../agent/tools/mcp/mcp_client.py | 5 ++ .../agent/tools/mcp/mcp_tool.py | 40 ++++++++++- tests/agent/tools/test_integration_tool.py | 23 ++++++ tests/agent/tools/test_mcp/test_mcp_tool.py | 70 ++++++++++++++++++- 5 files changed, 151 insertions(+), 2 deletions(-) diff --git a/src/uipath_langchain/agent/tools/integration_tool.py b/src/uipath_langchain/agent/tools/integration_tool.py index e9d7dbf63..273912fcc 100644 --- a/src/uipath_langchain/agent/tools/integration_tool.py +++ b/src/uipath_langchain/agent/tools/integration_tool.py @@ -4,6 +4,7 @@ import re from typing import Any +import httpx from langchain_core.tools import StructuredTool from uipath.agent.models.agent import ( AgentIntegrationToolParameter, @@ -19,6 +20,8 @@ from uipath.runtime.errors import UiPathErrorCategory from uipath_langchain.agent.exceptions import ( + AgentRuntimeError, + AgentRuntimeErrorCode, AgentStartupError, AgentStartupErrorCode, raise_for_enriched, @@ -334,6 +337,18 @@ async def integration_tool_fn(**kwargs: Any): tool=resource.name, ) raise + except httpx.TimeoutException as e: + raise AgentRuntimeError( + code=AgentRuntimeErrorCode.HTTP_ERROR, + title=f"Tool '{resource.name}' timed out", + detail=( + f"The Integration Service request for tool '{resource.name}' " + "did not complete in time. This is usually a transient issue; " + "please retry the run later." + ), + category=UiPathErrorCategory.SYSTEM, + should_wrap=False, + ) from e return result diff --git a/src/uipath_langchain/agent/tools/mcp/mcp_client.py b/src/uipath_langchain/agent/tools/mcp/mcp_client.py index 458b9faaf..4329e7b0b 100644 --- a/src/uipath_langchain/agent/tools/mcp/mcp_client.py +++ b/src/uipath_langchain/agent/tools/mcp/mcp_client.py @@ -123,6 +123,11 @@ def __init__( self._session: ClientSession | None = None self._client_initialized: bool = False + @property + def server_slug(self) -> str: + """Slug of the configured MCP server.""" + return self._config.slug + async def get_session_id(self) -> str | None: """Get the current session ID from the SessionInfo.""" if self._session_info is None: diff --git a/src/uipath_langchain/agent/tools/mcp/mcp_tool.py b/src/uipath_langchain/agent/tools/mcp/mcp_tool.py index 0a2646f3c..ab9b5f773 100644 --- a/src/uipath_langchain/agent/tools/mcp/mcp_tool.py +++ b/src/uipath_langchain/agent/tools/mcp/mcp_tool.py @@ -3,6 +3,7 @@ from typing import Any, AsyncGenerator from langchain_core.tools import BaseTool +from mcp.shared.exceptions import McpError from uipath.agent.models.agent import ( AgentMcpResourceConfig, AgentMcpTool, @@ -10,7 +11,12 @@ DynamicToolsConfig, ) from uipath.eval.mocks import mockable +from uipath.runtime.errors import UiPathErrorCategory +from uipath_langchain.agent.exceptions import ( + AgentRuntimeError, + AgentRuntimeErrorCode, +) from uipath_langchain.agent.tools.structured_tool_with_argument_properties import ( StructuredToolWithArgumentProperties, ) @@ -292,6 +298,35 @@ async def create_mcp_tools( return tools +def _map_mcp_error( + error: McpError, tool_name: str, server_slug: str +) -> AgentRuntimeError: + """Map a protocol-level McpError to a categorized AgentRuntimeError. + + MCP tool execution failures come back as ``CallToolResult.isError`` results, + so an McpError raised during a call is a protocol/session/transport failure — + hence the SYSTEM category. + """ + if error.error.code in McpClient.SESSION_ERROR_CODES: + detail = ( + f"The connection to MCP server '{server_slug}' was terminated and " + f"could not be re-established while calling tool '{tool_name}'. " + "This is usually a transient issue; please retry the run later." + ) + else: + detail = ( + f"MCP server '{server_slug}' returned an error for tool " + f"'{tool_name}': {error.error.message}" + ) + return AgentRuntimeError( + code=AgentRuntimeErrorCode.HTTP_ERROR, + title=f"MCP tool '{tool_name}' failed", + detail=detail, + category=UiPathErrorCategory.SYSTEM, + should_wrap=False, + ) + + def _normalize_tool_result(result: Any) -> Any: """Normalize an MCP ``call_tool`` result into JSON-serializable content.""" content = result.content if hasattr(result, "content") else result @@ -339,7 +374,10 @@ async def tool_fn(**kwargs: Any) -> Any: retry_message = await _refresh_tool_schema(mcp_tool, mcpClient, tool_holder) if retry_message is not None: return retry_message - result = await mcpClient.call_tool(mcp_tool.name, arguments=kwargs) + try: + result = await mcpClient.call_tool(mcp_tool.name, arguments=kwargs) + except McpError as e: + raise _map_mcp_error(e, mcp_tool.name, mcpClient.server_slug) from e logger.info(f"Tool call successful for {mcp_tool.name}") return _normalize_tool_result(result) diff --git a/tests/agent/tools/test_integration_tool.py b/tests/agent/tools/test_integration_tool.py index a10beefd3..35f0fdeb4 100644 --- a/tests/agent/tools/test_integration_tool.py +++ b/tests/agent/tools/test_integration_tool.py @@ -2,6 +2,7 @@ from unittest.mock import AsyncMock, MagicMock, patch +import httpx import pytest from pydantic import BaseModel from uipath.agent.models.agent import ( @@ -1026,6 +1027,28 @@ async def test_non_400_enriched_exception_propagates( with pytest.raises(EnrichedException): await tool.ainvoke({"query": "test"}) + @pytest.mark.asyncio + @patch("uipath_langchain.agent.tools.integration_tool.UiPath") + async def test_timeout_raises_agent_runtime_error_with_system_category( + self, mock_uipath_cls, resource + ): + mock_sdk = MagicMock() + mock_sdk.connections.invoke_activity_async = AsyncMock( + side_effect=httpx.ReadTimeout("") + ) + mock_uipath_cls.return_value = mock_sdk + + tool = create_integration_tool(resource) + + with pytest.raises(AgentRuntimeError) as exc_info: + await tool.ainvoke({"query": "test"}) + assert exc_info.value.error_info.category == UiPathErrorCategory.SYSTEM + assert exc_info.value.error_info.code == AgentRuntimeError.full_code( + AgentRuntimeErrorCode.HTTP_ERROR + ) + assert "test_tool" in exc_info.value.error_info.detail + assert isinstance(exc_info.value.__cause__, httpx.ReadTimeout) + class TestRemoveAsteriskFromProperties: """Test cases for remove_asterisk_from_properties function.""" diff --git a/tests/agent/tools/test_mcp/test_mcp_tool.py b/tests/agent/tools/test_mcp/test_mcp_tool.py index 8fcbd1c0c..b6a18fd82 100644 --- a/tests/agent/tools/test_mcp/test_mcp_tool.py +++ b/tests/agent/tools/test_mcp/test_mcp_tool.py @@ -7,7 +7,8 @@ import pytest from langchain_core.tools import BaseTool -from mcp.types import ListToolsResult, Tool +from mcp.shared.exceptions import McpError +from mcp.types import ErrorData, ListToolsResult, Tool from uipath.agent.models.agent import ( AgentMcpResourceConfig, AgentMcpTool, @@ -16,7 +17,12 @@ DynamicToolsConfig, ToolsConfiguration, ) +from uipath.runtime.errors import UiPathErrorCategory +from uipath_langchain.agent.exceptions import ( + AgentRuntimeError, + AgentRuntimeErrorCode, +) from uipath_langchain.agent.tools.mcp import McpClient from uipath_langchain.agent.tools.mcp.mcp_tool import ( _schema_change_message, @@ -669,6 +675,68 @@ async def test_plain_value_returned_as_is(self, mcp_tool): assert result == "plain string" +class TestMcpToolErrorHandling: + """Test that protocol-level McpErrors are mapped to categorized AgentRuntimeErrors.""" + + @pytest.fixture + def mcp_tool(self): + return AgentMcpTool( + name="test_tool", + description="Test tool", + input_schema={"type": "object", "properties": {}}, + ) + + def _mock_client(self, error: McpError) -> MagicMock: + client = MagicMock(spec=McpClient) + client.server_slug = "my-mcp-server" + client.call_tool = AsyncMock(side_effect=error) + return client + + @pytest.mark.asyncio + async def test_session_terminated_raises_system_error_with_retry_hint( + self, mcp_tool + ): + error = McpError(ErrorData(code=32600, message="Session terminated")) + client = self._mock_client(error) + + tool_fn = build_mcp_tool(mcp_tool, client) + + with pytest.raises(AgentRuntimeError) as exc_info: + await tool_fn() + assert exc_info.value.error_info.category == UiPathErrorCategory.SYSTEM + assert exc_info.value.error_info.code == AgentRuntimeError.full_code( + AgentRuntimeErrorCode.HTTP_ERROR + ) + assert "my-mcp-server" in exc_info.value.error_info.detail + assert "terminated" in exc_info.value.error_info.detail + assert "retry" in exc_info.value.error_info.detail + assert exc_info.value.__cause__ is error + + @pytest.mark.asyncio + async def test_non_session_mcp_error_includes_server_message(self, mcp_tool): + error = McpError(ErrorData(code=-32601, message="Method not found")) + client = self._mock_client(error) + + tool_fn = build_mcp_tool(mcp_tool, client) + + with pytest.raises(AgentRuntimeError) as exc_info: + await tool_fn() + assert exc_info.value.error_info.category == UiPathErrorCategory.SYSTEM + assert "Method not found" in exc_info.value.error_info.detail + assert "test_tool" in exc_info.value.error_info.detail + + @pytest.mark.asyncio + async def test_non_mcp_error_propagates_unchanged(self, mcp_tool): + client = MagicMock(spec=McpClient) + client.server_slug = "my-mcp-server" + client.call_tool = AsyncMock(side_effect=RuntimeError("boom")) + + tool_fn = build_mcp_tool(mcp_tool, client) + + with pytest.raises(RuntimeError, match="boom"): + await tool_fn() + + class TestMcpToolNameSanitization: """Test that MCP tool names are properly sanitized."""