Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions src/uipath_langchain/agent/tools/integration_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -19,6 +20,8 @@
from uipath.runtime.errors import UiPathErrorCategory

from uipath_langchain.agent.exceptions import (
AgentRuntimeError,
AgentRuntimeErrorCode,
AgentStartupError,
AgentStartupErrorCode,
raise_for_enriched,
Expand Down Expand Up @@ -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

Expand Down
5 changes: 5 additions & 0 deletions src/uipath_langchain/agent/tools/mcp/mcp_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
40 changes: 39 additions & 1 deletion src/uipath_langchain/agent/tools/mcp/mcp_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,20 @@
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,
CachedToolsConfig,
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,
)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down
23 changes: 23 additions & 0 deletions tests/agent/tools/test_integration_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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."""
Expand Down
70 changes: 69 additions & 1 deletion tests/agent/tools/test_mcp/test_mcp_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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."""

Expand Down
Loading