2121logger : 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
25152async 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
0 commit comments