Initial commit

This commit is contained in:
scorpion21211-cmd
2026-10-03 23:29:41 -04:00
commit e24bbbda5d
10586 changed files with 1030090 additions and 0 deletions
@@ -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