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."""