@tool extends RefCounted ## Tracks the live extension set and reconciles it with editor filesystem / ## settings changes — the hot-reload aggregate. ## ## After startup discovery hands off, this persistent instance reacts to ## EditorFileSystem.filesystem_changed / ProjectSettings.settings_changed: on each ## debounced (500ms) scan it diffs the global class list against its known state ## (add / remove / modify / retry-previously-failed), applies the deltas to the ## live registry, and broadcasts an "extensions.changed" notification to all ## connected MCP bridges. It also serves extensions.refresh (a caller-forced ## rescan), registered against its own cmd_refresh in setup(). ## ## The six state dictionaries below are this aggregate's internals, kept mutually ## consistent across one _do_rescan (a removed class is erased from all of them in ## lockstep). Nothing outside this module pokes them. const _Support := preload("res://addons/godot_mcp_toolkit/extensions/services/extension_support.gd") const _MetaCommands := preload("res://addons/godot_mcp_toolkit/extensions/services/extension_meta_commands.gd") # Retain references to C# extension instances loaded during hot-reload to prevent # GC from invalidating registered Callables (the startup pass retains its own via # the registry meta — see extension_discovery.gd). var _instances: Array = [] var _registry: MCPToolkitCommandRegistry = null var _server: Node = null var _known_extensions: Dictionary = {} # class_name_str -> script_path var _class_methods: Dictionary = {} # class_name_str -> Array[String] (methods) var _class_metadata: Dictionary = {} # class_name_str -> Dictionary (method -> str(meta)) var _failed_classes: Dictionary = {} # class_name_str -> true (failed validation, retry on scan) var _debounce_pending := false # class_name_str -> hash of the extension's source file at the moment it was last # loaded into the live registry. Used ONLY on Godot 4.2 to detect an in-session # edit to an existing extension that the 4.2 cache path (CACHE_MODE_REUSE) cannot # apply live, so extensions.refresh can nudge the caller to restart the editor. var _class_source_fingerprint: Dictionary = {} # class_name_str -> script_path for extensions whose source changed on disk but # could not be re-applied in-session on Godot 4.2 (restart needed). Drained by # cmd_refresh into a response hint. var _pending_restart_modifies: Dictionary = {} ## Wire this watcher to the live registry + server and arm it: snapshot the current ## extension classes, rebuild the class->methods map, connect the EditorFileSystem ## and ProjectSettings signals, and register extensions.refresh against cmd_refresh. ## The caller MUST retain this instance (prevents GC; it owns the live state and the ## signal connections the composer later disconnects). func setup(registry: MCPToolkitCommandRegistry, server: Node) -> void: _registry = registry _server = server # Snapshot current extension classes. _snapshot_current_extensions() # Build class->methods map from already-registered extension methods. _rebuild_class_methods_map() # Connect to EditorFileSystem. var efs := EditorInterface.get_resource_filesystem() efs.filesystem_changed.connect(on_filesystem_changed) # Also rescan when project settings change (catches addon enable/disable toggles). ProjectSettings.settings_changed.connect(on_settings_changed) # Register extensions.refresh — allows LLMs / headless mode to force # a filesystem scan + extension re-discovery without editor focus. registry.add("extensions.refresh", func(params: Dictionary) -> Dictionary: return await cmd_refresh(params) , MCPToolkitCommandOptions.new() .with_description("Force a filesystem scan and re-discover extensions (use when files were created externally)") .mark_idempotent() .mark_scene_independent()) print("[MCPExtensions] Hot-reload watcher active") func _snapshot_current_extensions() -> void: _known_extensions.clear() var classes: Array = ProjectSettings.get_global_class_list() for entry in classes: if not _Support.is_extension_candidate(entry): continue if not _Support.is_addon_enabled(entry.get("path", "")): continue _known_extensions[entry.get("class", "")] = entry.get("path", "") func _rebuild_class_methods_map() -> void: ## Build class_name -> methods + metadata mapping from the registry's extension methods. ## Called once at watcher start. During live reload, new methods are tracked ## incrementally in _load_extension_tracked(). _class_methods.clear() _class_metadata.clear() _class_source_fingerprint.clear() var all_ext_methods := _registry.get_extension_methods() # We can't perfectly attribute methods to classes after the fact, so we # re-scan: for each known class, load its script and call Register on a # temporary registry to capture which methods it adds. for cn: String in _known_extensions: var sp: String = _known_extensions[cn] # Baseline the on-disk source fingerprint so a later in-session edit is # detectable (drives the Godot 4.2 restart nudge; harmless on 4.3+). _class_source_fingerprint[cn] = _Support.hash_extension_source(sp) var probe := _Support.probe_extension(cn, sp, _server) var methods: Array = probe["methods"] if not methods.is_empty(): _class_methods[cn] = methods _class_metadata[cn] = probe["metadata"] ## Force a filesystem scan and immediate extension re-discovery. Registered as the ## extensions.refresh handler in setup() — it mutates (drives a rescan) and returns ## the refreshed list, an acknowledged CQS exception (one round-trip for the caller). func cmd_refresh(_params: Dictionary) -> Dictionary: ## Uses scan() (not scan_sources()) so NEW files are discovered — ## scan_sources() only re-checks already-known resources. var efs := EditorInterface.get_resource_filesystem() if _Support.is_godot_42(): # Godot 4.2: the lighter is_scanning() wait. The 4.3+ signal-wait below both # (a) widens the await window enough that the editor's @tool reimport races our # extension (re)load and natively crashes 4.2, and (b) has a bounded settle that # is unreliable for 4.2's in-memory class-list flush. Empirically this scan + # is_scanning() wait discovers new extensions immediately on 4.2. efs.scan() var deadline := Time.get_ticks_msec() + 5000 while efs.is_scanning() and Time.get_ticks_msec() < deadline: await _server.get_tree().create_timer(0.1).timeout else: # Godot 4.3+: arm the global-class-flush barrier BEFORE scanning. is_scanning() # alone is NOT a safe barrier: EditorFileSystem clears `scanning` on its worker # thread at the END of the FS walk — BEFORE the main thread flushes the global # class list (store_global_class_list, inside _update_script_classes) and emits # script_classes_updated. A rescan gated only on is_scanning() therefore diffs a # STALE ProjectSettings.get_global_class_list() and misses NEW class_names — # observed as extensions_refresh returning "commands:[]" for a just-added extension. var classes_flushed := [false] var on_flush := func() -> void: classes_flushed[0] = true var has_flush_signal := efs.has_signal("script_classes_updated") if has_flush_signal: efs.script_classes_updated.connect(on_flush) efs.scan() # Phase 1 — wait out the FS walk via the canonical scan-idle guard. await MCPToolkitSafeSceneOps.wait_for_scan_idle() # Phase 2 — wait for the main-thread class-list flush, bounded. A no-change scan # never emits script_classes_updated, so settle briefly then proceed rather than # hang; the flag (armed pre-scan) also captures a flush from Phase 1. if has_flush_signal: var settle := Time.get_ticks_msec() + 750 while not classes_flushed[0] and Time.get_ticks_msec() < settle: await _server.get_tree().create_timer(0.05).timeout if efs.script_classes_updated.is_connected(on_flush): efs.script_classes_updated.disconnect(on_flush) # Run rescan on the now-fresh class list (bypass debounce). _debounce_pending = false _do_rescan() # Return current extension list with full metadata (same shape as # extensions.list and extensions.changed — input_schema, annotations, etc.). var methods := _registry.get_extension_methods() var result: Array[Dictionary] = [] var grouped_keywords: PackedStringArray = [] for method: String in methods: var entry := _MetaCommands.build_command_entry(_registry, method) # Collect keywords for the activation hint (a refresh-local post-step on the # shared wire entry — read back from the entry the builder produced). for kw in entry.get("group", {}).get("keywords", []): if str(kw) not in grouped_keywords: grouped_keywords.append(str(kw)) result.append(entry) var response := {"success": true, "refreshed": true, "commands": result} if not grouped_keywords.is_empty(): response["hint"] = ( "Some extension tools are in on-demand groups and need activation " + "before use. Call discover_tools(request: '%s') to load them." ) % ", ".join(grouped_keywords) # Godot 4.2: surface in-session edits that couldn't be applied live (their updated # tools are absent from `commands` above) so the caller knows to restart the editor. if not _pending_restart_modifies.is_empty(): var modified_names: PackedStringArray = [] for cn: String in _pending_restart_modifies: modified_names.append(cn) var restart_hint := ( "Godot 4.2: extension(s) [%s] were modified on disk but in-session changes " + "can't be applied — restart the editor to load the updated version. " + "(New/removed extensions apply live; Godot 4.3+ applies modifications live too.)" ) % ", ".join(modified_names) response["hint"] = (str(response["hint"]) + " " + restart_hint) if response.has("hint") else restart_hint return response func on_filesystem_changed() -> void: _schedule_rescan() func on_settings_changed() -> void: # Don't rescan if the toolkit itself is being disabled — avoids race # conditions during teardown. if not EditorInterface.is_plugin_enabled("godot_mcp_toolkit"): return _schedule_rescan() func _schedule_rescan() -> void: if _debounce_pending: return _debounce_pending = true # Use a SceneTree timer for debounce (500ms). The watcher is a RefCounted # so it can't own timers directly — the server node's tree provides them. _server.get_tree().create_timer(0.5).timeout.connect(_do_rescan) ## Pure set-diff kernel: classify the freshly-scanned class set against the ## watcher's known + previously-failed state, with no script loads and no global ## state — every input is passed in, so it is headless-unit-testable. Returns ## {"added": Dictionary, "removed": Array[String], "retry": Dictionary}: ## added = classes in `current` not in `known` (class_name -> path) ## removed = classes in `known` not in `current` (class_name list) ## retry = previously-failed classes now present and NOT newly-added. ## `retry` is computed against `added`, so `added` is built first below. static func compute_class_diff(current: Dictionary, known: Dictionary, failed: Dictionary) -> Dictionary: var added: Dictionary = {} # class_name -> path var removed: Array[String] = [] for cn: String in current: if cn not in known: added[cn] = current[cn] for cn: String in known: if cn not in current: removed.append(cn) # Retry previously-failed classes (script was fixed since last scan). var retry: Dictionary = {} for cn: String in failed: if cn in current and cn not in added: retry[cn] = current[cn] return {"added": added, "removed": removed, "retry": retry} ## Compute-phase: scan the live class list and classify it against known state, ## WITHOUT mutating the registry or the watcher's tracking dicts (the one ## exception is _pending_restart_modifies, the 4.2 restart-nudge ledger, which is ## a probe artifact, not registry state). Returns a delta dict carrying ## everything _apply_delta needs: {current, added, removed, retry, modified}. func _compute_delta() -> Dictionary: var current: Dictionary = {} var classes: Array = ProjectSettings.get_global_class_list() for entry in classes: if not _Support.is_extension_candidate(entry): continue if not _Support.is_addon_enabled(entry.get("path", "")): continue current[entry.get("class", "")] = entry.get("path", "") # Pure add/remove/retry diff against known state. var diff := compute_class_diff(current, _known_extensions, _failed_classes) var added_classes: Dictionary = diff["added"] var removed_classes: Array[String] = diff["removed"] var retry_classes: Dictionary = diff["retry"] # Detect content changes in existing extensions (tools added/removed/modified # within the same class). Re-probe each known class and compare method lists # AND metadata (annotations, description, schema, timeout). var modified_classes: Dictionary = {} # class_name -> path for cn: String in current: if cn in added_classes or cn in retry_classes: continue if not _class_methods.has(cn): continue var sp: String = current[cn] var probe := _Support.probe_extension(cn, sp, _server) var fresh_methods: Array = probe["methods"] var old_methods: Array = _class_methods.get(cn, []) var fresh_meta: Dictionary = probe["metadata"] var old_meta: Dictionary = _class_metadata.get(cn, {}) if not _Support.arrays_equal(fresh_methods, old_methods) or fresh_meta != old_meta: modified_classes[cn] = sp # Godot 4.2 only: detect an in-session edit to an existing extension that the 4.2 # cache path (CACHE_MODE_REUSE) can't apply live — the modified-detection probe above # reads the cached pre-edit script, so the change is invisible there. Compare the # on-disk source fingerprint instead and queue a one-time restart nudge. if _Support.is_godot_42(): for cn: String in current: if cn in added_classes or cn in retry_classes or not _class_source_fingerprint.has(cn): continue if _Support.hash_extension_source(current[cn]) != _class_source_fingerprint[cn]: if cn not in _pending_restart_modifies: push_warning("[MCPExtensions] '%s' was edited but in-session changes can't be applied on Godot 4.2 - restart the editor to load the updated version (Godot 4.3+ applies edits live)." % cn) _pending_restart_modifies[cn] = current[cn] else: # Fingerprint matches the loaded version again (e.g. the edit was reverted). _pending_restart_modifies.erase(cn) return { "current": current, "added": added_classes, "removed": removed_classes, "retry": retry_classes, "modified": modified_classes, } ## Apply-phase: mutate the live registry + watcher tracking dicts to match the ## computed delta — unregister removed/modified methods, (re)load added/retry/ ## modified classes, adopt the new known set, then broadcast + log the change. func _apply_delta(delta: Dictionary) -> void: var current: Dictionary = delta["current"] var added_classes: Dictionary = delta["added"] var removed_classes: Array[String] = delta["removed"] var retry_classes: Dictionary = delta["retry"] var modified_classes: Dictionary = delta["modified"] # Collect removed method names before modifying state. var removed_methods: Array[String] = [] for cn: String in removed_classes: if _class_methods.has(cn): removed_methods.append_array(_class_methods[cn]) _class_methods.erase(cn) _class_metadata.erase(cn) _failed_classes.erase(cn) _class_source_fingerprint.erase(cn) _pending_restart_modifies.erase(cn) # Handle modified extensions: unregister old methods, then re-load fresh. for cn: String in modified_classes: if _class_methods.has(cn): for method: String in _class_methods[cn]: _registry.remove(method) print("[MCPExtensions] ~ %s (modified, re-registering)" % method) removed_methods.append_array(_class_methods[cn]) _class_methods.erase(cn) _class_metadata.erase(cn) # Unregister removed methods from the live registry. for method: String in removed_methods: if _registry.has_command(method): _registry.remove(method) print("[MCPExtensions] - %s (removed)" % method) # Register new extensions. for cn: String in added_classes: _load_extension_tracked(cn, added_classes[cn]) # Retry previously-failed classes. for cn: String in retry_classes: _load_extension_tracked(cn, retry_classes[cn]) # Re-load modified extensions. for cn: String in modified_classes: _load_extension_tracked(cn, modified_classes[cn]) # Update known state. _known_extensions = current # Broadcast if anything changed. var total_changes := removed_methods.size() for cn: String in added_classes: if _class_methods.has(cn): total_changes += _class_methods[cn].size() for cn: String in retry_classes: if _class_methods.has(cn): total_changes += _class_methods[cn].size() for cn: String in modified_classes: if _class_methods.has(cn): total_changes += _class_methods[cn].size() if total_changes > 0: _broadcast_extensions_changed(removed_methods) var parts: Array[String] = [] var add_count := 0 for cn: String in added_classes: if _class_methods.has(cn): add_count += 1 for cn: String in retry_classes: if _class_methods.has(cn): add_count += 1 if add_count > 0: parts.append("+%d" % add_count) if not modified_classes.is_empty(): parts.append("~%d" % modified_classes.size()) if not removed_classes.is_empty(): parts.append("-%d" % removed_classes.size()) if not parts.is_empty(): print("[MCPExtensions] Hot-reload: %s class(es) changed" % " ".join(parts)) func _do_rescan() -> void: _debounce_pending = false var delta := _compute_delta() # Nothing changed — skip the apply phase entirely (no registry mutation, no # broadcast). Mirrors the pre-split early return. if delta["added"].is_empty() and delta["removed"].is_empty() \ and delta["retry"].is_empty() and delta["modified"].is_empty(): return _apply_delta(delta) func _load_extension_tracked(class_name_str: String, script_path: String) -> void: ## Load and register a single extension, tracking its methods + metadata. var before: Array = _registry.get_all_methods() var instance := _Support.load_extension(class_name_str, script_path, _registry, _server) if instance != null: # Retain the instance (C# GC-safety) — load_extension no longer holds it. _instances.append(instance) var after: Array = _registry.get_all_methods() var new_methods: Array[String] = [] var new_metadata: Dictionary = {} for method: String in after: if method not in before: new_methods.append(method) new_metadata[method] = str(_registry.get_command_metadata(method)) _class_methods[class_name_str] = new_methods _class_metadata[class_name_str] = new_metadata _failed_classes.erase(class_name_str) # Record the source fingerprint we just loaded, and clear any pending # restart nudge (this version is now live). _class_source_fingerprint[class_name_str] = _Support.hash_extension_source(script_path) _pending_restart_modifies.erase(class_name_str) else: _failed_classes[class_name_str] = true func _broadcast_extensions_changed(removed_methods: Array[String]) -> void: ## Build the extensions.changed notification payload (same shape as ## extensions.list response + removed array) and broadcast to all peers. var commands: Array[Dictionary] = [] var methods := _registry.get_extension_methods() for method: String in methods: commands.append(_MetaCommands.build_command_entry(_registry, method)) var params := {"commands": commands, "removed": removed_methods} _server.broadcast_notification("extensions.changed", params)