Skip to content
Merged
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
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "uipath-langchain"
version = "0.18.13"
version = "0.18.14"
description = "Python SDK that enables developers to build and deploy LangGraph agents to the UiPath Cloud Platform"
readme = { file = "README.md", content-type = "text/markdown" }
requires-python = ">=3.11"
Expand Down
34 changes: 25 additions & 9 deletions src/uipath_langchain/agent/react/guardrails/guardrails_subgraph.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
AgentGuardrailsGraphState,
)
from uipath_langchain.agent.react.utils import create_guardrails_state_with_input
from uipath_langchain.agent.tools.utils import sanitize_tool_name

_VALIDATOR_ALLOWED_STAGES = {
"prompt_injection": {ExecutionStage.PRE_EXECUTION},
Expand Down Expand Up @@ -275,6 +276,13 @@ def create_llm_guardrails_subgraph(
)


def _tool_metadata(tool_node: RunnableCallable) -> dict[str, Any] | None:
"""A UiPathToolNode's underlying tool metadata, when it carries any."""
tool = getattr(tool_node, "tool", None)
metadata = getattr(tool, "metadata", None) if tool is not None else None
return metadata if isinstance(metadata, dict) else None


def _extract_tool_type(tool_node: RunnableCallable) -> str | None:
"""Extract tool_type from a UiPathToolNode's underlying tool metadata.

Expand All @@ -284,12 +292,8 @@ def _extract_tool_type(tool_node: RunnableCallable) -> str | None:
Returns:
The tool_type string if available, otherwise None.
"""
tool = getattr(tool_node, "tool", None)
if tool is not None:
metadata = getattr(tool, "metadata", None)
if isinstance(metadata, dict):
return metadata.get("tool_type")
return None
metadata = _tool_metadata(tool_node)
return metadata.get("tool_type") if metadata else None


def create_tools_guardrails_subgraph(
Expand All @@ -309,12 +313,13 @@ def create_tools_guardrails_subgraph(
"""
result: dict[str, RunnableCallable] = {}
for tool_name, tool_node in tool_nodes.items():
tool_type = _extract_tool_type(tool_node)
metadata = _tool_metadata(tool_node)
subgraph = create_tool_guardrails_subgraph(
(tool_name, tool_node),
guardrails,
input_schema=input_schema,
tool_type=tool_type,
tool_type=metadata.get("tool_type") if metadata else None,
display_name=metadata.get("display_name") if metadata else None,
Comment thread
radugheo marked this conversation as resolved.
)
result[tool_name] = subgraph

Expand Down Expand Up @@ -420,6 +425,7 @@ def create_tool_guardrails_subgraph(
guardrails: Sequence[tuple[BaseGuardrail, GuardrailAction]] | None,
input_schema: type[BaseModel] | None = None,
tool_type: str | None = None,
display_name: str | None = None,
):
"""Create a guarded tool node.

Expand All @@ -428,19 +434,29 @@ def create_tool_guardrails_subgraph(
guardrails: Optional sequence of (guardrail, action) tuples.
input_schema: Optional input schema to include in state.
tool_type: Optional type of the tool (e.g., "process", "escalation", "mcp").
display_name: The tool's own name, where it differs from the node key. A
selector naming an MCP tool holds that name sanitized, not the qualified
key, and ``guardrails_factory`` accepts it when validating the same
selector.

Returns:
Either the original tool node callable (if no matching guardrails) or a compiled
LangGraph subgraph that enforces the matching tool guardrails.
"""
tool_name, _ = tool_node
# Tool-scope selectors are sanitized before they reach here
# (_sanitize_selector_tool_names), so the display name is normalized the same
# way before being matched against them.
selector_names = {tool_name}
if display_name:
selector_names.add(sanitize_tool_name(display_name))
applicable_guardrails = [
(guardrail, action)
for (guardrail, action) in (guardrails or [])
if guardrail.selector is not None
and GuardrailScope.TOOL in guardrail.selector.scopes
and guardrail.selector.match_names is not None
and tool_name in guardrail.selector.match_names
and not selector_names.isdisjoint(guardrail.selector.match_names)
]
if applicable_guardrails is None or len(applicable_guardrails) == 0:
return tool_node[1]
Expand Down
9 changes: 9 additions & 0 deletions src/uipath_langchain/agent/tools/mcp/claude.md
Original file line number Diff line number Diff line change
Expand Up @@ -457,6 +457,15 @@ finally:

Creates tools for a single MCP resource config using an existing McpClient.

**Tool naming:** the LLM-facing name is `mcp-{resource}-tool-{tool}`, built by
`mcp_tool_identity()`. MCP scopes tool names per server, so the resource is what
makes the name unique across the flat tool list the model is given; resource names
are unique within an agent definition, so the pair is unique by construction. A pair
past the provider's 64-character cap spends the cap on the tool name first, since that
is the part the model reads when choosing between tools, and shortens the resource to
a stub; a digest of the full pair keeps two shortened names apart. The tool's own name
stays on `metadata["display_name"]`, which is what span titles show.

The discovery mode comes from `config.tools_configuration.discovery_mode`, defaulting
to cached when `tools_configuration` is unset.

Expand Down
50 changes: 49 additions & 1 deletion src/uipath_langchain/agent/tools/mcp/mcp_tool.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import hashlib
import logging
from contextlib import AsyncExitStack, asynccontextmanager
from typing import Any, AsyncGenerator
Expand Down Expand Up @@ -26,6 +27,53 @@

logger: logging.Logger = logging.getLogger(__name__)

# Providers cap a tool name at 64 characters; the digest that keeps a shortened
# name unique costs 8 hex characters plus its separator.
_MAX_TOOL_NAME_LENGTH = 64
_DIGEST_LENGTH = 8
# A separator no name can contain, so distinct pairs cannot hash alike.
_IDENTITY_SEPARATOR = "\x00"
# "mcp-" + "-tool-", the scaffolding the resource and tool names sit in.
_SCAFFOLDING_LENGTH = 10
# What a shortened resource keeps, enough to tell two servers apart by eye.
_MIN_RESOURCE_LENGTH = 8


def mcp_tool_identity(resource_name: str, tool_name: str) -> str:
"""The LLM-facing name for an MCP tool, qualified by the resource that owns it.

MCP scopes tool names per server, so the resource is what makes the name unique
across the one flat tool list the model is given. Resource names are unique
within an agent definition, so ``<resource, tool>`` identifies one tool. Two
resource names that normalize alike, such as ``Case Management`` and
``Case_Management``, share an identity; ``create_tool_node`` reports that pair
at startup.

The format matches the Temporal runtime's, so a trace or an eval assertion reads
the same on either runtime.

A pair past the 64-character cap spends the cap on the tool name first, since
that is the part the model reads when choosing between tools, and shortens the
resource down to a stub. A digest of the full pair keeps two shortened names
apart.
"""
resource = sanitize_tool_name(resource_name).lower()
tool = sanitize_tool_name(tool_name).lower()
qualified = f"mcp-{resource}-tool-{tool}"
if len(qualified) <= _MAX_TOOL_NAME_LENGTH:
return qualified

budget = (
_MAX_TOOL_NAME_LENGTH - _SCAFFOLDING_LENGTH - _DIGEST_LENGTH - 1
) # shared by the two names
tool = tool[: budget - min(len(resource), _MIN_RESOURCE_LENGTH)]
resource = resource[: budget - len(tool)]
digest = hashlib.blake2s(
_IDENTITY_SEPARATOR.join((resource_name, tool_name)).encode(),
digest_size=_DIGEST_LENGTH // 2,
).hexdigest()
return f"mcp-{resource}-tool-{tool}-{digest}"


def _breaking_schema_change(cached: dict[str, Any], live: dict[str, Any]) -> bool:
"""Whether the live input schema differs from the cached one in a way that would
Expand Down Expand Up @@ -278,7 +326,7 @@ async def create_mcp_tools(
# it can refresh its own args_schema on schema drift (see _refresh_tool_schema).
tool_holder: dict[str, BaseTool] = {}
structured_tool = StructuredToolWithArgumentProperties(
name=sanitize_tool_name(mcp_tool.name),
name=mcp_tool_identity(config.name, mcp_tool.name),
Comment thread
radugheo marked this conversation as resolved.
description=mcp_tool.description,
args_schema=mcp_tool.input_schema,
coroutine=build_mcp_tool(
Expand Down
24 changes: 24 additions & 0 deletions src/uipath_langchain/agent/tools/tool_node.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
from uipath_langchain.agent.exceptions import (
AgentRuntimeError,
AgentRuntimeErrorCode,
AgentStartupError,
AgentStartupErrorCode,
)
from uipath_langchain.agent.react.types import AgentGraphState
from uipath_langchain.agent.react.utils import (
Expand Down Expand Up @@ -323,6 +325,12 @@ def set_tool_wrappers(
self.awrapper = awrapper


def _describe_tool_origin(tool: BaseTool) -> str:
"""Name the resource a tool came from, for a name-collision message."""
resource = (tool.metadata or {}).get("resource_name")
return f"'{tool.name}' from resource '{resource}'" if resource else f"'{tool.name}'"


def create_tool_node(tools: Sequence[BaseTool]) -> dict[str, UiPathToolNode]:
"""Create individual ToolNode for each tool.

Expand All @@ -332,9 +340,25 @@ def create_tool_node(tools: Sequence[BaseTool]) -> dict[str, UiPathToolNode]:
Returns:
Dict mapping tool.name -> UiPathToolNode.
Each tool gets its own dedicated node for middleware composition.

Raises:
AgentStartupError: Two tools share a name. The name keys both this mapping
and the flat tool list the model is given, so it has to be unique.
"""
dict_mapping: dict[str, UiPathToolNode] = {}
for tool in tools:
if (clash := dict_mapping.get(tool.name)) is not None:
raise AgentStartupError(
code=AgentStartupErrorCode.INVALID_TOOL_CONFIG,
title="Two tools share a name",
detail=(
f"The agent exposes two tools named '{tool.name}': "
f"{_describe_tool_origin(clash.tool)} and "
f"{_describe_tool_origin(tool)}. Rename one of them so the "
f"model can tell the two tools apart."
),
category=UiPathErrorCategory.USER,
)
if isinstance(tool, ToolWrapperMixin):
dict_mapping[tool.name] = UiPathToolNode(
tool,
Expand Down
10 changes: 8 additions & 2 deletions testcases/simple-http-mcp/src/simple-http-mcp/agents_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
from urllib.parse import quote

import httpx
from langchain_core.tools import BaseTool
from pydantic import BaseModel, Field
from uipath._utils._ssl_context import get_httpx_client_kwargs
from uipath.agent.models.agent import (
Expand Down Expand Up @@ -349,6 +350,11 @@ def _negotiated_version(client: McpClient) -> str | None:
return None if version is None else str(version)


def _mcp_name(tool: BaseTool) -> str:
"""The tool's name on the MCP server, which its LLM-facing name qualifies."""
return (tool.metadata or {}).get("display_name", tool.name)


async def _run_leg(
label: str,
resource: AgentMcpResourceConfig,
Expand Down Expand Up @@ -393,12 +399,12 @@ async def _run_leg(
)
clients = [client]
tools = await create_mcp_tools(resource, client)
add_tool = next(tool for tool in tools if tool.name == "add")
add_tool = next(tool for tool in tools if _mcp_name(tool) == "add")
blocks = await add_tool.ainvoke({"a": a, "b": b})
summary = LegSummary(
label=label,
protocol_mode=protocol_mode,
tools=sorted(tool.name for tool in tools),
tools=sorted(_mcp_name(tool) for tool in tools),
tool_result=_first_text(blocks),
session_id=await clients[0].get_session_id(),
negotiated_version=_negotiated_version(clients[0]),
Expand Down
47 changes: 47 additions & 0 deletions tests/agent/guardrails/test_tool_guardrails_subgraph.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,53 @@ def test_no_applicable_guardrails_returns_original_node(self):
)
assert result_wrong_scope == inner[1]

def test_selector_matches_the_tools_display_name(self):
"""An MCP selector holds the tool's own name, not the qualified node key."""
inner = ("mcp-sales_mcp-tool-add", lambda s: s)
guardrail = MagicMock()
guardrail.selector = types.SimpleNamespace(
scopes=[GuardrailScope.TOOL], match_names=["add"]
)

result = mod.create_tool_guardrails_subgraph(
tool_node=inner,
guardrails=[(guardrail, MagicMock())],
display_name="add",
)

assert result != inner[1]

def test_selector_matches_a_sanitized_display_name(self):
"""Selectors arrive sanitized, so a display name with spaces still matches."""
inner = ("mcp-sales_mcp-tool-search_tool", lambda s: s)
guardrail = MagicMock()
guardrail.selector = types.SimpleNamespace(
scopes=[GuardrailScope.TOOL], match_names=["Search_Tool"]
)

result = mod.create_tool_guardrails_subgraph(
tool_node=inner,
guardrails=[(guardrail, MagicMock())],
display_name="Search Tool!",
)

assert result != inner[1]

def test_selector_ignores_a_display_name_it_does_not_name(self):
inner = ("mcp-sales_mcp-tool-add", lambda s: s)
guardrail = MagicMock()
guardrail.selector = types.SimpleNamespace(
scopes=[GuardrailScope.TOOL], match_names=["subtract"]
)

result = mod.create_tool_guardrails_subgraph(
tool_node=inner,
guardrails=[(guardrail, MagicMock())],
display_name="add",
)

assert result == inner[1]

def test_two_guardrails_build_chains_pre_and_post(self, monkeypatch):
"""Two guardrails should create reverse-ordered pre/post chains with failure edges."""
monkeypatch.setattr(mod, "StateGraph", FakeStateGraph)
Expand Down
2 changes: 1 addition & 1 deletion tests/agent/tools/test_mcp/claude.md
Original file line number Diff line number Diff line change
Expand Up @@ -561,7 +561,7 @@ assert len(clients) == 2 # One per MCP server
with patch(..., return_value=mock_uipath_class):
tools, clients = await create_mcp_tools_and_clients(agent)
assert len(tools) == 1 # Only enabled server's tool
assert tools[0].name == "enabled_tool"
assert tools[0].name == "mcp-enabled_server-tool-enabled_tool"
```

#### test_returns_empty_for_empty_resources
Expand Down
6 changes: 5 additions & 1 deletion tests/agent/tools/test_mcp/test_mcp_client_real_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -544,7 +544,11 @@ async def test_tool_built_by_the_factory_invokes_over_real_http() -> None:
async with serve(gateway) as url:
async with connected_client(url) as client:
tools = await create_mcp_tools(make_resource_config(), client)
add_tool = next(tool for tool in tools if tool.name == "add")
add_tool = next(
tool
for tool in tools
if (tool.metadata or {}).get("display_name") == "add"
)
result = await add_tool.ainvoke({"a": 2, "b": 3})

blocks = result if isinstance(result, list) else [result]
Expand Down
Loading
Loading