Skip to content

Commit 0d45813

Browse files
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N4bvjRA16sKYhsW3mHHpiZ
1 parent 4fd50f7 commit 0d45813

5 files changed

Lines changed: 151 additions & 2 deletions

File tree

src/uipath_langchain/agent/tools/integration_tool.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import re
55
from typing import Any
66

7+
import httpx
78
from langchain_core.tools import StructuredTool
89
from uipath.agent.models.agent import (
910
AgentIntegrationToolParameter,
@@ -19,6 +20,8 @@
1920
from uipath.runtime.errors import UiPathErrorCategory
2021

2122
from uipath_langchain.agent.exceptions import (
23+
AgentRuntimeError,
24+
AgentRuntimeErrorCode,
2225
AgentStartupError,
2326
AgentStartupErrorCode,
2427
raise_for_enriched,
@@ -334,6 +337,18 @@ async def integration_tool_fn(**kwargs: Any):
334337
tool=resource.name,
335338
)
336339
raise
340+
except httpx.TimeoutException as e:
341+
raise AgentRuntimeError(
342+
code=AgentRuntimeErrorCode.HTTP_ERROR,
343+
title=f"Tool '{resource.name}' timed out",
344+
detail=(
345+
f"The Integration Service request for tool '{resource.name}' "
346+
"did not complete in time. This is usually a transient issue; "
347+
"please retry the run later."
348+
),
349+
category=UiPathErrorCategory.SYSTEM,
350+
should_wrap=False,
351+
) from e
337352

338353
return result
339354

src/uipath_langchain/agent/tools/mcp/mcp_client.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,11 @@ def __init__(
123123
self._session: ClientSession | None = None
124124
self._client_initialized: bool = False
125125

126+
@property
127+
def server_slug(self) -> str:
128+
"""Slug of the configured MCP server."""
129+
return self._config.slug
130+
126131
async def get_session_id(self) -> str | None:
127132
"""Get the current session ID from the SessionInfo."""
128133
if self._session_info is None:

src/uipath_langchain/agent/tools/mcp/mcp_tool.py

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,20 @@
33
from typing import Any, AsyncGenerator
44

55
from langchain_core.tools import BaseTool
6+
from mcp.shared.exceptions import McpError
67
from uipath.agent.models.agent import (
78
AgentMcpResourceConfig,
89
AgentMcpTool,
910
CachedToolsConfig,
1011
DynamicToolsConfig,
1112
)
1213
from uipath.eval.mocks import mockable
14+
from uipath.runtime.errors import UiPathErrorCategory
1315

16+
from uipath_langchain.agent.exceptions import (
17+
AgentRuntimeError,
18+
AgentRuntimeErrorCode,
19+
)
1420
from uipath_langchain.agent.tools.structured_tool_with_argument_properties import (
1521
StructuredToolWithArgumentProperties,
1622
)
@@ -292,6 +298,35 @@ async def create_mcp_tools(
292298
return tools
293299

294300

301+
def _map_mcp_error(
302+
error: McpError, tool_name: str, server_slug: str
303+
) -> AgentRuntimeError:
304+
"""Map a protocol-level McpError to a categorized AgentRuntimeError.
305+
306+
MCP tool execution failures come back as ``CallToolResult.isError`` results,
307+
so an McpError raised during a call is a protocol/session/transport failure —
308+
hence the SYSTEM category.
309+
"""
310+
if error.error.code in McpClient.SESSION_ERROR_CODES:
311+
detail = (
312+
f"The connection to MCP server '{server_slug}' was terminated and "
313+
f"could not be re-established while calling tool '{tool_name}'. "
314+
"This is usually a transient issue; please retry the run later."
315+
)
316+
else:
317+
detail = (
318+
f"MCP server '{server_slug}' returned an error for tool "
319+
f"'{tool_name}': {error.error.message}"
320+
)
321+
return AgentRuntimeError(
322+
code=AgentRuntimeErrorCode.HTTP_ERROR,
323+
title=f"MCP tool '{tool_name}' failed",
324+
detail=detail,
325+
category=UiPathErrorCategory.SYSTEM,
326+
should_wrap=False,
327+
)
328+
329+
295330
def _normalize_tool_result(result: Any) -> Any:
296331
"""Normalize an MCP ``call_tool`` result into JSON-serializable content."""
297332
content = result.content if hasattr(result, "content") else result
@@ -339,7 +374,10 @@ async def tool_fn(**kwargs: Any) -> Any:
339374
retry_message = await _refresh_tool_schema(mcp_tool, mcpClient, tool_holder)
340375
if retry_message is not None:
341376
return retry_message
342-
result = await mcpClient.call_tool(mcp_tool.name, arguments=kwargs)
377+
try:
378+
result = await mcpClient.call_tool(mcp_tool.name, arguments=kwargs)
379+
except McpError as e:
380+
raise _map_mcp_error(e, mcp_tool.name, mcpClient.server_slug) from e
343381
logger.info(f"Tool call successful for {mcp_tool.name}")
344382
return _normalize_tool_result(result)
345383

tests/agent/tools/test_integration_tool.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from unittest.mock import AsyncMock, MagicMock, patch
44

5+
import httpx
56
import pytest
67
from pydantic import BaseModel
78
from uipath.agent.models.agent import (
@@ -1026,6 +1027,28 @@ async def test_non_400_enriched_exception_propagates(
10261027
with pytest.raises(EnrichedException):
10271028
await tool.ainvoke({"query": "test"})
10281029

1030+
@pytest.mark.asyncio
1031+
@patch("uipath_langchain.agent.tools.integration_tool.UiPath")
1032+
async def test_timeout_raises_agent_runtime_error_with_system_category(
1033+
self, mock_uipath_cls, resource
1034+
):
1035+
mock_sdk = MagicMock()
1036+
mock_sdk.connections.invoke_activity_async = AsyncMock(
1037+
side_effect=httpx.ReadTimeout("")
1038+
)
1039+
mock_uipath_cls.return_value = mock_sdk
1040+
1041+
tool = create_integration_tool(resource)
1042+
1043+
with pytest.raises(AgentRuntimeError) as exc_info:
1044+
await tool.ainvoke({"query": "test"})
1045+
assert exc_info.value.error_info.category == UiPathErrorCategory.SYSTEM
1046+
assert exc_info.value.error_info.code == AgentRuntimeError.full_code(
1047+
AgentRuntimeErrorCode.HTTP_ERROR
1048+
)
1049+
assert "test_tool" in exc_info.value.error_info.detail
1050+
assert isinstance(exc_info.value.__cause__, httpx.ReadTimeout)
1051+
10291052

10301053
class TestRemoveAsteriskFromProperties:
10311054
"""Test cases for remove_asterisk_from_properties function."""

tests/agent/tools/test_mcp/test_mcp_tool.py

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77

88
import pytest
99
from langchain_core.tools import BaseTool
10-
from mcp.types import ListToolsResult, Tool
10+
from mcp.shared.exceptions import McpError
11+
from mcp.types import ErrorData, ListToolsResult, Tool
1112
from uipath.agent.models.agent import (
1213
AgentMcpResourceConfig,
1314
AgentMcpTool,
@@ -16,7 +17,12 @@
1617
DynamicToolsConfig,
1718
ToolsConfiguration,
1819
)
20+
from uipath.runtime.errors import UiPathErrorCategory
1921

22+
from uipath_langchain.agent.exceptions import (
23+
AgentRuntimeError,
24+
AgentRuntimeErrorCode,
25+
)
2026
from uipath_langchain.agent.tools.mcp import McpClient
2127
from uipath_langchain.agent.tools.mcp.mcp_tool import (
2228
_schema_change_message,
@@ -669,6 +675,68 @@ async def test_plain_value_returned_as_is(self, mcp_tool):
669675
assert result == "plain string"
670676

671677

678+
class TestMcpToolErrorHandling:
679+
"""Test that protocol-level McpErrors are mapped to categorized AgentRuntimeErrors."""
680+
681+
@pytest.fixture
682+
def mcp_tool(self):
683+
return AgentMcpTool(
684+
name="test_tool",
685+
description="Test tool",
686+
input_schema={"type": "object", "properties": {}},
687+
)
688+
689+
def _mock_client(self, error: McpError) -> MagicMock:
690+
client = MagicMock(spec=McpClient)
691+
client.server_slug = "my-mcp-server"
692+
client.call_tool = AsyncMock(side_effect=error)
693+
return client
694+
695+
@pytest.mark.asyncio
696+
async def test_session_terminated_raises_system_error_with_retry_hint(
697+
self, mcp_tool
698+
):
699+
error = McpError(ErrorData(code=32600, message="Session terminated"))
700+
client = self._mock_client(error)
701+
702+
tool_fn = build_mcp_tool(mcp_tool, client)
703+
704+
with pytest.raises(AgentRuntimeError) as exc_info:
705+
await tool_fn()
706+
assert exc_info.value.error_info.category == UiPathErrorCategory.SYSTEM
707+
assert exc_info.value.error_info.code == AgentRuntimeError.full_code(
708+
AgentRuntimeErrorCode.HTTP_ERROR
709+
)
710+
assert "my-mcp-server" in exc_info.value.error_info.detail
711+
assert "terminated" in exc_info.value.error_info.detail
712+
assert "retry" in exc_info.value.error_info.detail
713+
assert exc_info.value.__cause__ is error
714+
715+
@pytest.mark.asyncio
716+
async def test_non_session_mcp_error_includes_server_message(self, mcp_tool):
717+
error = McpError(ErrorData(code=-32601, message="Method not found"))
718+
client = self._mock_client(error)
719+
720+
tool_fn = build_mcp_tool(mcp_tool, client)
721+
722+
with pytest.raises(AgentRuntimeError) as exc_info:
723+
await tool_fn()
724+
assert exc_info.value.error_info.category == UiPathErrorCategory.SYSTEM
725+
assert "Method not found" in exc_info.value.error_info.detail
726+
assert "test_tool" in exc_info.value.error_info.detail
727+
728+
@pytest.mark.asyncio
729+
async def test_non_mcp_error_propagates_unchanged(self, mcp_tool):
730+
client = MagicMock(spec=McpClient)
731+
client.server_slug = "my-mcp-server"
732+
client.call_tool = AsyncMock(side_effect=RuntimeError("boom"))
733+
734+
tool_fn = build_mcp_tool(mcp_tool, client)
735+
736+
with pytest.raises(RuntimeError, match="boom"):
737+
await tool_fn()
738+
739+
672740
class TestMcpToolNameSanitization:
673741
"""Test that MCP tool names are properly sanitized."""
674742

0 commit comments

Comments
 (0)