@@ -624,6 +624,23 @@ def _publish_connected_mcp_tools(self, runtime: Runtime) -> None:
624624
625625 runtime .mcp_tools [mcp_tool_runtime_key (server_name , tool .name )] = tool
626626
627+ def _rebuild_published_mcp_tools (self , runtime : Runtime ) -> None :
628+ """Atomically rebuild the published MCP tool registry from connected servers.
629+
630+ Drop every currently-published MCP tool (non-MCP tools are preserved), then
631+ republish all *connected* servers in configured order via
632+ :meth:`_publish_connected_mcp_tools`. This keeps last-wins collision order
633+ deterministic and lets a disconnect/refresh re-claim a tool name another
634+ still-connected server provides, instead of orphaning it. The method runs
635+ synchronously (no ``await`` between the drop and the republish), so the two
636+ registries are never observed half-rebuilt.
637+ """
638+ stale = [name for name , tool in self ._tool_dict .items () if isinstance (tool , MCPTool )]
639+ for name in stale :
640+ del self ._tool_dict [name ]
641+ runtime .mcp_tools .clear ()
642+ self ._publish_connected_mcp_tools (runtime )
643+
627644 def hide (self , tool_name : str ) -> bool :
628645 """Hide a tool from the LLM tool list. Returns True if the tool exists."""
629646 if tool_name in self ._tool_dict :
@@ -1300,10 +1317,12 @@ async def _connect():
13001317 results = await asyncio .gather (* tasks ) if tasks else []
13011318 failed_servers = {name : error for name , error in results if error is not None }
13021319
1320+ # Publish before raising so servers that DID connect become callable in
1321+ # this session even when another server fails the aggregate connect.
1322+ self ._publish_connected_mcp_tools (runtime )
13031323 if failed_servers :
13041324 _toast_mcp ("mcp connection failed" )
13051325 raise MCPRuntimeError (f"Failed to connect MCP servers: { failed_servers } " )
1306- self ._publish_connected_mcp_tools (runtime )
13071326 if unauthorized_servers :
13081327 _toast_mcp ("mcp authorization needed" )
13091328 else :
@@ -1353,19 +1372,6 @@ async def wait_for_mcp_tools(self) -> None:
13531372 if self ._mcp_loading_task is task and task .done ():
13541373 self ._mcp_loading_task = None
13551374
1356- def _unregister_mcp_server_tools (self , server_name : str , runtime : Runtime ) -> None :
1357- info = self ._mcp_servers .get (server_name )
1358- if info is None :
1359- return
1360- from pythinker_code .utils .mcp_names import mcp_tool_runtime_key
1361-
1362- for tool in info .tools :
1363- registered = self ._tool_dict .get (tool .name )
1364- if registered is tool :
1365- del self ._tool_dict [tool .name ]
1366- runtime .mcp_tools .pop (mcp_tool_runtime_key (server_name , tool .name ), None )
1367- info .tools = []
1368-
13691375 async def _inventory_mcp_server (
13701376 self , server_name : str , server_info : MCPServerInfo , runtime : Runtime
13711377 ) -> None :
@@ -1427,16 +1433,6 @@ async def _connect_mcp_server(
14271433 server_info .error = _classify_mcp_connect_error (e , server_name )
14281434 return server_name , e
14291435
1430- def _publish_mcp_server_tools (self , server_name : str , runtime : Runtime ) -> None :
1431- info = self ._mcp_servers .get (server_name )
1432- if info is None :
1433- return
1434- self ._register_mcp_tools (server_name , info .tools )
1435- from pythinker_code .utils .mcp_names import mcp_tool_runtime_key
1436-
1437- for tool in info .tools :
1438- runtime .mcp_tools [mcp_tool_runtime_key (server_name , tool .name )] = tool
1439-
14401436 def _ensure_mcp_idle (self ) -> None :
14411437 if self ._mcp_loading_task is not None and not self ._mcp_loading_task .done ():
14421438 raise MCPRuntimeError ("MCP servers are still loading" )
@@ -1463,7 +1459,10 @@ async def disconnect_mcp_server(self, server_name: str, runtime: Runtime) -> Non
14631459 if info is None :
14641460 raise MCPRuntimeError (f"Unknown MCP server: { server_name } " )
14651461 await self ._stop_mcp_session_holder (info )
1466- self ._unregister_mcp_server_tools (server_name , runtime )
1462+ # Drop this server's inventory, then rebuild the published registry so any
1463+ # tool name it was shadowing falls back to another still-connected server.
1464+ info .tools = []
1465+ self ._rebuild_published_mcp_tools (runtime )
14671466 prior_error = info .error
14681467 close_error : str | None = None
14691468 try :
@@ -1504,12 +1503,21 @@ async def refresh_mcp_server(self, server_name: str, runtime: Runtime) -> None:
15041503 raise MCPRuntimeError (
15051504 f"MCP server '{ server_name } ' is not connected (status={ info .status } )"
15061505 )
1507- self ._unregister_mcp_server_tools (server_name , runtime )
1508- await asyncio .wait_for (
1509- self ._inventory_mcp_server (server_name , info , runtime ),
1510- timeout = runtime .config .mcp .client .startup_timeout_ms / 1000 ,
1511- )
1512- self ._publish_mcp_server_tools (server_name , runtime )
1506+ # Inventory first: on failure ``info.tools`` keeps its last-known-good value
1507+ # and the live registry is untouched, so a failed refresh never drops tools.
1508+ # Convert raw timeout/inventory errors to MCPRuntimeError so callers (e.g. the
1509+ # /mcp slash handler) receive a single typed boundary error.
1510+ try :
1511+ await asyncio .wait_for (
1512+ self ._inventory_mcp_server (server_name , info , runtime ),
1513+ timeout = runtime .config .mcp .client .startup_timeout_ms / 1000 ,
1514+ )
1515+ except TimeoutError as exc :
1516+ raise MCPRuntimeError (f"Refresh of MCP server '{ server_name } ' timed out" ) from exc
1517+ except Exception as exc :
1518+ raise MCPRuntimeError (f"Failed to refresh MCP server '{ server_name } ': { exc } " ) from exc
1519+ # Inventory succeeded; swap the old tool set for the new one atomically.
1520+ self ._rebuild_published_mcp_tools (runtime )
15131521
15141522 async def reconnect_mcp_server (self , server_name : str , runtime : Runtime ) -> None :
15151523 """Close and reconnect one MCP server from its stored config."""
@@ -1532,7 +1540,7 @@ async def reconnect_mcp_server(self, server_name: str, runtime: Runtime) -> None
15321540 raise MCPRuntimeError (
15331541 info .error or f"Failed to reconnect MCP server '{ server_name } ': { error } "
15341542 )
1535- self ._publish_mcp_server_tools ( server_name , runtime )
1543+ self ._rebuild_published_mcp_tools ( runtime )
15361544
15371545 async def cleanup (self ) -> None :
15381546 """Cleanup any resources held by the toolset."""
0 commit comments