Initial commit
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
@tool
|
||||
extends RefCounted
|
||||
|
||||
## bridge between dispatcher and addon handler
|
||||
|
||||
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
|
||||
|
||||
var _spec: McpCustomToolSpec
|
||||
var _locator: McpServiceLocator
|
||||
var _handler_instance = null # lazily loaded from _spec.script_path
|
||||
|
||||
func _init(spec: McpCustomToolSpec, locator: McpServiceLocator) -> void:
|
||||
_spec = spec
|
||||
_locator = locator
|
||||
|
||||
## Invoked by dispatcher._call_handler as .call(params) — SINGLE ARG.
|
||||
## Internally splits into (clean_params, ctx) for the addon handler.
|
||||
func invoke(params: Dictionary) -> Dictionary:
|
||||
## Dock enable/disable gate: a disabled tool is dropped from the
|
||||
## catalog push, but a client holding a stale list (or batch_execute)
|
||||
## can still name it — reject at dispatch too.
|
||||
var registry := McpToolRegistry.get_instance()
|
||||
if registry != null and not registry.is_tool_enabled(_spec.name):
|
||||
return ErrorCodes.make(ErrorCodes.CUSTOM_TOOL_DISABLED,
|
||||
"Custom tool '%s' is disabled in the Godot AI dock" % _spec.name)
|
||||
## Readiness gate (https://github.com/hi-godot/godot-ai/issues/781#issuecomment-5036376599 #1): block writes during play/import.
|
||||
## Checked BEFORE the lazy load so a gated tool's handler script (and any
|
||||
## _init side effects) never runs while the editor is busy.
|
||||
var _readiness := McpConnection.get_readiness()
|
||||
if _spec.requires_writable and (_readiness == "importing" or _readiness == "playing"):
|
||||
return ErrorCodes.make(ErrorCodes.EDITOR_NOT_READY, "Editor is '%s' — write blocked for custom tool '%s'" % [_readiness, _spec.name])
|
||||
if _handler_instance == null:
|
||||
var script := load(_spec.script_path) as GDScript
|
||||
if script == null:
|
||||
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Cannot load %s" % _spec.script_path)
|
||||
_handler_instance = script.new() # no-arg; per-call context arrives via ctx
|
||||
if not _handler_instance.has_method(_spec.method):
|
||||
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "%s has no method '%s' for custom tool '%s'" % [_spec.script_path, _spec.method, _spec.name])
|
||||
## Extract _request_id (dispatcher injected it) and strip from params
|
||||
## so the addon sees clean params matching its declared schema (https://github.com/hi-godot/godot-ai/issues/781#issuecomment-5036376599 #2).
|
||||
var request_id: String= params.get("_request_id", "")
|
||||
var clean_params := params.duplicate()
|
||||
clean_params.erase("_request_id")
|
||||
## Construct ctx with transport metadata + live-object locator.
|
||||
var ctx := McpCallContext.new()
|
||||
ctx.request_id = request_id
|
||||
ctx.session_id = _locator.get_connection().get_session_id()
|
||||
ctx.spec = _spec
|
||||
ctx.attach_locator(_locator)
|
||||
ctx.deadline_msec = Time.get_ticks_msec() + _spec.timeout_ms
|
||||
var result: Dictionary = _handler_instance.call(_spec.method, clean_params, ctx)
|
||||
if result.get("_deferred", false):
|
||||
## Enforce the declared contract: batch_execute and the server's
|
||||
## timeout budget both trust spec.deferred, so a non-deferred spec
|
||||
## whose handler defers anyway would report success while its real
|
||||
## reply arrives uncorrelated later.
|
||||
if not _spec.deferred:
|
||||
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR,
|
||||
"Custom tool '%s' returned a deferred response but its spec declares deferred=false" % _spec.name)
|
||||
if _spec.timeout_ms > 0:
|
||||
## Handlers return the SHARED McpDispatcher.DEFERRED_RESPONSE
|
||||
## const, which Godot makes read-only — stamping the per-spec
|
||||
## budget on it directly is a script error that aborts the call.
|
||||
result = result.duplicate()
|
||||
result["_deferred_timeout_ms"] = _spec.timeout_ms
|
||||
return result
|
||||
@@ -0,0 +1 @@
|
||||
uid://b68uel6lsb7jr
|
||||
@@ -0,0 +1,44 @@
|
||||
@tool
|
||||
class_name McpCallContext
|
||||
extends RefCounted
|
||||
|
||||
## Per-call context handed to addon handlers.
|
||||
##
|
||||
## STABLE addon-facing surface (see AGENTS.md — published class_names are
|
||||
## permanent compat surface): `request_id`, `session_id`, `deadline_msec`,
|
||||
## `spec` (read-only), `is_expired()`, `send_deferred()`. Anything
|
||||
## underscore-prefixed is internal wiring owned by the wrapper and may
|
||||
## change between releases — addons must not reach into it. Keeping this
|
||||
## surface narrow now is deliberate: it is much cheaper than breaking
|
||||
## published addons later (#875 review).
|
||||
|
||||
var request_id: String = ""
|
||||
var session_id: String = ""
|
||||
var deadline_msec: int = 0
|
||||
var spec: McpCustomToolSpec = null
|
||||
|
||||
## Internal — injected by custom_tool_wrapper via attach_locator(). Do not
|
||||
## use from addon code; the capability methods below are the contract.
|
||||
var _locator: McpServiceLocator = null
|
||||
|
||||
|
||||
## Internal — called by the wrapper during ctx construction.
|
||||
func attach_locator(locator: McpServiceLocator) -> void:
|
||||
_locator = locator
|
||||
|
||||
|
||||
func is_expired() -> bool:
|
||||
if deadline_msec == 0:
|
||||
return false
|
||||
return Time.get_ticks_msec() > deadline_msec
|
||||
|
||||
|
||||
func send_deferred(payload: Dictionary) -> void:
|
||||
if _locator == null:
|
||||
push_error("McpCallContext: cannot send deferred response, locator is null")
|
||||
return
|
||||
var conn := _locator.get_connection()
|
||||
if conn == null:
|
||||
push_error("McpCallContext: cannot send deferred response, connection is null")
|
||||
return
|
||||
conn.send_deferred_response(request_id, payload)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bdgqe4gyian2o
|
||||
@@ -0,0 +1,66 @@
|
||||
@tool
|
||||
class_name McpCustomToolSpec
|
||||
extends RefCounted
|
||||
|
||||
## Descriptor for one custom tool registered by an addon. Mirrors the
|
||||
## clients/_base.gd data-only descriptor pattern: fields are set by the
|
||||
## caller, NO Callables (handler instance is materialized lazily by the
|
||||
## dispatcher from script_path + method, so hot-reload can't SEGV a
|
||||
## worker mid-call — same rationale as McpClient, issue #229).
|
||||
##
|
||||
## Construction: instantiate and set fields, then pass to
|
||||
## McpToolRegistry.register(spec). Do NOT subclass — custom tools are
|
||||
## dynamic (one addon may register several), not file-system-scanned
|
||||
## like clients.
|
||||
|
||||
# --- identity ---
|
||||
var name: String = "" ## tool name, e.g. "gdunit_run". Must not shadow a built-in.
|
||||
var description: String = "" ## shown to the agent by the MCP server
|
||||
var params_schema: Dictionary = {} ## JSON Schema; advertised to the agent for shaping calls. Params are forwarded UNVALIDATED — the handler must validate its own input.
|
||||
|
||||
# --- handler resolution (lazy materialization by dispatcher) ---
|
||||
var script_path: String = "" ## "res://addons/.../handler.gd"; load()ed on first call
|
||||
var method: StringName = &"" ## method on the handler; signature: (params: Dictionary, ctx: McpCallContext) -> Dictionary
|
||||
|
||||
# --- source identity (https://github.com/hi-godot/godot-ai/issues/781#issuecomment-5036376599 #8) ---
|
||||
var source_path: String = "" ## "plugin.cfg" path: "res://addons/gdunit4_mcp/plugin.cfg". Same path = same addon (replace allowed); different path colliding = reject + dock warning. NOTE: self-declared — a collision/ownership policy for cooperating addons, NOT a security boundary (any in-editor code can claim any path).
|
||||
var source: String = "" ## display: "gdunit4_mcp". If empty, registry reads [plugin] name from source_path.
|
||||
|
||||
# --- exposure ---
|
||||
var promoted: bool = false ## opt-in: ask the server to ALSO register this tool as a first-class MCP tool ("custom_<name>") with params_schema attached, so agents get native schemas/validation instead of the custom_manage indirection. The server caps promoted count; overflow stays reachable via custom_manage.
|
||||
|
||||
# --- execution contract ---
|
||||
var timeout_ms: int = 4500 ## deferred timeout → DEFERRED_TIMEOUT_MS_BY_COMMAND["custom:<name>"]
|
||||
var deferred: bool = false ## true → handler may return DEFERRED_RESPONSE, push later via ctx.send_deferred(payload)
|
||||
var requires_writable: bool = false ## https://github.com/hi-godot/godot-ai/issues/781#issuecomment-5036376599 #1: readiness gate. false → reads run any time; true → blocks during play/import
|
||||
var undoable: bool = false ## https://github.com/hi-godot/godot-ai/issues/781#issuecomment-5036376599 #6: must be true to participate in undo=true batch_execute
|
||||
|
||||
# --- budgets (https://github.com/hi-godot/godot-ai/issues/781#issuecomment-5036376599 #5, enforced at register time) ---
|
||||
const MAX_DESCRIPTION_CHARS := 600
|
||||
const MAX_SCHEMA_BYTES := 8192
|
||||
const MAX_TIMEOUT_MS := 120000
|
||||
const MIN_TIMEOUT_MS := 500
|
||||
|
||||
|
||||
## Returns an array of human-readable validation errors (empty = valid).
|
||||
## Called by McpToolRegistry.register() and usable standalone for early
|
||||
## feedback in addon _enter_tree before the registry is even live.
|
||||
func validate() -> Array[String]:
|
||||
var errors: Array[String] = []
|
||||
if name.is_empty():
|
||||
errors.append("name is empty\n")
|
||||
if not name.is_valid_ascii_identifier():
|
||||
errors.append("name '%s' is not a valid identifier\n" % name)
|
||||
if description.length() > MAX_DESCRIPTION_CHARS:
|
||||
errors.append("description exceeds %d chars\n" % MAX_DESCRIPTION_CHARS)
|
||||
if JSON.stringify(params_schema).to_utf8_buffer().size() > MAX_SCHEMA_BYTES:
|
||||
errors.append("params_schema exceeds %d bytes\n" % MAX_SCHEMA_BYTES)
|
||||
if script_path.is_empty() or not ResourceLoader.exists(script_path):
|
||||
errors.append("script_path '%s' does not exist" % script_path)
|
||||
if method.is_empty():
|
||||
errors.append("method is empty")
|
||||
if timeout_ms < MIN_TIMEOUT_MS or timeout_ms > MAX_TIMEOUT_MS:
|
||||
errors.append("timeout_ms %d out of range [%d, %d]" % [timeout_ms, MIN_TIMEOUT_MS, MAX_TIMEOUT_MS])
|
||||
if source_path.is_empty() or not source_path.ends_with("plugin.cfg") or not FileAccess.file_exists(source_path):
|
||||
errors.append("source_path '%s' must be an existing plugin.cfg path" % source_path)
|
||||
return errors
|
||||
@@ -0,0 +1 @@
|
||||
uid://h7k8j5tpscwy
|
||||
@@ -0,0 +1,16 @@
|
||||
@tool
|
||||
class_name McpServiceLocator
|
||||
extends RefCounted
|
||||
|
||||
var _connection: McpConnection = null
|
||||
var _log_buffer: McpLogBuffer = null
|
||||
|
||||
func setup(connection: McpConnection, log_buffer: McpLogBuffer) -> void:
|
||||
_connection = connection
|
||||
_log_buffer = log_buffer
|
||||
|
||||
func get_connection() -> McpConnection:
|
||||
return _connection
|
||||
|
||||
func get_log_buffer() -> McpLogBuffer:
|
||||
return _log_buffer
|
||||
@@ -0,0 +1 @@
|
||||
uid://dfvphmvo7en3i
|
||||
@@ -0,0 +1,277 @@
|
||||
@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
|
||||
@@ -0,0 +1 @@
|
||||
uid://bq2btxtm8flhb
|
||||
Reference in New Issue
Block a user