Skip to content

Commit 7ae425f

Browse files
edis-uipathclaude
andcommitted
feat: mcp lazy load debug bindings
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 3a14ae0 commit 7ae425f

5 files changed

Lines changed: 256 additions & 181 deletions

File tree

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

Lines changed: 57 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ from .mcp_tool import (
4141

4242
`McpClient` implements `UiPathDisposableProtocol` and manages the lifecycle of MCP connections for tool invocations with **two distinct initialization phases**:
4343

44-
1. **Client Initialization** (first call): Full stack creation
44+
1. **Client Initialization** (first call): Retrieves MCP server URL via SDK, then full stack creation
4545
2. **Session Reinitialization** (on 404): Lightweight, reuses existing client
4646

4747
```
@@ -50,11 +50,15 @@ from .mcp_tool import (
5050
├─────────────────────────────────────────────────────────────┤
5151
│ Configuration (immutable after __init__) │
5252
│ ───────────────────────────────────────── │
53-
│ _url: str │
54-
│ _headers: dict[str, str] │
53+
│ _config: AgentMcpResourceConfig # Contains slug, folder │
5554
│ _timeout: httpx.Timeout │
5655
│ _max_retries: int │
5756
├─────────────────────────────────────────────────────────────┤
57+
│ Lazy-Resolved State (set during _initialize_client) │
58+
│ ─────────────────────────────────────────────────── │
59+
│ _url: str | None # Retrieved from SDK │
60+
│ _headers: dict[str, str] # Auth header from SDK │
61+
├─────────────────────────────────────────────────────────────┤
5862
│ Synchronization │
5963
│ ─────────────── │
6064
│ _lock: asyncio.Lock # Protects both init phases │
@@ -82,7 +86,7 @@ from .mcp_tool import (
8286
├─────────────────────────────────────────────────────────────┤
8387
│ Private Methods │
8488
│ ─────────────── │
85-
│ - _initialize_client() -> None # Full init (once)
89+
│ - _initialize_client() -> None # SDK + full init (once) │
8690
│ - _initialize_session() -> None # MCP handshake only │
8791
│ - _ensure_session() -> ClientSession │
8892
│ - _reinitialize_session() -> None │
@@ -105,8 +109,8 @@ async def create_mcp_tools_from_agent(
105109
Iterates over all MCP resources in the agent definition and creates tools
106110
for each enabled MCP server. Each MCP server gets its own McpClient instance.
107111
108-
The UiPath SDK is lazily initialized inside this function using environment
109-
variables (UIPATH_URL, UIPATH_ACCESS_TOKEN).
112+
The MCP server URL is loaded lazily on first tool call via the UiPath SDK,
113+
using environment variables (UIPATH_URL, UIPATH_ACCESS_TOKEN).
110114
111115
Returns:
112116
A tuple of (tools, mcp_clients) where:
@@ -143,6 +147,11 @@ The key design principle is separating **client initialization** from **session
143147
```
144148
Phase 1: Client Initialization (expensive, done once)
145149
──────────────────────────────────────────────────────
150+
┌─────────────────┐
151+
│ UiPath SDK │ ─── Retrieves MCP server URL
152+
│ mcp.retrieve() │ and auth token (Bearer)
153+
└─────────────────┘
154+
146155
┌─────────────────┐
147156
│ httpx.AsyncClient │ ─┐
148157
└─────────────────┘ │
@@ -179,8 +188,9 @@ Phase 2: Session Initialization (lightweight, can repeat)
179188
│ Initializing │
180189
│ (Phase 1) │
181190
└──────┬───────┘
182-
│ creates HTTP client, streams, session
183-
│ then calls _initialize_session()
191+
│ 1. UiPath SDK retrieves MCP URL
192+
│ 2. creates HTTP client, streams, session
193+
│ 3. calls _initialize_session()
184194
185195
┌──────────────┐
186196
│ Session │
@@ -251,7 +261,42 @@ The following error codes trigger automatic session reinitialization:
251261

252262
## Key Implementation Details
253263

254-
### 1. HTTP Client Configuration
264+
### 1. Lazy SDK Loading
265+
266+
The MCP server URL and authorization headers are loaded lazily on first tool call:
267+
268+
```python
269+
async def _initialize_client(self) -> None:
270+
# Lazy import to improve cold start time
271+
from uipath.platform import UiPath
272+
273+
# Retrieve MCP server URL from SDK
274+
sdk = UiPath()
275+
mcp_server = await sdk.mcp.retrieve_async(
276+
slug=self._config.slug, folder_path=self._config.folder_path
277+
)
278+
279+
if mcp_server.mcp_url is None:
280+
raise ValueError(f"MCP server '{self._config.slug}' has no URL configured")
281+
282+
self._url = mcp_server.mcp_url
283+
self._headers = {"Authorization": f"Bearer {sdk._config.secret}"}
284+
```
285+
286+
**Why lazy loading is required:**
287+
288+
The `uipath debug` command loads resource bindings (which can override MCP server URLs)
289+
**after** the LangGraph agent graph is built. This means bindings are only available at
290+
execution time, not at graph construction time. By deferring the SDK call to the first
291+
tool invocation, we ensure the bindings are properly loaded and applied.
292+
293+
**Benefits:**
294+
- Bindings are correctly applied (loaded after graph construction)
295+
- No SDK calls during tool creation (only during first use)
296+
- Faster agent startup time
297+
- Errors surface only when tools are actually used
298+
299+
### 2. HTTP Client Configuration
255300

256301
The HTTP client MUST use `get_httpx_client_kwargs()` for proper SSL/proxy configuration:
257302

@@ -268,7 +313,7 @@ self._http_client = await self._stack.enter_async_context(
268313
)
269314
```
270315

271-
### 2. Single Lock for Both Phases
316+
### 3. Single Lock for Both Phases
272317

273318
One `asyncio.Lock` protects both client initialization and session reinitialization:
274319

@@ -289,7 +334,7 @@ async def _reinitialize_session(self) -> None:
289334
await self._initialize_session() # Lightweight!
290335
```
291336

292-
### 3. No `with` Statement for AsyncExitStack
337+
### 4. No `with` Statement for AsyncExitStack
293338

294339
Manual lifecycle management:
295340

@@ -305,7 +350,7 @@ async with AsyncExitStack() as stack:
305350
... # Stack closes here!
306351
```
307352

308-
### 4. Reinitialization Reuses Client
353+
### 5. Reinitialization Reuses Client
309354

310355
The key optimization - on 404, only `_initialize_session()` is called:
311356

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

Lines changed: 41 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import asyncio
88
import logging
99
from contextlib import AsyncExitStack
10-
from typing import Any
10+
from typing import TYPE_CHECKING, Any
1111

1212
import httpx
1313
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
@@ -22,6 +22,9 @@
2222
from uipath._utils._ssl_context import get_httpx_client_kwargs
2323
from uipath.runtime.base import UiPathDisposableProtocol
2424

25+
if TYPE_CHECKING:
26+
from uipath.agent.models.agent import AgentMcpResourceConfig
27+
2528
logger = logging.getLogger(__name__)
2629

2730

@@ -31,7 +34,8 @@ class McpClient(UiPathDisposableProtocol):
3134
This class handles the lifecycle of MCP connections with two distinct phases:
3235
3336
1. **Client Initialization** (first call):
34-
- Creates HTTP client
37+
- Instantiates UiPath SDK to retrieve MCP server URL
38+
- Creates HTTP client with authorization headers
3539
- Establishes streamable HTTP connection
3640
- Creates ClientSession
3741
- Calls session.initialize() to get session ID
@@ -48,24 +52,28 @@ class McpClient(UiPathDisposableProtocol):
4852

4953
def __init__(
5054
self,
51-
url: str,
52-
headers: dict[str, str] | None = None,
55+
config: "AgentMcpResourceConfig",
5356
timeout: httpx.Timeout | None = None,
5457
max_retries: int = 1,
5558
) -> None:
5659
"""Initialize the MCP tool session.
5760
61+
The MCP server URL and authorization headers are retrieved lazily
62+
from the UiPath SDK on first use, using the config's slug and folder_path.
63+
5864
Args:
59-
url: The MCP server endpoint URL.
60-
headers: Optional headers to include in HTTP requests.
65+
config: The MCP resource configuration containing slug and folder_path.
6166
timeout: Optional timeout configuration for HTTP requests.
6267
max_retries: Maximum number of retries on session disconnect errors.
6368
"""
64-
self._url = url
65-
self._headers = headers or {}
69+
self._config = config
6670
self._timeout = timeout or httpx.Timeout(600)
6771
self._max_retries = max_retries
6872

73+
# URL and headers are resolved lazily from SDK
74+
self._url: str | None = None
75+
self._headers: dict[str, str] = {}
76+
6977
# Lock for both client initialization and session reinitialization
7078
self._lock = asyncio.Lock()
7179

@@ -97,13 +105,36 @@ async def _initialize_client(self) -> None:
97105
"""Initialize the HTTP client and streamable connection.
98106
99107
This is called once on first use. Creates:
100-
- httpx.AsyncClient
108+
- UiPath SDK instance to retrieve MCP server URL
109+
- httpx.AsyncClient with authorization headers
101110
- Streamable HTTP connection (read/write streams)
102111
- ClientSession
103112
104113
Then calls _initialize_session() to complete the MCP handshake.
105114
"""
106-
logger.debug("Initializing MCP client")
115+
logger.debug(
116+
f"Initializing MCP client for '{self._config.slug}' "
117+
f"in folder '{self._config.folder_path}'"
118+
)
119+
120+
# Lazy import to improve cold start time
121+
from uipath.platform import UiPath
122+
123+
# Retrieve MCP server URL from SDK
124+
sdk = UiPath()
125+
mcp_server = await sdk.mcp.retrieve_async(
126+
slug=self._config.slug, folder_path=self._config.folder_path
127+
)
128+
129+
if mcp_server.mcp_url is None:
130+
raise ValueError(
131+
f"MCP server '{self._config.slug}' has no URL configured"
132+
)
133+
134+
self._url = mcp_server.mcp_url
135+
self._headers = {"Authorization": f"Bearer {sdk._config.secret}"}
136+
137+
logger.debug(f"Retrieved MCP server URL: {self._url}")
107138

108139
# Create exit stack for resource management
109140
self._stack = AsyncExitStack()

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

Lines changed: 4 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,6 @@
1515
LowCodeAgentDefinition,
1616
)
1717
from uipath.eval.mocks import mockable
18-
from uipath.platform import UiPath
19-
from uipath.platform.orchestrator.mcp import McpServer
2018

2119
from uipath_langchain.agent.tools.base_uipath_structured_tool import (
2220
BaseUiPathStructuredTool,
@@ -177,8 +175,8 @@ async def create_mcp_tools_from_agent(
177175
Iterates over all MCP resources in the agent definition and creates tools
178176
for each enabled MCP server. Each MCP server gets its own McpClient instance.
179177
180-
The UiPath SDK is lazily initialized inside this function using environment
181-
variables (UIPATH_URL, UIPATH_ACCESS_TOKEN).
178+
The MCP server URL is loaded lazily on first tool call via the UiPath SDK,
179+
using environment variables (UIPATH_URL, UIPATH_ACCESS_TOKEN).
182180
183181
Args:
184182
agent: The agent definition containing MCP resources.
@@ -194,7 +192,6 @@ async def create_mcp_tools_from_agent(
194192
"""
195193
tools: list[BaseTool] = []
196194
clients: list[McpClient] = []
197-
sdk: UiPath = UiPath() # Lazy initialization of SDK
198195

199196
for resource in agent.resources:
200197
if not isinstance(resource, AgentMcpResourceConfig):
@@ -206,16 +203,8 @@ async def create_mcp_tools_from_agent(
206203

207204
logger.info(f"Creating MCP tools for resource '{resource.name}'")
208205

209-
mcpServer: McpServer = await sdk.mcp.retrieve_async(
210-
slug=resource.slug, folder_path=resource.folder_path
211-
)
212-
if mcpServer.mcp_url is None:
213-
raise ValueError(f"MCP server '{resource.slug}' has no URL configured")
214-
215-
mcpClient = McpClient(
216-
mcpServer.mcp_url,
217-
headers={"Authorization": f"Bearer {sdk._config.secret}"},
218-
)
206+
# McpClient will lazily load the MCP server URL on first tool call
207+
mcpClient = McpClient(config=resource)
219208
clients.append(mcpClient)
220209

221210
resource_tools = await create_mcp_tools_from_metadata_for_mcp_server(

0 commit comments

Comments
 (0)