Files
Brachistochrone/addons/godot_ai/custom_tools/mcp_tool_registry.gd
T
scorpion21211-cmd 72ca74c67f Initial commit
2026-10-03 23:29:41 -04:00

278 lines
10 KiB
GDScript

@tool
class_name McpToolRegistry
extends RefCounted
## Central registry for custom tools. INSTANCE SINGLETON (not static) —
## unlike McpClientRegistry which is read-only/loaded-once/query-only (static
## is fine there), this registry is MUTABLE, has LIFECYCLE (clear on teardown),
## must EMIT SIGNALS to multiple consumers, and needs DEPENDENCY INJECTION
## (dispatcher, locator). Static funcs can't emit instance signals in GDScript,
## and static vars create #46 freed-instance regressions across reloads.
##
## plugin.gd owns the instance (new + setup + clear). Addons access it via
## get_instance() so they don't hold a reference. Source identity is
## spec.source_path (explicit plugin.cfg path, https://github.com/hi-godot/godot-ai/issues/781#issuecomment-5036376599 #8) — same source_path
## = same addon (hot-reload replace); different source_path colliding on
## a name = reject + dock warning.
## singleton instance
static var _instance: McpToolRegistry = null
## Singleton accessor for addons. Returns null if Godot AI isn't loaded.
static func get_instance() -> McpToolRegistry:
return _instance
## Instance fields (NOT static — lifecycle tied to plugin.gd)
var _specs: Dictionary = {} # name -> McpCustomToolSpec
var _by_source_path: Dictionary = {} # source_path -> Array[McpCustomToolSpec] (batch unregister index)
var _dispatcher: McpDispatcher = null # injected by plugin.gd via setup()
var _locator: McpServiceLocator = null # injected by plugin.gd via setup()
var _ready: bool = false
var _promoted_cnt = 0
## Instance signals (LEGAL — instance funcs can emit these). Multiple consumers
## (plugin.gd, dock, addons) can connect.
signal tools_changed # triggers custom_tools_changed push to server
signal registry_ready # https://github.com/hi-godot/godot-ai/issues/781#issuecomment-5036376599 #8: emit after Godot AI reload so addons can re-register
## Called by plugin.gd in _enter_tree. Injects live dependencies and registers
## the singleton. Addons can now reach the registry via get_instance().
func setup(dispatcher: McpDispatcher, locator: McpServiceLocator) -> void:
_dispatcher = dispatcher
_locator = locator
_load_disabled_tools()
_instance = self
func register(spec: McpCustomToolSpec) -> bool:
if not _validate_one(spec):
return false
_commit_one(spec)
tools_changed.emit()
return true
## Atomic batch registration: validate ALL specs first (phase 1, zero
## mutation), then commit ALL (phase 2, cannot fail), then emit ONCE.
## A phase-1 failure leaves the registry AND dispatcher untouched — a
## per-spec register-then-bail loop would leave earlier specs committed
## and dispatchable while the server's tool list never hears about them.
func batch_register(specs: Array[McpCustomToolSpec]) -> bool:
if specs.is_empty():
return true
## Phase 1 — validate only. Also reject duplicate names WITHIN the
## batch: _validate_one checks against committed state (nothing from
## this batch is committed yet), and silently letting the last
## duplicate win would hide an addon packaging bug.
var batch_names := {}
for spec in specs:
if not _validate_one(spec):
return false
if batch_names.has(spec.name):
push_warning("McpToolRegistry: duplicate name '%s' within batch — rejected" % spec.name)
return false
batch_names[spec.name] = true
## Phase 2 — commit everything, emit once.
for spec in specs:
_commit_one(spec)
tools_changed.emit()
return true
## Phase 1 helper: validation + collision policy. NO registry/dispatcher
## mutation — the only side effect is filling the display-source fallback
## on the spec itself, which is harmless on reject.
func _validate_one(spec: McpCustomToolSpec) -> bool:
var errors := spec.validate()
if not errors.is_empty():
push_error("McpToolRegistry: rejecting spec '%s': %s" % [spec.name, ", ".join(errors)])
return false
## Fill display source from plugin.cfg if addon left it empty.
if spec.source.is_empty():
spec.source = _read_plugin_name(spec.source_path)
## reject-on-collision: same source_path (hot-reload) replaces, different rejects
var existing: McpCustomToolSpec= _specs.get(spec.name)
if existing != null and existing.source_path != spec.source_path:
push_warning("McpToolRegistry: name '%s' collision between %s and %s — rejected" % [spec.name, existing.source_path, spec.source_path])
return false
return true
## Phase 2 helper: commit one ALREADY-VALIDATED spec. Handles the
## hot-reload replace (same source_path — guaranteed by _validate_one).
## Never fails, never emits — callers own the tools_changed emit.
func _commit_one(spec: McpCustomToolSpec) -> void:
var existing: McpCustomToolSpec = _specs.get(spec.name)
if existing != null:
## Replace: purge the OLD registration from all four dispatcher
## dicts. Command name is "custom_tool:<name>" — passing the bare
## name would leave a materialized Callable in _handlers and the
## hot-reloaded spec would silently never take effect.
_dispatcher.unregister("custom_tool:" + existing.name, "custom:" + existing.name)
_erase_from_source_path_index_locked(existing)
_specs[spec.name] = spec
_by_source_path.get_or_add(spec.source_path, []).append(spec)
## Bridge to dispatcher lazy registration: register_lazy_handler + register_lazy.
## handler_key = "custom:<name>"; wrapper is CustomToolWrapper (https://github.com/hi-godot/godot-ai/issues/781#issuecomment-5036376599 #2).
var handler_key := "custom:" + spec.name
var wrapper_path := "res://addons/godot_ai/custom_tools/custom_tool_wrapper.gd"
_dispatcher.register_lazy_handler(handler_key, wrapper_path, [spec, _locator])
_dispatcher.register_lazy("custom_tool:" + spec.name, handler_key, &"invoke")
func unregister(name: String) -> bool:
var spec: McpCustomToolSpec = _specs.get(name)
if spec == null:
return false
if _dispatcher != null:
_dispatcher.unregister("custom_tool:" + name, "custom:" + name)
_specs.erase(name)
_erase_from_source_path_index_locked(spec)
tools_changed.emit()
return true
## https://github.com/hi-godot/godot-ai/issues/781#issuecomment-5036376599 #8: batch-unregister all tools from one addon. Pass the source_path
## (plugin.cfg path) — the registry removes every spec whose source_path matches.
func unregister_source(source_path: String) -> int:
var specs: Array = _by_source_path.get(source_path, [])
var count := specs.size()
for spec in specs.duplicate():
if _dispatcher != null:
_dispatcher.unregister("custom_tool:" + spec.name, "custom:" + spec.name)
_specs.erase(spec.name)
_by_source_path.erase(source_path)
if count > 0:
tools_changed.emit()
return count
func all() -> Array[McpCustomToolSpec]:
var out: Array[McpCustomToolSpec] = []
for spec in _specs.values():
out.append(spec)
return out
## Enabled specs only — the catalog-push source. Disabled tools stay
## registered (the dock still lists them for re-enabling) but are never
## advertised to the server and are rejected at dispatch.
func enabled() -> Array[McpCustomToolSpec]:
var out: Array[McpCustomToolSpec] = []
for spec in _specs.values():
if is_tool_enabled(spec.name):
out.append(spec)
return out
# --- per-tool enable state (dock UI) ---
## Persisted per-project via EditorSettings project metadata (the same
## store the editor uses for per-project UI state — no project.godot
## churn, no cross-project bleed). Applies LIVE: toggling re-emits
## tools_changed, which re-pushes the filtered catalog to the server.
const _META_SECTION := "godot_ai"
const _META_KEY_DISABLED := "disabled_custom_tools"
var _disabled_tools: Dictionary = {} # name -> true
func is_tool_enabled(name: String) -> bool:
return not _disabled_tools.has(name)
func set_tool_enabled(name: String, tool_enabled: bool) -> void:
if tool_enabled:
if not _disabled_tools.has(name):
return
_disabled_tools.erase(name)
else:
if _disabled_tools.has(name):
return
_disabled_tools[name] = true
_save_disabled_tools()
tools_changed.emit()
func _load_disabled_tools() -> void:
_disabled_tools.clear()
var es := _editor_settings()
if es == null:
return
var stored: Variant = es.get_project_metadata(_META_SECTION, _META_KEY_DISABLED, PackedStringArray())
for name in PackedStringArray(stored):
_disabled_tools[String(name)] = true
func _save_disabled_tools() -> void:
var es := _editor_settings()
if es == null:
return
var names := PackedStringArray()
for name in _disabled_tools.keys():
names.append(String(name))
es.set_project_metadata(_META_SECTION, _META_KEY_DISABLED, names)
static func _editor_settings() -> EditorSettings:
if not Engine.is_editor_hint():
return null
return EditorInterface.get_editor_settings()
func get_spec(name: String) -> McpCustomToolSpec:
var out: McpCustomToolSpec = _specs.get(name)
return out
func is_ready() -> bool:
return _ready
## Called from plugin.gd teardown — wipes everything so stale specs/Callables
## don't survive a Godot AI reload (#46 regression class). Signals die with
## the instance (consumers disconnect via plugin.gd's own teardown).
func clear() -> void:
_specs.clear()
_by_source_path.clear()
_disabled_tools.clear() # in-memory only; persistence reloads on next setup()
_ready = false
_dispatcher = null
_locator = null
_instance = null
## Called from plugin.gd _enter_tree after setup(). Addons that missed
## _enter_tree during a Godot AI reload listen for registry_ready (via
## get_instance().registry_ready.connect(...)) and re-register deterministically.
func mark_ready() -> void:
_ready = true
registry_ready.emit()
# --- internal helpers ---
func _erase_from_source_path_index_locked(spec: McpCustomToolSpec) -> void:
var specs: Array = _by_source_path.get(spec.source_path, [])
specs.erase(spec)
if specs.is_empty():
_by_source_path.erase(spec.source_path)
## Read [plugin] name from a plugin.cfg for display purposes.
## Returns "" on any error — display source is best-effort.
static func _read_plugin_name(plugin_cfg_path: String) -> String:
var cfg := ConfigFile.new()
if cfg.load(plugin_cfg_path) != OK:
return ""
return cfg.get_value("plugin", "name", "")
static func _get_command_name(spec: McpCustomToolSpec) -> String:
return "custom_tool:" + spec.name
static func _get_handler_name(spec: McpCustomToolSpec) -> String:
return "custom:" + spec.name