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.13.13"
version = "0.13.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
9 changes: 8 additions & 1 deletion src/uipath_langchain/agent/tools/mcp/claude.md
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,7 @@ to cached when `tools_configuration` is unset.

**Cached mode + self-healing schema (`refresh_schema_before_call`):** `CachedToolsConfig`
carries a `refresh_schema_before_call` flag (default `True`). When set, each tool's
`tool_fn` calls `mcpClient.list_tools()` immediately before `call_tool()` and compares
`tool_fn` calls `mcpClient.list_tools()` before `call_tool()` and compares
the live input schema with the cached one (`_refresh_tool_schema` + `_breaking_schema_change`):

- **No breaking change** (identical, or only additive/cosmetic): the call proceeds
Expand All @@ -285,6 +285,13 @@ ReAct loop). `list_tools()` is **not** called at tool-creation time for cached m
The flag is read directly from the cached `discovery_mode.refresh_schema_before_call`
field (default `True`).

`McpClient.list_tools()` caches its result in memory, so the live list is fetched
**once per run** and reused; `dispose()` clears the cache, so a resumed run (fresh
client) fetches it again. The self-heal is evaluated against a tool list refreshed once
at the start of each run (and each resume), so a schema change that lands mid-run is
picked up on the next run or resume. `force_refresh=True` bypasses the cache for a live
re-query.

