33from __future__ import annotations
44
55import asyncio
6+ import hashlib
7+ import json
68import logging
79import os
810import time
@@ -62,23 +64,39 @@ def _read_bounded_wait_env(name: str, default_s: float, max_s: float) -> float:
6264_session_tracking_installed = False
6365
6466
67+ def _track_mcp_session (session : Any ) -> None :
68+ _active_mcp_sessions .add (session )
69+
70+
71+ def _untrack_mcp_session (session : Any ) -> None :
72+ _active_mcp_sessions .discard (session )
73+
74+
6575def _install_session_tracking () -> None :
6676 """Patch *MiddlewareServerSession* to track active MCP client sessions."""
6777 global _session_tracking_installed
6878 if _session_tracking_installed :
6979 return
70- _session_tracking_installed = True
7180
7281 from fastmcp .server .low_level import MiddlewareServerSession
7382
7483 _original_aenter = MiddlewareServerSession .__aenter__
84+ _original_aexit = MiddlewareServerSession .__aexit__
7585
7686 async def _tracking_aenter (self ): # type: ignore[override]
7787 result = await _original_aenter (self )
78- _active_mcp_sessions . add (self )
88+ _track_mcp_session (self )
7989 return result
8090
91+ async def _tracking_aexit (self , exc_type , exc_value , traceback ): # type: ignore[override]
92+ try :
93+ return await _original_aexit (self , exc_type , exc_value , traceback )
94+ finally :
95+ _untrack_mcp_session (self )
96+
8197 MiddlewareServerSession .__aenter__ = _tracking_aenter # type: ignore[assignment]
98+ MiddlewareServerSession .__aexit__ = _tracking_aexit # type: ignore[assignment]
99+ _session_tracking_installed = True
82100
83101
84102class PluginDisconnectedError (RuntimeError ):
@@ -147,6 +165,7 @@ class PluginHub(WebSocketEndpoint):
147165 _last_pong : ClassVar [dict [str , float ]] = {}
148166 # session_id -> ping task
149167 _ping_tasks : ClassVar [dict [str , asyncio .Task ]] = {}
168+ _published_tool_fingerprint : ClassVar [str | None ] = None
150169
151170 @classmethod
152171 def configure (
@@ -160,6 +179,7 @@ def configure(
160179 cls ._loop = loop or asyncio .get_running_loop ()
161180 # Ensure coordination primitives are bound to the configured loop
162181 cls ._lock = asyncio .Lock ()
182+ cls ._published_tool_fingerprint = None
163183 # Start tracking MCP client sessions for tool-change notifications
164184 if mcp is not None :
165185 _install_session_tracking ()
@@ -530,13 +550,16 @@ async def _handle_register_tools(self, websocket: WebSocket, payload: RegisterTo
530550 logger .info (
531551 f"Registered { len (payload .tools )} tools for session { session_id } " )
532552
533- # Sync server-level FastMCP visibility so new MCP client sessions
534- # (e.g. new Claude Code conversations) see the correct tool set.
535- self ._sync_server_tool_visibility (payload .tools )
553+ if cls ._tool_list_changed (payload .tools ):
554+ # Sync server-level FastMCP visibility so new MCP client sessions
555+ # (e.g. new Claude Code conversations) see the correct tool set.
556+ self ._sync_server_tool_visibility (payload .tools )
536557
537- # Notify any already-connected MCP clients (e.g. CC over stdio) that
538- # the tool list has changed so they re-fetch.
539- await cls ._notify_mcp_tool_list_changed ()
558+ # Notify any already-connected MCP clients that the published tool
559+ # schema changed so they re-fetch it.
560+ await cls ._notify_mcp_tool_list_changed ()
561+ else :
562+ logger .debug ("Unity tool schema unchanged; skipping tools/list_changed" )
540563
541564 try :
542565 from services .custom_tool_service import CustomToolService
@@ -555,6 +578,33 @@ async def _handle_register_tools(self, websocket: WebSocket, payload: RegisterTo
555578 exc_info = exc ,
556579 )
557580
581+ @classmethod
582+ def _tool_list_changed (cls , registered_tools : list ) -> bool :
583+ serialized : list [str ] = []
584+ for tool in registered_tools :
585+ if isinstance (tool , dict ):
586+ payload = tool
587+ elif hasattr (tool , "model_dump" ):
588+ try :
589+ payload = tool .model_dump (mode = "json" )
590+ except TypeError :
591+ payload = tool .model_dump ()
592+ else :
593+ payload = vars (tool )
594+
595+ serialized .append (
596+ json .dumps (payload , sort_keys = True , separators = ("," , ":" ), default = str )
597+ )
598+
599+ digest = hashlib .sha256 (
600+ "\n " .join (sorted (serialized )).encode ("utf-8" )
601+ ).hexdigest ()
602+ if digest == cls ._published_tool_fingerprint :
603+ return False
604+
605+ cls ._published_tool_fingerprint = digest
606+ return True
607+
558608 @classmethod
559609 def _sync_server_tool_visibility (cls , registered_tools : list ) -> None :
560610 """Sync FastMCP server-level tool group visibility to match Unity's state.
@@ -641,17 +691,20 @@ async def _notify_mcp_tool_list_changed(cls) -> None:
641691 sessions = list (_active_mcp_sessions )
642692 if not sessions :
643693 return
694+ notified = 0
644695 for session in sessions :
645696 try :
646697 await session .send_tool_list_changed ()
698+ notified += 1
647699 except Exception :
700+ _untrack_mcp_session (session )
648701 logger .debug (
649- "Failed to notify MCP session of tool list change" ,
702+ "Failed to notify MCP session of tool list change; removed stale session " ,
650703 exc_info = True ,
651704 )
652705 logger .info (
653706 "Sent tools/list_changed notification to %d MCP session(s)" ,
654- len ( sessions ) ,
707+ notified ,
655708 )
656709
657710 async def _handle_command_result (self , payload : CommandResultMessage ) -> None :
0 commit comments