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

237 lines
11 KiB
GDScript

@tool
extends RefCounted
## Shared extension-support leaf: load, validate, and probe a single MCP-toolkit
## extension, plus the candidate-detection and Godot-4.2 cache/version questions
## that both the discovery pass and the live watcher rely on.
##
## Stateless — every function takes its inputs (registry, server, class name,
## script path) as parameters and returns its result. load_extension() returns
## the loaded instance so the CALLER owns retention (the C# GC-safety reference),
## rather than this leaf holding any cross-call state.
const Modules := preload("res://addons/godot_mcp_toolkit/core/modules.gd")
const _PREFIX := "MCPToolkit"
# Built-in namespaces that extensions cannot override.
const RESERVED_PREFIXES: Array[String] = [
"scene.", "script.", "editor.", "node.", "runtime.", "server.",
"resource.", "folder.", "file.", "signal.", "playtest.", "project.",
"input_map.", "animation.", "tilemap.", "asset.", "save.", "meta.",
"game.", "diff.", "autoload.", "extensions.",
]
## Check whether a global-class-list entry is an extension candidate.
## GDScript: detected by base class (extends MCPToolkitExtension) — no naming restriction.
## C#: detected by MCPToolkit prefix (cross-language inheritance isn't possible).
## The base-class check naturally excludes internal toolkit classes (their base
## is RefCounted/Node, not MCPToolkitExtension).
static func is_extension_candidate(entry: Dictionary) -> bool:
var base_class: String = entry.get("base", "")
if base_class == "MCPToolkitExtension":
return true
# C# can't extend the GDScript base class — use prefix convention instead.
var class_name_str: String = entry.get("class", "")
var script_path: String = entry.get("path", "")
if class_name_str.begins_with(_PREFIX) and script_path.ends_with(".cs"):
return true
return false
## Returns false only when the script lives inside a formal Godot addon
## (has plugin.cfg) AND that addon is disabled. Everything else → true.
static func is_addon_enabled(script_path: String) -> bool:
if not script_path.begins_with("res://addons/"):
return true
var addon_name := script_path.trim_prefix("res://addons/").get_slice("/", 0)
if addon_name.is_empty():
return true
# No plugin.cfg → not a formal addon, no toggle mechanism exists.
if not FileAccess.file_exists("res://addons/%s/plugin.cfg" % addon_name):
return true
return EditorInterface.is_plugin_enabled(addon_name)
## True on Godot 4.2.x ONLY. The 4.2 editor natively crashes when a brand-new or
## just-modified @tool extension is (re)loaded in-session via a fresh
## CACHE_MODE_IGNORE load while EditorFileSystem is still reimporting it: IGNORE forces
## a second, unregistered, SYNCHRONOUS parallel load (resource_loader.cpp:478-479)
## that collides with the editor's own reimport during global-class registration
## (engine bugs godot#95527 / #58883 / #59669). 4.3+ handles this cleanly. The match
## is explicit major.minor equality so a future 5.2 is NOT caught and all 4.2.x patch
## releases ARE.
static func is_godot_42() -> bool:
return Modules.VersionUtils.is_engine_version_pair("4.2")
## Cache mode for loading extension scripts. 4.2 uses CACHE_MODE_REUSE, which returns
## the editor's existing (or in-flight) resource instead of spawning the crash-prone
## duplicate synchronous load — the trade-off is that an in-session EDIT to an existing
## extension is not reflected until restart (a cached read; surfaced as a nudge). 4.3+
## keeps CACHE_MODE_IGNORE: a guaranteed-fresh read (reliable live modify-detection),
## proven crash-free.
static func _extension_cache_mode() -> int:
return ResourceLoader.CACHE_MODE_REUSE if is_godot_42() else ResourceLoader.CACHE_MODE_IGNORE
## Hash of an extension's source file, or 0 if unreadable. A plain text read — no
## script instantiation — so it is safe to call mid-reimport on 4.2. Used to detect
## an in-session edit the 4.2 cache path cannot apply live.
static func hash_extension_source(script_path: String) -> int:
if not FileAccess.file_exists(script_path):
return 0
var f := FileAccess.open(script_path, FileAccess.READ)
if f == null:
return 0
var text := f.get_as_text()
f.close()
return text.hash()
static func arrays_equal(a: Array, b: Array) -> bool:
if a.size() != b.size():
return false
for i in a.size():
if a[i] != b[i]:
return false
return true
## Load an extension into a scratch registry to discover which methods it
## registers and their metadata. Does NOT modify the live registry.
## Returns {"methods": Array[String], "metadata": Dictionary}.
static func probe_extension(class_name_str: String, script_path: String, server: Node) -> Dictionary:
var empty := {"methods": [] as Array[String], "metadata": {}}
var script: Script = ResourceLoader.load(script_path, "", _extension_cache_mode())
if script == null:
return empty
var instance = script.new()
if instance == null:
return empty
var scratch := MCPToolkitCommandRegistry.new()
if instance.has_method("Register"):
instance.Register(scratch, server)
elif instance.has_method("register"):
instance.register(scratch, server)
var methods: Array[String] = []
var metadata: Dictionary = {}
for method: String in scratch.get_all_methods():
methods.append(method)
metadata[method] = str(scratch.get_command_metadata(method))
return {"methods": methods, "metadata": metadata}
## Load, validate, and register a single extension into the live registry. Returns
## the retained instance on success, or null on any failure (unloadable script,
## failed contract validation, or zero new commands registered). The CALLER must
## retain the returned instance — it is the C# GC-safety reference that keeps the
## extension's registered Callables alive.
static func load_extension(class_name_str: String, script_path: String, registry: MCPToolkitCommandRegistry, server: Node) -> Object:
var script: Script = ResourceLoader.load(script_path, "", _extension_cache_mode())
if script == null:
push_warning("[MCPExtensions] '%s': failed to load script at %s" % [class_name_str, script_path])
return null
var is_csharp := script_path.ends_with(".cs")
var instance = script.new()
if instance == null:
push_warning("[MCPExtensions] '%s': script.new() returned null" % class_name_str)
return null
# Validate extension contract.
if is_csharp:
# C# cannot extend GDScript classes — use duck typing.
if not instance.has_method("Register") and not instance.has_method("register"):
push_warning("[MCPExtensions] '%s': C# class missing Register() method - skipped" % class_name_str)
return null
else:
# GDScript must extend MCPToolkitExtension.
if not (instance is MCPToolkitExtension):
push_warning("[MCPExtensions] '%s': GDScript class does not extend MCPToolkitExtension - skipped" % class_name_str)
return null
# Record methods before registration to detect new ones.
var before: Array = registry.get_all_methods()
# Open the load-time collision-guard window around register(). While it is
# active, any add() of an already-registered name (built-in or earlier extension)
# is refused in the registry rather than silently overwriting it — closing the
# accidental-reuse footgun that registry.add() would otherwise allow (it is
# last-writer-wins outside this window). First-loaded wins within the window;
# the loser is reported below. Note: the guard is window-scoped — it does NOT
# prevent a full-trust extension from deferring an add() past register()
# (installed extensions are full-trust in-process code; that is out of scope).
registry.begin_extension_load()
# Call register — handle both GDScript (snake_case) and C# (PascalCase).
if instance.has_method("Register"):
instance.Register(registry, server)
elif instance.has_method("register"):
instance.register(registry, server)
# Drain + report any collisions the guard refused during register(). These
# went to the editor (push_error + a dock toast where available) only — never
# into the MCP response stream, so the LLM sees no extra noise.
var refused: Array[Dictionary] = registry.end_extension_load()
for entry: Dictionary in refused:
_report_collision(class_name_str, str(entry.get("method", "")))
# Validate newly registered methods.
var after: Array = registry.get_all_methods()
var new_count := 0
for method: String in after:
if method in before:
continue
var rejected := false
for prefix: String in RESERVED_PREFIXES:
if method.begins_with(prefix):
registry.remove(method)
push_warning("[MCPExtensions] '%s': '%s' uses reserved namespace '%s*' - rejected" % [class_name_str, method, prefix])
rejected = true
break
if not rejected:
registry.mark_extension(method)
var meta := registry.get_command_metadata(method)
var group_name: String = meta.get("group", {}).get("name", "")
if group_name:
print("[MCPExtensions] + %s (group: %s)" % [method, group_name])
else:
print("[MCPExtensions] + %s" % method)
new_count += 1
if new_count == 0:
# When every add() collided, the refusals above are the reason — say so
# rather than a bare "zero new commands". Not retaining the instance is
# correct here: it registered nothing live, so there is no live Callable
# bound to it (no GC-break risk), and every incumbent command it tried to
# overwrite is untouched.
if refused.is_empty():
push_warning("[MCPExtensions] '%s': registered zero new commands" % class_name_str)
else:
push_warning("[MCPExtensions] '%s': registered zero new commands - all %d add(s) collided with already-registered commands" % [class_name_str, refused.size()])
return null
# Return the instance so the caller can retain it (critical for C# —
# prevents GC from invalidating Callables).
return instance
## Surface a refused extension command collision to the EDITOR only — never the
## MCP response stream (no agent-facing noise). push_error always fires (Output
## panel + Errors tab); a dock toast is added when the EditorToaster exists
## (Godot 4.4+; null-safe degrade below). Names the offending extension + the
## command it tried to claim. The colliding add() was already refused in the
## registry, so this is a report, not a mutation.
static func _report_collision(class_name_str: String, method: String) -> void:
var msg := (
"MCP Toolkit: extension '%s' tried to register '%s', " % [class_name_str, method]
+ "but that command is already registered - keeping the existing one. "
+ "Rename the extension's command to a unique <namespace>.<action>.")
push_error("[MCPExtensions] " + msg)
# EditorToaster is 4.4+ — get_toaster() returns null below that, so degrade
# to the push_error alone. Severity 2 == EditorToaster.SEVERITY_ERROR.
var toaster = Modules.EditorAccess.get_toaster()
if toaster != null:
toaster.push_toast(msg, 2)