**Limitation:** tools with static argument bindings (non-empty `argument_properties`)
are re-bound each turn from a cached copy in `StaticArgsHandler`, so the `args_schema`
mutation may not reach the model; those tools fall back to the server's validation
Expand Down
70 changes: 48 additions & 22 deletions src/uipath_langchain/agent/tools/mcp/mcp_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,12 @@ def __init__(
# Lock for both client initialization and session reinitialization
self._lock = asyncio.Lock()

# Tool list cached in memory and fetched once per client lifetime, with its own
# lock so a concurrent first call does not deadlock against ``_lock`` (held by
# session initialization inside ``_execute_with_retry``).
self._tools_lock = asyncio.Lock()
self._tools_cache: ListToolsResult | None = None

# Client state (created once, reused across session reinitializations)
self._http_client: httpx.AsyncClient | None = None
self._read_stream: (
Expand Down Expand Up @@ -340,12 +346,28 @@ async def _execute_with_retry(

raise RuntimeError("Exited retry loop unexpectedly")

async def list_tools(self) -> ListToolsResult:
"""List available tools from the MCP server."""
return await self._execute_with_retry(
lambda session: session.list_tools(),
"list_tools",
)
async def list_tools(self, *, force_refresh: bool = False) -> ListToolsResult:
"""List available tools from the MCP server.

The result is cached in memory on the first successful call and reused for the
lifetime of this client. ``dispose()`` clears the cache, so a fresh client
fetches the list again on its next call. Pass ``force_refresh=True`` to re-query
the server and refresh the cache.

Args:
force_refresh: When True, re-query the server and refresh the cache.
"""
if not force_refresh and self._tools_cache is not None:
return self._tools_cache
async with self._tools_lock:
if not force_refresh and self._tools_cache is not None:
return self._tools_cache
result = await self._execute_with_retry(
lambda session: session.list_tools(),
"list_tools",
)
self._tools_cache = result
return result

async def call_tool(
self,
Expand Down Expand Up @@ -374,19 +396,23 @@ async def dispose(self) -> None:
After calling dispose(), the client can be reused - a new call_tool()
will reinitialize everything.
"""
async with self._lock:
if self._stack is not None:
try:
await self._stack.__aexit__(None, None, None)
except Exception as e:
logger.debug(f"Error during cleanup: {e}")
finally:
self._stack = None
self._session = None
self._http_client = None
self._read_stream = None
self._write_stream = None
self._session_info = None
self._client_initialized = False

logger.info("MCP client disposed")
# Acquire _tools_lock before _lock (the same order list_tools uses) so the tool
# cache is cleared atomically with respect to an in-flight list_tools().
async with self._tools_lock:
self._tools_cache = None
async with self._lock:
if self._stack is not None:
try:
await self._stack.__aexit__(None, None, None)
except Exception as e:
logger.debug(f"Error during cleanup: {e}")
finally:
self._stack = None
self._session = None
self._http_client = None
self._read_stream = None
self._write_stream = None
self._session_info = None
self._client_initialized = False

logger.info("MCP client disposed")
18 changes: 11 additions & 7 deletions src/uipath_langchain/agent/tools/mcp/mcp_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,9 @@ async def _refresh_tool_schema(
) -> str | None:
"""Fetch the live tool schema before invoking a cached tool and self-heal on drift.

Lists the tools from the server and compares the live input schema with the cached
one. If the change would break a call built from the cached schema, the cached
Lists the tools from the server (the McpClient caches this list for the lifetime of
the run) and compares the live input schema with the cached one. If the change would
break a call built from the cached schema, the cached
snapshot and the schema the model is bound to are updated to the live schema and a
retry instruction is returned; the caller must then NOT run the stale call. The
ReAct loop re-binds tools on the next LLM turn, so the model re-issues the call
Expand Down Expand Up @@ -188,9 +189,11 @@ async def create_mcp_tools(
Cached when tools_configuration is unset):
- Cached: Uses the tools and schemas saved in config.available_tools. When
the cached config has refresh_schema_before_call=True (the default), the
live tool schema is fetched immediately before each tool invocation; if it
live tool schema is fetched (once per run, cached on the McpClient) and
compared with the design-time snapshot before a tool invocation; if it
changed in a breaking way, the bound schema is refreshed and the model is
asked to retry the call against the live schema (self-healing).
asked to retry the call against the live schema (self-healing). A resumed run
uses a fresh client, which fetches the live list again.
- Dynamic with allow_all=True: Lists all tools from the MCP
server via mcpClient, ignoring config.available_tools as a source
of truth.
Expand Down Expand Up @@ -324,9 +327,10 @@ async def tool_fn(**kwargs: Any) -> Any:
"""Execute MCP tool call with ephemeral session.

When ``refresh_schema_before_call`` is set (cached discovery mode), the live
tool schema is fetched first. If it changed in a breaking way, the tool is not
executed: the bound schema is refreshed and a retry instruction is returned so
the model re-issues the call against the live schema on the next turn.
tool schema is checked first against the McpClient's cached tool list (fetched
once per run). If it changed in a breaking way, the tool is not executed: the
bound schema is refreshed and a retry instruction is returned so the model
re-issues the call against the live schema on the next turn.

If a session disconnect error occurs (e.g., 404 or session terminated),
the tool will retry once by re-initializing the session.
Expand Down
13 changes: 11 additions & 2 deletions tests/agent/tools/test_mcp/claude.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ The tests mock **only the HTTP layer** (`httpx.AsyncClient`), allowing the real

```
tests/agent/tools/test_mcp/
├── test_mcp_client.py # McpClient session tests (7 tests)
├── test_mcp_client.py # McpClient session + tool-list caching tests
│ └── TestMcpClient (class)
│ ├── create_mock_stream_response()
│ ├── create_mock_http_client()
Expand All @@ -38,7 +38,10 @@ tests/agent/tools/test_mcp/
│ ├── test_max_retries_exceeded
│ ├── test_dispose_releases_resources
│ ├── test_client_initialized_property
│ └── test_session_can_be_reused_after_dispose
│ ├── test_session_can_be_reused_after_dispose
│ ├── test_list_tools_caches_result_across_calls ← list_tools fetched once per lifetime
│ ├── test_list_tools_force_refresh_bypasses_cache
│ └── test_dispose_clears_tools_cache
└── test_mcp_tool.py # Tool factory tests (17 tests)
├── TestMcpToolMetadata (class)
Expand Down Expand Up @@ -99,6 +102,12 @@ without a retry. These tests invoke the tool's `coroutine` directly (not `ainvok
because the stale arguments would fail `args_schema` validation before reaching the
tool.

`tool_fn` tests mock `mcpClient.list_tools` directly, so they exercise the refresh
logic per invocation independent of the client's caching. The once-per-run caching
itself lives in `McpClient.list_tools` and is covered in `test_mcp_client.py`
(`test_list_tools_caches_result_across_calls`, `..._force_refresh_bypasses_cache`,
`test_dispose_clears_tools_cache`).

## Mocking Strategy

### What We Mock
Expand Down
76 changes: 70 additions & 6 deletions tests/agent/tools/test_mcp/test_mcp_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -750,10 +750,11 @@ async def test_list_tools_initializes_session_and_returns_result(

@pytest.mark.asyncio
@patch("httpx.AsyncClient")
async def test_list_tools_reuses_session(
async def test_list_tools_caches_result_across_calls(
self, mock_async_client_class, mcp_resource_config, mock_uipath_sdk
):
"""Test that list_tools reuses existing session on subsequent calls."""
"""list_tools caches its result: a second call reuses the session and the
cached tool list, issuing only one tools/list RPC."""
method_call_sequence: list[str] = []
initialize_count = [0]
tool_call_count = [0]
Expand All @@ -771,16 +772,79 @@ async def test_list_tools_reuses_session(
"uipath.platform.UiPath",
return_value=mock_uipath_sdk,
):
await client.list_tools()
first = await client.list_tools()
assert initialize_count[0] == 1

await client.list_tools()
second = await client.list_tools()
assert initialize_count[0] == 1 # Still only one initialization

list_tools_count = method_call_sequence.count("tools/list")
assert list_tools_count == 2
# Fetched once per lifetime: second call returns the cached result, no new RPC.
assert method_call_sequence.count("tools/list") == 1
assert first is second

await client.dispose()

@pytest.mark.asyncio
@patch("httpx.AsyncClient")
async def test_list_tools_force_refresh_bypasses_cache(
self, mock_async_client_class, mcp_resource_config, mock_uipath_sdk
):
"""force_refresh=True re-queries the server instead of returning the cache."""
method_call_sequence: list[str] = []
initialize_count = [0]
tool_call_count = [0]

MockStreamResponse = self.create_mock_stream_response(
method_call_sequence, initialize_count, tool_call_count
)

mock_http_client = self.create_mock_http_client(MockStreamResponse)
mock_async_client_class.return_value = mock_http_client

client = McpClient(config=mcp_resource_config)

with patch(
"uipath.platform.UiPath",
return_value=mock_uipath_sdk,
):
await client.list_tools()
await client.list_tools(force_refresh=True)

# Session reused, but the server is queried twice.
assert initialize_count[0] == 1
assert method_call_sequence.count("tools/list") == 2

await client.dispose()

@pytest.mark.asyncio
@patch("httpx.AsyncClient")
async def test_dispose_clears_tools_cache(
self, mock_async_client_class, mcp_resource_config, mock_uipath_sdk
):
"""dispose() clears the cached tool list so a reused (or resumed) client
re-fetches it once."""
method_call_sequence: list[str] = []
initialize_count = [0]
tool_call_count = [0]

MockStreamResponse = self.create_mock_stream_response(
method_call_sequence, initialize_count, tool_call_count
)

mock_http_client = self.create_mock_http_client(MockStreamResponse)
mock_async_client_class.return_value = mock_http_client

client = McpClient(config=mcp_resource_config)

with patch(
"uipath.platform.UiPath",
return_value=mock_uipath_sdk,
):
await client.list_tools()
assert client._tools_cache is not None

await client.dispose()
assert client._tools_cache is None

@pytest.mark.asyncio
@patch.dict(os.environ, {"UIPATH_FOLDER_PATH": "/Shared/TestFolder"})
Expand Down
4 changes: 2 additions & 2 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading