2026-10-09 12:49:07 +02:00

429 lines
19 KiB
GDScript

@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)