Skip to content

Commit 9f49d90

Browse files
authored
feat: add async wrapper for mcp tools config (#652)
1 parent 123c597 commit 9f49d90

9 files changed

Lines changed: 234 additions & 316 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "uipath-langchain"
3-
version = "0.7.17"
3+
version = "0.8.0"
44
description = "Python SDK that enables developers to build and deploy LangGraph agents to the UiPath Cloud Platform"
55
readme = { file = "README.md", content-type = "text/markdown" }
66
requires-python = ">=3.11"

src/uipath_langchain/agent/tools/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from .extraction_tool import create_ixp_extraction_tool
66
from .integration_tool import create_integration_tool
77
from .ixp_escalation_tool import create_ixp_escalation_tool
8+
from .mcp import open_mcp_tools
89
from .process_tool import create_process_tool
910
from .tool_factory import (
1011
create_tools_from_resources,
@@ -15,6 +16,7 @@
1516
"create_tools_from_resources",
1617
"create_tool_node",
1718
"create_context_tool",
19+
"open_mcp_tools",
1820
"create_process_tool",
1921
"create_integration_tool",
2022
"create_escalation_tool",

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,16 @@
33
from .mcp_client import McpClient, SessionInfoFactory
44
from .mcp_tool import (
55
create_mcp_tools,
6-
create_mcp_tools_from_agent,
7-
create_mcp_tools_from_metadata_for_mcp_server,
6+
create_mcp_tools_and_clients,
7+
open_mcp_tools,
88
)
99
from .streamable_http import SessionInfo
1010

1111
__all__ = [
1212
"McpClient",
1313
"SessionInfo",
1414
"SessionInfoFactory",
15+
"create_mcp_tools_and_clients",
16+
"open_mcp_tools",
1517
"create_mcp_tools",
16-
"create_mcp_tools_from_agent",
17-
"create_mcp_tools_from_metadata_for_mcp_server",
1818
]

src/uipath_langchain/agent/tools/mcp/claude.md

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,9 @@ src/uipath_langchain/agent/tools/mcp/
3232
```python
3333
from .mcp_client import McpClient, SessionInfoFactory
3434
from .mcp_tool import (
35+
create_mcp_tools_and_clients,
36+
open_mcp_tools,
3537
create_mcp_tools,
36-
create_mcp_tools_from_agent,
37-
create_mcp_tools_from_metadata_for_mcp_server,
3838
)
3939
from .streamable_http import SessionInfo
4040
```
@@ -228,12 +228,12 @@ On session reinitialization (404 retry), only steps 5-6 repeat.
228228

229229
### Tool Factory Functions
230230

231-
#### `create_mcp_tools_from_agent(agent, session_info_factory)``tuple[list[BaseTool], list[McpClient]]`
231+
#### `create_mcp_tools_and_clients(agent, session_info_factory)``tuple[list[BaseTool], list[McpClient]]`
232232

233233
**Primary factory function** for creating MCP tools from a LowCodeAgentDefinition.
234234

235235
```python
236-
async def create_mcp_tools_from_agent(
236+
async def create_mcp_tools_and_clients(
237237
agent: LowCodeAgentDefinition,
238238
session_info_factory: SessionInfoFactory | None = None,
239239
) -> tuple[list[BaseTool], list[McpClient]]:
@@ -245,23 +245,23 @@ defaults to the base `SessionInfoFactory`. Pass a custom factory (e.g.
245245

246246
**Usage:**
247247
```python
248-
tools, clients = await create_mcp_tools_from_agent(agent, session_info_factory=factory)
248+
tools, clients = await create_mcp_tools_and_clients(agent, session_info_factory=factory)
249249
try:
250250
# Use tools...
251251
finally:
252252
for client in clients:
253253
await client.dispose()
254254
```
255255

256-
#### `create_mcp_tools_from_metadata_for_mcp_server(config, mcpClient)``list[BaseTool]`
256+
#### `create_mcp_tools(config, mcpClient)``list[BaseTool]`
257257

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

260-
#### `create_mcp_tools(config)` → Context Manager
260+
#### `open_mcp_tools(config)` → Context Manager
261261

262-
Async context manager that creates live MCP sessions using the **upstream SDK's**
263-
`mcp.client.streamable_http.streamable_http_client` (not our local copy). This
264-
is a simpler path that does not support `SessionInfo`.
262+
Async context manager that wraps `create_mcp_tools_and_clients()` with automatic
263+
client lifecycle management. Yields a list of `BaseTool` instances and
264+
disposes all `McpClient` instances on exit.
265265

266266
## Two-Phase Initialization
267267

@@ -487,7 +487,7 @@ and `SessionInfo` instance are all reused.
487487
uipath-langchain (this package)
488488
├── streamable_http.py → SessionInfo (base class)
489489
├── mcp_client.py → SessionInfoFactory (base factory)
490-
└── mcp_tool.py → create_mcp_tools_from_agent(session_info_factory=...)
490+
└── mcp_tool.py → create_mcp_tools_and_clients(session_info_factory=...)
491491
492492
uipath-agents (consumer)
493493
├── session_info_debug_state.py
@@ -592,7 +592,7 @@ When the upstream MCP SDK changes its transport:
592592
2. Override `get_session_id` and/or `set_session_id` for custom behavior
593593
3. Create a corresponding factory that inherits `SessionInfoFactory`
594594
4. The factory receives `McpServer` — use its `slug`, `folder_key`, etc.
595-
5. Pass the factory to `create_mcp_tools_from_agent(session_info_factory=...)`
595+
5. Pass the factory to `create_mcp_tools_and_clients(session_info_factory=...)`
596596

597597
## Related Files
598598

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

Lines changed: 25 additions & 106 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,15 @@
1-
import asyncio
21
import logging
3-
import os
4-
from collections import Counter, defaultdict
52
from contextlib import AsyncExitStack, asynccontextmanager
6-
from itertools import chain
73
from typing import Any, AsyncGenerator
84

9-
import httpx
105
from langchain_core.tools import BaseTool
11-
from uipath._utils._ssl_context import get_httpx_client_kwargs
126
from uipath.agent.models.agent import (
137
AgentMcpResourceConfig,
148
AgentMcpTool,
159
DynamicToolsMode,
16-
LowCodeAgentDefinition,
1710
)
1811
from uipath.eval.mocks import mockable
19-
from uipath.runtime.errors import UiPathErrorCategory
2012

21-
from uipath_langchain.agent.exceptions import AgentStartupError, AgentStartupErrorCode
2213
from uipath_langchain.agent.tools.base_uipath_structured_tool import (
2314
BaseUiPathStructuredTool,
2415
)
@@ -29,97 +20,29 @@
2920
logger: logging.Logger = logging.getLogger(__name__)
3021

3122

32-
def _deduplicate_tools(tools: list[BaseTool]) -> list[BaseTool]:
33-
"""Deduplicate tools by appending numeric suffix to duplicate names."""
34-
counts = Counter(tool.name for tool in tools)
35-
seen: defaultdict[str, int] = defaultdict(int)
36-
37-
for tool in tools:
38-
if counts[tool.name] > 1:
39-
seen[tool.name] += 1
40-
tool.name = f"{tool.name}_{seen[tool.name]}"
41-
42-
return tools
43-
44-
45-
def _filter_tools(tools: list[BaseTool], cfg: AgentMcpResourceConfig) -> list[BaseTool]:
46-
"""Filter tools to only include those in available_tools."""
47-
allowed = {t.name for t in cfg.available_tools}
48-
return [t for t in tools if t.name in allowed]
49-
50-
5123
@asynccontextmanager
52-
async def create_mcp_tools(
53-
config: AgentMcpResourceConfig | list[AgentMcpResourceConfig],
54-
max_concurrency: int = 5,
24+
async def open_mcp_tools(
25+
config: list[AgentMcpResourceConfig],
5526
) -> AsyncGenerator[list[BaseTool], None]:
56-
"""Connect to UiPath MCP server(s) and yield LangChain-compatible tools."""
57-
if not (base_url := os.getenv("UIPATH_URL")):
58-
raise AgentStartupError(
59-
code=AgentStartupErrorCode.INVALID_TOOL_CONFIG,
60-
title="Missing UIPATH_URL",
61-
detail="UIPATH_URL environment variable is not set.",
62-
category=UiPathErrorCategory.SYSTEM,
63-
)
64-
if not (access_token := os.getenv("UIPATH_ACCESS_TOKEN")):
65-
raise AgentStartupError(
66-
code=AgentStartupErrorCode.INVALID_TOOL_CONFIG,
67-
title="Missing UIPATH_ACCESS_TOKEN",
68-
detail="UIPATH_ACCESS_TOKEN environment variable is not set.",
69-
category=UiPathErrorCategory.SYSTEM,
70-
)
27+
"""Connect to UiPath MCP server(s) via McpClient and yield LangChain-compatible tools.
7128
72-
configs = config if isinstance(config, list) else [config]
73-
enabled = [c for c in configs if c.is_enabled is not False]
74-
75-
if not enabled:
76-
yield []
77-
return
78-
79-
base_url = base_url.rstrip("/")
80-
semaphore = asyncio.Semaphore(max_concurrency)
81-
82-
default_client_kwargs = get_httpx_client_kwargs()
83-
client_kwargs = {
84-
**default_client_kwargs,
85-
"headers": {"Authorization": f"Bearer {access_token}"},
86-
"timeout": httpx.Timeout(60),
87-
}
88-
89-
# Lazy import to improve cold start time
90-
from langchain_mcp_adapters.tools import load_mcp_tools
91-
from mcp import ClientSession
92-
from mcp.client.streamable_http import streamable_http_client
93-
94-
async def init_session(
95-
session: ClientSession, cfg: AgentMcpResourceConfig
96-
) -> list[BaseTool]:
97-
async with semaphore:
98-
await session.initialize()
99-
tools = await load_mcp_tools(session)
100-
for tool in tools:
101-
tool.metadata = {"tool_type": "mcp", "display_name": tool.name}
102-
return _filter_tools(tools, cfg)
103-
104-
async def create_session(
105-
stack: AsyncExitStack, cfg: AgentMcpResourceConfig
106-
) -> ClientSession:
107-
url = f"{base_url}/agenthub_/mcp/{cfg.folder_path}/{cfg.slug}"
108-
http_client = await stack.enter_async_context(
109-
httpx.AsyncClient(**client_kwargs)
110-
)
111-
read, write, _ = await stack.enter_async_context(
112-
streamable_http_client(url=url, http_client=http_client)
113-
)
114-
return await stack.enter_async_context(ClientSession(read, write))
29+
Wraps create_mcp_tools_and_clients() with automatic client lifecycle management.
30+
Tools are lazily initialized on first call via the UiPath SDK.
11531
32+
Args:
33+
config: List of MCP resource configurations.
34+
35+
Yields:
36+
List of BaseTool instances for all enabled MCP resources.
37+
"""
11638
async with AsyncExitStack() as stack:
117-
sessions = [(await create_session(stack, cfg), cfg) for cfg in enabled]
118-
results = await asyncio.gather(*[init_session(s, cfg) for s, cfg in sessions])
119-
yield _deduplicate_tools(list(chain.from_iterable(results)))
39+
tools, clients = await create_mcp_tools_and_clients(config)
40+
for client in clients:
41+
stack.push_async_callback(client.dispose)
42+
yield tools
12043

12144

122-
async def create_mcp_tools_from_metadata_for_mcp_server(
45+
async def create_mcp_tools(
12346
config: AgentMcpResourceConfig,
12447
mcpClient: McpClient,
12548
) -> list[BaseTool]:
@@ -242,24 +165,25 @@ async def tool_fn(**kwargs: Any) -> Any:
242165
return tool_fn
243166

244167

245-
async def create_mcp_tools_from_agent(
246-
agent: LowCodeAgentDefinition,
168+
async def create_mcp_tools_and_clients(
169+
resources: list[AgentMcpResourceConfig],
247170
session_info_factory: SessionInfoFactory | None = None,
248171
terminate_on_close: bool = True,
249172
) -> tuple[list[BaseTool], list[McpClient]]:
250-
"""Create MCP tools from a LowCodeAgentDefinition.
173+
"""Create MCP tools from a list of MCP resource configurations.
251174
252-
Iterates over all MCP resources in the agent definition and creates tools
253-
for each enabled MCP server. Each MCP server gets its own McpClient instance.
175+
Iterates over all MCP resources and creates tools for each enabled MCP
176+
server. Each MCP server gets its own McpClient instance.
254177
255178
The MCP server URL is loaded lazily on first tool call via the UiPath SDK,
256179
using environment variables (UIPATH_URL, UIPATH_ACCESS_TOKEN).
257180
258181
Args:
259-
agent: The agent definition containing MCP resources.
182+
resources: List of MCP resource configurations.
260183
session_info_factory: Factory for creating SessionInfo instances.
261184
Defaults to the base ``SessionInfoFactory``. Pass
262185
``SessionInfoDebugStateFactory()`` for playground mode.
186+
terminate_on_close: Whether to terminate the MCP session on close.
263187
264188
Returns:
265189
A tuple of (tools, mcp_clients) where:
@@ -273,10 +197,7 @@ async def create_mcp_tools_from_agent(
273197
tools: list[BaseTool] = []
274198
clients: list[McpClient] = []
275199

276-
for resource in agent.resources:
277-
if not isinstance(resource, AgentMcpResourceConfig):
278-
continue
279-
200+
for resource in resources:
280201
if resource.is_enabled is False:
281202
logger.info(f"Skipping disabled MCP resource '{resource.name}'")
282203
continue
@@ -290,9 +211,7 @@ async def create_mcp_tools_from_agent(
290211
)
291212
clients.append(mcpClient)
292213

293-
resource_tools = await create_mcp_tools_from_metadata_for_mcp_server(
294-
resource, mcpClient
295-
)
214+
resource_tools = await create_mcp_tools(resource, mcpClient)
296215
tools.extend(resource_tools)
297216
logger.info(
298217
f"Created {len(resource_tools)} tools for MCP resource '{resource.name}'"

tests/agent/tools/test_mcp/claude.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -328,7 +328,7 @@ Note: All tests use `patch("uipath_langchain.agent.tools.mcp.mcp_tool.UiPath")`
328328
**Assertions:**
329329
```python
330330
with patch(..., return_value=mock_uipath_class):
331-
tools, clients = await create_mcp_tools_from_agent(agent)
331+
tools, clients = await create_mcp_tools_and_clients(agent)
332332
assert len(tools) == 3 # 2 from server 1 + 1 from server 2
333333
```
334334

@@ -339,7 +339,7 @@ assert len(tools) == 3 # 2 from server 1 + 1 from server 2
339339
**Assertions:**
340340
```python
341341
with patch(..., return_value=mock_uipath_class):
342-
tools, clients = await create_mcp_tools_from_agent(agent)
342+
tools, clients = await create_mcp_tools_and_clients(agent)
343343
assert len(clients) == 2 # One per MCP server
344344
```
345345

@@ -350,7 +350,7 @@ assert len(clients) == 2 # One per MCP server
350350
**Assertions:**
351351
```python
352352
with patch(..., return_value=mock_uipath_class):
353-
tools, clients = await create_mcp_tools_from_agent(agent)
353+
tools, clients = await create_mcp_tools_and_clients(agent)
354354
assert len(tools) == 1 # Only enabled server's tool
355355
assert tools[0].name == "enabled_tool"
356356
```
@@ -362,7 +362,7 @@ assert tools[0].name == "enabled_tool"
362362
**Assertions:**
363363
```python
364364
with patch(..., return_value=mock_uipath_class):
365-
tools, clients = await create_mcp_tools_from_agent(agent)
365+
tools, clients = await create_mcp_tools_and_clients(agent)
366366
assert tools == []
367367
assert clients == []
368368
```
@@ -375,7 +375,7 @@ assert clients == []
375375
```python
376376
with patch(..., return_value=mock_sdk_no_url):
377377
with pytest.raises(ValueError, match="has no URL configured"):
378-
await create_mcp_tools_from_agent(agent)
378+
await create_mcp_tools_and_clients(agent)
379379
```
380380

381381
#### test_tools_have_correct_metadata
@@ -548,7 +548,7 @@ assert tool_call_count[0] == 3
548548
assert initialize_count[0] == 1 # Session reused
549549
```
550550

551-
### Testing create_mcp_tools_from_agent
551+
### Testing create_mcp_tools_and_clients
552552

553553
The function uses lazy SDK initialization (`sdk = UiPath()`), so we patch the `UiPath` class:
554554

@@ -570,7 +570,7 @@ async def test_example(self, agent_fixture, mock_uipath_class):
570570
"uipath_langchain.agent.tools.mcp.mcp_tool.UiPath",
571571
return_value=mock_uipath_class,
572572
):
573-
tools, clients = await create_mcp_tools_from_agent(agent_fixture)
573+
tools, clients = await create_mcp_tools_and_clients(agent_fixture)
574574
```
575575

576576
## Debugging Failed Tests

0 commit comments

Comments
 (0)