Skip to content

Commit a607e87

Browse files
radugheojepadil23
authored andcommitted
feat: refresh cached MCP tool schema before call (#915)
1 parent 6fbe316 commit a607e87

6 files changed

Lines changed: 543 additions & 38 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.13.4"
3+
version = "0.13.5"
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/mcp/claude.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,39 @@ finally:
257257

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

260+
The discovery mode comes from `config.tools_configuration.discovery_mode`, defaulting
261+
to cached when `tools_configuration` is unset.
262+
263+
**Cached mode + self-healing schema (`refresh_schema_before_call`):** `CachedToolsConfig`
264+
carries a `refresh_schema_before_call` flag (default `True`). When set, each tool's
265+
`tool_fn` calls `mcpClient.list_tools()` immediately before `call_tool()` and compares
266+
the live input schema with the cached one (`_refresh_tool_schema` + `_breaking_schema_change`):
267+
268+
- **No breaking change** (identical, or only additive/cosmetic): the call proceeds
269+
against the cached schema as normal.
270+
- **Breaking change** (a newly required param, a dropped/renamed cached param, or a
271+
type change on a shared param): the tool is **not** executed. The cached snapshot
272+
(`mcp_tool`) and the model-facing `args_schema` are updated in place to the live
273+
schema, and `tool_fn` returns a retry instruction (`_schema_change_message`, which
274+
lists each refreshed param with its type and optionality). The ReAct loop re-binds
275+
tools on the next LLM turn (`llm_node.py` binds fresh every step),
276+
so the model re-issues the call against the live schema and it succeeds on retry.
277+
- **Tool removed** (no longer in the live `list_tools()`): the tool is **not** executed;
278+
`tool_fn` returns a message (`_tool_removed_message`) telling the model the tool is
279+
gone, so it stops calling it instead of retrying a doomed call.
280+
281+
The tool wrapper reference needed to mutate `args_schema` is passed to `build_mcp_tool`
282+
via a small `tool_holder` dict filled right after the tool is constructed. This keeps
283+
the whole mechanism inside the MCP tool (the tool does not import or know about the
284+
ReAct loop). `list_tools()` is **not** called at tool-creation time for cached mode.
285+
The flag is read directly from the cached `discovery_mode.refresh_schema_before_call`
286+
field (default `True`).
287+
288+
**Limitation:** tools with static argument bindings (non-empty `argument_properties`)
289+
are re-bound each turn from a cached copy in `StaticArgsHandler`, so the `args_schema`
290+
mutation may not reach the model; those tools fall back to the server's validation
291+
error instead of self-healing.
292+
260293
#### `open_mcp_tools(config)` → Context Manager
261294

262295
Async context manager that wraps `create_mcp_tools_and_clients()` with automatic

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

Lines changed: 189 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,133 @@
2121
logger: logging.Logger = logging.getLogger(__name__)
2222

2323

24+
def _breaking_schema_change(cached: dict[str, Any], live: dict[str, Any]) -> bool:
25+
"""Whether the live input schema differs from the cached one in a way that would
26+
break a call the model built from the cached schema.
27+
28+
Treated as breaking (the model cannot produce a valid call without seeing the new
29+
schema): a newly required parameter, a cached parameter the server dropped or
30+
renamed, or a type change on a shared parameter. Purely additive or cosmetic
31+
changes (new optional params, reworded descriptions) are not breaking, so the call
32+
proceeds against the cached schema.
33+
"""
34+
cached_props = cached.get("properties") or {}
35+
live_props = live.get("properties") or {}
36+
cached_required = set(cached.get("required") or [])
37+
live_required = set(live.get("required") or [])
38+
39+
if live_required - cached_required:
40+
return True
41+
if set(cached_props) - set(live_props):
42+
return True
43+
for name in set(cached_props) & set(live_props):
44+
if (cached_props.get(name) or {}).get("type") != (
45+
live_props.get(name) or {}
46+
).get("type"):
47+
return True
48+
return False
49+
50+
51+
def _describe_param(name: str, spec: Any, required: bool) -> str:
52+
"""Format a parameter for the schema-change message, e.g. ``city (string, optional)``.
53+
54+
Falls back to just the name (optionally with ``(optional)``) when the param has no
55+
simple JSON-schema ``type`` (unions, ``$ref``, etc.), to avoid a misleading hint.
56+
"""
57+
type_hint = spec.get("type") if isinstance(spec, dict) else None
58+
attrs: list[str] = [type_hint] if isinstance(type_hint, str) else []
59+
if not required:
60+
attrs.append("optional")
61+
return f"{name} ({', '.join(attrs)})" if attrs else name
62+
63+
64+
def _schema_change_message(name: str, schema: dict[str, Any]) -> str:
65+
"""Instruction returned to the model when a cached tool's schema changed, telling
66+
it to re-issue the call against the refreshed schema."""
67+
props = schema.get("properties") or {}
68+
required = set(schema.get("required") or [])
69+
if props:
70+
params = ", ".join(_describe_param(p, props[p], p in required) for p in props)
71+
else:
72+
params = "no parameters"
73+
return (
74+
f"The schema for tool '{name}' changed on the MCP server, so the tool was not "
75+
f"executed. Its parameters have been refreshed to: {params}. "
76+
f"Call '{name}' again using these parameters."
77+
)
78+
79+
80+
def _tool_removed_message(name: str) -> str:
81+
"""Instruction returned to the model when a cached tool is no longer exposed by the
82+
MCP server, telling it to stop calling that tool."""
83+
return (
84+
f"The tool '{name}' is no longer available on the MCP server, so it was not "
85+
f"executed. Do not call '{name}' again; use a different tool or tell the user "
86+
f"it is unavailable."
87+
)
88+
89+
90+
async def _refresh_tool_schema(
91+
mcp_tool: AgentMcpTool,
92+
mcpClient: McpClient,
93+
tool_holder: dict[str, BaseTool] | None = None,
94+
) -> str | None:
95+
"""Fetch the live tool schema before invoking a cached tool and self-heal on drift.
96+
97+
Lists the tools from the server and compares the live input schema with the cached
98+
one. If the change would break a call built from the cached schema, the cached
99+
snapshot and the schema the model is bound to are updated to the live schema and a
100+
retry instruction is returned; the caller must then NOT run the stale call. The
101+
ReAct loop re-binds tools on the next LLM turn, so the model re-issues the call
102+
against the refreshed schema.
103+
104+
Returns None to let the call proceed against the cached schema when there is no
105+
breaking change, or, as a non-fatal fallback, when the live schema cannot be
106+
fetched. When the tool is missing from the live list (removed or renamed), returns
107+
a message telling the model the tool is gone so it does not retry a doomed call.
108+
109+
Note: tools that carry static argument bindings (non-empty argument_properties) are
110+
re-bound each turn from a cached copy, so the rebind may not reach the model; such
111+
tools fall back to the server's own validation error rather than self-healing.
112+
"""
113+
try:
114+
result = await mcpClient.list_tools()
115+
except Exception as exc: # noqa: BLE001 - refresh is best effort
116+
logger.warning(
117+
f"Could not refresh schema for tool '{mcp_tool.name}' before call; "
118+
f"using cached schema. Error: {exc}"
119+
)
120+
return None
121+
122+
fresh = next((t for t in result.tools if t.name == mcp_tool.name), None)
123+
if fresh is None:
124+
logger.warning(
125+
f"Tool '{mcp_tool.name}' is no longer exposed by the MCP server; "
126+
f"the model will be told to stop calling it"
127+
)
128+
return _tool_removed_message(mcp_tool.name)
129+
130+
if not _breaking_schema_change(mcp_tool.input_schema, fresh.inputSchema):
131+
return None
132+
133+
logger.warning(
134+
f"MCP tool '{mcp_tool.name}' schema changed on the server since design time; "
135+
f"refreshing the bound schema and asking the model to retry"
136+
)
137+
# Heal: update the cached baseline and the schema the model is bound to, so the
138+
# next LLM turn re-binds the live schema and the model can build a valid call.
139+
mcp_tool.input_schema = fresh.inputSchema
140+
mcp_tool.output_schema = fresh.outputSchema
141+
if fresh.description:
142+
mcp_tool.description = fresh.description
143+
tool = tool_holder.get("tool") if tool_holder else None
144+
if tool is not None:
145+
tool.args_schema = fresh.inputSchema
146+
if fresh.description:
147+
tool.description = fresh.description
148+
return _schema_change_message(mcp_tool.name, fresh.inputSchema)
149+
150+
24151
@asynccontextmanager
25152
async def open_mcp_tools(
26153
config: list[AgentMcpResourceConfig],
@@ -57,9 +184,13 @@ async def create_mcp_tools(
57184
List of BaseTool instances, one for each tool in the config.
58185
Returns empty list if config.is_enabled is False.
59186
60-
Behavior depends on config.tools_configuration.discovery_mode:
61-
- Cached (default when unset): Uses the tools and schemas saved in
62-
config.available_tools.
187+
Behavior depends on config.tools_configuration.discovery_mode (defaults to
188+
Cached when tools_configuration is unset):
189+
- Cached: Uses the tools and schemas saved in config.available_tools. When
190+
the cached config has refresh_schema_before_call=True (the default), the
191+
live tool schema is fetched immediately before each tool invocation; if it
192+
changed in a breaking way, the bound schema is refreshed and the model is
193+
asked to retry the call against the live schema (self-healing).
63194
- Dynamic with allow_all=True: Lists all tools from the MCP
64195
server via mcpClient, ignoring config.available_tools as a source
65196
of truth.
@@ -77,9 +208,14 @@ async def create_mcp_tools(
77208
if config.tools_configuration is not None
78209
else CachedToolsConfig()
79210
)
211+
refresh_schema_before_call = (
212+
isinstance(discovery_mode, CachedToolsConfig)
213+
and discovery_mode.refresh_schema_before_call
214+
)
80215
logger.info(
81216
f"Loading MCP tools for server '{config.slug}' "
82-
f"(discovery_mode={discovery_mode.type})"
217+
f"(discovery_mode={discovery_mode.type}, "
218+
f"refresh_schema_before_call={refresh_schema_before_call})"
83219
)
84220

85221
config_tools_by_name = {t.name: t for t in config.available_tools}
@@ -129,26 +265,49 @@ async def create_mcp_tools(
129265

130266
tools: list[BaseTool] = []
131267
for mcp_tool in mcp_tools:
132-
tools.append(
133-
StructuredToolWithArgumentProperties(
134-
name=sanitize_tool_name(mcp_tool.name),
135-
description=mcp_tool.description,
136-
args_schema=mcp_tool.input_schema,
137-
coroutine=build_mcp_tool(mcp_tool, mcpClient),
138-
output_type=Any,
139-
metadata={
140-
"tool_type": "mcp",
141-
"display_name": mcp_tool.name,
142-
"folder_path": config.folder_path,
143-
"slug": config.slug,
144-
},
145-
argument_properties=mcp_tool.argument_properties,
146-
)
268+
# The holder gives the tool's coroutine a reference back to the tool wrapper so
269+
# it can refresh its own args_schema on schema drift (see _refresh_tool_schema).
270+
tool_holder: dict[str, BaseTool] = {}
271+
structured_tool = StructuredToolWithArgumentProperties(
272+
name=sanitize_tool_name(mcp_tool.name),
273+
description=mcp_tool.description,
274+
args_schema=mcp_tool.input_schema,
275+
coroutine=build_mcp_tool(
276+
mcp_tool, mcpClient, refresh_schema_before_call, tool_holder
277+
),
278+
output_type=Any,
279+
metadata={
280+
"tool_type": "mcp",
281+
"display_name": mcp_tool.name,
282+
"folder_path": config.folder_path,
283+
"slug": config.slug,
284+
},
285+
argument_properties=mcp_tool.argument_properties,
147286
)
287+
tool_holder["tool"] = structured_tool
288+
tools.append(structured_tool)
148289
return tools
149290

150291

151-
def build_mcp_tool(mcp_tool: AgentMcpTool, mcpClient: McpClient) -> Any:
292+
def _normalize_tool_result(result: Any) -> Any:
293+
"""Normalize an MCP ``call_tool`` result into JSON-serializable content."""
294+
content = result.content if hasattr(result, "content") else result
295+
if isinstance(content, list):
296+
return [
297+
item.model_dump(exclude_none=True) if hasattr(item, "model_dump") else item
298+
for item in content
299+
]
300+
if hasattr(content, "model_dump"):
301+
return content.model_dump(exclude_none=True)
302+
return content
303+
304+
305+
def build_mcp_tool(
306+
mcp_tool: AgentMcpTool,
307+
mcpClient: McpClient,
308+
refresh_schema_before_call: bool = False,
309+
tool_holder: dict[str, BaseTool] | None = None,
310+
) -> Any:
152311
output_schema: Any
153312
if mcp_tool.output_schema:
154313
output_schema = mcp_tool.output_schema
@@ -164,22 +323,21 @@ def build_mcp_tool(mcp_tool: AgentMcpTool, mcpClient: McpClient) -> Any:
164323
async def tool_fn(**kwargs: Any) -> Any:
165324
"""Execute MCP tool call with ephemeral session.
166325
326+
When ``refresh_schema_before_call`` is set (cached discovery mode), the live
327+
tool schema is fetched first. If it changed in a breaking way, the tool is not
328+
executed: the bound schema is refreshed and a retry instruction is returned so
329+
the model re-issues the call against the live schema on the next turn.
330+
167331
If a session disconnect error occurs (e.g., 404 or session terminated),
168332
the tool will retry once by re-initializing the session.
169333
"""
334+
if refresh_schema_before_call:
335+
retry_message = await _refresh_tool_schema(mcp_tool, mcpClient, tool_holder)
336+
if retry_message is not None:
337+
return retry_message
170338
result = await mcpClient.call_tool(mcp_tool.name, arguments=kwargs)
171339
logger.info(f"Tool call successful for {mcp_tool.name}")
172-
content = result.content if hasattr(result, "content") else result
173-
if isinstance(content, list):
174-
return [
175-
item.model_dump(exclude_none=True)
176-
if hasattr(item, "model_dump")
177-
else item
178-
for item in content
179-
]
180-
if hasattr(content, "model_dump"):
181-
return content.model_dump(exclude_none=True)
182-
return content
340+
return _normalize_tool_result(result)
183341

184342
return tool_fn
185343

tests/agent/tools/test_mcp/claude.md

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -64,10 +64,40 @@ tests/agent/tools/test_mcp/
6464
├── TestMcpToolInvocation (class)
6565
│ └── test_tool_invocation_initializes_session_and_returns_result
6666
67-
└── TestMcpToolNameSanitization (class)
68-
├── test_tool_name_with_spaces
69-
└── test_tool_name_with_special_chars
70-
```
67+
├── TestMcpToolNameSanitization (class)
68+
│ ├── test_tool_name_with_spaces
69+
│ └── test_tool_name_with_special_chars
70+
71+
└── TestCachedRefreshSchemaBeforeCall (class) ← refresh_schema_before_call + self-heal
72+
├── test_refresh_enabled_lists_tools_before_call
73+
├── test_refresh_disabled_skips_list_tools
74+
├── test_refresh_falls_back_when_list_tools_fails
75+
├── test_refresh_returns_removed_message_when_tool_missing
76+
├── test_cached_default_enables_refresh
77+
├── test_cached_refresh_disabled_via_config
78+
├── test_breaking_drift_heals_and_asks_retry
79+
├── test_after_heal_next_call_executes
80+
├── test_nonbreaking_change_executes_without_retry
81+
└── test_schema_change_message_lists_param_types
82+
```
83+
84+
### TestCachedRefreshSchemaBeforeCall
85+
86+
Covers the cached-mode `refresh_schema_before_call` flag (default `True`) and the
87+
self-healing behaviour. Asserts ordering via `manager.attach_mock` (list_tools before
88+
call_tool), graceful fallback when `list_tools` raises, a clear "tool removed" message
89+
when the tool is gone from the server, that `create_mcp_tools` does not list tools at
90+
creation time, and that
91+
`refresh_schema_before_call=False` disables the refresh.
92+
93+
Self-heal cases: on a **breaking** schema change (cached `query` vs live `question`)
94+
the tool is not executed (`call_tool` not awaited), `tool_fn` returns a retry message
95+
listing each refreshed param with its type, and the wrapper's `args_schema` is healed
96+
to the live schema; a subsequent call against
97+
the healed schema executes; an **additive/non-breaking** change executes normally
98+
without a retry. These tests invoke the tool's `coroutine` directly (not `ainvoke`)
99+
because the stale arguments would fail `args_schema` validation before reaching the
100+
tool.
71101

72102
## Mocking Strategy
73103

0 commit comments

Comments
 (0)