diff --git a/addons/godot_ai/LICENSE b/addons/godot_ai/LICENSE
new file mode 100644
index 0000000..7806d22
--- /dev/null
+++ b/addons/godot_ai/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2025 Godot AI contributors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/addons/godot_ai/README.md b/addons/godot_ai/README.md
new file mode 100644
index 0000000..88b524f
--- /dev/null
+++ b/addons/godot_ai/README.md
@@ -0,0 +1,54 @@
+# Godot AI
+
+Connect AI assistants to a live Godot editor via the [Model Context Protocol](https://modelcontextprotocol.io/introduction) (MCP).
+
+Godot AI bridges Claude Code, Codex, Antigravity, DeepSeek Harness, and other MCP clients with your editor — inspect scenes, create nodes, modify properties, run tests, search project files, and more, all from a prompt.
+
+## Quick Start
+
+1. For a fresh install, verify and extract the signed v4 exact-tree archive;
+ for v3 migration, follow `docs/v4-migration.md` in the repository
+2. Enable the plugin: **Project > Project Settings > Plugins > Godot AI**
+3. Pick your MCP client in the **Godot AI** dock and press **Configure**
+
+The plugin auto-starts the MCP server and connects over WebSocket. No manual configuration required.
+
+## Requirements
+
+- Godot 4.7+ within the 4.x line
+- [uv](https://docs.astral.sh/uv/) (used to install the Python server)
+
+ Install uv
+
+ **macOS / Linux:**
+ ```bash
+ curl -LsSf https://astral.sh/uv/install.sh | sh
+ ```
+
+ **Windows (PowerShell):**
+ ```powershell
+ powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
+ ```
+
+ **Homebrew (macOS / Linux):**
+ ```bash
+ brew install uv
+ ```
+
+ **pipx:**
+ ```bash
+ pipx install uv
+ ```
+
+ See the [uv install docs](https://docs.astral.sh/uv/getting-started/installation/) for more options.
+
+
+- An MCP client ([Claude Code](https://docs.anthropic.com/en/docs/claude-code) | [Codex](https://openai.com/index/codex/) | [Antigravity](https://www.antigravity.dev/))
+
+## Documentation
+
+Full documentation, contributing guide, and source code: [github.com/hi-godot/godot-ai](https://github.com/hi-godot/godot-ai)
+
+## License
+
+[MIT](LICENSE)
diff --git a/addons/godot_ai/client_configurator.gd b/addons/godot_ai/client_configurator.gd
new file mode 100644
index 0000000..bd3980f
--- /dev/null
+++ b/addons/godot_ai/client_configurator.gd
@@ -0,0 +1,2067 @@
+@tool
+class_name McpClientConfigurator
+extends RefCounted
+
+## Public facade for the MCP client configuration system.
+##
+## Per-client logic lives in clients/*.gd (one descriptor per client) and is
+## dispatched through clients/_registry.gd. This file:
+## - owns server-side identifiers (SERVER_NAME, HTTP/WS port helpers)
+## - registers the EditorSettings port overrides and resolves the live
+## port/URL via `http_port()` / `ws_port()` / `http_url()`
+## - keeps server-launch discovery (.venv → uvx → system godot-ai)
+## - exposes string-id wrappers around configure / check_status / remove /
+## manual_command so callers don't need to touch the registry directly
+##
+## To add a new client: drop a file in clients/, then preload it in
+## clients/_registry.gd. No edits required here.
+
+const Client := preload("res://addons/godot_ai/clients/_base.gd")
+const ClientRegistry := preload("res://addons/godot_ai/clients/_registry.gd")
+const JsonStrategy := preload("res://addons/godot_ai/clients/_json_strategy.gd")
+const TomlStrategy := preload("res://addons/godot_ai/clients/_toml_strategy.gd")
+const YamlStrategy := preload("res://addons/godot_ai/clients/_yaml_strategy.gd")
+const DshStrategy := preload("res://addons/godot_ai/clients/_dsh_strategy.gd")
+const CliStrategy := preload("res://addons/godot_ai/clients/_cli_strategy.gd")
+const ManualCommand := preload("res://addons/godot_ai/clients/_manual_command.gd")
+const CliFinder := preload("res://addons/godot_ai/clients/_cli_finder.gd")
+const MutationLock := preload("res://addons/godot_ai/utils/client_mutation_lock.gd")
+const WindowsPortReservation := preload("res://addons/godot_ai/utils/windows_port_reservation.gd")
+const PortResolver := preload("res://addons/godot_ai/utils/port_resolver.gd")
+const UvResolution := preload("res://addons/godot_ai/utils/uv_resolution_policy.gd")
+
+const SERVER_NAME := "godot-ai"
+
+## Fallback ports. Live port selection goes through `http_port()` / `ws_port()`,
+## which read overrides from EditorSettings first. Users on Windows whose 8000
+## is grabbed by Hyper-V / WSL2 / Docker can pick a different port in
+## Editor Settings > Plugins > godot_ai without touching code. See #146 for
+## the Windows-reservation diagnostics this is the escape hatch for.
+const DEFAULT_HTTP_PORT := 8000
+const DEFAULT_WS_PORT := 9500
+const STARTUP_TRACE_ENV := "GODOT_AI_STARTUP_TRACE"
+const MIN_PORT := 1024
+const MAX_PORT := 65535
+## Cap on `can_bind_local_port` probes per `suggest_free_port` call so a
+## pathological run of occupied ports can't stall the (cold-path) caller.
+## 64 localhost binds are sub-millisecond; finding a free port realistically
+## takes one or two probes, so this only bounds the worst case.
+const SUGGEST_PORT_MAX_PROBES := 64
+const SETTING_WS_PORT := "godot_ai/ws_port"
+const SETTING_V4_ENDPOINT_PORTS := "godot_ai/v4_endpoint_ports"
+const SETTING_STARTUP_TRACE := "godot_ai/log_startup_timing"
+const SETTING_KEEP_SERVER_ON_EXIT := "godot_ai/keep_server_on_exit"
+## External cwd the user can set when an MCP client (Pi, code-server, …) reads
+## project-tier config files from a directory this editor can't see. The
+## merge-tier strategies include it as an extra `_project_candidate_paths` root
+## so project overrides there are detected instead of silently shadowed by a
+## global-tier write. Codex-review finding F1.
+const SETTING_EXTERNAL_CLIENT_CWD := "godot_ai/external_client_cwd"
+const _DISCOVERY_TIMEOUT_MS := 3000
+## Codex launches Windows console-subsystem MCP commands in a visible terminal.
+## A GUI-subsystem Python keeps the bridge attached to Codex's redirected MCP
+## pipes without allocating a console, then starts console launchers such as
+## uvx with CREATE_NO_WINDOW. Keep stdin/stdout/stderr explicit: pythonw can use
+## its own inherited pipes, but subprocess defaults do not reliably forward
+## them to a child when no console exists.
+## This string is a wire format written verbatim into user config `args`.
+## Whitespace or formatting changes make every existing Windows entry report
+## CONFIGURED_MISMATCH, so changing it is a deliberate migration decision, not
+## a refactor. The inline `-c` script is required because the uvx and system
+## tiers resolve a system interpreter where `godot_ai` is not importable.
+const _WINDOWS_STDIO_BOOTSTRAP := (
+ "import subprocess,sys; "
+ + "raise SystemExit(subprocess.call(sys.argv[1:], stdin=sys.stdin, stdout=sys.stdout, "
+ + "stderr=sys.stderr, creationflags=0x08000000))"
+)
+
+
+## Active HTTP port: user override (if in range) or `DEFAULT_HTTP_PORT`.
+static func http_port() -> int:
+ var override := v4_endpoint_ports_status()
+ if bool(override.present):
+ return int(override.get("http_port", 0))
+ return _read_port_setting(McpSettings.SETTING_HTTP_PORT, DEFAULT_HTTP_PORT)
+
+
+## Active WebSocket port: user override (if in range) or `DEFAULT_WS_PORT`.
+static func ws_port() -> int:
+ var override := v4_endpoint_ports_status()
+ if bool(override.present):
+ return int(override.get("ws_port", 0))
+ return _read_port_setting(SETTING_WS_PORT, DEFAULT_WS_PORT)
+
+
+## An absent override preserves historical settings. A malformed present
+## override grants no endpoint; callers can surface its error before startup.
+static func v4_endpoint_ports_status() -> Dictionary:
+ var es := EditorInterface.get_editor_settings()
+ if es == null or not es.has_setting(SETTING_V4_ENDPOINT_PORTS):
+ return {"ok": true, "present": false}
+ var pair: Variant = es.get_setting(SETTING_V4_ENDPOINT_PORTS)
+ var invalid := {"ok": false, "present": true,
+ "error": "Invalid %s: set distinct integer http_port and ws_port values between %d and %d, or remove the override." % [SETTING_V4_ENDPOINT_PORTS, MIN_PORT, MAX_PORT]}
+ if not (pair is Dictionary) or pair.size() != 2:
+ return invalid
+ for key in ["http_port", "ws_port"]:
+ var value: Variant = pair.get(key)
+ if not (value is int or value is float) or not is_finite(float(value)):
+ return invalid
+ if float(value) < MIN_PORT or float(value) > MAX_PORT or float(value) != float(int(value)):
+ return invalid
+ if int(pair.http_port) == int(pair.ws_port):
+ return invalid
+ return {"ok": true, "present": true, "http_port": int(pair.http_port), "ws_port": int(pair.ws_port)}
+
+
+## Call only after a verified successful upgrade and the activation barrier.
+## This selects independent ports, not authority over the legacy backend.
+static func prepare_major_upgrade_endpoints(from_version: String, to_version: String) -> Dictionary:
+ var previous := McpServerVersionCheck.version_tuple(from_version)
+ var installed := McpServerVersionCheck.version_tuple(to_version)
+ if previous.is_empty() or installed.is_empty() or int(previous[0]) >= 4 or int(installed[0]) < 4:
+ return {"ok": true, "changed": false}
+ if OS.get_thread_caller_id() != OS.get_main_thread_id():
+ return {"ok": false, "error": "upgrade endpoint selection requires the main thread"}
+ var existing := v4_endpoint_ports_status()
+ if not bool(existing.ok) or bool(existing.present):
+ existing["changed"] = false
+ return existing
+ var es := EditorInterface.get_editor_settings()
+ if es == null:
+ return {"ok": false, "error": "EditorSettings is unavailable"}
+ var legacy_http := _read_port_setting(McpSettings.SETTING_HTTP_PORT, DEFAULT_HTTP_PORT)
+ var legacy_ws := _read_port_setting(SETTING_WS_PORT, DEFAULT_WS_PORT)
+ var selected: Array[int] = []
+ var candidate := legacy_http + 1 if legacy_http < MAX_PORT else MIN_PORT
+ candidate = WindowsPortReservation.suggest_non_excluded_port(candidate, MAX_PORT - candidate + 1, MAX_PORT)
+ var reserved_output := str(WindowsPortReservation._get_cached_excluded_output().get("text", ""))
+ for _probe in range(SUGGEST_PORT_MAX_PROBES):
+ candidate = WindowsPortReservation.suggest_non_excluded_port_from_output(reserved_output, candidate, MAX_PORT - candidate + 1, MAX_PORT)
+ if candidate < MIN_PORT or candidate > MAX_PORT:
+ break
+ if candidate not in [legacy_http, legacy_ws] and not selected.has(candidate) and PortResolver.can_bind_local_port(candidate) and not PortResolver.is_port_in_use(candidate):
+ selected.append(candidate)
+ if selected.size() == 2:
+ break
+ candidate += 1
+ if candidate > MAX_PORT:
+ candidate = MIN_PORT
+ if selected.size() != 2:
+ return {"ok": false, "error": "No independent HTTP/WebSocket port pair was available for this major upgrade."}
+ for port in selected:
+ if WindowsPortReservation.parse_excluded(reserved_output, port) or not PortResolver.can_bind_local_port(port) or PortResolver.is_port_in_use(port):
+ return {"ok": false, "error": "The selected upgrade port %d became unavailable; retry endpoint selection." % port}
+ var pair := {"http_port": selected[0], "ws_port": selected[1]}
+ es.set_setting(SETTING_V4_ENDPOINT_PORTS, pair)
+ return {"ok": true, "changed": true, "http_port": selected[0], "ws_port": selected[1]}
+
+
+static func http_url() -> String:
+ return "http://127.0.0.1:%d/mcp" % http_port()
+
+
+## Read a URL already captured on the main thread without evaluating the
+## EditorSettings-backed fallback unless the snapshot is genuinely incomplete.
+static func server_url_from(launch_context: Dictionary) -> String:
+ if launch_context.has("server_url"):
+ return str(launch_context["server_url"])
+ return http_url()
+
+
+static func _read_port_setting(key: String, default_port: int) -> int:
+ var es := EditorInterface.get_editor_settings()
+ if es == null or not es.has_setting(key):
+ return default_port
+ var value: int = int(es.get_setting(key))
+ if value < MIN_PORT or value > MAX_PORT:
+ return default_port
+ return value
+
+
+## Register the port overrides in EditorSettings so they show up in the
+## editor's Settings > Plugins section with a range hint. Called once from
+## `plugin.gd._enter_tree` before `_start_server` so spawn args see the
+## configured values. Safe to call repeatedly — `add_property_info` is
+## idempotent and `set_initial_value` only seeds the default.
+static func ensure_settings_registered() -> void:
+ var es := EditorInterface.get_editor_settings()
+ if es == null:
+ return
+ _register_port_setting(es, McpSettings.SETTING_HTTP_PORT, DEFAULT_HTTP_PORT)
+ _register_port_setting(es, SETTING_WS_PORT, DEFAULT_WS_PORT)
+ _register_bool_setting(es, SETTING_STARTUP_TRACE, false)
+ _register_bool_setting(es, SETTING_KEEP_SERVER_ON_EXIT, false)
+ _register_string_setting(es, McpSettings.SETTING_EXCLUDED_DOMAINS, "")
+ _register_bool_setting(es, McpSettings.SETTING_TELEMETRY_ENABLED, true)
+ _register_string_setting(es, McpSettings.SETTING_ALLOW_HOSTS, "")
+ _register_client_scope_setting(es)
+ _register_string_setting(es, SETTING_EXTERNAL_CLIENT_CWD, "")
+
+
+static func _register_port_setting(es: EditorSettings, key: String, default_port: int) -> void:
+ if not es.has_setting(key):
+ es.set_setting(key, default_port)
+ es.set_initial_value(key, default_port, false)
+ es.add_property_info({
+ "name": key,
+ "type": TYPE_INT,
+ "hint": PROPERTY_HINT_RANGE,
+ "hint_string": "%d,%d,1" % [MIN_PORT, MAX_PORT],
+ })
+
+
+## Surface the CLI registration scope as an enum in Settings > Plugins so it is
+## discoverable without hand-editing editor_settings-4.tres. Kept at `user` by
+## default; `project` writes the entry into /.mcp.json instead.
+static func _register_client_scope_setting(es: EditorSettings) -> void:
+ var key := McpSettings.SETTING_CLIENT_SCOPE
+ if not es.has_setting(key):
+ es.set_setting(key, McpSettings.DEFAULT_CLIENT_SCOPE)
+ es.set_initial_value(key, McpSettings.DEFAULT_CLIENT_SCOPE, false)
+ es.add_property_info({
+ "name": key,
+ "type": TYPE_STRING,
+ "hint": PROPERTY_HINT_ENUM,
+ "hint_string": ",".join(PackedStringArray(McpSettings.CLIENT_SCOPES)),
+ })
+
+
+static func _register_bool_setting(es: EditorSettings, key: String, default_value: bool) -> void:
+ if not es.has_setting(key):
+ es.set_setting(key, default_value)
+ es.set_initial_value(key, default_value, false)
+ es.add_property_info({
+ "name": key,
+ "type": TYPE_BOOL,
+ })
+
+
+static func _register_string_setting(es: EditorSettings, key: String, default_value: String) -> void:
+ ## Same idempotent set-then-hint dance as the bool helper; `PROPERTY_HINT_NONE`
+ ## leaves the editor's plain text-input widget in place (the cwd string is
+ ## freeform). The hint-string slot is required by the API even when empty.
+ if not es.has_setting(key):
+ es.set_setting(key, default_value)
+ es.set_initial_value(key, default_value, false)
+ es.add_property_info({
+ "name": key,
+ "type": TYPE_STRING,
+ "hint": PROPERTY_HINT_NONE,
+ "hint_string": "",
+ })
+
+
+static func startup_trace_enabled() -> bool:
+ ## env_lookup + _editor_setting_lookup for the same worker-thread
+ ## reason as mode_override (#691).
+ var raw := McpPathTemplate.env_lookup(STARTUP_TRACE_ENV).strip_edges().to_lower()
+ if raw == "1" or raw == "true" or raw == "yes" or raw == "on":
+ return true
+ var setting: Variant = _editor_setting_lookup(SETTING_STARTUP_TRACE)
+ if setting != null:
+ return bool(setting)
+ return false
+
+
+## keep_server_on_exit (#800): when enabled, editor teardown detaches from
+## the managed server instead of killing it, and the spawn env opts the
+## server out of both self-reap paths (owner-PID watchdog, session-idle
+## backstop) — so MCP clients connected over HTTP stay served across editor
+## sessions, and the next editor start adopts the survivor. Off by default:
+## "server dies with the editor" stays the shipped behavior. Read via
+## _editor_setting_lookup for the same worker-thread reason as
+## startup_trace_enabled (#691).
+static func keep_server_on_exit() -> bool:
+ var setting: Variant = _editor_setting_lookup(SETTING_KEEP_SERVER_ON_EXIT)
+ if setting != null:
+ return bool(setting)
+ return false
+
+
+## #691: EditorSettings counterpart of McpPathTemplate.env_lookup. The #678
+## startup walk's discovery worker reaches mode_override() (via
+## get_server_command) and EditorInterface / EditorSettings are not
+## thread-safe objects. Main thread: live read + mutex-guarded snapshot
+## refresh. Worker thread: snapshot only — a never-warmed key reads as
+## null (unset), never a live EditorInterface call. Warmed alongside the
+## env snapshot in warm_env_snapshot(), which runs on the main thread
+## before any worker dispatch.
+static var _setting_snapshot := {}
+static var _setting_snapshot_mutex := Mutex.new()
+## The aggregate MCP status command runs on a worker thread. Keep the same
+## main-thread-only LaunchContext contract used by dock workers by publishing a
+## deep snapshot whenever capture_launch_context() runs on the main thread.
+static var _launch_context_snapshot := {}
+static var _endpoint_policy_snapshot := {}
+static var _launch_context_snapshot_mutex := Mutex.new()
+
+
+static func _editor_setting_lookup(key: String) -> Variant:
+ if OS.get_thread_caller_id() == OS.get_main_thread_id():
+ var live: Variant = null
+ if Engine.is_editor_hint():
+ var es := EditorInterface.get_editor_settings()
+ if es != null and es.has_setting(key):
+ live = es.get_setting(key)
+ _setting_snapshot_mutex.lock()
+ _setting_snapshot[key] = live
+ _setting_snapshot_mutex.unlock()
+ return live
+ _setting_snapshot_mutex.lock()
+ var cached: Variant = _setting_snapshot.get(key, null)
+ _setting_snapshot_mutex.unlock()
+ return cached
+
+
+## Read the `godot_ai/excluded_domains` EditorSetting as a canonicalized
+## comma-separated list (sorted, deduplicated, whitespace-stripped). Returns
+## "" when the setting is missing or resolves to an empty set — callers can
+## skip appending the flag in that case so older servers that don't know
+## `--exclude-domains` don't see an empty argument.
+##
+## Unknown domain names (e.g. a domain removed since the setting was last
+## written) are dropped here, at the single chokepoint both the startup
+## flag builder (plugin.gd) and the dock display read — the server's
+## `parse_exclude_list` hard-fails on unknown names, so a stale setting
+## would otherwise block server startup.
+static func excluded_domains() -> String:
+ var es := EditorInterface.get_editor_settings()
+ if es == null or not es.has_setting(McpSettings.SETTING_EXCLUDED_DOMAINS):
+ return ""
+ return _canonicalize_excluded_domains(str(es.get_setting(McpSettings.SETTING_EXCLUDED_DOMAINS)))
+
+
+## Pure canonicalizer shared by the main-thread LaunchContext capture and
+## tests. Unknown domains are dropped for the same startup-safety reason as
+## `excluded_domains()` above.
+static func _canonicalize_excluded_domains(raw: String) -> String:
+ var parts := PackedStringArray()
+ for p in raw.split(","):
+ var t := p.strip_edges()
+ if t.is_empty() or parts.find(t) != -1:
+ continue
+ if not McpToolCatalog.is_excludable_domain(t):
+ continue
+ parts.append(t)
+ parts.sort()
+ return ",".join(parts)
+
+
+## One activation-scoped desired endpoint policy. The composition root resolves
+## the WS port, publishes this value once, and derives both managed-server and
+## attach plans from it. Returned dictionaries never alias the retained copy.
+static func capture_endpoint_policy(resolved_ws_port: int = -1) -> Dictionary:
+ var selected_ws_port := ws_port() if resolved_ws_port <= 0 else resolved_ws_port
+ return {
+ "http_port": http_port(),
+ "ws_port": selected_ws_port,
+ "excluded_domains": excluded_domains(),
+ "allow_hosts": allow_hosts(),
+ "keep_alive": keep_server_on_exit(),
+ "telemetry_enabled": McpSettings.telemetry_enabled(),
+ }
+
+
+## Snapshot every value needed to render or verify an attach launch command.
+## Endpoint fields come only from the root-published activation policy; later
+## main-thread refreshes update discovery/project values without re-reading a
+## divergent desired endpoint. Workers receive the deep snapshot only (#691).
+static func capture_launch_context(endpoint_policy: Dictionary = {}) -> Dictionary:
+ if OS.get_thread_caller_id() != OS.get_main_thread_id():
+ _launch_context_snapshot_mutex.lock()
+ var cached := _launch_context_snapshot.duplicate(true)
+ _launch_context_snapshot_mutex.unlock()
+ return cached
+ var policy := endpoint_policy.duplicate(true)
+ if policy.is_empty():
+ _launch_context_snapshot_mutex.lock()
+ policy = _endpoint_policy_snapshot.duplicate(true)
+ _launch_context_snapshot_mutex.unlock()
+ if policy.is_empty():
+ ## Standalone/test callers without a composition root still get a
+ ## complete context. A real plugin activation always publishes first.
+ policy = capture_endpoint_policy()
+ var captured_http_port := int(policy.get("http_port", DEFAULT_HTTP_PORT))
+ var context := {
+ "http_port": captured_http_port,
+ "ws_port": int(policy.get("ws_port", DEFAULT_WS_PORT)),
+ "excluded_domains": str(policy.get("excluded_domains", "")),
+ "plugin_version": get_plugin_version(),
+ "allow_dev_venv": mode_override() != "user",
+ "platform": OS.get_name(),
+ "server_url": "http://127.0.0.1:%d/mcp" % captured_http_port,
+ "project_roots": capture_project_roots(),
+ ## The opt-out must ride the attach argv: the client spawns the bridge
+ ## (and the bridge its backend) with no editor in the loop, so the
+ ## env-injection path in server_lifecycle.gd never runs for them.
+ "telemetry_enabled": bool(policy.get("telemetry_enabled", true)),
+ }
+ _launch_context_snapshot_mutex.lock()
+ _endpoint_policy_snapshot = policy.duplicate(true)
+ _launch_context_snapshot = context.duplicate(true)
+ _launch_context_snapshot_mutex.unlock()
+ return context
+
+
+## Validate and persist a Dock settings intent at the existing configurator
+## boundary. Validation completes before the first write, so a bad mixed delta
+## cannot partially change desired launch policy.
+static func apply_endpoint_settings(changes: Dictionary) -> Dictionary:
+ if OS.get_thread_caller_id() != OS.get_main_thread_id():
+ return {"ok": false, "error": "settings writes require the main thread"}
+ var normalized := {}
+ for key in changes:
+ var value: Variant = changes[key]
+ match str(key):
+ "http_port":
+ if not (value is int or value is float):
+ return {"ok": false, "error": "HTTP port must be an integer"}
+ var port := int(value)
+ if float(value) != float(port) or port < MIN_PORT or port > MAX_PORT:
+ return {"ok": false, "error": "HTTP port is outside the supported range"}
+ normalized[McpSettings.SETTING_HTTP_PORT] = port
+ "ws_port":
+ if not (value is int or value is float):
+ return {"ok": false, "error": "WebSocket port must be an integer"}
+ var ws_port_value := int(value)
+ if (
+ float(value) != float(ws_port_value)
+ or ws_port_value < MIN_PORT
+ or ws_port_value > MAX_PORT
+ ):
+ return {"ok": false, "error": "WebSocket port is outside the supported range"}
+ normalized[SETTING_WS_PORT] = ws_port_value
+ "excluded_domains":
+ if not (value is String):
+ return {"ok": false, "error": "excluded domains must be text"}
+ normalized[McpSettings.SETTING_EXCLUDED_DOMAINS] = _canonicalize_excluded_domains(value)
+ "telemetry_enabled":
+ if not (value is bool):
+ return {"ok": false, "error": "telemetry preference must be boolean"}
+ normalized[McpSettings.SETTING_TELEMETRY_ENABLED] = value
+ "allow_hosts":
+ if not (value is String):
+ return {"ok": false, "error": "allow-host policy must be text"}
+ var allowed := McpAllowHosts.normalize(value)
+ if not McpAllowHosts.invalid_tokens(allowed).is_empty():
+ return {"ok": false, "error": "allow-host policy is invalid"}
+ normalized[McpSettings.SETTING_ALLOW_HOSTS] = allowed
+ _:
+ return {"ok": false, "error": "unknown endpoint setting: %s" % key}
+ var next_http := int(normalized.get(McpSettings.SETTING_HTTP_PORT, http_port()))
+ var next_ws := int(normalized.get(SETTING_WS_PORT, ws_port()))
+ var override := v4_endpoint_ports_status()
+ var ports_changed := normalized.has(McpSettings.SETTING_HTTP_PORT) or normalized.has(SETTING_WS_PORT)
+ if bool(override.present) and ports_changed:
+ if next_http < MIN_PORT or next_http > MAX_PORT or next_ws < MIN_PORT or next_ws > MAX_PORT:
+ return {"ok": false, "error": "A complete valid HTTP/WebSocket pair is required to repair the v4 override."}
+ normalized.erase(McpSettings.SETTING_HTTP_PORT)
+ normalized.erase(SETTING_WS_PORT)
+ normalized[SETTING_V4_ENDPOINT_PORTS] = {"http_port": next_http, "ws_port": next_ws}
+ if next_http == next_ws and (ports_changed or not bool(override.present)):
+ return {"ok": false, "error": "HTTP and WebSocket ports must differ"}
+ var es := EditorInterface.get_editor_settings()
+ if es == null:
+ return {"ok": false, "error": "EditorSettings is unavailable"}
+ for key in normalized:
+ es.set_setting(key, normalized[key])
+ return {"ok": true, "changes": normalized.duplicate(true)}
+
+
+## Resolve roots used by cwd-relative client project config tiers while the
+## engine singletons are safe to access. Workers receive this immutable snapshot.
+static func capture_project_roots() -> PackedStringArray:
+ var current_access := DirAccess.open(".")
+ var current_root := "" if current_access == null else current_access.get_current_dir().simplify_path()
+ var project_root := ProjectSettings.globalize_path("res://").simplify_path()
+ # Codex F1: when the user sets `godot_ai/external_client_cwd` we probe that
+ # directory in addition to the two guessed roots. The setter / setter-snapshot
+ # pathway is the same as the existing EditorSettings (warm_env_snapshot
+ # pre-warms this key so worker-thread callers see the snapshot, not the live
+ # EditorInterface). Empty string is filtered out by `_canonicalize_roots_for_test`.
+ var external_cwd := str(_editor_setting_lookup(SETTING_EXTERNAL_CLIENT_CWD))
+ return _canonicalize_roots_for_test(PackedStringArray([current_root, project_root, external_cwd]))
+
+
+## Canonicalize a list of root paths to one filesystem representation per
+## directory and deduplicate. `ProjectSettings.globalize_path` maps any
+## `res://` / `user://` form to the absolute path and is idempotent on an
+## already-absolute path, so a `res://`-form candidate and its absolute twin
+## collapse to the same entry. Without this pass, `_project_candidate_paths`
+## would probe `/.pi/mcp.json` against both representations of the same
+## directory and `manual_target_details` would mis-report a single project
+## override as multiple tiers (codex-review finding F4). Public-ish seam
+## (named `_..._for_test`) so the clients suite can pin the behaviour without
+## having to mock `DirAccess.open(".")`.
+static func _canonicalize_roots_for_test(raws: PackedStringArray) -> PackedStringArray:
+ var seen := {}
+ var roots := PackedStringArray()
+ for raw in raws:
+ var s := str(raw).strip_edges()
+ if s.is_empty():
+ continue
+ var canon := ProjectSettings.globalize_path(s).simplify_path()
+ if canon.is_empty() or seen.has(canon):
+ continue
+ seen[canon] = true
+ roots.append(canon)
+ return roots
+
+
+static func _project_roots_from_context(context: Dictionary) -> PackedStringArray:
+ var roots = context.get("project_roots", PackedStringArray())
+ return roots if roots is PackedStringArray else PackedStringArray()
+
+
+## Read the `godot_ai/allow_remote_hosts` EditorSetting as a canonicalized
+## comma-separated list of CIDRs / bare IPs (#507). Returns "" when the
+## setting is missing or empty — callers skip appending `--allow-host` in
+## that case so spawns stay byte-for-byte identical to the loopback-only
+## default (and compatible with pre-#421 servers). Mirrors
+## `excluded_domains()` above.
+static func allow_hosts() -> String:
+ var es := EditorInterface.get_editor_settings()
+ if es == null or not es.has_setting(McpSettings.SETTING_ALLOW_HOSTS):
+ return ""
+ return McpAllowHosts.normalize(str(es.get_setting(McpSettings.SETTING_ALLOW_HOSTS)))
+
+
+## Suggest a port the caller can actually switch to. Walks
+## `candidate`..`candidate+span-1` and returns the first port that is both
+## (a) NOT inside a Windows winnat reservation range (Hyper-V / WSL2 / Docker
+## grab these; bind fails with WinError 10013 and netstat shows nothing) and
+## (b) actually bindable right now on 127.0.0.1. The bind probe is what makes
+## "free" honest on macOS/Linux, where the reservation table is empty but the
+## next port up may still be occupied — the same suggestion feeds the dock
+## crash body, the port-picker spinbox, and the non-recoverable INCOMPATIBLE
+## log line. Falls back to the clamped candidate if nothing in the window
+## clears both checks (caller surfaces it as a best-effort hint; the user can
+## retry or pick another). Best-effort by nature: a TOCTOU window remains
+## between the probe and the caller actually binding the port. The bind probe
+## is bounded to `SUGGEST_PORT_MAX_PROBES` attempts so this cold path can't
+## stall on a pathological run of occupied ports.
+static func suggest_free_port(start: int, span: int = 2048) -> int:
+ var candidate := clampi(start, MIN_PORT, MAX_PORT - span + 1)
+ var limit := mini(candidate + span - 1, MAX_PORT)
+ var p := candidate
+ var probes := 0
+ while p <= limit and probes < SUGGEST_PORT_MAX_PROBES:
+ ## Jump past a whole Windows-reserved range in one step (no-op on
+ ## POSIX: returns `p` unchanged), so we don't probe port-by-port
+ ## through the large adjacent ranges those services reserve. The
+ ## jump itself runs no bind probes, so it doesn't count against the cap.
+ var not_reserved := WindowsPortReservation.suggest_non_excluded_port(p, limit - p + 1, MAX_PORT)
+ if not_reserved < p or not_reserved > limit:
+ break
+ p = not_reserved
+ probes += 1
+ if PortResolver.can_bind_local_port(p):
+ return p
+ p += 1
+ return candidate
+
+
+# --- Client operations (string id) ---------------------------------------
+
+static func client_ids() -> PackedStringArray:
+ return ClientRegistry.ids()
+
+
+static func has_client(id: String) -> bool:
+ return ClientRegistry.has_id(id)
+
+
+static func client_display_name(id: String) -> String:
+ var c := ClientRegistry.get_by_id(id)
+ return c.display_name if c != null else id
+
+
+## Pass an explicit `url` when calling from a worker thread: `http_url()`
+## reads `EditorInterface.get_editor_settings()`, which is main-thread-only.
+## Empty defaults to the live server URL — appropriate for MCP-tool callers
+## that always run on main.
+static func configure(id: String, url: String = "", launch_context: Dictionary = {}) -> Dictionary:
+ if ClientRegistry.stale_session_detected():
+ return {"status": "error", "message": ClientRegistry.RESTART_TO_FINISH_UPDATE}
+ var client := ClientRegistry.get_by_id(id)
+ if client == null:
+ return {"status": "error", "message": "Unknown client: %s" % id}
+ if not client.automatic_config_edits:
+ return _manual_edit_result(client, "configure")
+ var path_error := _config_path_resolution_error(client)
+ if not path_error.is_empty():
+ return {"status": "error", "message": path_error}
+ ## Capture `url` once so a port flip in EditorSettings between write and
+ ## verify can't trigger a spurious CONFIGURED_MISMATCH against an entry
+ ## that just landed correctly.
+ if url.is_empty():
+ url = http_url()
+ var context := launch_context
+ if client.command_shape != Client.CommandShape.NONE and context.is_empty():
+ if OS.get_thread_caller_id() != OS.get_main_thread_id():
+ return {
+ "status": "error",
+ "message": "Cannot configure %s without a main-thread launch snapshot; retry from the dock." % client.display_name,
+ }
+ context = capture_launch_context()
+ var launch := (
+ resolve_attach_launch(context)
+ if client.command_shape != Client.CommandShape.NONE
+ else {}
+ )
+ var claim := MutationLock.acquire(client.id, "configure")
+ if not bool(claim.get("ok", false)):
+ return {
+ "status": "error",
+ "message": str(claim.get("error", MutationLock.recovery_message())),
+ }
+ var result := _dispatch_configure(client, url, launch, context)
+ ## Trust-but-verify: a strategy may report ok and have actually written the
+ ## file, yet the entry is missing/stale on the read-back path — most often
+ ## because the user's installed client is reading a different file than
+ ## `path_template` resolves to (issue #201). Re-read the live state and
+ ## surface a clear error before the dock reports a bogus green dot.
+ var verified := _verify_post_state(
+ client, result, Client.Status.CONFIGURED, url, "configure", launch, context
+ )
+ return _finish_client_mutation(claim, verified)
+
+
+static func check_status(id: String) -> Client.Status:
+ if ClientRegistry.stale_session_detected():
+ return Client.Status.ERROR
+ var client := ClientRegistry.get_by_id(id)
+ if client == null:
+ return Client.Status.NOT_CONFIGURED
+ var context := capture_launch_context() if client.command_shape != Client.CommandShape.NONE else {}
+ return _dispatch_check_status(client, http_url(), context)
+
+
+static func check_status_for_url_with_cli_path(
+ id: String, url: String, cli_path: String, launch_context: Dictionary = {}
+) -> Client.Status:
+ return check_status_details_for_url_with_cli_path(id, url, cli_path, launch_context).get("status", Client.Status.NOT_CONFIGURED)
+
+
+## True when 's stored entry verifies EXACTLY against the launch this
+## editor would have rendered at `from_version` — same ports, exclusions,
+## telemetry flag, command shape; nothing differs but the version pin. This
+## is the post-update auto-repin gate: only such entries are provably "what
+## Configure wrote before the update", so rewriting them to the current
+## version restores the user's own prior intent. Anything else — entries
+## pointing at another editor's ports (the smoke-fixture blast radius that
+## motivated this gate), hand-edits, changed settings — stays untouched for
+## the drift banner's human click. An empty `from_version` (marker written
+## by a pre-gate runner) fails closed.
+##
+## The version-substituted context is safe to resolve and cache:
+## `plugin_version` is part of both the attach-launch resolution
+## (`_resolve_attach_launch_uncached`) and its cache key.
+static func entry_drift_is_version_pin_only(
+ id: String, from_version: String, launch_context: Dictionary = {}
+) -> bool:
+ var pinned_from := from_version.strip_edges()
+ if pinned_from.is_empty():
+ return false
+ ## Never shell out from this gate — it runs on the MAIN thread from the
+ ## dock's sweep-completion callback (#890 review P2). The cli-descriptor
+ ## probe paths run a subprocess with a multi-second timeout (`claude mcp
+ ## get` is 6s), which would freeze the editor; those clients fail closed
+ ## to the drift banner's click flow, whose fan-out already runs on
+ ## worker threads. Every path left after these guards is a bounded
+ ## config-file read plus cached launch discovery (worst case one CLI
+ ## lookup on a cold CliFinder cache — the same bounded one-shot the
+ ## dock's `_reprobe_uv_if_negative` accepts on this thread).
+ var client := ClientRegistry.get_by_id(id)
+ if client == null:
+ return false
+ if client.config_type == "cli":
+ if client.command_shape == Client.CommandShape.NONE:
+ return false
+ if not client.has_json_fallback():
+ return false
+ if _scope_diverges_from_json_fallback(client):
+ return false
+ var context := launch_context if not launch_context.is_empty() else capture_launch_context()
+ var old_context := context.duplicate(true)
+ old_context["plugin_version"] = pinned_from
+ ## Same URL under both versions: the URL carries the port, not the
+ ## version, so URL-mode entries can never be version-pin-only drift and
+ ## correctly fail this check.
+ var url := str(context.get("server_url", http_url()))
+ var status := check_status_for_url_with_cli_path(id, url, "", old_context)
+ return status == Client.Status.CONFIGURED
+
+
+## Detailed variant used by the dock refresh worker. Returns
+## `{"status": Status, "error_msg": String}` so the worker can surface
+## "probe timed out" on the row instead of silently flipping it to
+## NOT_CONFIGURED. Callers that only need the status can use the simpler
+## helper above.
+static func check_status_details_for_url_with_cli_path(
+ id: String,
+ url: String,
+ cli_path: String,
+ launch_context: Dictionary = {},
+ resolved_launch: Dictionary = {},
+) -> Dictionary:
+ ## One comprehensible line per row beats a wall of per-field type errors —
+ ## the dock keeps painting, every row names the same repair (restart).
+ if ClientRegistry.stale_session_detected():
+ return {"status": Client.Status.ERROR, "error_msg": ClientRegistry.RESTART_TO_FINISH_UPDATE}
+ var client := ClientRegistry.get_by_id(id)
+ if client == null:
+ return {"status": Client.Status.NOT_CONFIGURED, "error_msg": ""}
+ # A cli client with no resolved binary normally reads as NOT_CONFIGURED.
+ # Skip that shortcut when the client has a JSON fallback (#463): the
+ # dispatch below reads its config file directly so the status dot reflects
+ # a fallback-configured entry instead of always showing red.
+ if client.config_type == "cli" and cli_path.is_empty() and not client.has_json_fallback():
+ return {"status": Client.Status.NOT_CONFIGURED, "error_msg": ""}
+ var path_error := _config_path_resolution_error(client)
+ if not path_error.is_empty():
+ return {"status": Client.Status.ERROR, "error_msg": path_error}
+ if client.command_shape != Client.CommandShape.NONE and launch_context.is_empty():
+ return {
+ "status": Client.Status.ERROR,
+ "error_msg": "Missing launch-context snapshot; retry the status refresh.",
+ }
+ return _dispatch_check_status_with_cli_path_details(
+ client, url, cli_path, launch_context, resolved_launch
+ )
+
+
+## #691: main-thread pre-warm of McpPathTemplate's env snapshot, covering
+## the base vars plus every descriptor-declared config-file/config-home env
+## (OPENCODE_CONFIG, CLAUDE_CONFIG_DIR, CODEX_HOME, …), so worker-thread config-path
+## resolution never calls OS.get_environment concurrently with the spawn
+## window's setenv/unsetenv. Also warms the EditorSettings snapshot for
+## the mode/trace overrides so worker-thread mode_override() /
+## startup_trace_enabled() never touch EditorInterface. Idempotent;
+## called from plugin _enter_tree and before each dock worker dispatch.
+static func warm_env_snapshot(endpoint_policy: Dictionary = {}) -> void:
+ var extras := PackedStringArray()
+ for id in client_ids():
+ var client := ClientRegistry.get_by_id(String(id))
+ if client == null:
+ continue
+ ## Reflected get(): after an in-session self-update these fields can
+ ## read as Nil on stale instances, and String(Nil) is a hard error (#850). Skipping just degrades env-override
+ ## resolution until the restart the registry is already asking for.
+ for env_name in [client.get("config_file_env"), client.get("config_home_env")]:
+ if env_name is String and not env_name.is_empty() and not extras.has(env_name):
+ extras.append(env_name)
+ McpPathTemplate.warm_env_snapshot(extras)
+ UvResolution.warm_environment()
+ _editor_setting_lookup(MODE_OVERRIDE_SETTING)
+ _editor_setting_lookup(SETTING_STARTUP_TRACE)
+ _editor_setting_lookup(SETTING_KEEP_SERVER_ON_EXIT)
+ _editor_setting_lookup(SETTING_EXTERNAL_CLIENT_CWD)
+ McpSettings.warm_client_scope()
+ # Publish the complete launch context while EditorInterface access is safe;
+ # worker callers of capture_launch_context() read this snapshot only.
+ capture_launch_context(endpoint_policy)
+
+
+static func client_status_probe_snapshot(id: String) -> Dictionary:
+ var client := ClientRegistry.get_by_id(id)
+ if client == null:
+ return {}
+ var cli_path := ""
+ var installed := false
+ if client.config_type == "cli":
+ cli_path = CliStrategy.resolve_cli_path(client)
+ # #463: a JSON-fallback cli client (Claude Code as a VS Code extension)
+ # is "installed" when its fallback config exists, even with no binary.
+ installed = not cli_path.is_empty() or client.is_installed()
+ else:
+ installed = client.is_installed()
+ return {
+ "id": id,
+ "cli_path": cli_path,
+ "installed": installed,
+ "automatic_config_edits": client.automatic_config_edits,
+ }
+
+
+## Pure response projection shared by the one client-work owner and tests.
+## Missing rows are explicit errors, never silently reported as unconfigured.
+static func client_status_response(results: Dictionary) -> Dictionary:
+ var clients := []
+ for client_id in client_ids():
+ var details: Dictionary = results.get(client_id, {
+ "status": Client.Status.ERROR,
+ "installed": false,
+ "error_msg": "Client status probe returned no result.",
+ })
+ clients.append(_client_status_sweep_entry(
+ client_id, details, bool(details.get("installed", false))
+ ))
+ return {"data": {"clients": clients}}
+
+
+static func _client_status_sweep_entry(
+ client_id: String, details: Dictionary, installed: bool
+) -> Dictionary:
+ var status = details.get("status", Client.Status.NOT_CONFIGURED)
+ var entry := {
+ "id": client_id,
+ "display_name": client_display_name(client_id),
+ "status": Client.status_label(status),
+ "installed": installed,
+ }
+ var error_msg := str(details.get("error_msg", ""))
+ if not error_msg.is_empty():
+ entry["error"] = error_msg
+ return entry
+
+
+## Pass an explicit `url` when calling from a worker thread — see
+## `configure()` above for why. The url is only used to format the
+## verify-after-write diagnostic message; the remove itself doesn't need it.
+static func remove(id: String, url: String = "", launch_context: Dictionary = {}) -> Dictionary:
+ if ClientRegistry.stale_session_detected():
+ return {"status": "error", "message": ClientRegistry.RESTART_TO_FINISH_UPDATE}
+ var client := ClientRegistry.get_by_id(id)
+ if client == null:
+ return {"status": "error", "message": "Unknown client: %s" % id}
+ if not client.automatic_config_edits:
+ return _manual_edit_result(client, "remove")
+ var path_error := _config_path_resolution_error(client)
+ if not path_error.is_empty():
+ return {"status": "error", "message": path_error}
+ if url.is_empty():
+ url = http_url()
+ var context := launch_context
+ if client.command_shape != Client.CommandShape.NONE and context.is_empty():
+ if OS.get_thread_caller_id() != OS.get_main_thread_id():
+ return {
+ "status": "error",
+ "message": "Cannot remove %s without a main-thread launch snapshot; retry from the dock." % client.display_name,
+ }
+ context = capture_launch_context()
+ var launch := (
+ resolve_attach_launch(context)
+ if client.command_shape != Client.CommandShape.NONE
+ else {}
+ )
+ var claim := MutationLock.acquire(client.id, "remove")
+ if not bool(claim.get("ok", false)):
+ return {
+ "status": "error",
+ "message": str(claim.get("error", MutationLock.recovery_message())),
+ }
+ var result := _dispatch_remove(client, context)
+ var verified := _verify_post_state(
+ client, result, Client.Status.NOT_CONFIGURED, url, "remove", launch, context
+ )
+ return _finish_client_mutation(claim, verified)
+
+
+## Resolve config-backed path errors before attach-launch discovery. This both
+## gives ambiguity precedence over unrelated launcher failures and avoids
+## spending the status worker's command budget on a Configure action that must
+## fail closed regardless. CLI clients keep their existing CLI/fallback dispatch.
+static func _config_path_resolution_error(client: Client) -> String:
+ if client.config_type == "cli":
+ return ""
+ return str(client.resolved_config_path_details().get("error", ""))
+
+
+# --- Strategy dispatch + verify (testable seam) --------------------------
+
+static func _manual_edit_result(client: Client, action: String) -> Dictionary:
+ return {
+ "status": "error",
+ "message": (
+ "%s settings require a manual edit; godot-ai will not %s this file automatically. "
+ + "Use the configuration shown below."
+ ) % [client.display_name, action],
+ }
+
+
+## One global claim covers the mutation and its read-back proof for every
+## automatic strategy. Ambiguous CLI termination deliberately strands it;
+## every other outcome releases the exact token or fails closed.
+static func _finish_client_mutation(claim: Dictionary, result: Dictionary) -> Dictionary:
+ if bool(result.get("termination_failed", false)):
+ return result
+ if MutationLock.release(claim):
+ return result
+ return {
+ "status": "error",
+ "message": MutationLock.recovery_message(),
+ "mutation_lock_release_failed": true,
+ }
+
+static func _dispatch_configure(
+ client: Client, url: String, launch: Dictionary = {}, launch_context: Dictionary = {}
+) -> Dictionary:
+ launch = launch_for_client(client, launch)
+ match client.config_type:
+ "json":
+ return JsonStrategy.configure(client, SERVER_NAME, url, launch, _project_roots_from_context(launch_context))
+ "toml":
+ return TomlStrategy.configure(client, SERVER_NAME, url, launch)
+ "yaml":
+ return YamlStrategy.configure(client, SERVER_NAME, url, launch)
+ "dsh":
+ return DshStrategy.configure(client, SERVER_NAME, url, launch)
+ "cli":
+ # #463: fall back to writing the config file directly when the CLI
+ # binary isn't on PATH (Claude Code as a VS Code/Cursor extension).
+ if client.has_json_fallback() and CliStrategy.resolve_cli_path(client).is_empty():
+ return _note_unhonoured_scope(
+ client, JsonStrategy.configure(client, SERVER_NAME, url, launch, _project_roots_from_context(launch_context))
+ )
+ return CliStrategy.configure(client, SERVER_NAME, url, launch)
+ return {"status": "error", "message": "Unknown config_type for %s: %s" % [client.id, client.config_type]}
+
+
+static func _dispatch_remove(client: Client, launch_context: Dictionary = {}) -> Dictionary:
+ match client.config_type:
+ "json":
+ return JsonStrategy.remove(client, SERVER_NAME, _project_roots_from_context(launch_context))
+ "toml":
+ return TomlStrategy.remove(client, SERVER_NAME)
+ "yaml":
+ return YamlStrategy.remove(client, SERVER_NAME)
+ "dsh":
+ return DshStrategy.remove(client, SERVER_NAME)
+ "cli":
+ # #463: mirror the configure fallback so Remove also works without
+ # the CLI binary — otherwise a fallback-written entry is unremovable.
+ if client.has_json_fallback() and CliStrategy.resolve_cli_path(client).is_empty():
+ return JsonStrategy.remove(client, SERVER_NAME, _project_roots_from_context(launch_context))
+ return CliStrategy.remove(client, SERVER_NAME)
+ return {"status": "error", "message": "Unknown config_type for %s: %s" % [client.id, client.config_type]}
+
+
+static func _dispatch_check_status(
+ client: Client, url: String, launch_context: Dictionary = {}
+) -> Client.Status:
+ return _dispatch_check_status_with_cli_path(client, url, "", launch_context)
+
+
+static func _dispatch_check_status_with_cli_path(
+ client: Client, url: String, cli_path: String, launch_context: Dictionary = {}
+) -> Client.Status:
+ return _dispatch_check_status_with_cli_path_details(client, url, cli_path, launch_context).get("status", Client.Status.NOT_CONFIGURED)
+
+
+static func _dispatch_check_status_with_cli_path_details(
+ client: Client,
+ url: String,
+ cli_path: String,
+ launch_context: Dictionary = {},
+ resolved_launch: Dictionary = {},
+) -> Dictionary:
+ match client.config_type:
+ "json":
+ var launch := {}
+ if client.command_shape != Client.CommandShape.NONE:
+ launch = _resolved_or_discovered_launch(client, resolved_launch, launch_context)
+ return JsonStrategy.check_status_details(client, SERVER_NAME, url, launch, _project_roots_from_context(launch_context))
+ "toml":
+ var launch := {}
+ if client.command_shape != Client.CommandShape.NONE:
+ launch = _resolved_or_discovered_launch(client, resolved_launch, launch_context)
+ return TomlStrategy.check_status_details(client, SERVER_NAME, url, launch)
+ "yaml":
+ var yaml_launch := {}
+ if client.command_shape != Client.CommandShape.NONE:
+ yaml_launch = _resolved_or_discovered_launch(client, resolved_launch, launch_context)
+ return YamlStrategy.check_status_details(client, SERVER_NAME, url, yaml_launch)
+ "dsh":
+ var dsh_launch := {}
+ if client.command_shape != Client.CommandShape.NONE:
+ dsh_launch = _resolved_or_discovered_launch(client, resolved_launch, launch_context)
+ return DshStrategy.check_status_details(client, SERVER_NAME, url, dsh_launch)
+ "cli":
+ # Command-shape CLI clients register through their CLI, but the entry
+ # lands in the same file the JSON fallback reads (`claude mcp add
+ # --scope user` writes mcpServers in ~/.claude.json). Reading that
+ # file gives exact launch-drift detection — a changed port, version
+ # pin, or exclusion list — which scanning `mcp list` stdout cannot,
+ # so it is preferred even when the CLI binary resolves.
+ # #872: ...but only while the selected scope is still the one
+ # `path_template` points at — see _scope_diverges_from_json_fallback.
+ if (
+ client.command_shape != Client.CommandShape.NONE
+ and client.has_json_fallback()
+ and not _scope_diverges_from_json_fallback(client)
+ ):
+ var command_launch := _resolved_or_discovered_launch(client, resolved_launch, launch_context)
+ return JsonStrategy.check_status_details(client, SERVER_NAME, url, command_launch, _project_roots_from_context(launch_context))
+ var resolved_cli := cli_path if not cli_path.is_empty() else CliStrategy.resolve_cli_path(client)
+ # #463: with no CLI binary, read the JSON fallback config so a
+ # fallback-configured entry reports CONFIGURED instead of red.
+ if resolved_cli.is_empty() and client.has_json_fallback():
+ var fallback_launch := {}
+ if client.command_shape != Client.CommandShape.NONE:
+ fallback_launch = _resolved_or_discovered_launch(client, resolved_launch, launch_context)
+ return JsonStrategy.check_status_details(client, SERVER_NAME, url, fallback_launch, _project_roots_from_context(launch_context))
+ var cli_launch := {}
+ if client.command_shape != Client.CommandShape.NONE:
+ cli_launch = _resolved_or_discovered_launch(client, resolved_launch, launch_context)
+ # #872: at a scope `path_template` can't see, a plain listing probe
+ # cannot tell our entry from a leftover one in another scope. When
+ # the descriptor offers a scope-aware probe, use it — same single
+ # subprocess, but it reports which scope actually resolved.
+ if (
+ not client.cli_scope_status_template.is_empty()
+ and CliStrategy.uses_scope_token(client)
+ ):
+ return CliStrategy.check_scope_status_details(
+ client, SERVER_NAME, url, resolved_cli, cli_launch, McpSettings.client_scope()
+ )
+ return CliStrategy.check_status_details(client, SERVER_NAME, url, resolved_cli, cli_launch)
+ return {"status": Client.Status.NOT_CONFIGURED, "error_msg": ""}
+
+
+## #872: the #463 JSON fallback writes the user-scope file whatever
+## `godot_ai/mcp_client_scope` says — that file IS `path_template`, and
+## `clients/_path_template.gd` has no project-relative token to aim it
+## elsewhere. Returning a bare "configured" would hide that the requested
+## scope was not honoured, so say so in the message.
+##
+## Deliberately a message and not a status. `_verify_post_state` compares the
+## post-write status against CONFIGURED and replaces the ok with an error on
+## any mismatch (pinned by test_verify_post_state_treats_drift_as_failure_
+## after_configure), so reporting this write as NOT_CONFIGURED or MISMATCH
+## would turn a *successful* configure into a user-facing failure — the same
+## false-error class #872's blocker was about, just moved onto the fallback
+## path. The entry is genuinely written and genuinely works; only its scope
+## differs from the request, and that is a caveat, not a failure.
+static func _note_unhonoured_scope(client: Client, result: Dictionary) -> Dictionary:
+ if result.get("status") != "ok":
+ return result
+ if not CliStrategy.uses_scope_token(client):
+ return result
+ var scope := McpSettings.client_scope()
+ if scope == McpSettings.DEFAULT_CLIENT_SCOPE:
+ return result
+ var noted := result.duplicate(true)
+ noted["message"] = (
+ "%s — wrote %s scope, not %s: the %s CLI wasn't found, and the file fallback can only write %s"
+ % [
+ str(result.get("message", "")),
+ McpSettings.DEFAULT_CLIENT_SCOPE,
+ scope,
+ client.display_name,
+ client.resolved_config_path(),
+ ]
+ )
+ return noted
+
+
+## True when this client's CLI writes its entry somewhere `path_template`
+## cannot see, so reading that file would describe the wrong thing (#872).
+##
+## `claude mcp add --scope project` writes /.mcp.json and `--scope local`
+## writes a per-project block inside ~/.claude.json — neither is the top-level
+## `mcpServers` map `path_template` + `server_key_path` resolve to. Reading it
+## anyway makes `_verify_post_state` turn every successful project-scope
+## Configure into "configure ok but verification still reads Not configured",
+## and pins the dock row red (or green, describing a stale user-scope entry).
+##
+## Diverging sends status down the CLI probe instead: `mcp list` is
+## scope-agnostic and inherits the same cwd the register ran in, so read-back
+## and write agree by construction. The cost is losing exact launch-drift
+## detection — a stdout scan sees the command, not the full argv — which is the
+## documented trade for a status that is merely coarse instead of wrong.
+##
+## Deliberately says nothing about whether the CLI resolves (#879). An earlier
+## version walked PATH here and returned false for an unresolvable binary, on
+## the reasoning that configure would then have taken the #463 JSON fallback —
+## which writes user scope whatever the setting says — so the file was the
+## right thing to read again. That extra clause could not change any outcome:
+## this is only consulted from the one call site below, already guarded on
+## `has_json_fallback()`, and when the CLI does not resolve the very next
+## branch (`resolved_cli.is_empty() and client.has_json_fallback()`) takes the
+## fallback read anyway. All it bought was a second full `McpCliFinder.find`
+## sweep per status check, on top of the one that immediately follows.
+static func _scope_diverges_from_json_fallback(client: Client) -> bool:
+ if not CliStrategy.uses_scope_token(client):
+ return false
+ return McpSettings.client_scope() != McpSettings.DEFAULT_CLIENT_SCOPE
+
+
+static func _resolved_or_discovered_launch(
+ client: Client, resolved_launch: Dictionary, launch_context: Dictionary
+) -> Dictionary:
+ var launch := (
+ resolved_launch
+ if not resolved_launch.is_empty()
+ else resolve_attach_launch(launch_context)
+ )
+ return launch_for_client(client, launch)
+
+
+## After a configure/remove returns ok, re-read the live status. If it doesn't
+## match `expected`, replace the result with an error that names the actual
+## status and the resolved config path so the user can self-diagnose. The
+## strategy's own error path is left untouched — already actionable.
+static func _verify_post_state(
+ client: Client,
+ result: Dictionary,
+ expected: Client.Status,
+ url: String,
+ action: String,
+ resolved_launch: Dictionary = {},
+ launch_context: Dictionary = {},
+) -> Dictionary:
+ if result.get("status") != "ok":
+ return result
+ var details := _dispatch_check_status_with_cli_path_details(
+ client, url, "", launch_context, resolved_launch
+ )
+ var actual := details.get("status", Client.Status.NOT_CONFIGURED)
+ if actual == expected:
+ return result
+ var path_hint := _post_state_path_hint(client, str(details.get("resolved_scope", "")))
+ return {
+ "status": "error",
+ "message": "%s reported %s ok but verification still reads %s (expected %s).%s" % [
+ client.display_name, action,
+ Client.status_label(actual), Client.status_label(expected),
+ path_hint,
+ ],
+ }
+
+
+## Where to send the user when verification finds an entry that should not be
+## there. `resolved_config_path()` is `path_template` — right only for the
+## default user scope. Naming ~/.claude.json for a project-scope survivor sends
+## them to a file with nothing in it, which is worse than naming no file at all
+## (#879). The scope probe supplies `resolved_scope`; `path_template` has no
+## project-relative token (see `_note_unhonoured_scope`), so the project case is
+## described rather than resolved to a path.
+static func _post_state_path_hint(client: Client, resolved_scope: String) -> String:
+ match resolved_scope:
+ "project":
+ return (
+ " The surviving entry is project-scoped: inspect the .mcp.json in the"
+ + " directory the editor was launched from and remove the godot-ai entry"
+ + " by hand if needed."
+ )
+ "local":
+ var local_path := client.resolved_config_path()
+ if local_path.is_empty():
+ return ""
+ return (
+ " The surviving entry is local-scoped: inspect the block for the"
+ + " directory the editor was launched from in %s and remove the"
+ + " godot-ai entry by hand if needed."
+ ) % local_path
+ _:
+ var path := client.resolved_config_path()
+ if path.is_empty():
+ return ""
+ return " Inspect %s and remove the godot-ai entry by hand if needed." % path
+
+
+## #877: Configure's first act is the all-scope pre-cleanup — it deletes the
+## `godot-ai` entry from every scope the descriptor can write to, including a
+## `.mcp.json` resolved against the CLI's working directory, which need not be
+## this project's folder. The manual-command panel that spells those removes
+## out is only shown when Configure FAILS (`mcp_dock.gd`), so on the success
+## path — the one where the sweep actually ran — this note is its only
+## disclosure. Empty for descriptors without a scope token: their single
+## implicit pass removes exactly the entry the register is about to rewrite,
+## which needs no warning (the same rule `_sweep_caveat` applies).
+##
+## "attempted", not "cleared": `CliStrategy.configure` discards every
+## pre-cleanup result, and `mcp remove` exits non-zero for an absent entry as
+## well as for a real failure, so a timed-out or failed remove leaves the scope
+## untouched while the register still returns ok. The note names what ran,
+## not what it can prove.
+static func configure_sweep_note(id: String) -> String:
+ var client := ClientRegistry.get_by_id(id)
+ if client == null or client.config_type != "cli":
+ return ""
+ if client.cli_unregister_template.is_empty() or not CliStrategy.uses_scope_token(client):
+ return ""
+ return "attempted to clear %s from %s" % [
+ SERVER_NAME, ", ".join(CliStrategy.cleanup_scopes(client)),
+ ]
+
+
+static func manual_command(id: String) -> String:
+ var client := ClientRegistry.get_by_id(id)
+ if client == null:
+ return ""
+ var path_resolution := client.resolved_config_path_details()
+ var path_error := str(path_resolution.get("error", ""))
+ if not path_error.is_empty():
+ return "Config path unavailable: %s" % path_error
+ var context := capture_launch_context() if client.command_shape != Client.CommandShape.NONE else {}
+ var launch := (
+ launch_for_client(client, resolve_attach_launch(context))
+ if client.command_shape != Client.CommandShape.NONE
+ else {}
+ )
+ var cmd := ManualCommand.build(
+ client,
+ SERVER_NAME,
+ server_url_from(context),
+ str(path_resolution.get("path", "")),
+ launch,
+ _project_roots_from_context(context),
+ )
+ if cmd.is_empty():
+ return cmd
+ ## #507: when the allow-host opt-in names a non-loopback range, also
+ ## surface the LAN URL so the user can copy-paste the right address into
+ ## a remote agent. Informational only — configure/remove still WRITE the
+ ## loopback URL above; nothing about the config-file contract changes.
+ var note := McpAllowHosts.lan_url_note(allow_hosts(), IP.get_local_addresses(), http_port())
+ if not note.is_empty():
+ cmd += "\n\n" + note
+ return cmd
+
+
+static func config_path(id: String) -> String:
+ var client := ClientRegistry.get_by_id(id)
+ return client.resolved_config_path() if client != null else ""
+
+
+## Resolve the highest-precedence config tier that actually contains the server
+## entry. For Pi-style merge clients, returns the tier path the entry lives in
+## (e.g. `~/.pi/agent/.mcp.json`) instead of the lowest-tier `path_template`
+## `config_path()` returns. Falls back to `path_template` when no entry is
+## found anywhere. Empty `launch_context` is allowed; command-shape clients
+## synthesize one via `capture_launch_context()` so dock row construction can
+## call this without first capturing launch state.
+static func effective_config_path(id: String, launch_context: Dictionary = {}) -> String:
+ var client := ClientRegistry.get_by_id(id)
+ if client == null:
+ return ""
+ var resolution := client.resolved_config_path_details()
+ var fallback := str(resolution.get("path", ""))
+ var context := launch_context
+ if context.is_empty() and client.command_shape != Client.CommandShape.NONE:
+ context = capture_launch_context()
+ var roots := _project_roots_from_context(context)
+ var target := JsonStrategy.manual_target_details(client, SERVER_NAME, fallback, roots)
+ if bool(target.get("ok", false)):
+ return str(target.get("path", fallback))
+ return fallback
+
+
+## Path that `_check_status_merged` would consider authoritative for the
+## server entry, or `path_template` when no entry is found anywhere.
+## For Pi-style merge clients this is the latest project tier when one
+## exists (matching the F2 last-wins status logic), falling back to the
+## latest global tier, then to `path_template`.
+##
+## The dock uses this for Open/Reveal so the file the user inspects is
+## the same file that drives the status check — `effective_config_path`
+## fails closed (returns `path_template`) when there are multiple project
+## tiers, which historically sent users to the wrong file (codex round 3,
+## F-3-4). Splitting this from `effective_config_path` keeps the manual
+## instructions fail-closed while letting the dock follow the
+## authoritative tier.
+static func effective_authoritative_path(id: String, launch_context: Dictionary = {}) -> String:
+ var client := ClientRegistry.get_by_id(id)
+ if client == null:
+ return ""
+ var resolution := client.resolved_config_path_details()
+ var fallback := str(resolution.get("path", ""))
+ var context := launch_context
+ if context.is_empty() and client.command_shape != Client.CommandShape.NONE:
+ context = capture_launch_context()
+ var roots := _project_roots_from_context(context)
+ var authoritative: String = JsonStrategy.authoritative_tier_path(client, SERVER_NAME, roots)
+ if not authoritative.is_empty():
+ return authoritative
+ return fallback
+
+
+static func is_installed(id: String) -> bool:
+ var client := ClientRegistry.get_by_id(id)
+ return client != null and client.is_installed()
+
+
+# --- Server command discovery --------------------------------------------
+#
+# Three-tier resolution:
+# 1. .venv python — dev checkout, source code
+# 2. uvx — user install, published package from PyPI
+# 3. godot-ai CLI — system-wide pip/pipx/uv install
+
+static func get_plugin_version() -> String:
+ var cfg := ConfigFile.new()
+ if cfg.load("res://addons/godot_ai/plugin.cfg") == OK:
+ return cfg.get_value("plugin", "version", "0.0.1")
+ return "0.0.1"
+
+
+## Strip PEP 440 local build metadata for PyPI pins: `3.0.2+local.1` → `3.0.2`.
+## Pre-release segments (`3.1.0-rc1`) are preserved — only `+…` is removed.
+static func _pypi_pin_version(version: String) -> String:
+ var v := version.strip_edges()
+ var plus := v.find("+")
+ if plus >= 0:
+ v = v.substr(0, plus)
+ return v
+
+
+## Resolve the client-owned `godot-ai attach` command from a main-thread
+## LaunchContext. Discovery itself is worker-safe: path/environment lookup is
+## snapshot-backed and subprocess probes are wall-clock bounded.
+##
+## `discovery_override` is a data-only test seam. Supplying a key (including
+## an empty value) bypasses that tier's live lookup; `system_version_result`
+## bypasses the real `godot-ai --version` subprocess.
+static func resolve_attach_launch(
+ launch_context: Dictionary, discovery_override: Dictionary = {}
+) -> Dictionary:
+ ## Test overrides always bypass the session cache so fixture-controlled
+ ## discovery remains deterministic. Production results are keyed by every
+ ## setting that affects the rendered command; a port/domain/version change
+ ## therefore cannot reuse stale arguments.
+ if not discovery_override.is_empty():
+ return _resolve_attach_launch_uncached(launch_context, discovery_override)
+ var cache_key := _attach_launch_cache_key(launch_context)
+ _attach_launch_cache_mutex.lock()
+ if _attach_launch_cache.has(cache_key):
+ var cached: Dictionary = _attach_launch_cache[cache_key].duplicate(true)
+ _attach_launch_cache_mutex.unlock()
+ return cached
+ ## Keep the cache lock through the bounded discovery probes. This cold path
+ ## runs at most once per distinct context and prevents simultaneous status
+ ## workers from repeating the same subprocess probes. Invalidation waits for
+ ## the in-flight result, then clears it, so stale work cannot repopulate a
+ ## freshly invalidated cache.
+ var resolved := _resolve_attach_launch_uncached(launch_context)
+ _attach_launch_cache[cache_key] = resolved.duplicate(true)
+ _attach_launch_cache_mutex.unlock()
+ return resolved
+
+
+static func _resolve_attach_launch_uncached(
+ launch_context: Dictionary, discovery_override: Dictionary = {}
+) -> Dictionary:
+ for key in ["http_port", "ws_port", "excluded_domains", "plugin_version", "allow_dev_venv", "platform"]:
+ if not launch_context.has(key):
+ return _attach_discovery_error("Launch context is missing `%s`; retry Configure." % key)
+
+ var plugin_version := str(launch_context.get("plugin_version", "")).strip_edges()
+ if plugin_version.is_empty():
+ return _attach_discovery_error("The bundled godot-ai version is unavailable; reinstall the plugin and retry Configure.")
+
+ var common_args: Array[String] = [
+ "attach",
+ "--port", str(int(launch_context.get("http_port", DEFAULT_HTTP_PORT))),
+ "--ws-port", str(int(launch_context.get("ws_port", DEFAULT_WS_PORT))),
+ ]
+ var exclusions := str(launch_context.get("excluded_domains", "")).strip_edges()
+ if not exclusions.is_empty():
+ common_args.append_array(["--exclude-domains", exclusions])
+ ## Default true when the key is absent (hand-built contexts in tests, stale
+ ## pre-upgrade snapshots) — matching the server's send-by-default posture.
+ ## Toggling the setting changes the rendered argv, so existing entries read
+ ## CONFIGURED_MISMATCH and the dock offers Reconfigure, like any other
+ ## launch-affecting value.
+ if not bool(launch_context.get("telemetry_enabled", true)):
+ common_args.append("--disable-telemetry")
+
+ var venv_python := ""
+ if discovery_override.has("venv_python"):
+ venv_python = str(discovery_override["venv_python"])
+ elif bool(launch_context.get("allow_dev_venv", true)):
+ venv_python = _cached_venv_python()
+ if bool(launch_context.get("allow_dev_venv", true)) and not venv_python.is_empty():
+ var venv_args: Array[String] = ["-m", "godot_ai"]
+ venv_args.append_array(common_args)
+ return _finalize_attach_launch(
+ "dev_venv", venv_python, venv_args, launch_context, discovery_override
+ )
+
+ var uvx := ""
+ if discovery_override.has("uvx_path"):
+ uvx = str(discovery_override["uvx_path"])
+ else:
+ ## Strict lookup for attach entries: never write a bare `uvx` command
+ ## that a GUI-launched client may be unable to resolve from its PATH.
+ uvx = find_uvx()
+ if not uvx.is_empty():
+ var uvx_args := UvResolution.args()
+ uvx_args.append_array([
+ "--link-mode", "copy",
+ "--from", "godot-ai==%s" % _pypi_pin_version(plugin_version),
+ "godot-ai",
+ ])
+ uvx_args.append_array(common_args)
+ return _finalize_attach_launch(
+ "uvx", uvx, uvx_args, launch_context, discovery_override
+ )
+
+ var system_cmd := ""
+ if discovery_override.has("system_path"):
+ system_cmd = str(discovery_override["system_path"])
+ else:
+ system_cmd = _find_system_install()
+ if not system_cmd.is_empty():
+ var probe: Dictionary
+ if discovery_override.has("system_version_result"):
+ probe = discovery_override["system_version_result"] as Dictionary
+ else:
+ probe = McpCliExec.run(system_cmd, ["--version"], _DISCOVERY_TIMEOUT_MS, false)
+ var version_check := _system_version_from_probe(probe)
+ if bool(version_check.get("ok", false)):
+ var found_version := str(version_check.get("version", ""))
+ if found_version == plugin_version:
+ return _finalize_attach_launch(
+ "system", system_cmd, common_args, launch_context, discovery_override
+ )
+ return _attach_discovery_error(
+ "System godot-ai is version %s, but this plugin requires %s. Install uv or update the system package, then retry Configure."
+ % [found_version, plugin_version]
+ )
+ if bool(probe.get("timed_out", false)):
+ return _attach_discovery_error(
+ "Timed out checking the system godot-ai version. Install uv or repair the system command, then retry Configure."
+ )
+ return _attach_discovery_error(
+ "Could not verify the system godot-ai version. Install uv or repair the system command, then retry Configure."
+ )
+
+ return _attach_discovery_error(
+ "No compatible godot-ai launcher was found. Install uv (provides uvx), then retry Configure."
+ )
+
+
+## Return a launch shape that cannot allocate a visible console on Windows.
+## The development tier can execute its sibling pythonw directly. uvx and the
+## system entry point still need their own environments, so pythonw acts only
+## as a stdio-preserving, CREATE_NO_WINDOW process bootstrap for those tiers.
+static func _finalize_attach_launch(
+ tier: String,
+ command: String,
+ args: Array[String],
+ launch_context: Dictionary,
+ discovery_override: Dictionary,
+) -> Dictionary:
+ if str(launch_context.get("platform", "")) != "Windows":
+ return {"ok": true, "tier": tier, "command": command, "args": args}
+
+ var pythonw := _resolve_consoleless_python(command, tier, discovery_override)
+ if pythonw.is_empty():
+ return _attach_discovery_error(
+ "Windows requires pythonw.exe to launch the MCP bridge without opening a terminal window. Repair this Python or uv installation, then retry Configure."
+ )
+
+ ## `console_command`/`console_args` carry the unwrapped console-subsystem
+ ## launch for clients that opt out of pythonw via
+ ## `needs_consoleless_launcher = false` (#863). Strategies only consume
+ ## `command`/`args`/`ok`; `launch_for_client` swaps the shapes per client.
+ if tier == "dev_venv":
+ return {
+ "ok": true, "tier": tier, "command": pythonw, "args": args,
+ "console_command": command, "console_args": args,
+ }
+
+ var wrapped_args: Array[String] = ["-c", _WINDOWS_STDIO_BOOTSTRAP, command]
+ wrapped_args.append_array(args)
+ return {
+ "ok": true, "tier": tier, "command": pythonw, "args": wrapped_args,
+ "console_command": command, "console_args": args,
+ }
+
+
+## Select the launch shape a specific client should see. Clients with
+## `needs_consoleless_launcher = false` (Antigravity, #863) get the plain
+## console command captured by `_finalize_attach_launch`; everyone else keeps
+## the pythonw shape unchanged. Idempotent: the returned dict carries no
+## console keys, so a second application is a no-op.
+static func launch_for_client(client: Client, launch: Dictionary) -> Dictionary:
+ if client == null or client.needs_consoleless_launcher:
+ return launch
+ if not launch.has("console_command"):
+ return launch
+ var selected := launch.duplicate(true)
+ selected["command"] = selected["console_command"]
+ selected["args"] = selected["console_args"]
+ selected.erase("console_command")
+ selected.erase("console_args")
+ return selected
+
+
+static func _resolve_consoleless_python(
+ command: String, tier: String, discovery_override: Dictionary
+) -> String:
+ ## Data-only override keeps resolver tests independent of the host's Python.
+ if discovery_override.has("consoleless_python"):
+ return str(discovery_override["consoleless_python"])
+
+ ## Venv/system console-script launchers normally keep pythonw beside their
+ ## python.exe. The dev tier must use that exact interpreter so godot_ai is
+ ## imported from the selected checkout rather than some unrelated install.
+ var sibling := command.get_base_dir().path_join("pythonw.exe")
+ if FileAccess.file_exists(sibling):
+ return sibling
+ if tier == "dev_venv":
+ return ""
+
+ ## uvx may be installed without a PATH-visible CPython. Ask its sibling uv
+ ## for the already-managed system interpreter; Godot AI's existing uvx
+ ## server launch ensures one normally exists before client configuration.
+ if tier == "uvx":
+ var uv := command.get_base_dir().path_join("uv.exe")
+ if not FileAccess.file_exists(uv):
+ uv = CliFinder.find(["uv.exe"])
+ if not uv.is_empty():
+ var probe := McpCliExec.run(
+ uv, ["python", "find", "--system"], _DISCOVERY_TIMEOUT_MS, false
+ )
+ if int(probe.get("exit_code", -1)) == 0:
+ var python := str(probe.get("stdout", "")).strip_edges()
+ if not python.is_empty():
+ var managed_pythonw := _consoleless_python_for_interpreter(python)
+ if not managed_pythonw.is_empty():
+ return managed_pythonw
+
+ ## A system Python GUI launcher is sufficient for the non-dev bootstrap;
+ ## it does not import godot_ai itself.
+ return CliFinder.find(["pythonw.exe"])
+
+
+## uv can return a launcher in ~/.local/bin whose GUI interpreter lives in
+## the managed Python installation. Ask that interpreter for its base path.
+## The optional result keeps tests independent of installed executables.
+static func _consoleless_python_for_interpreter(
+ python: String, probe_result: Dictionary = {}
+) -> String:
+ if not python.is_absolute_path() or not FileAccess.file_exists(python):
+ return ""
+ var sibling := python.get_base_dir().path_join("pythonw.exe")
+ if FileAccess.file_exists(sibling):
+ return sibling
+ var probe := probe_result
+ if probe.is_empty():
+ probe = McpCliExec.run(
+ python, ["-I", "-c", "import sys; print(getattr(sys, '_base_executable', sys.executable))"],
+ _DISCOVERY_TIMEOUT_MS, false
+ )
+ if int(probe.get("exit_code", -1)) != 0 or not probe.get("stdout") is String:
+ return ""
+ var base_python := str(probe["stdout"]).strip_edges()
+ if base_python.contains("\n") or base_python.contains("\r"):
+ return ""
+ if not base_python.is_absolute_path() or not FileAccess.file_exists(base_python):
+ return ""
+ var base_pythonw := base_python.get_base_dir().path_join("pythonw.exe")
+ return base_pythonw if FileAccess.file_exists(base_pythonw) else ""
+
+
+static func _system_version_from_probe(probe: Dictionary) -> Dictionary:
+ if int(probe.get("exit_code", -1)) != 0:
+ return {"ok": false}
+ var output := str(probe.get("stdout", "")).strip_edges()
+ var pattern := RegEx.new()
+ if pattern.compile("^godot-ai\\s+([^\\s]+)(?:\\s|$)") != OK:
+ return {"ok": false}
+ var matched := pattern.search(output)
+ if matched == null:
+ return {"ok": false}
+ return {"ok": true, "version": matched.get_string(1)}
+
+
+static func _attach_discovery_error(message: String) -> Dictionary:
+ return {"ok": false, "error": message}
+
+
+## Override for the dev-vs-user heuristic. Accepted values:
+## "dev" — force dev-checkout mode (skip update check + self-install)
+## "user" — force user-install mode (run update check, allow self-install)
+## as long as the data-safety guard (addons_dir_is_symlink) passes
+## other / unset — "auto": fall back to the .venv-proximity heuristic
+##
+## Use `user` to test the AssetLib self-update flow from inside a dev
+## checkout (there's a .venv nearby but `addons/godot_ai` is a plain copy —
+## e.g. after unpacking a release zip into `test_project/`).
+##
+## Two ways to set it, resolved in priority order:
+## 1. EditorSettings → `godot_ai/mode_override` — set manually via
+## Editor Settings (no dock UI writes it today); persists
+## per-editor-install and wins over the env var so an editor-side
+## choice always takes effect without relaunching.
+## 2. Env var `GODOT_AI_MODE` — useful for CLI launches and CI.
+const MODE_OVERRIDE_ENV := "GODOT_AI_MODE"
+const MODE_OVERRIDE_SETTING := "godot_ai/mode_override"
+
+
+static func mode_override() -> String:
+ # 1. EditorSetting wins — the user explicitly set it via Editor Settings.
+ # _editor_setting_lookup handles the `Engine.is_editor_hint()` gate
+ # (no-op in the game subprocess; see CLAUDE.md "Game-side code") and
+ # serves worker threads from a main-thread-warmed snapshot — this
+ # runs on the #678 startup walk's discovery worker, and
+ # EditorInterface/EditorSettings are not thread-safe (#691).
+ var setting: Variant = _editor_setting_lookup(MODE_OVERRIDE_SETTING)
+ if setting != null:
+ var setting_val := str(setting).strip_edges().to_lower()
+ if setting_val == "dev" or setting_val == "user":
+ return setting_val
+ # 2. Env var fallback. env_lookup, not OS.get_environment: same
+ # worker-thread reason (#691).
+ var raw := McpPathTemplate.env_lookup(MODE_OVERRIDE_ENV).strip_edges().to_lower()
+ if raw == "dev" or raw == "user":
+ return raw
+ return ""
+
+
+static func is_dev_checkout() -> bool:
+ match mode_override():
+ "dev":
+ return true
+ "user":
+ return false
+ return not _find_venv_python().is_empty()
+
+
+## Data-safety check for self-install: is `res://addons/godot_ai` a symbolic
+## link? In a dev checkout this points at the canonical `plugin/` source
+## tree, and writing files into it would clobber tracked source. This check
+## is independent of `is_dev_checkout()` so a forced-user mode override
+## still cannot extract a release zip over the symlink.
+static func addons_dir_is_symlink() -> bool:
+ return _is_symlink(ProjectSettings.globalize_path("res://addons/godot_ai"))
+
+
+## Mirrors the idiom used in `mcp_dock.gd::_resolve_plugin_symlink_target` —
+## open the parent dir and ask Godot via `DirAccess.is_link()`, which
+## handles symlinks on POSIX and reparse points on Windows natively.
+static func _is_symlink(path: String) -> bool:
+ if path.is_empty():
+ return false
+ var dir := DirAccess.open(path.get_base_dir())
+ if dir == null:
+ ## This is a data-safety guard (a symlinked addons dir is a dev
+ ## checkout self-update must never write through). When the path
+ ## exists but its parent can't be opened, we can't PROVE it isn't
+ ## a link — fail closed and treat it as one (#711).
+ return DirAccess.dir_exists_absolute(path) or FileAccess.file_exists(path)
+ return dir.is_link(path)
+
+
+static func get_server_command() -> Array[String]:
+ ## `mode_override() == "user"` skips the dev_venv tier even when a nearby
+ ## .venv exists — the override then becomes an actual workaround for
+ ## the "user venv misidentified as dev checkout" bug, not just a
+ ## cosmetic relabel.
+ if mode_override() != "user":
+ var venv_python := _cached_venv_python()
+ if not venv_python.is_empty():
+ print("MCP | using dev venv: %s" % venv_python)
+ return [venv_python, "-m", "godot_ai"]
+
+ var uvx := find_uvx()
+ if not uvx.is_empty():
+ var version := get_plugin_version()
+ ## PEP 440 local build tags (e.g. 3.0.2+local.1) are not on PyPI.
+ ## Pin uvx to the public base version so the server still boots;
+ ## checkout-local extras need the dev_venv tier above
+ ## (symlink/junction → repo .venv).
+ var pypi_version := _pypi_pin_version(version)
+ ## Pin to the EXACT plugin version rather than `~=`. Under the
+ ## tilde form, uvx was happy to reuse a cached tool env that matched
+ ## the minor constraint — so an install that first spawned 1.2.0 kept
+ ## using 1.2.0 even after 1.2.1/1.2.2 landed. Exact pinning makes the
+ ## cache key version-specific: if the cached env matches, fast hit;
+ ## otherwise uvx installs the exact version fresh. Keeps plugin and
+ ## server version in lockstep without needing `--refresh-package` on
+ ## every spawn. See issue #133.
+ if pypi_version != version:
+ print(
+ "MCP | using uvx (godot-ai==%s; local plugin %s not on PyPI)"
+ % [pypi_version, version]
+ )
+ else:
+ print("MCP | using uvx (godot-ai==%s)" % pypi_version)
+ var cmd: Array[String] = [uvx]
+ cmd.append_array(UvResolution.args())
+ cmd.append_array(["--from", "godot-ai==%s" % pypi_version, "godot-ai"])
+ return cmd
+
+ var system_cmd := _find_system_install()
+ if not system_cmd.is_empty():
+ print("MCP | using system install: %s" % system_cmd)
+ return [system_cmd]
+
+ push_warning("MCP | no server found — install uv or run: pip install godot-ai")
+ return []
+
+
+## Which tier `get_server_command` would resolve to, without side-effects.
+## Returned as a stable string so handshakes and session_list can expose it
+## to MCP callers. Values track the `Literal` on the Python side.
+static func get_server_launch_mode() -> String:
+ if mode_override() != "user" and not _cached_venv_python().is_empty():
+ return "dev_venv"
+ if not find_uvx().is_empty():
+ return "uvx"
+ if not _find_system_install().is_empty():
+ return "system"
+ return "unknown"
+
+
+## Wall-clock budget for the Configure-time pre-warm. Far above
+## `McpCliExec.DEFAULT_TIMEOUT_MS` (8s) on purpose: this call is *expected* to
+## take tens of seconds on a cold cache — downloading and unpacking the tool
+## environment is the entire point — so the usual CLI-registry budget would
+## kill it right when it is doing useful work. Still bounded so a wedged uv
+## can't hold the worker thread forever.
+const PREWARM_TIMEOUT_MS := 180000
+
+
+static func find_uvx() -> String:
+ return CliFinder.find(_uvx_cli_names())
+
+
+## Pure argv builder for the pre-warm spawn, split out so tests can pin the
+## exact command without spawning a process. Empty array when there is no
+## version to pin, or when the version carries characters outside the PEP
+## 440 alphabet — the value can originate from a GitHub release tag, and
+## while OS.create_process argv can't be shell-injected, constraining the
+## pin keeps a hostile tag from smuggling anything into the requirement
+## spec uv parses (#890 review).
+static func prewarm_server_package_argv(version: String) -> Array[String]:
+ var pinned := version.strip_edges()
+ if pinned.is_empty():
+ return []
+ ## No `*`: PEP 440 reserves it for prefix-match SPECIFIERS (`==3.2.*`),
+ ## not version identifiers — letting it through would turn the exact pin
+ ## into a prefix match (#890 CodeRabbit).
+ var re := RegEx.new()
+ if re.compile("^[A-Za-z0-9.+!-]+$") != OK or re.search(pinned) == null:
+ return []
+ var args := UvResolution.args()
+ args.append_array(["--from", "godot-ai==%s" % pinned, "godot-ai", "--version"])
+ return args
+
+
+## Bounded worker-thread prewarm for Configure and the post-update client
+## migration barrier (#851, and the reconnect timeouts reported alongside it).
+##
+## The Configure case that bites is when the plugin ADOPTS an already-running
+## server (no uvx
+## spawn, so nothing warms the env), the user clicks Configure, and the first
+## client launch is the one that pays for building the whole tool environment.
+## That build is ~67 packages; a warm launch is ~0.1s. Two symptoms fall out
+## of the cold path: a visible terminal window on Windows (#851), and a spawn
+## that overruns the MCP client's default 30s connect timeout, which the user
+## sees as the Godot AI tools silently disappearing.
+##
+## The work is deliberately owned and joined rather than detached. Configure is
+## an explicit click, so the cost is attributable and the Dock can show it as
+## progress rather than as an unexplained pause. Runs on the dock's client
+## action worker thread — never the main thread — and `McpCliExec.run` bounds
+## it, so a wedged uv cannot trap the worker.
+##
+## Best-effort by contract. The config file is already written and correct
+## when this runs; a failure here only means the next client launch pays the
+## cold cost it always used to. Callers must NOT downgrade a successful
+## Configure because of this result.
+##
+## Returns the `McpCliExec.run` dict, plus `skipped` (true when there is no
+## uvx tier to warm or no usable version pin).
+static func prewarm_server_package_blocking(
+ version: String,
+ timeout_ms: int = PREWARM_TIMEOUT_MS,
+ cancel_check: Callable = Callable(),
+) -> Dictionary:
+ var args := prewarm_server_package_argv(version)
+ if args.is_empty():
+ return {"skipped": true, "reason": "no version pin"}
+ var uvx := find_uvx()
+ if uvx.is_empty():
+ ## dev-venv and system tiers have no per-version cache to warm.
+ return {"skipped": true, "reason": "no uvx"}
+ var command: Array[String] = [uvx]
+ command.append_array(args)
+ var result := McpCliExec.run(
+ uvx,
+ args,
+ timeout_ms,
+ true,
+ cancel_check,
+ UvResolution.is_production_command(command),
+ )
+ result["skipped"] = false
+ return result
+
+
+## Decide whether the freshly-written entry has an environment worth warming.
+##
+## Pure and side-effect free, split from the spawn below so tests can pin the
+## decision without building a real uv environment — the same split
+## `prewarm_server_package_argv` uses for the argv (#890).
+##
+## Warms only the `uvx` tier: dev-venv and system launches run an
+## already-installed package and have no per-version environment to build.
+## Resolving the tier here rather than in the dock keeps launcher knowledge in
+## the configurator; the dock stays free of tier branching.
+##
+## Returns `{warm: bool, version: String, reason: String}`.
+static func prewarm_attach_plan(
+ launch_context: Dictionary, discovery_override: Dictionary = {}
+) -> Dictionary:
+ var launch := resolve_attach_launch(launch_context, discovery_override)
+ if not bool(launch.get("ok", false)):
+ return {"warm": false, "version": "", "reason": "launch discovery failed"}
+ var tier := str(launch.get("tier", ""))
+ if tier != "uvx":
+ return {"warm": false, "version": "", "reason": "tier %s has no env to warm" % tier}
+ var version := _pypi_pin_version(str(launch_context.get("plugin_version", "")))
+ if version.is_empty():
+ return {"warm": false, "version": "", "reason": "no version pin"}
+ return {"warm": true, "version": version, "reason": ""}
+
+
+## Warm the environment the freshly-written entry will actually launch.
+## Thin wrapper over `prewarm_attach_plan` + `prewarm_server_package_blocking`.
+static func prewarm_attach_launch(
+ launch_context: Dictionary,
+ timeout_ms: int = PREWARM_TIMEOUT_MS,
+ discovery_override: Dictionary = {},
+ cancel_check: Callable = Callable(),
+) -> Dictionary:
+ var plan := prewarm_attach_plan(launch_context, discovery_override)
+ if not bool(plan.get("warm", false)):
+ return {"skipped": true, "reason": str(plan.get("reason", ""))}
+ return prewarm_server_package_blocking(
+ str(plan.get("version", "")), timeout_ms, cancel_check
+ )
+
+
+static func _uvx_cli_names() -> Array[String]:
+ var names: Array[String] = []
+ names.append("uvx.exe" if OS.get_name() == "Windows" else "uvx")
+ return names
+
+
+## Drop the `CliFinder` cache for the platform-specific uvx binary
+## name. Pairs with `invalidate_uv_version_cache()` so the dock's
+## `_on_install_uv` can refresh both caches with one call each. The
+## OS-specific name matters: Windows caches under `uvx.exe`, every
+## other platform under `uvx`; hard-coding `"uvx"` here would leave
+## the CLI-path cache stale on Windows after a fresh install and the
+## dock would keep showing "uv: not found" for the rest of the session.
+static func invalidate_uvx_cli_cache() -> void:
+ for name in _uvx_cli_names():
+ CliFinder.invalidate(name)
+
+
+## Drop the entire `CliFinder` cache. Called from any explicit-user-action
+## refresh path (`force=true` in `_request_client_status_refresh` — manual
+## Refresh button, popup-open, compat wrapper, future external API) so a
+## freshly-installed CLI (claude, codex, gemini, …) gets detected without
+## an editor restart. Per-CLI invalidation (`invalidate_uvx_cli_cache`) is
+## preferred when the dock knows which binary changed; this catch-all
+## handles the "any CLI may have been installed since the last sweep" case.
+##
+## Thread safety: `CliFinder.invalidate()` guards `_cache` / `_searched`
+## with a mutex so it can race safely against worker threads calling
+## `find()` from `_run_client_action_worker`. The mutex is held only
+## across the dictionary clear, never across the bounded subprocess lookup,
+## so this call can never block the main thread on a subprocess.
+static func invalidate_cli_cache() -> void:
+ CliFinder.invalidate()
+
+
+static var _uv_version_cache: String = ""
+static var _uv_version_searched: bool = false
+
+
+## Cached for the editor session. The dock's `_refresh_setup_status`
+## (called via `call_deferred` from `_build_ui`) calls this on the
+## main thread in user mode, so the cold `uvx --version` probe is
+## wall-clock bounded and cached. Subsequent calls (focus-in refresh,
+## manual Refresh clicks) reuse the cached string.
+##
+## Invalidate via `invalidate_uv_version_cache()` when the user
+## installs / reinstalls uv via the dock so the next refresh reflects
+## the new install. The dock's `_on_install_uv` calls this alongside
+## `CliFinder.invalidate("uvx")` to clear both the path cache and
+## the version cache in one place.
+static func check_uv_version() -> String:
+ if _uv_version_searched:
+ return _uv_version_cache
+ var uvx := find_uvx()
+ if uvx.is_empty():
+ _uv_version_searched = true
+ _uv_version_cache = ""
+ return ""
+ var result := McpCliExec.run(uvx, ["--version"], _DISCOVERY_TIMEOUT_MS, false)
+ if int(result.get("exit_code", -1)) == 0:
+ var lines := PackedStringArray(str(result.get("stdout", "")).split("\n"))
+ _uv_version_cache = lines[0].strip_edges() if lines.size() > 0 else ""
+ else:
+ _uv_version_cache = ""
+ _uv_version_searched = true
+ return _uv_version_cache
+
+
+static func invalidate_uv_version_cache() -> void:
+ _uv_version_searched = false
+ _uv_version_cache = ""
+
+
+## True when a probe has run this session and came back empty — i.e. the
+## dock is currently rendering "uv: not found". Lets callers decide when
+## a re-probe is worth paying for (server-connect transition, manual
+## Refresh) without ever re-probing once uv has been found.
+static func uv_probe_negative() -> bool:
+ return _uv_version_searched and _uv_version_cache.is_empty()
+
+
+## Drop both uv caches — the resolved uvx path AND the cached
+## `uvx --version` output — so the next check_uv_version() re-runs the
+## full detection. #739: a probe that fails once at editor startup
+## (contended spawn, cold Defender scan, stale PATH under a
+## Steam-launched editor) used to pin "uv: not found" for the whole
+## session; the Install-uv click was the only invalidation path. Callers
+## invoke this on events that suggest the failure was transient.
+static func invalidate_uv_detection() -> void:
+ invalidate_uvx_cli_cache()
+ invalidate_uv_version_cache()
+ _attach_launch_cache_mutex.lock()
+ _attach_launch_cache.clear()
+ _attach_launch_cache_mutex.unlock()
+
+
+static var _venv_python_cache: String = ""
+static var _venv_python_searched: bool = false
+## #678 worker threads write this cache while main-thread callers read
+## it; same lock discipline as McpCliFinder (clients/_cli_finder.gd).
+static var _venv_mutex: Mutex = Mutex.new()
+static var _attach_launch_cache := {}
+static var _attach_launch_cache_mutex := Mutex.new()
+
+
+static func _attach_launch_cache_key(launch_context: Dictionary) -> String:
+ return JSON.stringify([
+ launch_context.get("http_port", null),
+ launch_context.get("ws_port", null),
+ launch_context.get("excluded_domains", null),
+ launch_context.get("plugin_version", null),
+ launch_context.get("allow_dev_venv", null),
+ launch_context.get("platform", null),
+ launch_context.get("telemetry_enabled", null),
+ ])
+
+
+static func _cached_venv_python() -> String:
+ _venv_mutex.lock()
+ if not _venv_python_searched:
+ _venv_python_cache = _find_venv_python()
+ _venv_python_searched = true
+ var cached := _venv_python_cache
+ _venv_mutex.unlock()
+ return cached
+
+
+## Absolute path to `res://addons/godot_ai`, resolving Windows junctions /
+## POSIX symlinks via `DirAccess.read_link`. Unresolved globalize_path only
+## walks the *logical* project path (e.g. MyGame/addons/godot_ai → MyGame)
+## and never reaches a fork checkout's `.venv` (…/godot-ai/.venv).
+static func resolve_addons_realpath() -> String:
+ var addons_path := ProjectSettings.globalize_path("res://addons/godot_ai").rstrip("/").rstrip("\\")
+ if addons_path.is_empty():
+ return ""
+ var parent := addons_path.get_base_dir()
+ var dir := DirAccess.open(parent)
+ if dir != null and dir.is_link(addons_path):
+ var target := dir.read_link(addons_path)
+ if not target.is_empty():
+ if target.is_relative_path():
+ target = parent.path_join(target).simplify_path()
+ return target.rstrip("/").rstrip("\\")
+ return addons_path
+
+
+static func _find_venv_python() -> String:
+ ## Optional hard override (junction edge cases / CI).
+ var env_py := McpPathTemplate.env_lookup("GODOT_AI_VENV_PYTHON").strip_edges()
+ if not env_py.is_empty():
+ if FileAccess.file_exists(env_py):
+ return env_py
+ ## An explicit override pointing nowhere is a misconfiguration the
+ ## user needs to see — falling through silently would make the dev
+ ## venv appear randomly ignored.
+ push_warning(
+ "godot-ai: GODOT_AI_VENV_PYTHON is set but no file exists at '%s'; ignoring override."
+ % env_py
+ )
+ ## 1) Walk up from the open project (classic monorepo / test_project layout).
+ var from_project := _find_venv_python_in(
+ ProjectSettings.globalize_path("res://").rstrip("/").rstrip("\\")
+ )
+ if not from_project.is_empty():
+ return from_project
+ ## 2) Junctioned plugin: resolve reparse target, then walk up to fork root.
+ var addons_real := resolve_addons_realpath()
+ if not addons_real.is_empty():
+ var from_addons := _find_venv_python_in(addons_real)
+ if not from_addons.is_empty():
+ return from_addons
+ return ""
+
+
+## Pure path-based lookup so tests can drive it with a scratch dir instead of
+## monkey-patching `res://`. Only treats a `.venv/bin/python` as a godot-ai dev
+## venv if a sibling `src/godot_ai/` exists in the same parent dir — otherwise
+## an unrelated user venv (e.g. `~/.venv` from a data-science side project)
+ ## gets picked up and `python -m godot_ai` fails with ModuleNotFoundError.
+ ## The detection therefore has to be right before launch.
+static func _find_venv_python_in(start_dir: String) -> String:
+ var dir := start_dir.rstrip("/").rstrip("\\")
+ var python_name := "python" if OS.get_name() != "Windows" else "python.exe"
+ var venv_dir := ".venv/bin/" if OS.get_name() != "Windows" else ".venv/Scripts/"
+ ## 8 hops: game project roots are shallow; junctioned plugins sit at
+ ## /plugin/addons/godot_ai (4) and nested worktrees may be deeper.
+ for i in 8:
+ var venv_path := dir.path_join(venv_dir + python_name)
+ if FileAccess.file_exists(venv_path) and DirAccess.dir_exists_absolute(dir.path_join("src/godot_ai")):
+ return venv_path
+ var parent := dir.get_base_dir()
+ if parent == dir or parent.is_empty():
+ break
+ dir = parent
+ return ""
+
+
+## Walk up from `start_dir` looking for a sibling `src/godot_ai/` — returns
+## the absolute path of the enclosing `src/` dir, or "". Used by the dev
+## server launcher to prepend the caller's own source to PYTHONPATH so a
+## worktree-launched editor serves the worktree's Python, not the root
+## repo's editable install. See #84.
+static func find_worktree_src_dir(start_dir: String) -> String:
+ var dir := start_dir.rstrip("/")
+ for i in 5:
+ var candidate := dir.path_join("src/godot_ai")
+ if DirAccess.dir_exists_absolute(candidate):
+ return dir.path_join("src")
+ var parent := dir.get_base_dir()
+ if parent == dir:
+ break
+ dir = parent
+ return ""
+
+
+## Delegates to McpCliFinder rather than shelling out to which/where
+## directly: the finder adds the well-known-install-dirs and login-shell
+## PATH tiers plus its per-exe cache, and this drops the private
+## `_pick_best_path` cross-class reach (#711).
+static func _find_system_install() -> String:
+ ## Built with append, not a ternary of untyped literals — assigning a
+ ## ternary's Array to Array[String] is a runtime error on newer Godot
+ ## builds (same idiom as _uvx_cli_names above).
+ var names: Array[String] = ["godot-ai"]
+ if OS.get_name() == "Windows":
+ names.push_front("godot-ai.exe")
+ return CliFinder.find(names)
diff --git a/addons/godot_ai/client_configurator.gd.uid b/addons/godot_ai/client_configurator.gd.uid
new file mode 100644
index 0000000..9182096
--- /dev/null
+++ b/addons/godot_ai/client_configurator.gd.uid
@@ -0,0 +1 @@
+uid://1kiy8hqyymyj
diff --git a/addons/godot_ai/clients/_atomic_write.gd b/addons/godot_ai/clients/_atomic_write.gd
new file mode 100644
index 0000000..3dbf72c
--- /dev/null
+++ b/addons/godot_ai/clients/_atomic_write.gd
@@ -0,0 +1,251 @@
+@tool
+class_name McpAtomicWrite
+extends RefCounted
+
+## Write text to a file via temp + rename so a crash mid-write never leaves
+## the user's MCP config truncated. Creates the parent dir if needed and
+## keeps a one-shot `.backup` of the prior file.
+##
+## On filesystems where rename-over-existing fails (Windows under AV / lock
+## pressure, some SMB shares), falls back to overwrite-copy plus a
+## backup-restore on failure. The original file is never removed before the
+## new bytes are verified on disk — if both the rename and the copy fail,
+## the user's prior config is restored from the `.backup` snapshot. See
+## issue #297 finding #10 for the data-loss scenario this guards against.
+
+
+static func write(path: String, content: String) -> bool:
+ # Last line of defence for the path-resolution layer above. A relative (or
+ # empty) destination resolves against the EDITOR's working directory, not the
+ # caller's. The empty-path case is especially deceptive: editor safe-save
+ # runs mkstemp on "-XXXXXX", writes the payload into the project root, then
+ # fails only when renaming back to "" and leaves the random file behind.
+ # Callers gate this already (`McpClient.resolved_config_path_details`), but a
+ # writer that cannot tell where it is writing must refuse rather than guess.
+ if not path.is_absolute_path():
+ return false
+ # If the target is a symlink (stow/chezmoi-managed dotfiles), rename-over
+ # would replace the LINK with a regular file, silently detaching the
+ # config from the user's dotfile repo (#534). Resolve the link chain and
+ # write to the real target so the symlink survives.
+ path = _resolve_symlink_target(path)
+ var dir_path := path.get_base_dir()
+ if not DirAccess.dir_exists_absolute(dir_path):
+ if DirAccess.make_dir_recursive_absolute(dir_path) != OK:
+ return false
+
+ # Decide the permission mode the final file (and its backup) must carry
+ # BEFORE we replace anything. A rewrite must preserve the prior file's
+ # mode: the Claude CLI creates ~/.claude.json as 0600 (it holds OAuth
+ # creds + history), and a naive FileAccess write + DirAccess copy would
+ # silently relax that to the umask default (0644) and leak it on shared
+ # machines. A brand-new config defaults to owner-only 0600 since these
+ # files routinely carry tokens. On platforms without POSIX permissions
+ # (Windows) the get/set calls no-op and this logic is inert. See #297
+ # finding TC-1.
+ var had_original := FileAccess.file_exists(path)
+ var target_mode := _resolve_target_mode(path, had_original)
+
+ # Suffix the temp name with this process's PID so two editors writing the
+ # same config concurrently (both clicking Configure) can't interleave
+ # bytes on a shared fixed ".tmp" path (#534). Each process stages its own
+ # temp file; the final rename remains the atomic commit point.
+ var tmp_path := "%s.tmp.%d" % [path, OS.get_process_id()]
+ var file := _open_restricted_temp(tmp_path, target_mode)
+ if file == null:
+ return false
+ file.store_string(content)
+ # Push Godot's internal buffer out to the OS before the rename. Godot
+ # exposes no fsync, so the bytes aren't guaranteed durable on the physical
+ # disk until the OS flushes its own cache — a power loss in that window can
+ # still lose the data. But flush() ensures the rename can't be ordered ahead
+ # of the write at the application layer, which is the failure this guards.
+ file.flush()
+ file.close()
+ # Re-assert the mode on the closed inode before rename. The temp was already
+ # restricted before this handle opened, so this is defence in depth rather
+ # than the first point at which secrets become owner-only.
+ if not _apply_mode(tmp_path, target_mode):
+ DirAccess.remove_absolute(tmp_path)
+ return false
+
+ # Verify the staged temp landed intact before committing it anywhere. The
+ # copy-fallback path below already guards this (`_written_size_matches` at
+ # the rename-fallback check); the rename path was the one gap — under
+ # disk-full/quota the temp can be silently truncated, and an unverified
+ # rename would swap a truncated file over the live target while the
+ # caller is told the write succeeded (#687).
+ if not _written_size_matches(tmp_path, content):
+ DirAccess.remove_absolute(tmp_path)
+ return false
+
+ # Best-effort: snapshot the prior file before we touch the target so we
+ # can restore on a failed swap. The backup is also kept on success as a
+ # one-shot rollback aid for the user — give it the same (preserved) mode
+ # so a 0600 config's backup isn't itself a world-readable copy.
+ #
+ # copy_absolute creates the backup at the umask default and we can only
+ # chmod it afterward, so there's a sub-millisecond window where the backup
+ # carries default perms. Accepted: it duplicates bytes already sitting at
+ # `path` (which the caller created 0600) inside the user's own config dir,
+ # and Godot exposes no API to create the copy pre-chmod'd. Not worth
+ # reimplementing copy by hand to shave that window.
+ var backup_path := path + ".backup"
+ var backup_made := false
+ if had_original:
+ DirAccess.remove_absolute(backup_path)
+ if DirAccess.copy_absolute(path, backup_path) == OK:
+ if not _apply_mode(backup_path, target_mode):
+ ## The backup contains the same secrets as the live config. Do
+ ## not leave that copy behind — or touch the destination — when
+ ## it cannot be restricted to the intended mode.
+ DirAccess.remove_absolute(backup_path)
+ DirAccess.remove_absolute(tmp_path)
+ return false
+ backup_made = true
+ else:
+ ## copy_absolute may leave a partial destination on failure. It
+ ## contains config bytes under the process umask, so remove it.
+ DirAccess.remove_absolute(backup_path)
+
+ if DirAccess.rename_absolute(tmp_path, path) == OK:
+ return true
+
+ # Rename-over-existing rejected (Windows + AV / lock timing, some SMB
+ # shares). Use overwrite-copy as the recovery path: copy_absolute never
+ # removes the original before writing the new bytes, so a failure here
+ # leaves the user's prior config in place rather than nuking it.
+ if DirAccess.copy_absolute(tmp_path, path) == OK and _written_size_matches(path, content):
+ # copy_absolute creates the destination with the default mode, so
+ # re-apply the preserved/owner-only mode after the copy lands. A
+ # permission failure is a failed write: fall through to the same
+ # restore/remove path as a partial copy instead of reporting success
+ # with a potentially exposed token-bearing config.
+ if _apply_mode(path, target_mode):
+ DirAccess.remove_absolute(tmp_path)
+ return true
+
+ # Copy didn't land cleanly. Restore the destination to its pre-call state.
+ if backup_made:
+ # Restore the snapshot we took before the swap. `copy_absolute`
+ # overwrites the destination, so we don't pre-remove `path` — the
+ # pre-remove created a window where `path` was gone if the
+ # subsequent copy itself failed. If the restore copy fails now the
+ # user's prior bytes are still in `.backup` for manual recovery
+ # and the false return value tells the caller the swap didn't
+ # complete.
+ var restored := (
+ DirAccess.copy_absolute(backup_path, path) == OK
+ and _apply_mode(path, target_mode)
+ )
+ if not restored:
+ ## The backup's mode was already verified before the swap. Remove
+ ## any partial/default-mode destination, then move that restricted
+ ## inode back into place. If even the same-directory rename fails,
+ ## the complete prior contents remain in `.backup` for recovery.
+ DirAccess.remove_absolute(path)
+ DirAccess.rename_absolute(backup_path, path)
+ elif not had_original and FileAccess.file_exists(path):
+ # No prior file existed but copy_absolute landed partial bytes at
+ # `path`. Remove them so the failure leaves nothing on disk rather
+ # than a truncated/invalid new file. The `file_exists` guard keeps
+ # us off non-file destinations (a path that points at a directory
+ # yields `had_original=false` too, but we must not try to delete
+ # the directory). Issue #297 PR review.
+ DirAccess.remove_absolute(path)
+ # (If `had_original` is true but the snapshot couldn't be taken, the
+ # original on disk is whatever copy_absolute managed to write before
+ # failing. This is a best-effort path — the false return value tells the
+ # caller the swap didn't complete; recovery beyond that requires a
+ # backup we couldn't take.)
+ DirAccess.remove_absolute(tmp_path)
+ return false
+
+
+## Follow a symlink chain at `path` and return the final real target, so the
+## temp+rename lands on the linked-to file instead of replacing the link.
+##
+## Best-effort by design: DirAccess.is_link()/read_link() are only implemented
+## on platforms with POSIX symlinks (Linux/macOS; on Windows and other
+## platforms is_link() returns false), and opening the parent dir can fail for
+## exotic paths. In every "can't tell" case we return `path` unchanged, which
+## is exactly the pre-#534 behavior — never worse, symlink-preserving where
+## the engine lets us detect one.
+static func _resolve_symlink_target(path: String) -> String:
+ var resolved := path
+ # Bounded hops so a symlink cycle can't loop us forever.
+ for _hop in 8:
+ var base_dir := resolved.get_base_dir()
+ var da := DirAccess.open(base_dir)
+ if da == null or not da.is_link(resolved):
+ return resolved
+ var target := da.read_link(resolved)
+ if target.is_empty():
+ return resolved
+ if target.is_relative_path():
+ target = base_dir.path_join(target)
+ resolved = target.simplify_path()
+ return resolved
+
+
+static func _resolve_target_mode(path: String, had_original: bool) -> int:
+ # Preserve the prior file's POSIX mode on a rewrite; default a brand-new
+ # config (or any case we can't read a mode for) to owner read+write (0600).
+ #
+ # get_unix_permissions returns 0 both on Windows (no POSIX perms) and for a
+ # genuine 0000 file. Treating 0 as "use the 0600 floor" is deliberate, not a
+ # missed case: these are config files the plugin must read and write, 0000 is
+ # unusable, and re-applying 0000 would lock the owner out next run. 0600 is
+ # still owner-only so this never widens access. (A genuinely-0000 file can't
+ # reach a rewrite through the config strategies anyway — their read-first
+ # guard fails to open it and refuses the write before we get here.)
+ if had_original:
+ var existing := FileAccess.get_unix_permissions(path)
+ if existing > 0:
+ return existing
+ return FileAccess.UNIX_READ_OWNER | FileAccess.UNIX_WRITE_OWNER
+
+
+## Create/truncate an EMPTY temp file, close it, restrict its mode, then reopen
+## it without truncation for the payload write. Godot 4.6 on macOS returns
+## FAILED when set_unix_permissions targets a path whose FileAccess is still
+## open; the old create -> chmod-while-open -> write sequence therefore emitted
+## one warning per configured client and briefly wrote secrets under the umask
+## mode. Closing the empty inode first makes the security ordering real and
+## removes that platform-specific warning without suppressing genuine errors.
+static func _open_restricted_temp(path: String, mode: int) -> FileAccess:
+ var created := FileAccess.open(path, FileAccess.WRITE)
+ if created == null:
+ return null
+ created.close()
+ if not _apply_mode(path, mode):
+ DirAccess.remove_absolute(path)
+ return null
+ return FileAccess.open(path, FileAccess.READ_WRITE)
+
+
+static func _apply_mode(path: String, mode: int) -> bool:
+ # Best-effort. set_unix_permissions returns ERR_UNAVAILABLE on platforms
+ # without POSIX permissions (Windows); that's expected and ignored so the
+ # write still works there. mode <= 0 should never happen (resolve always
+ # returns >0) but is guarded so a future caller can't chmod a file to nothing.
+ if mode <= 0:
+ return true
+ var err := FileAccess.set_unix_permissions(path, mode)
+ if err == OK or err == ERR_UNAVAILABLE:
+ return true
+ # Surface a real chmod failure (not the Windows no-op) so permission
+ # hardening on a sensitive config doesn't fail completely silently.
+ push_warning("MCP | could not set permissions on %s: %s" % [path, error_string(err)])
+ return false
+
+
+static func _written_size_matches(path: String, content: String) -> bool:
+ # `store_string` writes UTF-8 bytes with no BOM and no newline translation,
+ # so the byte length on disk must match `to_utf8_buffer().size()` exactly.
+ var f := FileAccess.open(path, FileAccess.READ)
+ if f == null:
+ return false
+ var on_disk := f.get_length()
+ f.close()
+ return on_disk == content.to_utf8_buffer().size()
diff --git a/addons/godot_ai/clients/_atomic_write.gd.uid b/addons/godot_ai/clients/_atomic_write.gd.uid
new file mode 100644
index 0000000..add9f7f
--- /dev/null
+++ b/addons/godot_ai/clients/_atomic_write.gd.uid
@@ -0,0 +1 @@
+uid://6fkb5uau0r4h
diff --git a/addons/godot_ai/clients/_base.gd b/addons/godot_ai/clients/_base.gd
new file mode 100644
index 0000000..0483496
--- /dev/null
+++ b/addons/godot_ai/clients/_base.gd
@@ -0,0 +1,578 @@
+@tool
+class_name McpClient
+extends RefCounted
+
+## Descriptor for one MCP client (Cursor, Claude Desktop, Codex, ...).
+##
+## Subclasses set fields in `_init()` and MUST NOT carry Callables — strategies
+## (json/toml/cli) interpret the data. Enforced by
+## `test_clients.gd::test_descriptors_are_data_only`.
+##
+## Why no Callables: per-client `.gd` files get hot-reloaded on disk-mtime
+## change. A worker thread mid-call into a descriptor lambda races the
+## bytecode swap and SEGVs (issue #229). Bonus: also obsoletes the stale-
+## Callable workaround from #192.
+
+## CONFIGURED_MISMATCH = an entry with our `SERVER_NAME` exists in the user's
+## client config, but its URL or launch command doesn't match the current
+## ports/version/exclusions — typical after a setting change or update.
+## Distinguishing this from `NOT_CONFIGURED` lets the dock surface a "your
+## saved client configuration is stale" banner instead of conflating it with
+## "you never configured this client".
+enum Status { NOT_CONFIGURED, CONFIGURED, CONFIGURED_MISMATCH, ERROR }
+
+
+## Lowercase string label for a `Status` value. Single source of truth so the
+## MCP `client_status` tool, the dock, and the verify-after-write diagnostic
+## in `McpClientConfigurator` all emit the same names — agents pattern-match
+## against this set, so a fifth value being silently introduced would break
+## them.
+static func status_label(status: McpClient.Status) -> String:
+ match status:
+ Status.CONFIGURED:
+ return "configured"
+ Status.NOT_CONFIGURED:
+ return "not_configured"
+ Status.CONFIGURED_MISMATCH:
+ return "configured_mismatch"
+ return "error"
+
+
+## One-line configure success message, shared by every strategy so the dock
+## and the `client_manage` tool describe the transport that was actually
+## written. Command-shape clients register the stdio `godot-ai attach`
+## bridge — the URL-era "(HTTP: )" suffix would name a transport the
+## write never touched (found live in the #838 Windows smoke).
+static func configured_message(client: McpClient, server_url: String) -> String:
+ if client.command_shape != CommandShape.NONE:
+ return "%s configured (stdio attach)" % client.display_name
+ return "%s configured (HTTP: %s)" % [client.display_name, server_url]
+
+var id: String = "" ## stable key, e.g. "cursor"
+var display_name: String = "" ## "Cursor"
+var config_type: String = "" ## "json" | "toml" | "yaml" | "cli" | "dsh"
+## False when the client's settings format cannot be round-tripped safely by
+## the matching strategy. Status checks remain read-only; Configure and Remove
+## return the existing manual instructions without touching the file.
+var automatic_config_edits: bool = true
+
+## True when the config file is JSONC (Zed ships a `//` header). Honored by the
+## JSON strategy's reads only — comments are stripped from a throwaway parse
+## copy, so the row shows a real status instead of a permanent parse error
+## (#914). Requires `automatic_config_edits = false`; the strategy ignores it
+## otherwise rather than let a re-serializing Configure drop the comments.
+var config_allows_comments: bool = false
+
+# JSON / TOML clients ------------------------------------------------------
+## {"darwin": "~/...", "windows": "$APPDATA/...", "linux": "$XDG_CONFIG_HOME/..."}
+## Keys may also use "unix" as a shorthand for darwin+linux.
+var path_template: Dictionary = {}
+
+## Optional ordered path candidates by platform. Each value is an Array of
+## templates; one `*` may appear in a directory segment so packaged-app roots
+## can be discovered without hardcoding publisher hashes.
+##
+## Resolution contract:
+## 1. Existing files win in descriptor order, except that a unique wildcard
+## match is authoritative even before its config leaf exists. A matching
+## package root therefore creates inside that package rather than writing
+## a fallback path that may become invisible after copy-on-write. When
+## that private leaf is new, Configure seeds it from the first later
+## existing candidate so read-through content is not shadowed.
+## 2. If no file or wildcard package match exists, the first non-wildcard
+## template is the deterministic create target.
+## 3. Multiple matches within any wildcard group are ambiguous and fail
+## closed instead of choosing an arbitrary package.
+##
+## Exact-file and config-home environment overrides still have higher
+## priority. When this map has no entry for the current platform,
+## `path_template` remains the fallback.
+var config_path_candidates: Dictionary = {}
+
+## Optional global JSON config files merged by the client from lowest to highest
+## precedence. Unlike `config_path_candidates`, every existing file participates;
+## Configure updates the effective last definition, status verifies it, and
+## Remove clears every global definition transactionally.
+var config_merge_path_templates: Dictionary = {}
+## Project-relative tiers that may override the global files. Their root is the
+## external client's working directory, which the Godot process cannot know.
+## The strategy checks plausible roots and fails closed instead of mutating them.
+var config_merge_project_paths: PackedStringArray = PackedStringArray()
+
+## De-duplicate persistent path-ambiguity warnings across recurring status
+## refreshes. The actionable message still returns on every resolution; only
+## the editor-console echo is single-shot until the ambiguity clears/changes.
+var _last_config_path_warning := ""
+var _config_path_warning_mutex := Mutex.new()
+
+## Path inside the config object where the per-server map lives.
+## Cursor / Claude Desktop / most others: ["mcpServers"]
+## VS Code: ["servers"]
+## OpenCode: ["mcp"]
+var server_key_path: PackedStringArray = PackedStringArray()
+## Alternative server-map paths accepted by the client, in precedence order
+## after `server_key_path`. JSON strategy operations select the first non-null
+## existing path so Configure, status, and Remove agree with the client parser.
+var server_key_path_aliases: Array[PackedStringArray] = []
+
+## Field inside the entry dict that holds our server URL.
+## "url" by default; some clients use "serverUrl" or "httpUrl".
+var entry_url_field: String = "url"
+
+## Required entry fields — written on every Configure AND verified by the
+## default verifier. Use this for transport pins (e.g. `type:
+## "streamable-http"`) where a missing/wrong value breaks negotiation: a
+## legacy entry without the pin fails verification and surfaces as drift.
+##
+## DO NOT put user-mutable state here (auto-approval lists, `disabled`
+## flags, opt-in toggles). Verifying those treats every user customisation
+## as drift, and Configure-All-Mismatched then silently overwrites them
+## back to defaults — see the `entry_initial_fields` doc below.
+var entry_extra_fields: Dictionary = {}
+
+## Default fields written ONLY when the entry doesn't yet exist. Reconfigure
+## preserves whatever the user (or the client itself) has set; the verifier
+## ignores these keys entirely. Use for opt-in flags and user-state arrays —
+## e.g. Roo / Cline / Kilo `alwaysAllow` / `autoApprove` lists, `disabled:
+## false`, `isActive: true`. The pre-#229 behaviour was equivalent: per-
+## client `entry_builder` lambdas seeded these as defaults but the
+## per-client `verify_entry` lambdas only checked transport pins, so a
+## user-customised array was `CONFIGURED`, not drift. Splitting the field
+## restores that contract under the data-only descriptor model.
+var entry_initial_fields: Dictionary = {}
+
+## Client-owned stdio launch shape. Each strategy renders the shape in its
+## config language:
+##
+## - FLAT — `command` string + `args` array as sibling keys. JSON and YAML
+## strategies. A client whose docs require a type discriminator next to the
+## flat keys (VS Code's `type: "stdio"`, Claude Code's fallback file) stays
+## FLAT and declares it via `command_transport_key` / `command_transport_value`,
+## so TYPED_FLAT remains reserved vocabulary.
+## - COMMAND_ARRAY — the launch argv carried as one array. In the JSON
+## strategy the entry's `command` field IS that array (OpenCode's
+## `"command": ["uvx", …]`). In the TOML strategy the launcher renders as a
+## `command = "…"` line plus an `args = […]` array (Codex, Grok) — the name
+## refers to the argv-as-TOML-array body it emits.
+## - NESTED_COMMAND — command/args nested inside a sub-object. Reserved; no
+## current client needs it and strategies reject it with an actionable error.
+##
+## CLI-registered clients (`config_type == "cli"`) express the launch through
+## `cli_register_template` tokens instead; their `command_shape` governs the
+## JSON-fallback file rendering (Claude Code, #463).
+##
+## Values are data-only shared vocabulary; keeping them data-only avoids
+## reintroducing the descriptor Callable race from #229.
+enum CommandShape { NONE, FLAT, TYPED_FLAT, COMMAND_ARRAY, NESTED_COMMAND }
+var command_shape: CommandShape = CommandShape.NONE
+
+## Optional discriminator required by a client's command transport shape
+## (for example `type = "stdio"`). Empty means command+args are sufficient.
+var command_transport_key: String = ""
+var command_transport_value: Variant = null
+
+## Whether this client's Windows stdio entry must launch through the
+## GUI-subsystem pythonw bootstrap (#827). The bootstrap exists for clients
+## that run console-subsystem MCP commands in a visible terminal (Codex);
+## Electron-family spawners hide child consoles themselves, and at least one
+## (Antigravity) hangs tool calls when handed a GUI-subsystem executable
+## (#863). Set false to write the plain console launcher on Windows.
+var needs_consoleless_launcher: bool = true
+
+## Keys from the legacy transport that Configure must delete. Codex removes
+## `url`, because Codex rejects a server entry containing both URL and stdio
+## launch fields.
+var command_legacy_keys: PackedStringArray = PackedStringArray()
+
+## Keys inside a preserved JSON `env` object that belonged to a legacy launch
+## shape and must be removed during migration. Other environment values remain
+## user-owned and survive Configure. Currently consumed by the JSON strategy.
+var command_env_legacy_keys: PackedStringArray = PackedStringArray()
+
+## Defaults seeded only for a new entry. Reconfigure preserves user values.
+## Codex uses this for enabled/startup/tool timeout defaults.
+var command_initial_fields: Dictionary = {}
+
+## Declarative documentation of fields owned by the user and timeout fields
+## supported by this client. Strategies preserve these values and tests pin
+## the descriptor contract; no control flow lives on the descriptor.
+var command_user_fields: PackedStringArray = PackedStringArray()
+var command_timeout_fields: PackedStringArray = PackedStringArray()
+
+## Paths whose existence implies the user has this client installed.
+## Used purely for the dock's "installed" badge. `is_installed()` additionally
+## checks `resolved_config_path()`, so a config relocated via an environment
+## override is detected without listing it here.
+var detect_paths: PackedStringArray = PackedStringArray()
+
+# Config-path env overrides --------------------------------------------------
+## Some clients name the exact config file in an environment variable
+## (OpenCode: `$OPENCODE_CONFIG`). When the variable is set and non-empty, it
+## wins over directory-valued `config_home_env` and `path_template`. Relative
+## values fail closed because the editor and client may have different working
+## directories; auto-configuration cannot safely assume they resolve alike.
+var config_file_env: String = ""
+
+## Some clients honor an env var that relocates their entire config home
+## (Codex: `$CODEX_HOME/config.toml`; Claude Code: `$CLAUDE_CONFIG_DIR/.claude.json`).
+## When `config_home_env` names an env var that is set and non-empty,
+## `resolved_config_path()` returns `/`
+## instead of resolving `path_template`. Both fields must be non-empty for the
+## override to apply. Only declare a mapping when the client's docs guarantee
+## the env var relocates the exact file we write — a wrong mapping writes the
+## MCP entry somewhere the client never reads and Configure false-succeeds.
+## Relative values fail closed for the same reason `config_file_env`'s do.
+var config_home_env: String = ""
+## Path of the config file relative to the env var's directory, e.g.
+## "config.toml". Joined verbatim — no per-OS variants needed because the env
+## value itself is already an absolute (or ~-prefixed) directory.
+var config_home_env_subpath: String = ""
+
+# CLI clients --------------------------------------------------------------
+var cli_names: PackedStringArray = PackedStringArray()
+## Argument templates with `{name}` and `{url}` tokens; the strategy
+## substitutes them at call time. Tokens are matched verbatim — no escaping
+## semantics, no shell expansion. Command-shape templates additionally use the
+## whole-element tokens `{command}` / `{args...}` (see `McpCliStrategy.format_args`).
+## Populated by CLI descriptors (currently `claude_code`; `kimi_code` moved to
+## mcp.json in #813).
+var cli_register_template: PackedStringArray = PackedStringArray()
+var cli_unregister_template: PackedStringArray = PackedStringArray()
+## Args run to read current state; stdout is scanned for the server name and
+## URL. Presence of `name` AND `url` → CONFIGURED, name only → MISMATCH,
+## neither → NOT_CONFIGURED.
+var cli_status_args: PackedStringArray = PackedStringArray()
+## Optional scope-aware status template with a `{name}` token, for descriptors
+## that also take `{scope}` in their register template. `cli_status_args` lists
+## whatever the CLI resolves for the current directory and cannot say WHICH
+## scope an entry came from, so a leftover user-scope entry reads as CONFIGURED
+## while the selected project scope is empty — the dock then shows green over
+## exactly the "loaded in every workspace" state the scope setting exists to
+## end (#872). This template must print a `Scope: …` line
+## (`claude mcp get ` does) and exit non-zero when the server is absent.
+## Only consulted when the selected scope isn't the one `path_template` reads.
+var cli_scope_status_template: PackedStringArray = PackedStringArray()
+
+# Codex / TOML clients -----------------------------------------------------
+## Dotted TOML path under which our entry lives, e.g. ["mcp_servers", "godot-ai"].
+## Strategies build the [section."name"] header from this.
+var toml_section_path: PackedStringArray = PackedStringArray()
+var toml_legacy_section_aliases: PackedStringArray = PackedStringArray()
+## Lines (without the [header]) emitted under the section, with `{url}`
+## tokens. Substituted at call time.
+var toml_body_template: PackedStringArray = PackedStringArray()
+
+
+## Resolved absolute config path for this client on the current OS. Exact-file
+## overrides win first, followed by directory-valued `config_home_env`, then
+## ordered candidates / `path_template`. Ignoring either override can write a
+## file the client never reads and false-succeed.
+func resolved_config_path() -> String:
+ return str(resolved_config_path_details().get("path", ""))
+
+
+## Detailed sibling used by status/configure/remove so safe resolution
+## failures reach the dock instead of collapsing into NOT_CONFIGURED. `error`
+## is empty for ordinary unsupported/missing path mappings to preserve the
+## long-standing status behavior for clients not installed on this platform.
+func resolved_config_path_details() -> Dictionary:
+ var details := _resolve_config_path_details()
+ var path := str(details.get("path", ""))
+ if path.is_empty() or path.is_absolute_path():
+ return details
+ ## Last gate before every strategy's read/write. `McpPathTemplate.expand`
+ ## leaves a token it cannot resolve in place, so an unset `$USERPROFILE` (or
+ ## a `~` with no HOME) reaches here as a RELATIVE path rather than as the
+ ## root-relative `/godot` that sails through `is_absolute_path()`. Acting on
+ ## it would resolve against the editor's own working directory and create a
+ ## literal `$USERPROFILE` folder in the Godot project. No descriptor
+ ## template is intentionally relative — every one is `~`- or `$VAR`-rooted —
+ ## so a non-absolute survivor is always a resolution failure. Report it with
+ ## the unexpanded path, which still shows what could not be resolved.
+ return {"path": "", "error": unresolved_config_path_error(display_name, path)}
+
+
+## Shared wording for a path template `McpPathTemplate.expand` could not fully
+## resolve. Used by the guard above and by the merge-tier loader in
+## `_json_strategy.gd`, which resolves its own templates and never passes
+## through `resolved_config_path_details`.
+static func unresolved_config_path_error(client_name: String, path: String) -> String:
+ return (
+ "Could not resolve %s's config path: %s did not expand to an absolute "
+ + "path. Set HOME/USERPROFILE (or the variable the path names) in the "
+ + "environment the editor was launched from."
+ ) % [client_name, path]
+
+
+func _resolve_config_path_details() -> Dictionary:
+ ## Reflected reads: after an in-session self-update, an instance created
+ ## before the update can answer Nil for vars the update added, and the
+ ## typed calls below would each hard-error (Nil -> Dictionary, #850's
+ ## per-row error wall). Fail with the
+ ## one repair message instead; the registry's coherence probe drives the
+ ## same text on the status path.
+ var candidates: Variant = get("config_path_candidates")
+ var template: Variant = get("path_template")
+ var file_env: Variant = get("config_file_env")
+ if not (candidates is Dictionary) or not (template is Dictionary) or not (file_env is String):
+ return {"path": "", "error": McpClientRegistry.RESTART_TO_FINISH_UPDATE}
+ var file_override := config_file_override_details()
+ if not str(file_override.get("path", "")).is_empty() or not str(file_override.get("error", "")).is_empty():
+ _clear_config_path_warning()
+ return file_override
+ var home_override := config_home_override_details()
+ if not str(home_override.get("path", "")).is_empty() or not str(home_override.get("error", "")).is_empty():
+ _clear_config_path_warning()
+ return home_override
+ var candidate_key := McpPathTemplate.platform_key(candidates)
+ if not candidate_key.is_empty():
+ return _resolve_ordered_config_path_candidates(candidates[candidate_key])
+ _clear_config_path_warning()
+ return {"path": McpPathTemplate.resolve(template), "error": ""}
+
+
+## The exact-file env override plus any fail-closed diagnostic. Empty path and
+## error means no override applies (no mapping, unset, or blank env var).
+func config_file_override_details() -> Dictionary:
+ if config_file_env.is_empty():
+ return {"path": "", "error": ""}
+ ## env_lookup, not OS.get_environment: this can run on dock workers (#691).
+ var raw_path := McpPathTemplate.env_lookup(config_file_env).strip_edges()
+ if raw_path.is_empty():
+ return {"path": "", "error": ""}
+ var expanded := McpPathTemplate.expand(raw_path)
+ if not expanded.is_absolute_path():
+ return {
+ "path": "",
+ "error": "%s's $%s override must be an absolute config-file path; got %s" % [
+ display_name, config_file_env, raw_path,
+ ],
+ }
+ if DirAccess.dir_exists_absolute(expanded):
+ return {
+ "path": "",
+ "error": "%s's $%s override must point to a config file, not a directory: %s" % [
+ display_name, config_file_env, expanded,
+ ],
+ }
+ return {"path": expanded, "error": ""}
+
+
+func _resolve_ordered_config_path_candidates(templates: Variant) -> Dictionary:
+ if not (templates is Array or templates is PackedStringArray):
+ _clear_config_path_warning()
+ return {"path": "", "error": ""}
+ var ordered_templates: Array = []
+ for template_variant in templates:
+ ordered_templates.append(str(template_variant))
+ var fallback_create_path := ""
+ for index in range(ordered_templates.size()):
+ var template := str(ordered_templates[index])
+ var expanded_template := McpPathTemplate.expand(template)
+ # An empty wildcard group ordinarily means "this package is not
+ # installed", but an unresolved root token produces the same empty
+ # group. Do not silently treat "could not inspect" as "not present" and
+ # fall through to a later writable candidate.
+ if not expanded_template.is_absolute_path():
+ _clear_config_path_warning()
+ return {
+ "path": "",
+ "error": unresolved_config_path_error(display_name, expanded_template),
+ }
+ var group := McpPathTemplate.expand_path_candidates(template)
+ if group.size() > 1:
+ var message := (
+ "%s has multiple matching config package paths for %s: %s. "
+ + "Remove the stale package installation or edit the intended config manually."
+ ) % [display_name, template, ", ".join(group)]
+ _warn_config_path_once(message)
+ return {"path": "", "error": message}
+ if group.is_empty():
+ continue
+ var path := String(group[0])
+ if FileAccess.file_exists(path):
+ _clear_config_path_warning()
+ return {"path": path, "error": ""}
+ # A wildcard only resolves when its package directory exists. Treat that
+ # installation evidence as authoritative and create its private config
+ # directly instead of relying on copy-on-write read-through. Preserve
+ # anything currently visible through read-through by naming the first
+ # later existing candidate as a one-time seed source.
+ if template.contains("*"):
+ var seed := _first_existing_later_candidate(ordered_templates, index + 1)
+ if not str(seed.get("error", "")).is_empty():
+ _clear_config_path_warning()
+ return {"path": "", "error": seed["error"]}
+ _clear_config_path_warning()
+ return {"path": path, "error": "", "seed_path": seed.get("path", "")}
+ if fallback_create_path.is_empty():
+ fallback_create_path = path
+ _clear_config_path_warning()
+ return {"path": fallback_create_path, "error": ""}
+
+
+## Find a readable seed for a newly-created authoritative wildcard target.
+## An unresolved later root is an error, not an absent seed: writing without
+## inspecting it could silently discard configuration visible through the
+## package's read-through fallback.
+func _first_existing_later_candidate(templates: Array, start_index: int) -> Dictionary:
+ for index in range(start_index, templates.size()):
+ var template := str(templates[index])
+ var expanded_template := McpPathTemplate.expand(template)
+ if not expanded_template.is_absolute_path():
+ return {
+ "path": "",
+ "error": unresolved_config_path_error(display_name, expanded_template),
+ }
+ var group := McpPathTemplate.expand_path_candidates(template)
+ # A seed is optional. Never choose among an ambiguous later wildcard;
+ # the authoritative target was already resolved by the earlier group.
+ if group.size() != 1:
+ continue
+ var path := String(group[0])
+ if FileAccess.file_exists(path):
+ return {"path": path, "error": ""}
+ return {"path": "", "error": ""}
+
+
+func _warn_config_path_once(message: String) -> void:
+ _config_path_warning_mutex.lock()
+ var should_warn := message != _last_config_path_warning
+ _last_config_path_warning = message
+ _config_path_warning_mutex.unlock()
+ if should_warn:
+ push_warning(message)
+
+
+func _clear_config_path_warning() -> void:
+ _config_path_warning_mutex.lock()
+ _last_config_path_warning = ""
+ _config_path_warning_mutex.unlock()
+
+
+## The config-home env override plus any fail-closed diagnostic. Empty path and
+## error means no override applies (no mapping declared, env var unset, or env
+## var empty/whitespace).
+func config_home_override_details() -> Dictionary:
+ if config_home_env.is_empty() or config_home_env_subpath.is_empty():
+ return {"path": "", "error": ""}
+ ## env_lookup, not OS.get_environment: this runs on dock worker threads,
+ ## which must not race the spawn window's setenv/unsetenv (#691).
+ var home := McpPathTemplate.env_lookup(config_home_env).strip_edges()
+ if home.is_empty():
+ return {"path": "", "error": ""}
+ # Expand a leading ~ so `CODEX_HOME=~/codex-alt` behaves like the shell.
+ var expanded := McpPathTemplate.expand(home)
+ ## Same fail-closed rule as `config_file_override_details`: a relative value
+ ## resolves against the EDITOR's working directory, not the client's, so
+ ## honouring it silently aims the write at the Godot project directory
+ ## instead of the file the client reads. The write layer can only name the
+ ## mangled path it was handed; only here do we still know which env var
+ ## produced it, so this is where the user-facing explanation belongs.
+ if not expanded.is_absolute_path():
+ return {
+ "path": "",
+ "error": "%s's $%s override must be an absolute config-home directory path; got %s" % [
+ display_name, config_home_env, home,
+ ],
+ }
+ return {"path": expanded.path_join(config_home_env_subpath), "error": ""}
+
+
+## The env-var-relocated config path, or "" when no override applies (no
+## mapping declared, env var unset, env var empty/whitespace, or a value that
+## failed closed — `config_home_override_details` carries that diagnostic).
+func config_home_override() -> String:
+ return str(config_home_override_details().get("path", ""))
+
+
+## True when a CLI client also declares where its config file lives, so it can
+## fall back to writing that file directly when the CLI binary isn't on PATH.
+## #463: Claude Code installed only as a VS Code / Cursor extension exposes no
+## `claude` binary, but `claude mcp add --scope user` just writes `mcpServers`
+## into ~/.claude.json — so we can produce the same entry ourselves.
+func has_json_fallback() -> bool:
+ return config_type == "cli" and not path_template.is_empty() and not server_key_path.is_empty()
+
+
+## True if the user appears to have this client installed locally.
+func is_installed() -> bool:
+ if config_type == "cli":
+ if not McpCliFinder.find(_array_from_packed(cli_names)).is_empty():
+ return true
+ # CLI not on PATH. A cli client with a JSON fallback (Claude Code as a
+ # VS Code/Cursor extension, #463) still counts as installed if its
+ # fallback config file already exists.
+ if has_json_fallback():
+ var cfg := resolved_config_path()
+ return not cfg.is_empty() and FileAccess.file_exists(cfg)
+ return false
+ for p in detect_paths:
+ for resolved in McpPathTemplate.expand_path_candidates(p):
+ if FileAccess.file_exists(resolved) or DirAccess.dir_exists_absolute(resolved):
+ return true
+ # Fall back to "config file already exists" — usually means installed at some point.
+ var cfg := resolved_config_path()
+ return not cfg.is_empty() and FileAccess.file_exists(cfg)
+
+
+static func _array_from_packed(packed: PackedStringArray) -> Array[String]:
+ var out: Array[String] = []
+ for s in packed:
+ out.append(s)
+ return out
+
+
+## Slice a PackedStringArray into a new PackedStringArray over [from, to).
+## Used by `_toml_strategy` and `_manual_command` to peel the section path
+## apart for `[a.b."c"]` header rendering.
+static func _packed_slice(packed: PackedStringArray, from: int, to: int) -> PackedStringArray:
+ var out := PackedStringArray()
+ for i in range(from, to):
+ out.append(packed[i])
+ return out
+
+
+## Whether an existing entry's launch text launches Godot AI: an executable
+## named `godot-ai`, a `godot-ai==` package pin, the bare `godot-ai`
+## console-script argument, or the `godot_ai` module. Tokens are matched
+## exactly, so a URL or path that merely contains the name (a docs link, a
+## project directory) does not count. The post-update major migration
+## rewrites only such entries; anything else is the user's own server.
+static func launch_mentions_godot_ai(text: String) -> bool:
+ ## Free text (a CLI probe's output) is split on spaces; structured launch
+ ## values should go through `launch_values_mention_godot_ai` unsplit.
+ return launch_values_mention_godot_ai(PackedStringArray(text.split(" ", false)))
+
+
+## Each value is one command, argument or URL. A URI never names an
+## executable, and Windows separators are normalized before the basename
+## check so `C:\Program Files\Godot AI\godot-ai.exe` is recognized whole.
+static func launch_values_mention_godot_ai(values: PackedStringArray) -> bool:
+ for raw_value in values:
+ var value := raw_value.strip_edges().lstrip("\"'[{(").rstrip("\"'])},")
+ if value.is_empty() or value.contains("://"):
+ continue
+ if value == "godot-ai" or value == "godot_ai" or value.begins_with("godot-ai=="):
+ return true
+ var base := value.replace("\\", "/").get_file()
+ if base == "godot-ai" or base == "godot-ai.exe":
+ return true
+ return false
+
+
+## The launch-bearing fields of an entry, one value each, for the check
+## above. The entry sits under our server name, so the name itself must not
+## count.
+static func entry_launch_values(entry: Dictionary) -> PackedStringArray:
+ var values := PackedStringArray()
+ for key in ["command", "args", "url"]:
+ if not entry.has(key):
+ continue
+ var value: Variant = entry[key]
+ if value is Array:
+ for item in value:
+ values.append(str(item))
+ else:
+ values.append(str(value))
+ return values
diff --git a/addons/godot_ai/clients/_base.gd.uid b/addons/godot_ai/clients/_base.gd.uid
new file mode 100644
index 0000000..5ea77d3
--- /dev/null
+++ b/addons/godot_ai/clients/_base.gd.uid
@@ -0,0 +1 @@
+uid://cyowqr1x12ilg
diff --git a/addons/godot_ai/clients/_cli_exec.gd b/addons/godot_ai/clients/_cli_exec.gd
new file mode 100644
index 0000000..c645730
--- /dev/null
+++ b/addons/godot_ai/clients/_cli_exec.gd
@@ -0,0 +1,219 @@
+@tool
+class_name McpCliExec
+extends RefCounted
+
+## Wall-clock-bounded CLI invocation. Every dock shell-out to a per-client
+## CLI (`claude mcp list`, `claude mcp add ...`, etc.) goes through here so
+## a hung subprocess can't trap the calling thread forever.
+##
+## Without the timeout, a contended `claude mcp list` has been observed to
+## hang for 6+ minutes (issues #238, #239) — wedging the dock's status
+## refresh worker, and on the Configure / Remove paths the editor main
+## thread itself.
+##
+## Why poll/kill instead of `OS.execute(..., true)`: GDScript can't
+## interrupt a blocking `OS.execute`, so a hung CLI takes its caller's
+## thread with it. `OS.execute_with_pipe` returns immediately with a PID;
+## we drive the wait ourselves and terminate the exact captured process if
+## budget expires. CLI registry commands have bounded output (a few hundred
+## bytes), so we don't bother draining the pipe during the poll loop —
+## the kernel buffer absorbs it.
+##
+## Returns a Dictionary with:
+## exit_code: process exit code (0 = success). -1 on timeout / spawn failure.
+## stdout: captured stdout text. May be partial on timeout.
+## stderr: captured stderr text. May be partial on timeout. Empty when
+## `capture_stderr` is false.
+## output: stdout + (newline + stderr if non-empty). Convenience for
+## the common case of "show whatever the CLI said when it
+## failed" — `claude mcp add` writes its real diagnostics to
+## stderr, so callers that only read `stdout` would surface
+## a generic "exit code 1" instead.
+## timed_out: true if the wall-clock budget expired.
+## cancelled: true if the caller requested a cooperative early stop.
+## spawn_failed: true if `OS.execute_with_pipe` didn't return a usable PID.
+## termination_failed: true if an expired/cancelled process or any of its
+## possible descendants could not be proven stopped. POSIX
+## only gives this helper exact-PID authority, so timeout and
+## cancellation are necessarily unproven there.
+
+const DEFAULT_TIMEOUT_MS := 8000
+const _POLL_INTERVAL_MS := 50
+const _QUICK_EXIT_WINDOW_MS := 250
+const _KILL_GRACE_MS := 500
+const PortResolver := preload("res://addons/godot_ai/utils/port_resolver.gd")
+const UvResolution := preload("res://addons/godot_ai/utils/uv_resolution_policy.gd")
+
+
+static func run(
+ exe: String,
+ args: Array,
+ timeout_ms: int = DEFAULT_TIMEOUT_MS,
+ capture_stderr: bool = true,
+ cancel_check: Callable = Callable(),
+ isolate_uv_resolution: bool = false,
+) -> Dictionary:
+ if exe.is_empty():
+ return _spawn_failed_result()
+ return _run_piped(
+ exe, args, timeout_ms, capture_stderr, cancel_check, isolate_uv_resolution
+ )
+
+
+static func _run_piped(
+ exe: String,
+ args: Array,
+ timeout_ms: int,
+ capture_stderr: bool,
+ cancel_check: Callable,
+ isolate_uv_resolution: bool,
+) -> Dictionary:
+
+ var spawn_exe := exe
+ var spawn_args := args
+ if OS.get_name() == "Windows":
+ var lower := exe.to_lower()
+ if lower.ends_with(".cmd") or lower.ends_with(".bat"):
+ ## CreateProcessW can't launch `.cmd` / `.bat` scripts on its
+ ## own — they're cmd.exe input, not PE binaries. Without this
+ ## wrap, the moment `McpCliFinder` resolves a Node-style shim
+ ## (npm's `claude.cmd`, pnpm's wrappers, …) the next
+ ## `OS.execute_with_pipe` surfaces "Could not create child
+ ## process: ..." in Godot's output log (#251). Passing
+ ## `exe` as a separate argv element keeps spaces in the path
+ ## quoted by Godot's standard quoter — no manual escaping.
+ spawn_exe = "cmd.exe"
+ spawn_args = ["/c", exe]
+ spawn_args.append_array(args)
+
+ PortResolver.lock_process_spawn()
+ var saved_uv_environment := (
+ UvResolution.isolate_environment() if isolate_uv_resolution else {}
+ )
+ var info := OS.execute_with_pipe(spawn_exe, spawn_args)
+ UvResolution.restore_environment(saved_uv_environment)
+ PortResolver.unlock_process_spawn()
+ if info.is_empty():
+ return _spawn_failed_result()
+
+ var pid: int = int(info.get("pid", -1))
+ var stdio: Variant = info.get("stdio", null)
+ var stderr_pipe: Variant = info.get("stderr", null)
+ if pid <= 0:
+ _close_pipes(stdio, stderr_pipe)
+ return _spawn_failed_result()
+ var deadline := Time.get_ticks_msec() + maxi(timeout_ms, _POLL_INTERVAL_MS)
+ var kill_grant: Dictionary = {}
+ ## Most discovery commands finish within a few polls. Do not spend Windows
+ ## identity-query time capturing kill authority for an already-exited child.
+ ## This short window shares the command deadline and observes cancellation;
+ ## a child still running afterward needs the same exact grant as before.
+ var quick_deadline := mini(deadline, Time.get_ticks_msec() + _QUICK_EXIT_WINDOW_MS)
+ var cancellation_requested := false
+ while OS.is_process_running(pid) and Time.get_ticks_msec() < quick_deadline:
+ if cancel_check.is_valid() and bool(cancel_check.call()):
+ cancellation_requested = true
+ break
+ OS.delay_msec(mini(_POLL_INTERVAL_MS, maxi(0, quick_deadline - Time.get_ticks_msec())))
+ if OS.is_process_running(pid):
+ kill_grant = PortResolver.capture_process_kill_grant(pid)
+
+ while OS.is_process_running(pid):
+ var cancelled := cancellation_requested or (cancel_check.is_valid() and bool(cancel_check.call()))
+ if cancelled or Time.get_ticks_msec() >= deadline:
+ ## Kill before draining: a pipe read can block while the launcher or
+ ## one of its descendants still owns the write end. Windows taskkill
+ ## /T gives us tree authority; POSIX OS.kill only targets this exact
+ ## PID, so even a dead launcher cannot prove its descendants stopped.
+ var killed: Array[int] = []
+ if not kill_grant.is_empty():
+ killed = PortResolver.kill_exact_processes([kill_grant], false, true)
+ var kill_deadline := Time.get_ticks_msec() + _KILL_GRACE_MS
+ while PortResolver.pid_alive(pid) and Time.get_ticks_msec() < kill_deadline:
+ OS.delay_msec(_POLL_INTERVAL_MS)
+
+ var termination_failed := (
+ PortResolver.pid_alive(pid)
+ or OS.get_name() != "Windows"
+ or not killed.has(pid)
+ )
+ var partial_stdout := ""
+ var partial_stderr := ""
+ if not termination_failed:
+ partial_stdout = _drain_pipe(stdio)
+ partial_stderr = _drain_pipe(stderr_pipe) if capture_stderr else ""
+ _close_pipes(stdio, stderr_pipe)
+ return {
+ "exit_code": -1,
+ "stdout": partial_stdout,
+ "stderr": partial_stderr,
+ "output": _join_streams(partial_stdout, partial_stderr),
+ "timed_out": not cancelled,
+ "cancelled": cancelled,
+ "spawn_failed": false,
+ "termination_failed": termination_failed,
+ }
+ OS.delay_msec(_POLL_INTERVAL_MS)
+
+ var stdout := _drain_pipe(stdio)
+ var stderr_text := _drain_pipe(stderr_pipe) if capture_stderr else ""
+ _close_pipes(stdio, stderr_pipe)
+
+ return {
+ "exit_code": OS.get_process_exit_code(pid),
+ "stdout": stdout,
+ "stderr": stderr_text,
+ "output": _join_streams(stdout, stderr_text),
+ "timed_out": false,
+ "cancelled": false,
+ "spawn_failed": false,
+ "termination_failed": false,
+ }
+
+
+static func _spawn_failed_result() -> Dictionary:
+ return {
+ "exit_code": -1,
+ "stdout": "",
+ "stderr": "",
+ "output": "",
+ "timed_out": false,
+ "cancelled": false,
+ "spawn_failed": true,
+ "termination_failed": false,
+ }
+
+
+static func _drain_pipe(pipe: Variant) -> String:
+ if not (pipe is FileAccess):
+ return ""
+ var f := pipe as FileAccess
+ var bytes := PackedByteArray()
+ var max_bytes := 1 << 20 # 1 MiB, far above expected client CLI output.
+ while bytes.size() < max_bytes:
+ var chunk := f.get_buffer(mini(4096, max_bytes - bytes.size()))
+ if chunk.is_empty():
+ break
+ bytes.append_array(chunk)
+ if f.eof_reached():
+ break
+ return bytes.get_string_from_utf8()
+
+
+static func _join_streams(stdout: String, stderr_text: String) -> String:
+ ## Most CLIs write their actionable diagnostics to one stream or the
+ ## other, never both — so concatenation gives "the message" without
+ ## the caller having to guess which key to read. Newline-separate so
+ ## callers that grep don't see two lines run together.
+ if stderr_text.is_empty():
+ return stdout
+ if stdout.is_empty():
+ return stderr_text
+ return "%s\n%s" % [stdout, stderr_text]
+
+
+static func _close_pipes(stdio: Variant, stderr_pipe: Variant) -> void:
+ if stdio is FileAccess:
+ (stdio as FileAccess).close()
+ if stderr_pipe is FileAccess:
+ (stderr_pipe as FileAccess).close()
diff --git a/addons/godot_ai/clients/_cli_exec.gd.uid b/addons/godot_ai/clients/_cli_exec.gd.uid
new file mode 100644
index 0000000..4a97a2c
--- /dev/null
+++ b/addons/godot_ai/clients/_cli_exec.gd.uid
@@ -0,0 +1 @@
+uid://dhoe3ypkhm12v
diff --git a/addons/godot_ai/clients/_cli_finder.gd b/addons/godot_ai/clients/_cli_finder.gd
new file mode 100644
index 0000000..394e76c
--- /dev/null
+++ b/addons/godot_ai/clients/_cli_finder.gd
@@ -0,0 +1,181 @@
+@tool
+class_name McpCliFinder
+extends RefCounted
+
+## Generic three-tier CLI resolution for clients whose binary lives somewhere
+## a GUI-launched Godot's minimal PATH won't see:
+## 1. Well-known install locations (~/.local/bin, /opt/homebrew/bin, ...)
+## 2. Login shell lookup (`bash -lc 'command -v '`) — picks up .zshrc / .bashrc
+## 3. Plain `which` / `where` against the inherited PATH
+## Caches per-exe so repeated dock refreshes don't fork a shell every frame.
+##
+## Thread safety: `find()` runs on action-worker threads
+## (`_run_client_action_worker` in `mcp_dock.gd`), and `invalidate()` runs on
+## the main thread (manual Refresh path). Godot `Dictionary` is not safe for
+## concurrent mutation, so `_cache` / `_searched` access is guarded by
+## `_mutex`. The mutex is held only across dictionary read/write — the slow
+## `_resolve()` path (FileAccess + bounded subprocess lookup) runs unlocked, so a
+## main-thread `invalidate()` can never block on a worker's subprocess.
+## Two workers racing the same exe both call `_resolve()` and both write
+## back the same answer; that's wasted work, not corruption.
+
+
+static var _mutex: Mutex = Mutex.new()
+static var _cache: Dictionary = {} # exe_name -> resolved path (or "")
+static var _searched: Dictionary = {}
+
+const _LOOKUP_TIMEOUT_MS := 3000
+
+
+## Find any of the supplied exe names; returns the first hit.
+## On Windows pass the .exe variant in `exe_names` if relevant.
+static func find(exe_names: Array[String]) -> String:
+ for name in exe_names:
+ var hit := _find_one(name)
+ if not hit.is_empty():
+ return hit
+ return ""
+
+
+## Drop cache for one exe (call after the user installs / reinstalls).
+static func invalidate(exe_name: String = "") -> void:
+ _mutex.lock()
+ if exe_name.is_empty():
+ _cache.clear()
+ _searched.clear()
+ else:
+ _cache.erase(exe_name)
+ _searched.erase(exe_name)
+ _mutex.unlock()
+
+
+static func _find_one(exe_name: String) -> String:
+ _mutex.lock()
+ var already_searched: bool = _searched.get(exe_name, false)
+ var cached: String = _cache.get(exe_name, "")
+ _mutex.unlock()
+ if already_searched:
+ return cached
+ # `_resolve()` does FileAccess + bounded subprocess lookup (forks
+ # `bash -lc` / `which`), which can take 100ms-1s. Holding the mutex across that
+ # would let a concurrent `invalidate()` on the main thread freeze the
+ # editor for the duration of the subprocess — which defeats the whole
+ # point of running CLI lookup off the main thread.
+ var hit := _resolve(exe_name)
+ _mutex.lock()
+ _cache[exe_name] = hit
+ _searched[exe_name] = true
+ _mutex.unlock()
+ return hit
+
+
+static func _resolve(exe_name: String) -> String:
+ var is_windows := OS.get_name() == "Windows"
+
+ # 1. Well-known locations
+ for dir in _well_known_dirs():
+ var full := dir.path_join(exe_name)
+ if FileAccess.file_exists(full):
+ return full
+
+ # 2. Login shell lookup (Unix only)
+ if not is_windows:
+ ## env_lookup, not OS.get_environment: CLI resolution runs on dock
+ ## worker threads (configure/remove actions) and must not race the
+ ## spawn window's setenv/unsetenv (#691).
+ var shell := McpPathTemplate.env_lookup("SHELL")
+ if shell.is_empty():
+ shell = "/bin/bash"
+ var stripped := exe_name.trim_suffix(".exe")
+ var login_result := McpCliExec.run(shell, ["-lc", "command -v %s" % stripped], _LOOKUP_TIMEOUT_MS, false)
+ if int(login_result.get("exit_code", -1)) == 0:
+ var login_found: String = str(login_result.get("stdout", "")).strip_edges()
+ if not login_found.is_empty() and FileAccess.file_exists(login_found):
+ return login_found
+
+ # 3. which / where with inherited PATH
+ var lookup := "where" if is_windows else "which"
+ var result := McpCliExec.run(lookup, [exe_name], _LOOKUP_TIMEOUT_MS, false)
+ if int(result.get("exit_code", -1)) == 0:
+ var output := str(result.get("stdout", ""))
+ var lines := PackedStringArray(output.split("\n"))
+ var found := _pick_best_path(lines) if is_windows else lines[0].strip_edges()
+ if not found.is_empty():
+ return found
+ return ""
+
+
+## Executable extensions Windows' CreateProcessW can launch from a path
+## (after the cmd.exe wrap in `_cli_exec.gd`). Order is preference: `.exe`
+## is a native PE binary; `.cmd` / `.bat` go through the shell; `.com` is
+## the legacy COM-format executable that some shims still ship.
+const _WINDOWS_EXEC_EXTS := [".exe", ".cmd", ".bat", ".com"]
+
+
+## Pick the best path from `where` output on Windows.
+##
+## npm-installed Node CLIs ship as BOTH `/` (a POSIX bash shim
+## for WSL / Git Bash users) AND `/.cmd` (the actual Windows
+## wrapper). `where ` lists both. CreateProcessW — the underlying
+## syscall behind `OS.execute_with_pipe` — refuses to launch the
+## extensionless POSIX shim, surfacing as
+## `ERROR: Could not create child process: "...\claude" mcp list`
+## in Godot's output log (#251). Picking a path with a real executable
+## extension dodges that entirely.
+##
+## Extension scan is the OUTER loop so the order in `_WINDOWS_EXEC_EXTS`
+## drives preference — `.exe` wins over `.cmd` even when the `.cmd` shows
+## up first in `where` output (one fewer process per shell-out). Falls
+## back to the first non-empty line when no entry has a recognised
+## extension, so we never come up empty when `where` returned *something*.
+static func _pick_best_path(lines: PackedStringArray) -> String:
+ var stripped := PackedStringArray()
+ for raw in lines:
+ var line := raw.strip_edges()
+ if not line.is_empty():
+ stripped.append(line)
+ if stripped.is_empty():
+ return ""
+ for ext in _WINDOWS_EXEC_EXTS:
+ for candidate in stripped:
+ if candidate.to_lower().ends_with(ext):
+ return candidate
+ return stripped[0]
+
+
+static func _well_known_dirs() -> Array[String]:
+ ## env_lookup, not OS.get_environment — see _resolve()'s worker-thread
+ ## note (#691).
+ var home := McpPathTemplate.env_lookup("HOME")
+ if home.is_empty():
+ home = McpPathTemplate.env_lookup("USERPROFILE")
+ match OS.get_name():
+ "macOS":
+ return [
+ home.path_join(".local/bin"),
+ home.path_join(".claude/local"),
+ home.path_join(".cargo/bin"),
+ "/opt/homebrew/bin",
+ "/usr/local/bin",
+ ]
+ "Windows":
+ var local := McpPathTemplate.env_lookup("LOCALAPPDATA")
+ var prog := McpPathTemplate.env_lookup("ProgramFiles")
+ var paths: Array[String] = []
+ if not home.is_empty():
+ paths.append(home.path_join(".claude/local"))
+ paths.append(home.path_join(".local/bin"))
+ paths.append(home.path_join(".cargo/bin"))
+ paths.append(home.path_join("AppData/Local/Programs/uv"))
+ if not local.is_empty():
+ paths.append(local.path_join("Programs/uv"))
+ if not prog.is_empty():
+ paths.append(prog.path_join("uv"))
+ return paths
+ _:
+ return [
+ home.path_join(".local/bin"),
+ home.path_join(".claude/local"),
+ home.path_join(".cargo/bin"),
+ "/usr/local/bin",
+ ]
diff --git a/addons/godot_ai/clients/_cli_finder.gd.uid b/addons/godot_ai/clients/_cli_finder.gd.uid
new file mode 100644
index 0000000..9985270
--- /dev/null
+++ b/addons/godot_ai/clients/_cli_finder.gd.uid
@@ -0,0 +1 @@
+uid://cnp5b6fcwou2y
diff --git a/addons/godot_ai/clients/_cli_strategy.gd b/addons/godot_ai/clients/_cli_strategy.gd
new file mode 100644
index 0000000..2d2d013
--- /dev/null
+++ b/addons/godot_ai/clients/_cli_strategy.gd
@@ -0,0 +1,466 @@
+@tool
+class_name McpCliStrategy
+extends RefCounted
+
+## Strategy for MCP clients that own their own state via a CLI (e.g.
+## `claude mcp add`). Reads `cli_register_template` / `cli_unregister_template`
+## / `cli_status_args` from the descriptor and substitutes `{name}` / `{url}`
+## tokens. Command-shape descriptors additionally use the whole-element launch
+## tokens `{command}` / `{args...}` (see `format_args`). No descriptor-supplied
+## Callables — see `_base.gd` for why.
+##
+## Every shell-out goes through `McpCliExec.run`, which wraps the call in a
+## wall-clock timeout. A hung CLI (e.g. `claude mcp list` under
+## inter-Claude-Code contention) gets killed at the budget instead of
+## locking up the caller forever — see issues #238 / #239.
+
+## The descriptor token resolved from `godot_ai/mcp_client_scope` (#872).
+## Only Claude Code takes it today; any `config_type = "cli"` descriptor can.
+const SCOPE_TOKEN := "{scope}"
+
+const _CONFIGURE_TIMEOUT_MS := 10000
+const _REMOVE_TIMEOUT_MS := 10000
+const _STATUS_TIMEOUT_MS := 6000
+const MutationLock := preload("res://addons/godot_ai/utils/client_mutation_lock.gd")
+
+
+static func configure(
+ client: McpClient,
+ server_name: String,
+ server_url: String,
+ launch: Dictionary = {},
+) -> Dictionary:
+ ## Fail closed before any subprocess runs: a command-shape client without a
+ ## verified attach launcher must not register anything (see
+ ## docs/client-configuration.md — an ERROR beats an entry known to be broken).
+ var launch_error := command_launch_error(client, launch)
+ if not launch_error.is_empty():
+ return {"status": "error", "message": launch_error}
+ var cli := _resolve_cli(client)
+ if cli.is_empty():
+ return {"status": "error", "message": "%s not found" % client.display_name}
+ return _configure_claimed(client, server_name, server_url, launch, cli)
+
+
+static func _configure_claimed(
+ client: McpClient,
+ server_name: String,
+ server_url: String,
+ launch: Dictionary,
+ cli: String,
+) -> Dictionary:
+
+ # Best-effort prior cleanup so re-configure is idempotent. Ordinary absent-
+ # entry/non-zero results remain ignorable; an unproven timeout is not,
+ # because its descendant could race the configure that follows.
+ if not client.cli_unregister_template.is_empty():
+ ## #872: a `{scope}` descriptor sweeps EVERY scope, not just the
+ ## selected one. Flipping the setting user -> project and pressing
+ ## Configure would otherwise leave the old user-scope entry alive —
+ ## exactly the "loaded in every unrelated workspace" problem the
+ ## setting exists to fix. `mcp remove` on an absent entry is a no-op,
+ ## so ordinary results are discarded; ambiguous termination aborts the
+ ## sequence. Each extra scope costs one bounded subprocess.
+ for pre_scope in _cleanup_scopes(client):
+ var pre_args := _format_args(
+ client.cli_unregister_template, server_name, server_url, {}, pre_scope
+ )
+ var cleaned := McpCliExec.run(cli, pre_args, _REMOVE_TIMEOUT_MS)
+ var cleanup_failure := _unproven_mutation_failure(
+ client, "Prior cleanup", cleaned
+ )
+ if not cleanup_failure.is_empty():
+ return cleanup_failure
+
+ if client.cli_register_template.is_empty():
+ return {"status": "error", "message": "%s descriptor missing cli_register_template" % client.display_name}
+ var args := _format_args(client.cli_register_template, server_name, server_url, launch)
+ var result := McpCliExec.run(cli, args, _CONFIGURE_TIMEOUT_MS)
+ var termination_failure := _unproven_mutation_failure(client, "Configure", result)
+ if not termination_failure.is_empty():
+ return termination_failure
+ if result.get("timed_out", false):
+ return {
+ "status": "error",
+ "message": "Configure %s timed out after %ds — see 'Run this manually' below to retry by hand" % [
+ client.display_name, _CONFIGURE_TIMEOUT_MS / 1000,
+ ],
+ }
+ if result.get("spawn_failed", false):
+ return {"status": "error", "message": "Failed to spawn %s" % client.display_name}
+ if int(result.get("exit_code", -1)) == 0:
+ return {"status": "ok", "message": McpClient.configured_message(client, server_url)}
+ ## `claude mcp add` writes its real failure diagnostics to stderr, so
+ ## prefer `output` (stdout + stderr) over `stdout` alone — otherwise
+ ## the user sees "exit code 1" instead of the actual error.
+ var combined := str(result.get("output", "")).strip_edges()
+ var err := combined if not combined.is_empty() else "exit code %d" % int(result.get("exit_code", -1))
+ return {"status": "error", "message": "Failed to configure %s: %s" % [client.display_name, err]}
+
+
+## Run the descriptor's `cli_status_args`, scan stdout for `server_name` and
+## the expected target. The matching rule is the only sensible one for "list
+## MCP entries" output across CLI clients we currently support: name AND
+## target present → CONFIGURED; name only → MISMATCH; neither →
+## NOT_CONFIGURED. For URL descriptors the target is `server_url`; for
+## command-shape descriptors it is the resolved attach launcher path (the
+## listing prints the registered command line, not a URL). Command-shape CLI
+## clients with a JSON fallback file get exact drift detection via the JSON
+## strategy instead — the configurator prefers that path and only lands here
+## for CLI clients whose state isn't file-readable.
+static func check_status(
+ client: McpClient, server_name: String, server_url: String, launch: Dictionary = {}
+) -> McpClient.Status:
+ return check_status_with_cli_path(client, server_name, server_url, _resolve_cli(client), launch)
+
+
+static func check_status_with_cli_path(
+ client: McpClient, server_name: String, server_url: String, cli: String, launch: Dictionary = {}
+) -> McpClient.Status:
+ return check_status_details(client, server_name, server_url, cli, launch).get("status", McpClient.Status.NOT_CONFIGURED)
+
+
+## Detailed variant used by the dock's refresh worker so it can surface a
+## "probe timed out" badge on the affected row instead of silently
+## conflating the timeout with NOT_CONFIGURED. Returns
+## `{"status": Status, "error_msg": String}`. The caller plumbs
+## `error_msg` straight into `_apply_row_status`.
+static func check_status_details(
+ client: McpClient, server_name: String, server_url: String, cli: String, launch: Dictionary = {}
+) -> Dictionary:
+ if cli.is_empty():
+ return _status_details(McpClient.Status.NOT_CONFIGURED)
+ if client.cli_status_args.is_empty():
+ return _status_details(McpClient.Status.NOT_CONFIGURED)
+ var expected_target := server_url
+ if client.command_shape != McpClient.CommandShape.NONE:
+ ## Same fail-closed contract as configure: without a verified launcher
+ ## there is no target to compare against, and guessing would report a
+ ## broken entry as green.
+ var launch_error := command_launch_error(client, launch)
+ if not launch_error.is_empty():
+ return _status_details(McpClient.Status.ERROR, launch_error)
+ expected_target = str(launch.get("command", ""))
+ var result := McpCliExec.run(
+ cli,
+ McpClient._array_from_packed(client.cli_status_args),
+ _STATUS_TIMEOUT_MS,
+ false
+ )
+ if result.get("timed_out", false):
+ return _status_details(McpClient.Status.ERROR, "probe timed out")
+ if result.get("spawn_failed", false):
+ return _status_details(McpClient.Status.NOT_CONFIGURED)
+ if int(result.get("exit_code", -1)) != 0:
+ return _status_details(McpClient.Status.NOT_CONFIGURED)
+ var text := str(result.get("stdout", ""))
+ if text.find(server_name) < 0:
+ return _status_details(McpClient.Status.NOT_CONFIGURED)
+ ## Server registered, but pointing somewhere else — drift after a
+ ## port change. Surface as mismatch so the dock offers Reconfigure.
+ if text.find(expected_target) < 0:
+ return _status_details(
+ McpClient.Status.CONFIGURED_MISMATCH, "", _probe_output_launches_godot_ai(text)
+ )
+ return _status_details(McpClient.Status.CONFIGURED)
+
+
+## Empty string when this client's launch requirements are satisfied. A
+## command-shape descriptor requires a successfully resolved attach launch;
+## URL descriptors (`CommandShape.NONE`) never require one. Mirrors
+## `McpJsonStrategy.command_launch_error` / the TOML equivalent.
+static func command_launch_error(client: McpClient, launch: Dictionary) -> String:
+ if client.command_shape == McpClient.CommandShape.NONE:
+ return ""
+ if not bool(launch.get("ok", false)):
+ return str(launch.get("error", "No compatible attach launcher was found."))
+ return ""
+
+
+static func _status_details(
+ status: McpClient.Status, error_msg: String = "", owned: bool = false
+) -> Dictionary:
+ return {"status": status, "error_msg": error_msg, "owned": owned}
+
+
+static func remove(client: McpClient, server_name: String) -> Dictionary:
+ var cli := _resolve_cli(client)
+ if cli.is_empty():
+ return {"status": "error", "message": "%s not found" % client.display_name}
+ return _remove_claimed(client, server_name, cli)
+
+
+static func _remove_claimed(client: McpClient, server_name: String, cli: String) -> Dictionary:
+ if client.cli_unregister_template.is_empty():
+ return {"status": "error", "message": "%s descriptor missing cli_unregister_template" % client.display_name}
+ var args := _format_args(client.cli_unregister_template, server_name, "")
+ var result := McpCliExec.run(cli, args, _REMOVE_TIMEOUT_MS)
+ var termination_failure := _unproven_mutation_failure(client, "Remove", result)
+ if not termination_failure.is_empty():
+ return termination_failure
+ if result.get("timed_out", false):
+ return {
+ "status": "error",
+ "message": "Remove %s timed out after %ds — see 'Run this manually' below to retry by hand" % [
+ client.display_name, _REMOVE_TIMEOUT_MS / 1000,
+ ],
+ }
+ if result.get("spawn_failed", false):
+ return {"status": "error", "message": "Failed to spawn %s" % client.display_name}
+ if int(result.get("exit_code", -1)) == 0:
+ return {"status": "ok", "message": "%s configuration removed" % client.display_name}
+ ## `claude mcp add` writes its real failure diagnostics to stderr, so
+ ## prefer `output` (stdout + stderr) over `stdout` alone — otherwise
+ ## the user sees "exit code 1" instead of the actual error.
+ var combined := str(result.get("output", "")).strip_edges()
+ var err := combined if not combined.is_empty() else "exit code %d" % int(result.get("exit_code", -1))
+ return {"status": "error", "message": "Failed to remove %s: %s" % [client.display_name, err]}
+
+
+## A POSIX exact-PID kill cannot prove that a CLI's descendants stopped. Never
+## issue the next write after that ambiguous boundary: the old process could
+## otherwise land a stale mutation after the new one. The durable global claim
+## remains until the user explicitly clears it after stopping those processes.
+static func _unproven_mutation_failure(
+ client: McpClient, operation: String, result: Dictionary
+) -> Dictionary:
+ if not bool(result.get("termination_failed", false)):
+ return {}
+ return {
+ "status": "error",
+ "message": (
+ "%s for %s could not be proven stopped. %s"
+ % [operation, client.display_name, MutationLock.recovery_message()]
+ ),
+ "termination_failed": true,
+ }
+
+
+## Substitute `{name}` and `{url}` tokens in every template entry.
+## Tokens match verbatim — `{name_suffix}` is NOT touched, so callers don't
+## have to worry about partial-token collisions in their argv.
+##
+## Launch tokens are whole-element only: an element that is exactly
+## `{command}` becomes the resolved attach launcher path, and an element that
+## is exactly `{args...}` is spliced into the argv as one element per launch
+## arg. Whole-element matching keeps a literal brace inside a path or flag
+## from ever triggering an expansion.
+##
+## `scope_override` forces a specific `{scope}` value instead of the live
+## setting; the configure pre-cleanup uses it to sweep the scopes the user is
+## NOT currently on. Empty means "resolve from the setting".
+static func format_args(
+ template: PackedStringArray,
+ server_name: String,
+ server_url: String,
+ launch: Dictionary = {},
+ scope_override: String = "",
+) -> Array[String]:
+ return _format_args(template, server_name, server_url, launch, scope_override)
+
+
+static func _format_args(
+ template: PackedStringArray,
+ server_name: String,
+ server_url: String,
+ launch: Dictionary = {},
+ scope_override: String = "",
+) -> Array[String]:
+ var scope := scope_override if not scope_override.is_empty() else McpSettings.client_scope()
+ var out: Array[String] = []
+ for arg in template:
+ var s := String(arg)
+ if s == "{command}":
+ out.append(str(launch.get("command", "")))
+ continue
+ if s == "{args...}":
+ for launch_arg in launch.get("args", []):
+ out.append(str(launch_arg))
+ continue
+ s = s.replace("{name}", server_name)
+ s = s.replace("{url}", server_url)
+ ## Resolved per-call rather than baked into the descriptor so the
+ ## setting can change without an editor restart, and so the manual
+ ## command shown in the dock always matches what Configure would run.
+ s = s.replace(SCOPE_TOKEN, scope)
+ out.append(s)
+ return out
+
+
+## Scope-aware status for descriptors that declare `cli_scope_status_template`.
+## Returns the same `{"status", "error_msg"}` shape as check_status_details.
+##
+## Split from the subprocess call so the verdict is a pure function of what the
+## CLI printed — `_scope_probe_verdict` is unit-tested against real recorded
+## `claude mcp get` output rather than needing a `claude` binary on the runner.
+static func check_scope_status_details(
+ client: McpClient,
+ server_name: String,
+ server_url: String,
+ cli: String,
+ launch: Dictionary,
+ expected_scope: String,
+) -> Dictionary:
+ if cli.is_empty() or client.cli_scope_status_template.is_empty():
+ return _status_details(McpClient.Status.NOT_CONFIGURED)
+ var expected_target := server_url
+ var expected_args: Array[String] = []
+ var expected_type := ""
+ if client.command_shape != McpClient.CommandShape.NONE:
+ ## Same fail-closed contract as configure and the plain status probe.
+ var launch_error := command_launch_error(client, launch)
+ if not launch_error.is_empty():
+ return _status_details(McpClient.Status.ERROR, launch_error)
+ expected_target = str(launch.get("command", ""))
+ for launch_arg in launch.get("args", []):
+ expected_args.append(str(launch_arg))
+ expected_type = str(client.command_transport_value)
+ var args := _format_args(client.cli_scope_status_template, server_name, server_url)
+ var result := McpCliExec.run(cli, args, _STATUS_TIMEOUT_MS, false)
+ if result.get("timed_out", false):
+ return _status_details(McpClient.Status.ERROR, "probe timed out")
+ if result.get("spawn_failed", false):
+ return _status_details(McpClient.Status.NOT_CONFIGURED)
+ return _scope_probe_verdict(
+ int(result.get("exit_code", -1)),
+ str(result.get("stdout", "")),
+ expected_scope,
+ expected_target,
+ expected_args,
+ expected_type,
+ )
+
+
+## The `Scope:` label the probe printed, normalised to a CLIENT_SCOPES value,
+## or "" when no line was recognisable. Matched on the first word after the
+## label so the parenthetical blurb ("(shared via .mcp.json)") can change
+## wording between CLI releases without breaking detection.
+static func _scope_from_probe_output(text: String) -> String:
+ for raw_line in text.split("\n"):
+ var line := String(raw_line).strip_edges()
+ var marker := line.find("Scope:")
+ if marker < 0:
+ continue
+ var rest := line.substr(marker + 6).strip_edges().to_lower()
+ for scope in McpSettings.CLIENT_SCOPES:
+ var name := String(scope)
+ if rest.begins_with(name):
+ return name
+ return ""
+
+
+## The probe output names our server; only its launch fields decide whether
+## the existing entry is ours to rewrite after an update.
+static func _probe_output_launches_godot_ai(text: String) -> bool:
+ return McpClient.launch_mentions_godot_ai(
+ _field_from_probe_output(text, "Command:")
+ + " "
+ + _field_from_probe_output(text, "Args:")
+ + " "
+ + _field_from_probe_output(text, "URL:")
+ )
+
+
+static func _field_from_probe_output(text: String, label: String) -> String:
+ for raw_line in text.split("\n"):
+ var line := String(raw_line).strip_edges()
+ if line.begins_with(label):
+ return line.substr(label.length()).strip_edges()
+ return ""
+
+
+## Pure verdict for a scope probe. A non-zero exit is the CLI's "no such
+## server" signal. An entry resolved from a scope other than the selected one
+## is MISMATCH, not CONFIGURED: the dock offers Reconfigure, and Configure's
+## all-scope pre-cleanup is what actually moves it.
+##
+## A missing or unrecognised `Scope:` line degrades to the target check alone,
+## deliberately rather than failing closed. `_verify_post_state` turns any
+## non-CONFIGURED status after a successful write into an error, so treating an
+## unparsed label as MISMATCH would mean a future CLI release that reworded or
+## dropped that line breaks Configure outright — a false error plus a
+## permanently amber row whose Reconfigure button cannot clear it. The entry
+## has already been matched by name and exact launcher path at that point; the
+## only thing in doubt is which scope it came from, and a slightly optimistic
+## dot is a much cheaper failure than a configure flow that reports success as
+## failure. Today's claude (2.1.241) always prints the line, so this is a
+## forward-compatibility hedge, not a live code path.
+static func _scope_probe_verdict(
+ exit_code: int,
+ text: String,
+ expected_scope: String,
+ expected_target: String,
+ expected_args: Array[String] = [],
+ expected_type: String = "",
+) -> Dictionary:
+ if exit_code != 0:
+ return _status_details(McpClient.Status.NOT_CONFIGURED)
+ ## `resolved_scope` rides along as structured data so callers can act on it
+ ## — `_verify_post_state`'s path hint needs to know WHICH scope survived to
+ ## name the right file, and parsing it back out of the human-facing message
+ ## would couple that hint to this wording (#879). Parsed BEFORE the target
+ ## check so both mismatch paths carry it: an entry whose launcher drifted
+ ## sends the user to the wrong file just as readily as one whose scope did.
+ var resolved := _scope_from_probe_output(text)
+ var command_matches := (
+ expected_target.is_empty()
+ or _field_from_probe_output(text, "Command:") == expected_target
+ )
+ var launch_details_match := true
+ if not expected_type.is_empty():
+ launch_details_match = (
+ _field_from_probe_output(text, "Type:") == expected_type
+ and _field_from_probe_output(text, "Args:") == " ".join(expected_args)
+ )
+ if not command_matches or not launch_details_match:
+ var drifted := _status_details(
+ McpClient.Status.CONFIGURED_MISMATCH, "", _probe_output_launches_godot_ai(text)
+ )
+ if not resolved.is_empty():
+ drifted["resolved_scope"] = resolved
+ return drifted
+ if resolved.is_empty():
+ return _status_details(McpClient.Status.CONFIGURED)
+ if resolved != expected_scope:
+ var details := _status_details(
+ McpClient.Status.CONFIGURED_MISMATCH,
+ "registered at %s scope, not %s" % [resolved, expected_scope],
+ _probe_output_launches_godot_ai(text),
+ )
+ details["resolved_scope"] = resolved
+ return details
+ return _status_details(McpClient.Status.CONFIGURED)
+
+
+## True when the descriptor's register template takes `{scope}`, i.e. where
+## its entry lands is decided by the `godot_ai/mcp_client_scope` setting rather
+## than fixed by the descriptor. The configurator uses this to decide whether
+## the JSON-fallback file is still a valid place to read status back from.
+static func uses_scope_token(client: McpClient) -> bool:
+ return client.cli_register_template.has(SCOPE_TOKEN)
+
+
+## Public view of the pre-cleanup sweep, for the manual-command text: what the
+## dock tells the user to run has to match what Configure actually runs (#877).
+static func cleanup_scopes(client: McpClient) -> Array[String]:
+ return _cleanup_scopes(client)
+
+
+## Scopes the configure pre-cleanup removes from. A descriptor without the
+## `{scope}` token has exactly one place its entry can live, so it keeps the
+## single pass it always had — "" means "resolve the scope normally".
+static func _cleanup_scopes(client: McpClient) -> Array[String]:
+ if not uses_scope_token(client):
+ return [""]
+ var scopes: Array[String] = []
+ for scope in McpSettings.CLIENT_SCOPES:
+ scopes.append(String(scope))
+ return scopes
+
+
+static func _resolve_cli(client: McpClient) -> String:
+ return McpCliFinder.find(McpClient._array_from_packed(client.cli_names))
+
+
+static func resolve_cli_path(client: McpClient) -> String:
+ return _resolve_cli(client)
diff --git a/addons/godot_ai/clients/_cli_strategy.gd.uid b/addons/godot_ai/clients/_cli_strategy.gd.uid
new file mode 100644
index 0000000..f84a92b
--- /dev/null
+++ b/addons/godot_ai/clients/_cli_strategy.gd.uid
@@ -0,0 +1 @@
+uid://bvib7d8eabbcm
diff --git a/addons/godot_ai/clients/_dsh_strategy.gd b/addons/godot_ai/clients/_dsh_strategy.gd
new file mode 100644
index 0000000..4d86c72
--- /dev/null
+++ b/addons/godot_ai/clients/_dsh_strategy.gd
@@ -0,0 +1,763 @@
+@tool
+class_name McpDshStrategy
+extends RefCounted
+
+## Configure / status / remove for DeepSeek Harness (dsh) MCP servers.
+##
+## DeepSeek Harness has no `mcp` CLI verb (verified against dsh
+## 0.1.0-rc.6 — `dsh --help` exposes only `web` and `plugin`). MCP servers
+## register as `@deepseek-ai/dsh-mcp-client` plugin entries in the HOME patch
+## layer `$DSH_HOME/cordis.patch.yml` — the machine-local layer dsh applies
+## over EVERY profile's own patch layer (the web GUI included), so one entry
+## covers every profile the user boots.
+##
+## The patch file is a top-level YAML array of loader patch entries
+## (`@deepseek-ai/cordis-plugin-include`'s `applyEntryPatches`). A non-insert
+## row only OVERRIDES an existing bundle row (a new id is skipped with a
+## warning), so a brand-new server must be added with an `insert` row:
+##
+## - insert:
+## - id: mcp-godot-ai
+## name: '@deepseek-ai/dsh-mcp-client'
+## config:
+## serverName: godot-ai
+## transport: stdio
+## command: "..."
+## args: [...]
+##
+## Both shapes verified live against the dsh loader
+## (`dsh --profile web --dump-config` composes the inserted entry; a plain
+## row for a new id warns "patch: entry not found" and is skipped). We write
+## one dedicated insert row per server, preserve every other row byte-for-byte
+## (including other users' insert rows, overrides, comments, and `!!js`
+## expressions), and status-verify the entry we wrote — the same file-read
+## drift contract the JSON/TOML/YAML strategies use.
+##
+## The entry's `config` nests the launch under `serverName`/`transport`/
+## `command`/`args`; `serverName` is the model-facing tool namespace
+## (`mcp__godot-ai__`), so it carries the server name like the
+## `server_key_path` map key does for the other strategies.
+
+## Loader entry id prefix inside the patch list. The full id is
+## `` — "mcp-godot-ai" for the godot-ai server.
+const ENTRY_ID_PREFIX := "mcp-"
+## The loader plugin that bridges MCP servers into the harness. Must be
+## installed in the profile's node_modules (dsh ships it as a dependency of
+## the CLI package). Quoted in the file: `@` is a reserved YAML indicator and
+## cannot start a plain scalar.
+const PLUGIN_NAME := "@deepseek-ai/dsh-mcp-client"
+## Indent of a nested entry header under `- insert:` (children step 2 spaces
+## deeper), matching the shipped profile patch files.
+const ENTRY_INDENT := 4
+
+
+static func entry_id(server_name: String) -> String:
+ return ENTRY_ID_PREFIX + server_name
+
+
+static func configure(
+ client: McpClient,
+ server_name: String,
+ server_url: String,
+ launch: Dictionary = {},
+) -> Dictionary:
+ var resolution := client.resolved_config_path_details()
+ var path := str(resolution.get("path", ""))
+ var path_error := str(resolution.get("error", ""))
+ if not path_error.is_empty():
+ return {"status": "error", "message": path_error}
+ if path.is_empty():
+ return {"status": "error", "message": "Could not resolve config path for %s on this OS" % client.display_name}
+ ## Fail closed before touching the file — same contract as JSON/TOML/YAML.
+ var launch_error := command_launch_error(client, launch)
+ if not launch_error.is_empty():
+ return {"status": "error", "message": launch_error}
+
+ var read := _read(path)
+ if not read["ok"]:
+ return {"status": "error", "message": "Refusing to overwrite %s: %s. Fix or move the file, then re-run Configure." % [path, read["error"]]}
+ ## Fail closed on content that is not a loader patch list: dsh's patch
+ ## parser rejects a non-array file at boot, so appending our row to a
+ ## top-level mapping would hand the user a broken file (#867 review).
+ ## `[]` is a valid empty sequence meaning "no rows"; the literal must not
+ ## survive an append (block rows after `[]` are malformed YAML), so only
+ ## that line is dropped — comments around it belong to the user and stay.
+ var text := String(read["data"])
+ text = _strip_empty_flow_sequence(text)
+ if not _is_patch_sequence(text):
+ return {
+ "status": "error",
+ "message": "Refusing to overwrite %s: it is not a top-level YAML list of loader entries. Fix or remove the file, then re-run Configure." % path,
+ }
+
+ var existing_config: Variant = null
+ var lines := _split_lines(text)
+ var found := _find_entry_block(lines, entry_id(server_name), true)
+ if found.is_empty():
+ var plain := _find_entry_block(lines, entry_id(server_name), false)
+ if not plain.is_empty():
+ ## A plain `- id: mcp-godot-ai` row is inert for a new id — the
+ ## loader skips it with a warning and never registers the server.
+ ## Replacing the whole row with the insert form migrates it.
+ found = plain
+ if not found.is_empty():
+ ## Pass the parsed config through so user-mutable fields (env,
+ ## toolCallTimeoutMs, reconnect) survive a reconfigure.
+ existing_config = _extract_config(lines, found)
+ var new_entry := build_entry(client, server_name, server_url, existing_config, launch)
+ ## Nested replacement must keep the sequence's existing header indent —
+ ## items of one block sequence share indentation, so a hand-written
+ ## 2-space file re-emitted at 4 spaces would no longer parse (#867
+ ## review). Fresh rows and plain-row migrations keep the 4-space style.
+ var base_indent := ENTRY_INDENT
+ if not found.is_empty():
+ var header_indent := int(found.get("indent", 0))
+ if header_indent > 0:
+ base_indent = header_indent
+ var rendered := render_nested_entry(entry_id(server_name), new_entry, base_indent)
+ var out := ""
+ if found.is_empty():
+ out = _append_row(lines, rendered)
+ else:
+ out = _replace_lines(lines, found, rendered)
+ if not McpAtomicWrite.write(path, out):
+ return {"status": "error", "message": "Cannot write to %s" % path}
+ return {"status": "ok", "message": McpClient.configured_message(client, server_url)}
+
+
+static func check_status(
+ client: McpClient,
+ server_name: String,
+ server_url: String,
+ launch: Dictionary = {},
+) -> McpClient.Status:
+ return check_status_details(client, server_name, server_url, launch)["status"]
+
+
+## Same contract as the other strategies (#711): {status, error_msg}. An
+## existing-but-unreadable config is ERROR with the diagnostic — not
+## NOT_CONFIGURED — so the dock row can tell "no config" from "config the
+## editor can't read" instead of offering a Configure that would fail.
+static func check_status_details(
+ client: McpClient, server_name: String, server_url: String, launch: Dictionary = {}
+) -> Dictionary:
+ var resolution := client.resolved_config_path_details()
+ var path := str(resolution.get("path", ""))
+ var path_error := str(resolution.get("error", ""))
+ if not path_error.is_empty():
+ return {"status": McpClient.Status.ERROR, "error_msg": path_error}
+ if path.is_empty() or not FileAccess.file_exists(path):
+ return {"status": McpClient.Status.NOT_CONFIGURED, "error_msg": ""}
+ var read := _read(path)
+ if not read["ok"]:
+ return {
+ "status": McpClient.Status.ERROR,
+ "error_msg": "Cannot read %s: %s" % [path, read["error"]],
+ }
+ var text := String(read["data"])
+ if not _is_patch_sequence(text):
+ ## A top-level mapping/scalar is not a dsh patch list: nothing in it
+ ## can be "configured", and a nested `- id:` inside a mapping must
+ ## not read as ours (#867 review).
+ return {
+ "status": McpClient.Status.ERROR,
+ "error_msg": "%s is not a top-level YAML list of loader entries; fix or remove the file." % path,
+ }
+ var lines := _split_lines(text)
+ var found := _find_entry_block(lines, entry_id(server_name), true)
+ if found.is_empty():
+ ## A plain row for our id is inert (the loader skips unknown ids), so
+ ## the server is NOT registered no matter what the file says. It only
+ ## becomes registered once Configure rewrites it as an insert row.
+ return {"status": McpClient.Status.NOT_CONFIGURED, "error_msg": ""}
+ var config := _extract_config(lines, found)
+ if config.is_empty():
+ return {"status": McpClient.Status.NOT_CONFIGURED, "error_msg": ""}
+ ## An entry exists but no verified launcher does — mirror the other
+ ## strategies: this is an environment ERROR, not entry drift.
+ var launch_error := command_launch_error(client, launch)
+ if not launch_error.is_empty():
+ return {"status": McpClient.Status.ERROR, "error_msg": launch_error}
+ if verify_entry(client, config, server_name, server_url, launch):
+ return {"status": McpClient.Status.CONFIGURED, "error_msg": ""}
+ return {
+ "status": McpClient.Status.CONFIGURED_MISMATCH,
+ "error_msg": "",
+ "owned": McpClient.launch_values_mention_godot_ai(McpClient.entry_launch_values(config)),
+ }
+
+
+static func remove(client: McpClient, server_name: String) -> Dictionary:
+ var resolution := client.resolved_config_path_details()
+ var path := str(resolution.get("path", ""))
+ var path_error := str(resolution.get("error", ""))
+ if not path_error.is_empty():
+ return {"status": "error", "message": path_error}
+ if path.is_empty() or not FileAccess.file_exists(path):
+ return {"status": "ok", "message": "Not configured"}
+ var read := _read(path)
+ if not read["ok"]:
+ return {"status": "error", "message": "Refusing to rewrite %s: %s." % [path, read["error"]]}
+ var text := String(read["data"])
+ if not _is_patch_sequence(text):
+ ## Same fail-closed contract as configure (#867 review): never modify
+ ## a file that dsh cannot load as a patch list.
+ return {
+ "status": "error",
+ "message": "Refusing to rewrite %s: it is not a top-level YAML list of loader entries. Fix or remove the file, then re-run Remove." % path,
+ }
+ var lines := _split_lines(text)
+ var found := _find_entry_block(lines, entry_id(server_name), true)
+ if found.is_empty():
+ found = _find_entry_block(lines, entry_id(server_name), false)
+ if found.is_empty():
+ return {"status": "ok", "message": "%s configuration removed" % client.display_name}
+ var out := _remove_lines(lines, found)
+ if _is_blank_or_comment_only(out):
+ ## dsh's patch parser requires a top-level YAML array: an empty or
+ ## comment-only patch file FAILS BOOT (`loadOptionalPatches` throws on
+ ## a non-array file), while an absent file means "no layer". After our
+ ## row is gone, an all-blank file must therefore be deleted, not
+ ## written back.
+ if FileAccess.file_exists(path):
+ var remove_err := DirAccess.remove_absolute(path)
+ if remove_err != OK:
+ return {"status": "error", "message": "Cannot remove %s (error %d)" % [path, remove_err]}
+ return {"status": "ok", "message": "%s configuration removed" % client.display_name}
+ if not McpAtomicWrite.write(path, out):
+ return {"status": "error", "message": "Cannot write to %s" % path}
+ return {"status": "ok", "message": "%s configuration removed" % client.display_name}
+
+
+## Build the `config` dict written inside the loader entry. Command mode
+## (FLAT, #838) nests `serverName`/`transport`/`command`/`args`; URL mode nests
+## `serverName`/`transport`/`url`. Existing user state (env, timeouts,
+## reconnect) survives a reconfigure via the deep copy.
+static func build_entry(
+ client: McpClient,
+ server_name: String,
+ server_url: String,
+ existing: Variant = null,
+ launch: Dictionary = {},
+) -> Dictionary:
+ var entry: Dictionary = (existing as Dictionary).duplicate(true) if existing is Dictionary else {}
+ entry["serverName"] = server_name
+ if client.command_shape == McpClient.CommandShape.FLAT:
+ entry["command"] = str(launch.get("command", ""))
+ entry["args"] = _array_copy(launch.get("args", []))
+ if not client.command_transport_key.is_empty():
+ entry[client.command_transport_key] = client.command_transport_value
+ for key in client.command_initial_fields:
+ if not entry.has(key):
+ entry[key] = client.command_initial_fields[key]
+ for key in client.command_legacy_keys:
+ entry.erase(String(key))
+ return entry
+ if client.command_shape == McpClient.CommandShape.NONE:
+ ## Single URL-mode source of truth (#867 review): build_entry's URL
+ ## branch delegates to build_url_entry so the two can never drift.
+ return build_url_entry(client, server_name, server_url, existing)
+ return {}
+
+
+## URL-mode sibling for the manual-instruction fallback text and the
+## build_entry URL branch. DeepSeek Harness' mcp-client bridge accepts
+## `transport: streamable-http` with a `url`, so the entry keeps the same
+## `serverName` namespace with an HTTP transport instead of the stdio attach
+## bridge. Existing user state survives via the deep copy.
+static func build_url_entry(
+ client: McpClient, server_name: String, server_url: String, existing: Variant = null
+) -> Dictionary:
+ var entry: Dictionary = (existing as Dictionary).duplicate(true) if existing is Dictionary else {}
+ entry["serverName"] = server_name
+ entry["transport"] = "streamable-http"
+ if not client.entry_url_field.is_empty():
+ entry[client.entry_url_field] = server_url
+ return entry
+
+
+## Verify a stored `config` dict matches the current launch. Command mode
+## checks every launch-affecting value exactly and requires legacy URL keys
+## gone; serverName must match the pinned namespace. URL mode checks the url
+## and the streamable-http pin.
+static func verify_entry(
+ client: McpClient,
+ config: Dictionary,
+ server_name: String,
+ server_url: String,
+ launch: Dictionary = {},
+) -> bool:
+ if config.get("serverName") != server_name:
+ return false
+ if client.command_shape != McpClient.CommandShape.NONE:
+ if client.command_shape != McpClient.CommandShape.FLAT or not bool(launch.get("ok", false)):
+ return false
+ for key in client.command_legacy_keys:
+ if config.has(String(key)):
+ return false
+ if config.get("command") != launch.get("command"):
+ return false
+ if not _arrays_equal(config.get("args", null), launch.get("args", null)):
+ return false
+ if not client.command_transport_key.is_empty():
+ if config.get(client.command_transport_key, null) != client.command_transport_value:
+ return false
+ return true
+ if config.get(client.entry_url_field, "") != server_url:
+ return false
+ return config.get("transport", "") == "streamable-http"
+
+
+## Empty string when this client's launch requirements are satisfied.
+## Mirrors `McpYamlStrategy.command_launch_error`; the patch-list dialect
+## supports FLAT (and the URL fallback) only.
+static func command_launch_error(client: McpClient, launch: Dictionary) -> String:
+ if client.command_shape == McpClient.CommandShape.NONE:
+ return ""
+ if client.command_shape != McpClient.CommandShape.FLAT:
+ return "%s uses a command shape not supported by DeepSeek Harness config yet" % client.display_name
+ if not bool(launch.get("ok", false)):
+ return str(launch.get("error", "No compatible attach launcher was found."))
+ return ""
+
+
+# --- Rendering --------------------------------------------------------------
+
+## Render a full `- insert:` row containing one nested loader entry. Public
+## seam for the dock's manual-instruction text so the pasted YAML matches
+## what Configure would write byte-for-byte.
+static func render_insert_row(entry_id_value: String, config: Dictionary) -> PackedStringArray:
+ var lines: PackedStringArray = ["- insert:"]
+ lines.append_array(render_nested_entry(entry_id_value, config))
+ return lines
+
+
+## Render one nested loader entry (the `- id:` block inside an `insert`
+## list). `base_indent` defaults to the 4-space style used by the shipped
+## patch files; a nested replacement reuses the indent the existing entry was
+## found at so the block sequence keeps a single indentation (#867 review).
+static func render_nested_entry(
+ entry_id_value: String, config: Dictionary, base_indent: int = ENTRY_INDENT
+) -> PackedStringArray:
+ var lines: PackedStringArray = []
+ lines.append("%s- id: %s" % [_indent_str(base_indent), entry_id_value])
+ lines.append("%sname: '%s'" % [_indent_str(base_indent + 2), PLUGIN_NAME])
+ lines.append("%sconfig:" % _indent_str(base_indent + 2))
+ for key in config:
+ lines.append_array(_emit_field(String(key), config[key], base_indent + 4))
+ return lines
+
+
+## Emit one `key: value` line (or a nested block for Dictionary values) at
+## the given indent. Arrays render in flow style; scalars through the shared
+## YAML emitter so quoting rules stay identical across the two YAML dialects.
+static func _emit_field(key: String, value: Variant, indent: int) -> PackedStringArray:
+ var lines: PackedStringArray = []
+ var prefix := _indent_str(indent)
+ if value is Dictionary:
+ lines.append("%s%s:" % [prefix, key])
+ for sub_key in value:
+ lines.append_array(_emit_field(String(sub_key), value[sub_key], indent + 2))
+ elif value is Array or value is PackedStringArray:
+ lines.append("%s%s: %s" % [prefix, key, McpYamlStrategy.emit_flow_array(_array_copy(value))])
+ else:
+ lines.append("%s%s: %s" % [prefix, key, McpYamlStrategy.emit_scalar(value)])
+ return lines
+
+
+# --- Line-level parsing (no YAML parser in Godot) ---------------------------
+
+## Split file text into lines, dropping only a single trailing newline so the
+## remainder round-trips byte-for-byte. `text` is never split on the empty
+## string, so an empty file yields an empty array.
+static func _split_lines(text: String) -> PackedStringArray:
+ if text.is_empty():
+ return PackedStringArray()
+ var lines := text.split("\n")
+ if lines.size() > 0 and String(lines[lines.size() - 1]).is_empty():
+ lines.remove_at(lines.size() - 1)
+ return lines
+
+
+## Locate the block owned by our loader entry:
+## require_nested=true — the `- id: ` line inside an `insert`
+## list (indent > 0), plus its indented children.
+## require_nested=false — a plain top-level `- id: ` row (indent
+## 0), plus its indented children.
+## Returns {"start": int, "end": int, "indent": int} (end exclusive) or {}
+## when absent. The header indent is detected dynamically so a hand-written
+## file using non-2-space indentation is still found; children are lines
+## deeper than the header. `end` stops right AFTER the last content line:
+## trailing blank and comment lines between our entry and the next sibling
+## belong to the file, not to our block, so `_replace_lines` / `_remove_lines`
+## (which splice [start, end)) never delete them (#867 review).
+static func _find_entry_block(lines: PackedStringArray, entry_id_value: String, require_nested: bool) -> Dictionary:
+ for i in range(lines.size()):
+ var indent := _indent_of(lines[i])
+ if require_nested and indent <= 0:
+ continue
+ if not require_nested and indent != 0:
+ continue
+ var parsed := _parse_entry_header(lines[i])
+ if parsed.is_empty() or parsed.get("id") != entry_id_value:
+ continue
+ ## A nested match must actually live inside an `- insert:` row: a
+ ## `- id:` list under some other top-level row (an override row's own
+ ## sequence, say) is not a loader entry and is not ours to touch.
+ if require_nested and not _under_insert_row(lines, i):
+ continue
+ var j := i + 1
+ var last_content := i
+ while j < lines.size():
+ var l := lines[j]
+ if _is_blank_or_comment(l):
+ j += 1
+ continue
+ if _indent_of(l) <= indent:
+ break
+ last_content = j
+ j += 1
+ return {"start": i, "end": last_content + 1, "indent": indent}
+ return {}
+
+
+## True when the nearest preceding 0-indent content line above `index` is an
+## `- insert:` row header. Blank and comment lines are skipped; hitting the
+## top of the file without a header means the nested line is orphaned and
+## cannot be a loader entry.
+static func _under_insert_row(lines: PackedStringArray, index: int) -> bool:
+ for i in range(index - 1, -1, -1):
+ if _is_blank_or_comment(lines[i]):
+ continue
+ if _indent_of(lines[i]) == 0:
+ return lines[i].strip_edges().begins_with("- insert:")
+ return false
+
+
+## Parse a `- id: ` header line into {"id": String}. Returns {} for
+## any other line. The value may be plain or single/double-quoted.
+static func _parse_entry_header(line: String) -> Dictionary:
+ var stripped := line.strip_edges()
+ if not stripped.begins_with("- "):
+ return {}
+ var body := stripped.substr(2).strip_edges()
+ if not body.begins_with("id:"):
+ return {}
+ var value := body.substr(3).strip_edges()
+ if value.begins_with("\"") and value.ends_with("\"") and value.length() >= 2:
+ var parsed: Variant = JSON.parse_string(value)
+ if parsed is String:
+ value = parsed
+ elif value.begins_with("'") and value.ends_with("'") and value.length() >= 2:
+ value = value.substr(1, value.length() - 2)
+ return {"id": value}
+
+
+## Extract the `config:` sub-dict from a found entry block. Fields may nest
+## (env, reconnect); deeper values are parsed with the same scalar coercion
+## the YAML strategy uses. Returns {} when the block has no config block.
+static func _extract_config(lines: PackedStringArray, block: Dictionary) -> Dictionary:
+ var start := int(block.get("start", -1))
+ var end := int(block.get("end", -1))
+ if start < 0:
+ return {}
+ var config_indent := -1
+ var config_start := -1
+ for i in range(start, end):
+ var stripped := lines[i].strip_edges()
+ if _is_blank_or_comment(lines[i]):
+ continue
+ if stripped == "config:":
+ config_indent = _indent_of(lines[i])
+ config_start = i
+ break
+ if config_start < 0:
+ return {}
+ return _parse_block_lines(lines, config_start + 1, end, config_indent)
+
+
+## Parse sibling `key: value` lines at a fixed minimum indent into a
+## Dictionary. Nested blocks (a key with no inline value) recurse and their
+## child lines are consumed by the recursion — the parent index advances past
+## them so they are never re-parsed as top-level config fields (#867 review).
+## Flow arrays parse back into Arrays via JSON. Quoted and plain scalars
+## strip via the YAML strategy's shared `coerce_scalar`.
+static func _parse_block_lines(lines: PackedStringArray, start: int, end: int, min_indent: int) -> Dictionary:
+ var out: Dictionary = {}
+ var i := start
+ while i < end:
+ var l := lines[i]
+ if _is_blank_or_comment(l):
+ i += 1
+ continue
+ var indent := _indent_of(l)
+ if indent <= min_indent:
+ break
+ var stripped := l.strip_edges()
+ var colon := stripped.find(":")
+ if colon < 0:
+ i += 1
+ continue
+ var key := stripped.substr(0, colon).strip_edges()
+ var value := stripped.substr(colon + 1).strip_edges()
+ if value.is_empty():
+ ## Nested block: find where its children end (a line at or above
+ ## the block key's indent), parse only that span, and jump past it.
+ var child_end := i + 1
+ while child_end < end:
+ if not _is_blank_or_comment(lines[child_end]) and _indent_of(lines[child_end]) <= indent:
+ break
+ child_end += 1
+ out[key] = _parse_block_lines(lines, i + 1, child_end, indent)
+ i = child_end
+ else:
+ ## Shared with the YAML strategy so the two dialects can never
+ ## drift on quoting/typing rules (#867 review).
+ out[key] = McpYamlStrategy.coerce_scalar(value)
+ i += 1
+ return out
+
+
+# --- Rewrites ---------------------------------------------------------------
+
+## Append a new `- insert:` row at the end of the file. A blank separator
+## line keeps rows visually distinct; the file always ends with a newline.
+static func _append_row(lines: PackedStringArray, nested_lines: PackedStringArray) -> String:
+ var parts: Array[String] = []
+ for l in lines:
+ parts.append(l)
+ ## Collapse trailing blank lines so the appended row is not orphaned
+ ## below stray empties; one separator blank keeps rows readable.
+ while parts.size() > 0 and String(parts[parts.size() - 1]).strip_edges().is_empty():
+ parts.remove_at(parts.size() - 1)
+ if parts.size() > 0:
+ parts.append("")
+ parts.append("- insert:")
+ for l in nested_lines:
+ parts.append(l)
+ return _join_lines(parts)
+
+
+## Replace the lines owned by our entry. When the found block is a plain
+## top-level row (indent 0 header), the whole row — including its children —
+## is replaced by the insert form. When it is a nested block inside an
+## insert row, only that block's lines are replaced, leaving sibling entries
+## and comments in the same row byte-for-byte intact.
+static func _replace_lines(lines: PackedStringArray, block: Dictionary, nested_lines: PackedStringArray) -> String:
+ var start := int(block.get("start", -1))
+ var end := int(block.get("end", -1))
+ var parts: Array[String] = []
+ for i in range(lines.size()):
+ if i == start:
+ ## A nested block keeps the surrounding `- insert:` row, so only
+ ## the nested entry lines (indent > 0) are spliced in.
+ if _indent_of(lines[start]) > 0:
+ for l in nested_lines:
+ parts.append(l)
+ else:
+ parts.append("- insert:")
+ for l in nested_lines:
+ parts.append(l)
+ if i >= start and i < end:
+ continue
+ parts.append(lines[i])
+ return _join_lines(parts)
+
+
+## Remove our entry. A nested block is spliced out; if its enclosing insert
+## row is left without any nested entries (only the bare `- insert:` header,
+## blanks, and comments), the row header goes too so the file stays a valid
+## patch list. A plain row for our id is removed wholesale.
+static func _remove_lines(lines: PackedStringArray, block: Dictionary) -> String:
+ var start := int(block.get("start", -1))
+ var end := int(block.get("end", -1))
+ var nested := _indent_of(lines[start]) > 0
+ var parts: Array[String] = []
+ if nested:
+ ## Find the enclosing `- insert:` row header: the nearest preceding
+ ## 0-indent `- ` line.
+ var row_start := -1
+ for i in range(start - 1, -1, -1):
+ if _indent_of(lines[i]) == 0 and lines[i].strip_edges().begins_with("- "):
+ row_start = i
+ break
+ ## Row end: first 0-indent non-blank/comment line after the block.
+ var row_end := lines.size()
+ for i in range(end, lines.size()):
+ if _indent_of(lines[i]) == 0 and not _is_blank_or_comment(lines[i]):
+ row_end = i
+ break
+ var has_other_entries := false
+ for i in range(row_start + 1, row_end):
+ if i >= start and i < end:
+ continue
+ if _is_blank_or_comment(lines[i]):
+ continue
+ if _indent_of(lines[i]) > 0 and lines[i].strip_edges().begins_with("- "):
+ has_other_entries = true
+ break
+ if has_other_entries or row_start < 0:
+ ## Only our block goes; sibling entries and any trailing
+ ## comments in the same row survive.
+ for i in range(lines.size()):
+ if i >= start and i < end:
+ continue
+ parts.append(lines[i])
+ else:
+ ## The row held only our entry: drop the `- insert:` header and
+ ## our block, but keep trailing blank/comment lines — a comment
+ ## the user wrote above the next row must survive (#867 review).
+ for i in range(lines.size()):
+ if i >= row_start and i < end:
+ continue
+ parts.append(lines[i])
+ parts = _trim_trailing_blanks(parts)
+ else:
+ ## Plain top-level row for our id: drop the whole block.
+ for i in range(lines.size()):
+ if i >= start and i < end:
+ continue
+ parts.append(lines[i])
+ parts = _trim_trailing_blanks(parts)
+ return _join_lines(parts)
+
+
+## Remove trailing blank lines left behind by a removal.
+static func _trim_trailing_blanks(parts: Array[String]) -> Array[String]:
+ while parts.size() > 0 and String(parts[parts.size() - 1]).strip_edges().is_empty():
+ parts.remove_at(parts.size() - 1)
+ return parts
+
+
+## True when the text holds no patch rows at all — blank lines and comments
+## only. Such a file cannot be written back (see `remove`), because dsh's
+## patch parser rejects a non-array file at boot.
+static func _is_blank_or_comment_only(text: String) -> bool:
+ for line in text.split("\n"):
+ var stripped := line.strip_edges()
+ if not stripped.is_empty() and not stripped.begins_with("#"):
+ return false
+ return true
+
+
+## When the document's only content line is the empty flow sequence `[]`,
+## return the text with that one line removed — blanks and comments stay,
+## because they belong to the user. Any other document comes back unchanged;
+## a `[]` mixed with real rows is left for `_is_patch_sequence` to refuse.
+static func _strip_empty_flow_sequence(text: String) -> String:
+ var lines := _split_lines(text)
+ var bracket_line := -1
+ for i in range(lines.size()):
+ if _is_blank_or_comment(lines[i]):
+ continue
+ if bracket_line < 0 and lines[i].strip_edges() == "[]":
+ bracket_line = i
+ continue
+ return text
+ if bracket_line < 0:
+ return text
+ var parts: Array[String] = []
+ for i in range(lines.size()):
+ if i != bracket_line:
+ parts.append(lines[i])
+ if parts.is_empty():
+ return ""
+ return _join_lines(parts)
+
+
+## True when the text is a valid loader patch list (or empty/comment-only /
+## the empty flow sequence `[]`, which all mean "no layer"). Anything else —
+## a top-level mapping or scalar — would make dsh fail boot, so Configure
+## refuses to write into it instead of producing a broken file (#867 review).
+## Only 0-indent content lines define the top-level structure: they must all
+## be `- ` rows. Deeper lines are row children and are not inspected. `[]` is
+## valid only as the COMPLETE document — appending rows after the empty flow
+## sequence would produce malformed YAML, so a mixed `[]` + rows file fails
+## the check too.
+static func _is_patch_sequence(text: String) -> bool:
+ if text.strip_edges().is_empty():
+ return true
+ var saw_brackets := false
+ var content_count := 0
+ for line in text.split("\n"):
+ var stripped := line.strip_edges()
+ if stripped.is_empty() or stripped.begins_with("#"):
+ continue
+ content_count += 1
+ if stripped == "[]":
+ saw_brackets = true
+ continue
+ if _indent_of(line) == 0 and not stripped.begins_with("- "):
+ return false
+ if saw_brackets and content_count > 1:
+ return false
+ return true
+
+
+static func _join_lines(parts: Array) -> String:
+ if parts.is_empty():
+ return ""
+ var lines: Array[String] = []
+ for p in parts:
+ lines.append(String(p))
+ return "\n".join(lines) + "\n"
+
+
+# --- Small helpers ----------------------------------------------------------
+
+static func _indent_str(n: int) -> String:
+ var out := ""
+ for i in range(n):
+ out += " "
+ return out
+
+
+static func _indent_of(line: String) -> int:
+ var n := 0
+ while n < line.length() and (line[n] == " " or line[n] == "\t"):
+ n += 1
+ return n
+
+
+static func _is_blank_or_comment(line: String) -> bool:
+ var stripped := line.strip_edges()
+ return stripped.is_empty() or stripped.begins_with("#")
+
+
+static func _array_copy(value: Variant) -> Array:
+ if value is Array:
+ return (value as Array).duplicate(true)
+ if value is PackedStringArray:
+ return McpClient._array_from_packed(value)
+ return []
+
+
+static func _arrays_equal(left: Variant, right: Variant) -> bool:
+ if not (left is Array or left is PackedStringArray):
+ return false
+ if not (right is Array or right is PackedStringArray):
+ return false
+ var left_array := _array_copy(left)
+ var right_array := _array_copy(right)
+ if left_array.size() != right_array.size():
+ return false
+ for i in range(left_array.size()):
+ if left_array[i] != right_array[i]:
+ return false
+ return true
+
+
+## Returns {"ok": true, "data": String} when the file is absent or readable,
+## and {"ok": false, "error": String} when unreadable. Callers must NOT fall
+## back to an empty string on the error path — doing so blows away the user's
+## other patch rows on the next write.
+static func _read(path: String) -> Dictionary:
+ if not FileAccess.file_exists(path):
+ return {"ok": true, "data": ""}
+ var f := FileAccess.open(path, FileAccess.READ)
+ if f == null:
+ var err := FileAccess.get_open_error()
+ return {"ok": false, "error": "could not open for reading (%s)" % error_string(err)}
+ var t := f.get_as_text()
+ f.close()
+ return {"ok": true, "data": t}
diff --git a/addons/godot_ai/clients/_dsh_strategy.gd.uid b/addons/godot_ai/clients/_dsh_strategy.gd.uid
new file mode 100644
index 0000000..36703f4
--- /dev/null
+++ b/addons/godot_ai/clients/_dsh_strategy.gd.uid
@@ -0,0 +1 @@
+uid://ch0brn7swb6qx
diff --git a/addons/godot_ai/clients/_json_strategy.gd b/addons/godot_ai/clients/_json_strategy.gd
new file mode 100644
index 0000000..e7f9a50
--- /dev/null
+++ b/addons/godot_ai/clients/_json_strategy.gd
@@ -0,0 +1,1114 @@
+@tool
+class_name McpJsonStrategy
+extends RefCounted
+
+## Read–merge–write strategy for JSON-backed MCP clients.
+## All knobs come from the McpClient descriptor as plain data — no Callables.
+## See `_base.gd` for why descriptors are data-only.
+
+
+static func configure(
+ client: McpClient,
+ server_name: String,
+ server_url: String,
+ launch: Dictionary = {},
+ project_roots: PackedStringArray = PackedStringArray(),
+) -> Dictionary:
+ if _uses_merge_tiers(client):
+ return _configure_merged(client, server_name, server_url, launch, project_roots)
+ var resolution := client.resolved_config_path_details()
+ var path := str(resolution.get("path", ""))
+ var path_error := str(resolution.get("error", ""))
+ if not path_error.is_empty():
+ return {"status": "error", "message": path_error}
+ if path.is_empty():
+ return {"status": "error", "message": "Could not resolve config path for %s on this OS" % client.display_name}
+
+ var seed_path := str(resolution.get("seed_path", ""))
+ var read_path := seed_path if not FileAccess.file_exists(path) and not seed_path.is_empty() else path
+ var read := _read_or_init(read_path)
+ if not read["ok"]:
+ return {"status": "error", "message": "Refusing to overwrite %s: %s. Fix or move the file, then re-run Configure." % [read_path, read["error"]]}
+ var launch_error := command_launch_error(client, launch)
+ if not launch_error.is_empty():
+ return {"status": "error", "message": launch_error}
+ var config: Dictionary = read["data"]
+ var holder := _ensure_path(config, select_server_key_path(config, client))
+ ## Pass the existing entry through so `build_entry` can preserve user-mutable
+ ## state (auto-approval lists, `disabled` toggles) instead of resetting it
+ ## to descriptor defaults on every Configure click. See `entry_initial_fields`
+ ## docs in `_base.gd`.
+ var existing: Variant = holder.get(server_name, null)
+ holder[server_name] = build_entry(client, server_url, existing, launch)
+
+ # F5: refuse to re-serialize a file whose parsed integers above 2^53 would
+ # lose precision. Editing by hand keeps those values intact.
+ if _has_lossy_numbers(config):
+ return {"status": "error", "message": "Refusing to rewrite %s: contains integers above 2^53 that Godot's JSON parser would re-emit imprecise. Edit the file by hand." % path}
+ if not McpAtomicWrite.write(path, JSON.stringify(_narrow_integral_numbers(config), "\t", false)):
+ return {"status": "error", "message": "Cannot write to %s" % path}
+ return {"status": "ok", "message": McpClient.configured_message(client, server_url)}
+
+## Pi-style clients merge several global config files. Update the effective
+## highest-precedence definition; fail closed when a project override exists
+## because Pi's external working directory cannot be inferred safely here.
+static func _configure_merged(
+ client: McpClient,
+ server_name: String,
+ server_url: String,
+ launch: Dictionary,
+ project_roots: PackedStringArray,
+) -> Dictionary:
+ var launch_error := command_launch_error(client, launch)
+ if not launch_error.is_empty():
+ return {"status": "error", "message": launch_error}
+ var project := _load_project_definitions(client, server_name, project_roots)
+ if not project.get("ok", false):
+ return {"status": "error", "message": str(project.get("error", "Cannot inspect project config tiers"))}
+ var project_tiers: Array = project.get("tiers", [])
+ if not project_tiers.is_empty():
+ return {"status": "error", "message": _project_override_message(project_tiers, "update or remove", client.display_name, server_name)}
+ var loaded := _load_merge_tiers(client)
+ if not loaded.get("ok", false):
+ return {"status": "error", "message": str(loaded.get("error", "Cannot read merged config tiers"))}
+ var tiers: Array = loaded.get("tiers", [])
+ if tiers.is_empty():
+ return {"status": "error", "message": "Could not resolve config path for %s on this OS" % client.display_name}
+ var target_index := 0
+ for index in range(tiers.size()):
+ var config: Dictionary = tiers[index]["data"]
+ var holder := _walk_path(config, select_server_key_path(config, client))
+ if holder is Dictionary and holder.has(server_name):
+ target_index = index
+ var tier: Dictionary = tiers[target_index]
+ var config: Dictionary = tier["data"]
+ var holder := _ensure_path(config, select_server_key_path(config, client))
+ var existing: Variant = holder.get(server_name, null)
+ holder[server_name] = build_entry(client, server_url, existing, launch)
+ var path := str(tier["path"])
+ # F5: refuse to re-serialize a tier whose parsed integers above 2^53 would
+ # lose precision. The same check guards the simple `configure` path.
+ if _has_lossy_numbers(config):
+ return {"status": "error", "message": "Refusing to rewrite %s: contains integers above 2^53 that Godot's JSON parser would re-emit imprecise. Edit the file by hand." % path}
+ if not McpAtomicWrite.write(path, JSON.stringify(_narrow_integral_numbers(config), "\t", false)):
+ return {"status": "error", "message": "Cannot write to %s" % path}
+ # Codex F1 caveat: when we wrote a global tier but couldn't find any project
+ # override in the roots we probed AND the user hasn't told us where Pi (or any
+ # external cwd client) actually runs from, we can't prove this write is the
+ # effective config. Surface the actionable hint instead of silently passing.
+ # The early `not project_tiers.is_empty()` return above already covers the
+ # "project override exists in probed roots" case (we fail closed there), so
+ # reaching this point with `project_tiers.is_empty()` means: no probed
+ # override was found.
+ var message := McpClient.configured_message(client, server_url)
+ var external_cwd := str(McpClientConfigurator._editor_setting_lookup(McpClientConfigurator.SETTING_EXTERNAL_CLIENT_CWD))
+ if external_cwd.is_empty() and project_tiers.is_empty():
+ message += " If %s is launched from a cwd this editor cannot see, the effective entry may still be in a project-tier file — set godot_ai/external_client_cwd to that cwd to verify." % client.display_name
+ return {"status": "ok", "message": message}
+
+
+static func check_status(
+ client: McpClient,
+ server_name: String,
+ server_url: String,
+ launch: Dictionary = {},
+ project_roots: PackedStringArray = PackedStringArray(),
+) -> McpClient.Status:
+ return check_status_details(client, server_name, server_url, launch, project_roots).get("status", McpClient.Status.NOT_CONFIGURED)
+
+
+## Detailed variant feeding the dock's error_msg plumbing (#711): a config
+## file that EXISTS but can't be read or parsed is Status.ERROR carrying the
+## read/parse error, not NOT_CONFIGURED — the write path refuses to touch
+## such a file (see `_read_or_init`), so the status dot must say "broken
+## file", not "click Configure".
+static func check_status_details(
+ client: McpClient,
+ server_name: String,
+ server_url: String,
+ launch: Dictionary = {},
+ project_roots: PackedStringArray = PackedStringArray(),
+) -> Dictionary:
+ if _uses_merge_tiers(client):
+ return _check_status_merged(client, server_name, server_url, launch, project_roots)
+ var resolution := client.resolved_config_path_details()
+ var path := str(resolution.get("path", ""))
+ var path_error := str(resolution.get("error", ""))
+ if not path_error.is_empty():
+ return {"status": McpClient.Status.ERROR, "error_msg": path_error}
+ if path.is_empty() or not FileAccess.file_exists(path):
+ return {"status": McpClient.Status.NOT_CONFIGURED, "error_msg": ""}
+ var read := _read_or_init(path, _status_allows_comments(client))
+ if not read["ok"]:
+ return {"status": McpClient.Status.ERROR, "error_msg": String(read["error"])}
+ var config: Dictionary = read["data"]
+ var holder := _walk_path(config, select_server_key_path(config, client))
+ if not (holder is Dictionary) or not holder.has(server_name):
+ return {"status": McpClient.Status.NOT_CONFIGURED, "error_msg": ""}
+ return _entry_status_details(client, holder[server_name], server_url, launch)
+
+
+## Verify the effective last definition after applying the client's merge order.
+static func _check_status_merged(
+ client: McpClient,
+ server_name: String,
+ server_url: String,
+ launch: Dictionary,
+ project_roots: PackedStringArray,
+) -> Dictionary:
+ var allow_comments := _status_allows_comments(client)
+ var loaded := _load_merge_tiers(client, allow_comments)
+ if not loaded.get("ok", false):
+ return {"status": McpClient.Status.ERROR, "error_msg": str(loaded.get("error", "Cannot read merged config tiers"))}
+ var effective: Variant = null
+ for tier in loaded.get("tiers", []):
+ var config: Dictionary = tier["data"]
+ var holder := _walk_path(config, select_server_key_path(config, client))
+ if holder is Dictionary and holder.has(server_name):
+ effective = holder[server_name]
+ var project := _load_project_definitions(client, server_name, project_roots, allow_comments)
+ if not project.get("ok", false):
+ return {"status": McpClient.Status.ERROR, "error_msg": str(project.get("error", "Cannot inspect project config tiers"))}
+ var project_tiers: Array = project.get("tiers", [])
+ if not project_tiers.is_empty():
+ # Last-definition-wins mirrors the global-tier fold above and how
+ # pi-codemode-mcp merges project tiers on disk. Earlier tiers are dead
+ # once a later one defines the same server, so an early-stale entry
+ # doesn't make Pi's effective config drift (codex-review finding F2).
+ # Pass `[latest]` to `_project_override_message` so the error names only
+ # the file the user actually has to edit.
+ var latest: Dictionary = project_tiers[project_tiers.size() - 1]
+ var details := _entry_status_details(client, latest["entry"], server_url, launch)
+ if details.get("status") != McpClient.Status.CONFIGURED:
+ ## Keep `owned` from the effective entry: the post-update migration
+ ## decides from it whether this mismatch is ours to repin.
+ var mismatch := details.duplicate()
+ mismatch["status"] = McpClient.Status.CONFIGURED_MISMATCH
+ mismatch["error_msg"] = _project_override_message([latest], "update or remove", client.display_name, server_name)
+ return mismatch
+ return {"status": McpClient.Status.CONFIGURED, "error_msg": ""}
+ if effective == null:
+ return {"status": McpClient.Status.NOT_CONFIGURED, "error_msg": ""}
+ return _entry_status_details(client, effective, server_url, launch)
+
+
+static func _entry_status_details(
+ client: McpClient,
+ entry: Variant,
+ server_url: String,
+ launch: Dictionary,
+) -> Dictionary:
+ if not (entry is Dictionary):
+ return {"status": McpClient.Status.NOT_CONFIGURED, "error_msg": ""}
+ var launch_error := command_launch_error(client, launch)
+ if not launch_error.is_empty():
+ return {"status": McpClient.Status.ERROR, "error_msg": launch_error}
+ if verify_entry(client, entry, server_url, launch):
+ return {"status": McpClient.Status.CONFIGURED, "error_msg": ""}
+ return {
+ "status": McpClient.Status.CONFIGURED_MISMATCH,
+ "error_msg": "",
+ "owned": McpClient.launch_values_mention_godot_ai(McpClient.entry_launch_values(entry)),
+ }
+
+
+static func remove(
+ client: McpClient,
+ server_name: String,
+ project_roots: PackedStringArray = PackedStringArray(),
+) -> Dictionary:
+ if _uses_merge_tiers(client):
+ return _remove_merged(client, server_name, project_roots)
+ var resolution := client.resolved_config_path_details()
+ var path := str(resolution.get("path", ""))
+ var path_error := str(resolution.get("error", ""))
+ if not path_error.is_empty():
+ return {"status": "error", "message": path_error}
+ if path.is_empty() or not FileAccess.file_exists(path):
+ return {"status": "ok", "message": "Not configured"}
+ # Token-preserving removal (F5): pull the original file text via
+ # `_read_file_text` and edit only the entry's bytes — keeps every other
+ # byte, including integers above 2^53, byte-for-byte identical to what
+ # the user wrote. The parsed-dict round-trip path is gone.
+ var file_read := _read_file_text(path)
+ if not file_read.get("ok", false):
+ return {"status": "error", "message": "Refusing to rewrite %s: %s." % [path, file_read.get("error", "")]}
+ var config: Dictionary = file_read.get("data", {})
+ var key_path := select_server_key_path(config, client)
+ var original_text: String = str(file_read.get("original_text", ""))
+ var updated: String = _text_remove_server_entry(original_text, key_path, server_name)
+ if updated == original_text:
+ return {"status": "ok", "message": "Not configured"}
+ if not McpAtomicWrite.write(path, updated):
+ return {"status": "error", "message": "Cannot write to %s" % path}
+ return {"status": "ok", "message": "%s configuration removed" % client.display_name}
+
+
+static func _remove_merged(
+ client: McpClient, server_name: String, project_roots: PackedStringArray
+) -> Dictionary:
+ var project := _load_project_definitions(client, server_name, project_roots)
+ if not project.get("ok", false):
+ return {"status": "error", "message": str(project.get("error", "Cannot inspect project config tiers"))}
+ var project_tiers: Array = project.get("tiers", [])
+ if not project_tiers.is_empty():
+ return {"status": "error", "message": _project_override_message(project_tiers, "remove", client.display_name, server_name)}
+ var loaded := _load_merge_tiers(client)
+ if not loaded.get("ok", false):
+ return {"status": "error", "message": str(loaded.get("error", "Cannot read merged config tiers"))}
+ var writes: Array[Dictionary] = []
+ for tier in loaded.get("tiers", []):
+ if not tier.get("exists", false):
+ continue
+ var key_path := select_server_key_path(tier["data"], client)
+ var original: String = str(tier.get("original_text", ""))
+ # Token-preserving removal (F5): edit `original` textually instead of
+ # re-serialising the parsed config. This preserves every byte outside
+ # the entry block, so unrelated integers (including those above the
+ # IEEE-754 exact-int range that `_narrow_integral_numbers` cannot
+ # round-trip) keep their original literals.
+ var updated: String = _text_remove_server_entry(original, key_path, server_name)
+ if updated == original:
+ continue # entry not in this tier; skip
+ writes.append({
+ "path": tier["path"],
+ "text": updated,
+ "original_text": original,
+ })
+ if writes.is_empty():
+ return {"status": "ok", "message": "Not configured"}
+ var written := _write_transaction(writes)
+ if not written.get("ok", false):
+ return {"status": "error", "message": written.get("error", "Cannot remove merged configuration")}
+ return {"status": "ok", "message": "%s configuration removed" % client.display_name}
+
+
+## Synthesize the entry dict the strategy writes under
+## `server_key_path[server_name]`. Both URL and command entries deep-copy the
+## existing dict before overwriting strategy-owned fields, preserving unknown
+## client additions as well as descriptor-documented user fields.
+static func build_entry(
+ client: McpClient,
+ server_url: String,
+ existing: Variant = null,
+ launch: Dictionary = {},
+) -> Dictionary:
+ if _is_supported_command_shape(client.command_shape):
+ var command_entry: Dictionary = (existing as Dictionary).duplicate(true) if existing is Dictionary else {}
+ if client.command_shape == McpClient.CommandShape.COMMAND_ARRAY:
+ ## OpenCode-style: the entry's `command` field IS the argv array.
+ ## A stale sibling `args` from a FLAT-style hand edit would be
+ ## ambiguous next to it, so it is strategy-owned and removed.
+ command_entry["command"] = _launch_argv(launch)
+ command_entry.erase("args")
+ else:
+ command_entry["command"] = str(launch.get("command", ""))
+ command_entry["args"] = _array_copy(launch.get("args", []))
+ if not client.command_transport_key.is_empty():
+ command_entry[client.command_transport_key] = client.command_transport_value
+ for key in client.command_initial_fields:
+ if not command_entry.has(key):
+ command_entry[key] = client.command_initial_fields[key]
+ for key in client.command_legacy_keys:
+ command_entry.erase(String(key))
+ _remove_legacy_env_keys(command_entry, client.command_env_legacy_keys)
+ return command_entry
+ if client.command_shape != McpClient.CommandShape.NONE:
+ return {}
+ return build_url_entry(client, server_url, existing)
+
+
+static func build_url_entry(client: McpClient, server_url: String, existing: Variant = null) -> Dictionary:
+ var entry: Dictionary = (existing as Dictionary).duplicate(true) if existing is Dictionary else {}
+ entry[client.entry_url_field] = server_url
+ for k in client.entry_extra_fields:
+ entry[k] = client.entry_extra_fields[k]
+ for k in client.entry_initial_fields:
+ if not entry.has(k):
+ entry[k] = client.entry_initial_fields[k]
+ return entry
+
+
+## Default verifier for a stored entry. Command entries must match every
+## launch-affecting value exactly; legacy URL or env keys are migration drift.
+## For URL clients, assert `entry[entry_url_field] == url` AND every
+## key in `entry_extra_fields` matches verbatim. Type-pinning for Cline /
+## Roo / Kilo (`type: "streamable-http"` etc.) falls out of this — pre-fix
+## entries that lack the type field fail verification and surface as drift.
+static func verify_entry(
+ client: McpClient,
+ entry: Dictionary,
+ server_url: String,
+ launch: Dictionary = {},
+) -> bool:
+ if client.command_shape != McpClient.CommandShape.NONE:
+ if not _is_supported_command_shape(client.command_shape) or not bool(launch.get("ok", false)):
+ return false
+ for key in client.command_legacy_keys:
+ if entry.has(String(key)):
+ return false
+ var env = entry.get("env", null)
+ if env is Dictionary:
+ for key in client.command_env_legacy_keys:
+ if env.has(String(key)):
+ return false
+ if client.command_shape == McpClient.CommandShape.COMMAND_ARRAY:
+ if not _arrays_equal(entry.get("command", null), _launch_argv(launch)):
+ return false
+ if entry.has("args"):
+ return false
+ else:
+ if entry.get("command") != launch.get("command"):
+ return false
+ if not _arrays_equal(entry.get("args", null), launch.get("args", null)):
+ return false
+ if not client.command_transport_key.is_empty():
+ if not entry.has(client.command_transport_key):
+ return false
+ if entry.get(client.command_transport_key) != client.command_transport_value:
+ return false
+ return true
+ if entry.get(client.entry_url_field, "") != server_url:
+ return false
+ for k in client.entry_extra_fields:
+ if entry.get(k) != client.entry_extra_fields[k]:
+ return false
+ return true
+
+
+static func command_launch_error(client: McpClient, launch: Dictionary) -> String:
+ if client.command_shape == McpClient.CommandShape.NONE:
+ return ""
+ if not _is_supported_command_shape(client.command_shape):
+ return "%s uses a command shape not supported by JSON yet" % client.display_name
+ if not bool(launch.get("ok", false)):
+ return str(launch.get("error", "No compatible attach launcher was found."))
+ return ""
+
+
+static func _is_supported_command_shape(shape: McpClient.CommandShape) -> bool:
+ return shape == McpClient.CommandShape.FLAT or shape == McpClient.CommandShape.COMMAND_ARRAY
+
+
+## The full launch argv as one array: launcher path followed by every arg.
+static func _launch_argv(launch: Dictionary) -> Array:
+ var argv: Array = [str(launch.get("command", ""))]
+ argv.append_array(_array_copy(launch.get("args", [])))
+ return argv
+
+
+static func _remove_legacy_env_keys(entry: Dictionary, legacy_keys: PackedStringArray) -> void:
+ if legacy_keys.is_empty():
+ return
+ var existing_env = entry.get("env", null)
+ if not (existing_env is Dictionary):
+ return
+ var env: Dictionary = (existing_env as Dictionary).duplicate(true)
+ for key in legacy_keys:
+ env.erase(String(key))
+ if env.is_empty():
+ entry.erase("env")
+ else:
+ entry["env"] = env
+
+
+static func _array_copy(value: Variant) -> Array:
+ if value is Array:
+ return (value as Array).duplicate(true)
+ if value is PackedStringArray:
+ return McpClient._array_from_packed(value)
+ return []
+
+
+static func _arrays_equal(left: Variant, right: Variant) -> bool:
+ if not (left is Array or left is PackedStringArray):
+ return false
+ if not (right is Array or right is PackedStringArray):
+ return false
+ var left_array := _array_copy(left)
+ var right_array := _array_copy(right)
+ if left_array.size() != right_array.size():
+ return false
+ for i in range(left_array.size()):
+ if left_array[i] != right_array[i]:
+ return false
+ return true
+
+
+## Read a config file once and parse the captured text. Returns
+## {"exists": bool, "ok": bool, "data": Dictionary, "original_text": String}
+## when the file is absent or parses cleanly, and
+## {"exists": true, "ok": false, "error": String, "original_text": String}
+## when the file exists with non-empty content we cannot safely round-trip.
+## Callers must NOT treat the error path as an empty config — doing so blows
+## away the user's other MCP entries on the next write. The `original_text`
+## is the exact captured source so transactional rollback can restore
+## byte-for-byte; the UTF-8 BOM is stripped only from the parsing copy.
+## `allow_comments` also strips JSONC comments from the parsing copy; only
+## read-only callers pass it (see `_status_allows_comments`).
+static func _read_file_text(path: String, allow_comments: bool = false) -> Dictionary:
+ if not FileAccess.file_exists(path):
+ return {"exists": false, "ok": true, "data": {}, "original_text": ""}
+ var file := FileAccess.open(path, FileAccess.READ)
+ if file == null:
+ var open_err := FileAccess.get_open_error()
+ return {"exists": true, "ok": false, "error": "could not open for reading (error %d)" % open_err, "original_text": ""}
+ # Read bytes, not `get_as_text()`. Godot's UTF-8 decoder (both `get_as_text`
+ # and `get_string_from_utf8`) silently consumes a leading EF BB BF, so a
+ # text-only read dropped the BOM from `original_text` and the next Remove
+ # wrote it back out of the user's file — a byte mutation outside the entry
+ # we were asked to touch. Detect the marker on the raw buffer and restore
+ # U+FEFF when the decoder ate it.
+ var buf := file.get_buffer(file.get_length())
+ file.close()
+ var had_bom := buf.size() >= 3 and buf[0] == 0xEF and buf[1] == 0xBB and buf[2] == 0xBF
+ var content := buf.get_string_from_utf8()
+ if had_bom and not content.begins_with(""):
+ content = "" + content
+ # Everything downstream of the BOM parses and measures the body: JSON.parse
+ # rejects a leading U+FEFF outright, and a BOM-only file is empty, not
+ # broken. `original_text` keeps the marker so writes round-trip it.
+ var body := content.substr(1) if content.begins_with("") else content
+ if body.strip_edges().is_empty():
+ return {"exists": true, "ok": true, "data": {}, "original_text": content}
+ var parse_copy := body
+ if allow_comments:
+ # Parse copy only — `original_text` stays byte-for-byte, and an
+ # unterminated block comment fails closed rather than letting the
+ # leftover source parse as valid JSON.
+ var stripped := _strip_jsonc(parse_copy)
+ if not stripped.get("ok", false):
+ var strip_msg := str(stripped.get("error", "invalid JSONC"))
+ push_warning("MCP | %s in %s" % [strip_msg, path])
+ return {"exists": true, "ok": false, "error": strip_msg, "original_text": content}
+ parse_copy = str(stripped.get("text", ""))
+ var json := JSON.new()
+ if json.parse(parse_copy) != OK:
+ var msg := "JSON parse error on line %d: %s" % [json.get_error_line(), json.get_error_message()]
+ push_warning("MCP | %s in %s" % [msg, path])
+ return {"exists": true, "ok": false, "error": msg, "original_text": content}
+ if not (json.data is Dictionary):
+ return {"exists": true, "ok": false, "error": "top-level value is %s, expected object" % type_string(typeof(json.data)), "original_text": content}
+ return {"exists": true, "ok": true, "data": json.data, "original_text": content}
+
+
+## Read-only by construction: requiring both flags means no Configure/Remove
+## can parse a file it would then re-serialize without the discarded comments.
+## Re-enabling automatic edits restores the parse error instead of a lossy write.
+static func _status_allows_comments(client: McpClient) -> bool:
+ return client.config_allows_comments and not client.automatic_config_edits
+
+
+## Strip `//` and `/* */` comments, leaving sequences inside JSON strings
+## intact. Parse copies only.
+## Returns `{"ok": true, "text": String}` or `{"ok": false, "error": String}`.
+static func _strip_jsonc(text: String) -> Dictionary:
+ var parts := PackedStringArray()
+ var i := 0
+ var n := text.length()
+ var in_string := false
+ var escape := false
+ var run_start := 0
+ while i < n:
+ var c := text[i]
+ if in_string:
+ if escape:
+ escape = false
+ elif c == "\\":
+ escape = true
+ elif c == '"':
+ in_string = false
+ i += 1
+ continue
+ if c == '"':
+ in_string = true
+ i += 1
+ continue
+ var skipped := _skip_jsonc_comment_at(text, i)
+ if skipped < 0:
+ return {"ok": false, "error": "unterminated block comment"}
+ if skipped != i:
+ if i > run_start:
+ parts.append(text.substr(run_start, i - run_start))
+ # Re-emit newlines so JSON.parse error lines still match the source.
+ var emitted_nl := false
+ for j in range(i, skipped):
+ if text[j] == "\n":
+ parts.append("\n")
+ emitted_nl = true
+ # A comment must not glue its neighbours (`tru/* x */e` is not
+ # `true`). A newline already splits them; otherwise use a space.
+ if not emitted_nl:
+ parts.append(" ")
+ i = skipped
+ run_start = i
+ continue
+ i += 1
+ if in_string:
+ return {"ok": false, "error": "unterminated string"}
+ if n > run_start:
+ parts.append(text.substr(run_start, n - run_start))
+ return {"ok": true, "text": "".join(parts)}
+
+
+## Skip a JSONC comment starting at `i`. Returns `i` unchanged when `i` is not
+## a comment opener, the index after a consumed comment, or -1 when a block
+## comment runs to EOF without `*/`.
+static func _skip_jsonc_comment_at(text: String, i: int) -> int:
+ var n := text.length()
+ if i + 1 >= n or text[i] != "/":
+ return i
+ if text[i + 1] == "/":
+ i += 2
+ # `get_string_from_utf8()` keeps CR, so a CR-only file never presents a `\n` and
+ # a `\n`-only terminator would swallow the rest of the file.
+ while i < n and text[i] != "\n" and text[i] != "\r":
+ i += 1
+ return i
+ if text[i + 1] == "*":
+ i += 2
+ while i + 1 < n:
+ if text[i] == "*" and text[i + 1] == "/":
+ return i + 2
+ i += 1
+ return -1
+ return i
+
+
+## Returns {"ok": true, "data": Dictionary} when the file is absent or parses
+## cleanly, and {"ok": false, "error": String} when the file exists with
+## non-empty content we cannot safely round-trip. Callers must NOT fall back
+## to an empty dict on the error path — doing so blows away the user's other
+## MCP entries on the next write.
+static func _read_or_init(path: String, allow_comments: bool = false) -> Dictionary:
+ var read := _read_file_text(path, allow_comments)
+ var result: Dictionary = {"ok": read.get("ok", false), "data": read.get("data", {})}
+ if not result.get("ok", false):
+ result["error"] = read.get("error", "")
+ return result
+
+
+## Walk a key path, creating intermediate Dicts as needed. Returns the leaf Dict.
+static func _ensure_path(root: Dictionary, key_path: PackedStringArray) -> Dictionary:
+ var cur := root
+ for key in key_path:
+ var next = cur.get(key)
+ if not (next is Dictionary):
+ next = {}
+ cur[key] = next
+ cur = next
+ return cur
+
+
+## Walk a key path, returning the leaf Dict if all hops exist; else null.
+static func _walk_path(root: Dictionary, key_path: PackedStringArray) -> Variant:
+ var cur: Variant = root
+ for key in key_path:
+ if not (cur is Dictionary) or not cur.has(key):
+ return null
+ cur = cur[key]
+ return cur
+
+
+## Match clients that accept multiple server-map keys without creating a
+## higher-precedence canonical map that shadows an existing legacy map. A
+## non-null scalar still wins, matching nullish-coalescing parsers; Configure's
+## `_ensure_path` then repairs it using the strategy's existing behavior.
+static func select_server_key_path(root: Dictionary, client: McpClient) -> PackedStringArray:
+ var candidates: Array[PackedStringArray] = [client.server_key_path]
+ # Dynamic access keeps mixed-snapshot self-updates parse-safe when a new
+ # strategy is briefly loaded with an older McpClient base (#398/#736).
+ var aliases = client.get("server_key_path_aliases")
+ if aliases is Array:
+ for alias in aliases:
+ if alias is PackedStringArray:
+ candidates.append(alias)
+ for key_path in candidates:
+ if _walk_path(root, key_path) != null:
+ return key_path
+ return client.server_key_path
+
+
+static func _uses_merge_tiers(client: McpClient) -> bool:
+ var templates = client.get("config_merge_path_templates")
+ return templates is Dictionary and not templates.is_empty()
+
+
+## Resolve the global config tiers in the client's documented merge order.
+static func _merge_paths(client: McpClient) -> PackedStringArray:
+ var result := PackedStringArray()
+ var templates = client.get("config_merge_path_templates")
+ if templates is Dictionary:
+ var platform_key := McpPathTemplate.platform_key(templates)
+ if not platform_key.is_empty():
+ var raw_paths: Variant = templates.get(platform_key, [])
+ if raw_paths is Array or raw_paths is PackedStringArray:
+ for template in raw_paths:
+ var path := McpPathTemplate.expand(str(template)).simplify_path()
+ if not path.is_empty() and not result.has(path):
+ result.append(path)
+ return result
+
+
+## Pi's project tiers are relative to Pi's process cwd, not Godot's. Inspect
+## plausible roots only to detect overrides; callers fail closed rather than
+## mutating both guesses.
+static func _project_candidate_paths(
+ client: McpClient, roots: PackedStringArray
+) -> PackedStringArray:
+ var result := PackedStringArray()
+ var project_paths = client.get("config_merge_project_paths")
+ if not (project_paths is PackedStringArray):
+ return result
+ for project_path in project_paths:
+ var absolute_path := String(project_path).simplify_path()
+ if absolute_path.is_absolute_path() and FileAccess.file_exists(absolute_path) and not result.has(absolute_path):
+ result.append(absolute_path)
+ for root in roots:
+ if String(root).is_empty():
+ continue
+ for relative_path in project_paths:
+ if String(relative_path).is_absolute_path():
+ continue
+ var path := String(root).path_join(String(relative_path)).simplify_path()
+ if FileAccess.file_exists(path) and not result.has(path):
+ result.append(path)
+ return result
+
+
+static func _load_project_definitions(
+ client: McpClient,
+ server_name: String,
+ project_roots: PackedStringArray,
+ allow_comments: bool = false,
+) -> Dictionary:
+ var tiers: Array[Dictionary] = []
+ for path in _project_candidate_paths(client, project_roots):
+ var read := _read_or_init(path, allow_comments)
+ if not read.get("ok", false):
+ return {"ok": false, "error": "Cannot inspect project config %s: %s" % [path, read.get("error", "invalid JSON")]}
+ var config: Dictionary = read["data"]
+ var key_path := select_server_key_path(config, client)
+ var holder := _walk_path(config, key_path)
+ if holder is Dictionary and holder.has(server_name):
+ tiers.append({
+ "path": path,
+ "data": config,
+ "key_path": key_path,
+ "entry": holder[server_name],
+ })
+ return {"ok": true, "tiers": tiers}
+
+
+static func _project_override_message(
+ tiers: Array, action: String, client_name: String, server_name: String
+) -> String:
+ var paths := PackedStringArray()
+ for tier in tiers:
+ paths.append(str(tier["path"]))
+ return "%s project config overrides %s at %s. %s resolves project files from its own working directory, so the dock cannot safely choose one; %s the entry manually." % [client_name, server_name, ", ".join(paths), client_name, action]
+
+
+static func _load_merge_tiers(client: McpClient, allow_comments: bool = false) -> Dictionary:
+ var tiers: Array[Dictionary] = []
+ for path in _merge_paths(client):
+ ## These templates never pass through `resolved_config_path_details`,
+ ## so they need its fail-closed gate here: `McpPathTemplate.expand`
+ ## leaves a token it cannot resolve in place, and the relative survivor
+ ## would be read — and on Configure, written — against the editor's own
+ ## working directory. Fail the whole fold rather than dropping the tier:
+ ## a dropped tier reads as "the user has no config there", which is
+ ## exactly the wrong thing to conclude when we could not look.
+ if not path.is_absolute_path():
+ return {
+ "ok": false,
+ "error": McpClient.unresolved_config_path_error(client.display_name, path),
+ }
+ var read := _read_file_text(path, allow_comments)
+ if not read.get("ok", false):
+ return {
+ "ok": false,
+ "error": "Refusing to rewrite %s: %s. Fix or move the file, then re-run Configure." % [path, read.get("error", "invalid JSON")],
+ }
+ tiers.append({
+ "path": path,
+ "exists": read.get("exists", false),
+ "original_text": read.get("original_text", ""),
+ "data": read.get("data", {}),
+ })
+ return {"ok": true, "tiers": tiers}
+
+
+## Commit a multi-file removal with best-effort rollback. Each individual write
+## is atomic; this layer restores earlier tiers if a later commit fails.
+static func _write_transaction(writes: Array[Dictionary]) -> Dictionary:
+ var completed: Array[Dictionary] = []
+ for write in writes:
+ var path := str(write["path"])
+ if McpAtomicWrite.write(path, str(write["text"])):
+ completed.append(write)
+ continue
+ var rollback_failed := PackedStringArray()
+ completed.reverse()
+ for previous in completed:
+ if not McpAtomicWrite.write(str(previous["path"]), str(previous["original_text"])):
+ rollback_failed.append(str(previous["path"]))
+ var suffix := ""
+ if not rollback_failed.is_empty():
+ suffix = " Rollback also failed for: %s" % ", ".join(rollback_failed)
+ return {"ok": false, "error": "Cannot write to %s; earlier tier changes were rolled back.%s" % [path, suffix]}
+ return {"ok": true}
+## Path that `_check_status_merged`'s last-wins logic would consider
+## authoritative for the server entry, or "" when no entry is found in
+## any tier. Mirrors the same project-tier / global-tier resolution the
+## status check uses so callers (notably the dock Open/Reveal buttons)
+## land on the file that actually drives Pi's effective config.
+##
+## Resolution order (codex round 3, F-3-4):
+## 1. Latest project tier containing the entry — F2 last-wins means the
+## latest of `.pi/mcp.json` and `.mcp.json` wins (matches `_check_status_merged`).
+## 2. Latest global tier containing the entry — already iterated in
+## merge order, last-iterated-wins (same loop pattern as
+## `manual_target_details` lines 660-665).
+## 3. "" when no tier has the entry — caller decides what fallback
+## (`path_template`) to use.
+##
+## Returns "" (not `path_template`) on the "nothing anywhere" branch so
+## the dock can distinguish "no entry yet" from "entry is at the
+## template path" if it wants to.
+static func authoritative_tier_path(
+ client: McpClient,
+ server_name: String,
+ project_roots: PackedStringArray,
+) -> String:
+ if not _uses_merge_tiers(client):
+ return ""
+ var allow_comments := _status_allows_comments(client)
+ var project := _load_project_definitions(client, server_name, project_roots, allow_comments)
+ if not bool(project.get("ok", false)):
+ return ""
+ var project_tiers: Array = project.get("tiers", [])
+ if not project_tiers.is_empty():
+ # F2 last-wins: latest project tier is authoritative. When there are
+ # multiple project tiers, the user-visible status is driven by the
+ # latest, so the Open/Reveal buttons should send them there too.
+ var latest: Dictionary = project_tiers[project_tiers.size() - 1]
+ return str(latest.get("path", ""))
+ var loaded := _load_merge_tiers(client, allow_comments)
+ if not bool(loaded.get("ok", false)):
+ return ""
+ var tiers: Array = loaded.get("tiers", [])
+ # Iterate in priority order, overwrite `selected_path` each time we
+ # find a tier containing the entry. With Pi's merge path order
+ # [mcp.json, .mcp.json] this leaves the higher-precedence `.mcp.json`.
+ var selected_path: String = ""
+ for tier in tiers:
+ var data: Dictionary = tier.get("data", {})
+ var holder: Variant = _walk_path(data, select_server_key_path(data, client))
+ if holder is Dictionary and holder.has(server_name):
+ selected_path = str(tier.get("path", ""))
+ return selected_path
+
+
+## Pick the file and top-level map shown by the manual JSON instructions using
+## the same merge and alias precedence as automatic Configure.
+static func manual_target_details(
+ client: McpClient,
+ server_name: String,
+ fallback_path: String,
+ project_roots: PackedStringArray = PackedStringArray(),
+) -> Dictionary:
+ # A read: the instructions a JSONC client is sent to must not carry
+ # "Target inspection failed" for the file its own client ships.
+ var allow_comments := _status_allows_comments(client)
+ if _uses_merge_tiers(client):
+ var project := _load_project_definitions(client, server_name, project_roots, allow_comments)
+ if not project.get("ok", false):
+ return {"ok": false, "error": project.get("error", "Cannot inspect project config tiers")}
+ var project_tiers: Array = project.get("tiers", [])
+ if project_tiers.size() > 1:
+ return {"ok": false, "error": _project_override_message(project_tiers, "update or remove", client.display_name, server_name)}
+ if project_tiers.size() == 1:
+ var selected_project: Dictionary = project_tiers[0]
+ return {
+ "ok": true,
+ "path": selected_project["path"],
+ "key_path": selected_project["key_path"],
+ }
+ var loaded := _load_merge_tiers(client, allow_comments)
+ if not loaded.get("ok", false):
+ return {"ok": false, "error": loaded.get("error", "Cannot read merged config tiers")}
+ var tiers: Array = loaded.get("tiers", [])
+ var selected: Variant = tiers[0] if not tiers.is_empty() else null
+ for tier in tiers:
+ var config: Dictionary = tier["data"]
+ var holder := _walk_path(config, select_server_key_path(config, client))
+ if holder is Dictionary and holder.has(server_name):
+ selected = tier
+ if selected != null:
+ var config: Dictionary = selected["data"]
+ return {
+ "ok": true,
+ "path": selected["path"],
+ "key_path": select_server_key_path(config, client),
+ }
+ var read := _read_or_init(fallback_path, allow_comments)
+ if not read.get("ok", false):
+ return {"ok": false, "error": "Cannot inspect %s: %s" % [fallback_path, read.get("error", "invalid JSON")]}
+ return {
+ "ok": true,
+ "path": fallback_path,
+ "key_path": select_server_key_path(read["data"], client),
+ }
+
+
+## Godot's JSON.parse turns every JSON number into a float, so a later
+## JSON.stringify re-emits the user's integer fields as "8080.0" — which strict
+## consumers (Go's encoding/json into an int field, etc.) reject, and which
+## needlessly rewrites every number across the user's *other* entries. Re-narrow
+## exactly-representable integral floats back to int so they serialize without
+## the ".0". Walks dicts/arrays in place and returns the (same) value.
+##
+## Integers above 2^53 already lost precision when Godot parsed them to double,
+## so they're left as the float Godot produced rather than faking exactness —
+## byte-perfect preservation would require not parsing the file at all, and such
+## magnitudes don't occur in MCP client configs.
+static func _narrow_integral_numbers(value: Variant) -> Variant:
+ match typeof(value):
+ TYPE_FLOAT:
+ if is_finite(value) and value == floor(value) and absf(value) <= 9007199254740992.0:
+ return int(value)
+ TYPE_DICTIONARY:
+ for k in value:
+ value[k] = _narrow_integral_numbers(value[k])
+ TYPE_ARRAY:
+ for i in value.size():
+ value[i] = _narrow_integral_numbers(value[i])
+ return value
+
+
+## True when `value` contains any float outside the IEEE-754 exact-int range
+## (`abs(f) > 2^53`) that would lose precision when JSON.stringify re-emits it.
+## The configure path refuses such files instead of silently mutating unrelated
+## integers; the remove path never needs this check because it does
+## token-preserving surgery (F5).
+static func _has_lossy_numbers(value: Variant) -> bool:
+ match typeof(value):
+ TYPE_FLOAT:
+ # `>=` not `>`: `2^53 + 1` rounds down to `2^53.0` when Godot parses
+ # the integer to double, so even a "clean" `2^53.0` after parse may
+ # have lost a least-significant bit we cannot recover. Refusing at
+ # the boundary is conservative but correct — and 2^53+ magnitudes
+ # don't appear in real MCP client configs anyway.
+ if is_finite(value) and absf(value) >= 9007199254740992.0:
+ return true
+ TYPE_DICTIONARY:
+ for k in value:
+ if _has_lossy_numbers(value[k]):
+ return true
+ TYPE_ARRAY:
+ for i in value.size():
+ if _has_lossy_numbers(value[i]):
+ return true
+ return false
+
+
+## Token-preserving entry removal. Walks the JSON text with a brace-balanced
+## scanner to locate the entry at `key_path[0]/.../[server_name]` and returns
+## `text` with that entry's bytes deleted (along with the surrounding
+## whitespace + trailing comma). Everything else — including integers above
+## the IEEE-754 exact-int range that `_narrow_integral_numbers` would silently
+## mutate — is preserved byte-for-byte. Returns `text` unchanged when the
+## entry isn't found in `text` (callers treat this as a no-op write).
+static func _text_remove_server_entry(text: String, key_path: PackedStringArray, server_name: String) -> String:
+ # Descend through `key_path`. Each step finds `"":` directly inside the
+ # current container and moves the cursor past the value's opening `{` or `[`.
+ # After the loop, `cursor` is positioned just past the opening bracket of the
+ # container that holds our entry, and `container_depth` tracks how deeply
+ # nested that container is from the root (0 = root object/array).
+ var cursor := 0
+ # Skip past the root's opening `{` or `[` (and any leading whitespace or
+ # UTF-8 BOM) so `_find_key_at_container_depth` starts scanning from
+ # inside the root container with `inner_depth == 0`. Without this step,
+ # the first character encountered is the root's `{`, which bumps
+ # `inner_depth` to 1 and makes the top-level key check fail. The BOM
+ # skip mirrors `_read_file_text`'s parse_copy BOM-strip above so the
+ # scanner sees the same view of the file the parser does; the BOM
+ # itself stays in `text` so the byte-survival F5 contract still holds
+ # (codex round 3, F-3-6 — without it, files saved with a Windows BOM
+ # left the entry in place after Remove).
+ # The BOM can only ever be byte 0, so it is skipped BEFORE the whitespace
+ # walk: a file saved as BOM + newline + `{` (common from Windows editors)
+ # otherwise leaves the cursor on the newline, the root `{` is never
+ # consumed, and the entry silently survives Remove.
+ if cursor < text.length() and text[cursor] == "":
+ cursor += 1
+ while cursor < text.length() and _is_json_ws(text[cursor]):
+ cursor += 1
+ if cursor < text.length() and (text[cursor] == "{" or text[cursor] == "["):
+ cursor += 1
+ var container_depth := 0
+ for key in key_path:
+ var key_bytes := JSON.stringify(String(key))
+ var match := _find_key_at_container_depth(text, cursor, key_bytes)
+ if match.is_empty():
+ return text
+ var open_char := text[match["value_start"]]
+ if open_char != "{" and open_char != "[":
+ return text # leaf isn't an object/array — path can't reach our entry
+ container_depth += 1
+ cursor = match["value_start"] + 1
+ # Now find `"":` at the top level of the container we descended
+ # into (the helper gates on `inner_depth == 0`).
+ var entry_bytes := JSON.stringify(server_name)
+ var entry_match := _find_key_at_container_depth(text, cursor, entry_bytes)
+ if entry_match.is_empty():
+ return text
+ var key_start: int = entry_match["key_start"]
+ var value_end: int = entry_match["value_end"]
+ # Trim the surrounding comma in a mutually exclusive way: the trailing comma
+ # (after the entry's value) carries the burden when the entry is NOT the last
+ # in its container, and the leading comma (before the entry's key) carries it
+ # only when the entry IS the last. Trimming BOTH unconditionally corrupts a
+ # middle-position entry into `"x"}"y"` with no separator — codex-review
+ # finding F5 (regression after the initial round).
+ var remove_end := value_end
+ # Skip JSON whitespace before checking for the trailing comma — valid
+ # (if unusual) formats like `"godot-ai":{} ,"other":{}` put whitespace
+ # between the value and its separator. codex round 4 finding.
+ var trailing_scan := remove_end
+ while trailing_scan < text.length() and _is_json_ws(text[trailing_scan]):
+ trailing_scan += 1
+ var had_trailing_comma := trailing_scan < text.length() and text[trailing_scan] == ","
+ if had_trailing_comma:
+ # Jump past the comma (and any whitespace before it we just skipped).
+ remove_end = trailing_scan + 1
+ while remove_end < text.length() and _is_json_ws(text[remove_end]):
+ remove_end += 1
+ var remove_start := key_start
+ if not had_trailing_comma:
+ while remove_start > 0 and _is_json_ws(text[remove_start - 1]):
+ remove_start -= 1
+ if remove_start > 0 and text[remove_start - 1] == ",":
+ remove_start -= 1
+ while remove_start > 0 and _is_json_ws(text[remove_start - 1]):
+ remove_start -= 1
+ return text.substr(0, remove_start) + text.substr(remove_end)
+
+
+## Look for `key_bytes` (a JSON-encoded string literal including the surrounding
+## quotes) at the TOP LEVEL of the container we're inside, scanning `text` from
+## `start` onwards. The caller is expected to advance `start` just past the
+## container's opening `{` or `[`, so we begin with `inner_depth == 0` and a
+## key matches when we hit it while `inner_depth` is still 0. The scan exits
+## immediately if `inner_depth` drops below 0 (the target container closed)
+## to prevent same-named keys in later sibling containers from being picked
+## up. Returns `{key_start, value_start, value_end}` on success or `{}` if
+## the key isn't present in that container.
+static func _find_key_at_container_depth(text: String, start: int, key_bytes: String) -> Dictionary:
+ var i := start
+ var in_string := false
+ var escape := false
+ var inner_depth := 0 # depth *inside* the current container
+ while i < text.length():
+ var c := text[i]
+ if in_string:
+ if escape:
+ escape = false
+ elif c == "\\":
+ escape = true
+ elif c == '"':
+ in_string = false
+ i += 1
+ continue
+ if c == '"':
+ if text.substr(i, key_bytes.length()) == key_bytes:
+ var after_key := i + key_bytes.length()
+ while after_key < text.length() and _is_json_ws(text[after_key]):
+ after_key += 1
+ if after_key < text.length() and text[after_key] == ":":
+ var value_start := after_key + 1
+ while value_start < text.length() and _is_json_ws(text[value_start]):
+ value_start += 1
+ var value_end := _json_value_span_end(text, value_start)
+ if value_end >= 0 and inner_depth == 0:
+ return {"key_start": i, "value_start": value_start, "value_end": value_end}
+ in_string = true
+ i += 1
+ continue
+ if c == "{" or c == "[":
+ inner_depth += 1
+ elif c == "}" or c == "]":
+ inner_depth -= 1
+ # Bail out the moment we leave the container we're scanning. Without
+ # this guard, a later sibling container at the same level (e.g. a
+ # top-level `extensions` block after `mcpServers` closes) re-balances
+ # `inner_depth` back to 0 and a same-named key in that sibling would
+ # match — silently deleting data we never intended to touch
+ # (codex-review finding F5, regression after the initial round).
+ if inner_depth < 0:
+ return {}
+ i += 1
+ return {}
+
+
+## Given `value_start` pointing at the first char of a JSON value, return the
+## position AFTER the value's last char. Handles objects, arrays, strings,
+## and bare literals (numbers, true/false/null). Returns -1 if the value is
+## unterminated.
+static func _json_value_span_end(text: String, value_start: int) -> int:
+ if value_start >= text.length():
+ return -1
+ var c := text[value_start]
+ if c == "{" or c == "[":
+ var open_char := c
+ var close_char := "}" if c == "{" else "]"
+ var depth := 1
+ var i := value_start + 1
+ var in_string := false
+ var escape := false
+ while i < text.length() and depth > 0:
+ var ch := text[i]
+ if in_string:
+ if escape:
+ escape = false
+ elif ch == "\\":
+ escape = true
+ elif ch == '"':
+ in_string = false
+ i += 1
+ continue
+ if ch == '"':
+ in_string = true
+ elif ch == open_char:
+ depth += 1
+ elif ch == close_char:
+ depth -= 1
+ i += 1
+ return i if depth == 0 else -1
+ if c == '"':
+ var i := value_start + 1
+ var escape := false
+ while i < text.length():
+ var ch := text[i]
+ if escape:
+ escape = false
+ elif ch == "\\":
+ escape = true
+ elif ch == '"':
+ return i + 1
+ i += 1
+ return -1
+ # Bare literal: number / true / false / null. Walk until a JSON-significant
+ # delimiter (`,`, `}`, `]`, or whitespace).
+ var i := value_start
+ while i < text.length():
+ var ch := text[i]
+ if ch == "," or ch == "}" or ch == "]" or _is_json_ws(ch):
+ break
+ i += 1
+ return i
+
+
+static func _is_json_ws(c: String) -> bool:
+ return c == " " or c == "\t" or c == "\n" or c == "\r"
diff --git a/addons/godot_ai/clients/_json_strategy.gd.uid b/addons/godot_ai/clients/_json_strategy.gd.uid
new file mode 100644
index 0000000..5e41fbb
--- /dev/null
+++ b/addons/godot_ai/clients/_json_strategy.gd.uid
@@ -0,0 +1 @@
+uid://g8a4iijpk22w
diff --git a/addons/godot_ai/clients/_manual_command.gd b/addons/godot_ai/clients/_manual_command.gd
new file mode 100644
index 0000000..fef2ffc
--- /dev/null
+++ b/addons/godot_ai/clients/_manual_command.gd
@@ -0,0 +1,353 @@
+@tool
+class_name McpManualCommand
+extends RefCounted
+
+const SHELL_POSIX := "posix"
+const SHELL_POWERSHELL := "powershell"
+## Keep this intersection deliberately small. PowerShell treats a leading `@`
+## as splatting syntax and commas as list separators, while POSIX shells accept
+## both literally; quoting either is safer than trying to infer token position.
+const _SHELL_BARE_SAFE_CHARS := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_+=:./-"
+
+## Synthesize the "Run this manually" string the dock surfaces when
+## auto-configure can't find a CLI / write a file. Generated from the
+## descriptor's declarative fields — there is no per-client builder
+## Callable. See `_base.gd` for why descriptors are data-only.
+
+
+static func build(
+ client: McpClient,
+ server_name: String,
+ server_url: String,
+ resolved_path: String,
+ launch: Dictionary = {},
+ project_roots: PackedStringArray = PackedStringArray(),
+) -> String:
+ if client.command_shape == McpClient.CommandShape.NONE:
+ return "%s has no authenticated v4 configuration path; do not persist the backend URL." % client.display_name
+ match client.config_type:
+ "cli":
+ return _build_cli(client, server_name, server_url, resolved_path, launch, project_roots)
+ "json":
+ return _build_json(client, server_name, server_url, resolved_path, launch, project_roots)
+ "toml":
+ return _build_toml(client, server_name, server_url, resolved_path, launch)
+ "yaml":
+ return _build_yaml(client, server_name, server_url, resolved_path, launch)
+ "dsh":
+ return _build_dsh(client, server_name, server_url, resolved_path, launch)
+ return ""
+
+
+## CLI clients: format the register template against the *short* CLI name so
+## the user can paste it into a terminal regardless of where their binary
+## lives. (The auto-configure path resolves to an absolute uvx-style path;
+## that's noise for a paste-into-terminal hint. The attach launcher path
+## inside a command-shape line stays absolute — status verification compares
+## the registered command against the resolved launcher verbatim.)
+static func _build_cli(
+ client: McpClient,
+ server_name: String,
+ server_url: String,
+ resolved_path: String = "",
+ launch: Dictionary = {},
+ project_roots: PackedStringArray = PackedStringArray(),
+) -> String:
+ if client.cli_register_template.is_empty() or client.cli_names.is_empty():
+ return ""
+ var shell_kind := _shell_kind_for_platform()
+ var short_name: String = String(client.cli_names[0])
+ # Prefer the non-.exe form for a cross-platform-looking command line.
+ for n in client.cli_names:
+ if not String(n).ends_with(".exe"):
+ short_name = String(n)
+ break
+ var cmd := ""
+ if client.command_shape != McpClient.CommandShape.NONE:
+ var launch_error := McpCliStrategy.command_launch_error(client, launch)
+ if not launch_error.is_empty():
+ cmd = "Attach launch command unavailable: %s" % launch_error
+ else:
+ var args := McpCliStrategy.format_args(client.cli_register_template, server_name, server_url, launch)
+ var parts: Array[String] = [short_name]
+ for arg in args:
+ parts.append(String(arg))
+ var lines := _sweep_lines(client, server_name, server_url, short_name, shell_kind)
+ lines.append(_render_command_line(parts, shell_kind))
+ cmd = _format_shell_block(lines, shell_kind) + _sweep_caveat(client)
+ else:
+ var args := McpCliStrategy.format_args(client.cli_register_template, server_name, server_url)
+ var parts: Array[String] = [short_name]
+ for arg in args:
+ parts.append(String(arg))
+ var lines := _sweep_lines(client, server_name, server_url, short_name, shell_kind)
+ lines.append(_render_command_line(parts, shell_kind))
+ cmd = _format_shell_block(lines, shell_kind) + _sweep_caveat(client)
+ # #463: a CLI client with a JSON fallback (Claude Code) may have no `claude`
+ # binary at all — e.g. installed only as a VS Code/Cursor extension. The CLI
+ # line above is useless to that user, so also show the config-file edit that
+ # auto-configure falls back to writing.
+ if client.has_json_fallback() and not resolved_path.is_empty():
+ return "%s\n\nNo `%s` CLI (e.g. installed as a VS Code/Cursor extension)? %s" % [
+ cmd, short_name, _build_json(client, server_name, server_url, resolved_path, launch, project_roots),
+ ]
+ return cmd
+
+
+## #877: Configure's first act is the pre-cleanup — it runs the unregister
+## template once per scope the descriptor can write to (`_cli_strategy.gd`
+## `configure`) before registering. Rendering only the register line made this
+## hint disagree with what the button does, which is the worst property a
+## "run this manually" string can have. The removes go in the SAME shell block
+## as the register, in the order Configure runs them, so the whole thing stays
+## one copy-paste rather than a labelled command plus loose prose.
+static func _sweep_lines(
+ client: McpClient,
+ server_name: String,
+ server_url: String,
+ short_name: String,
+ shell_kind: String,
+) -> Array[String]:
+ var lines: Array[String] = []
+ if client.cli_unregister_template.is_empty():
+ return lines
+ for pre_scope in McpCliStrategy.cleanup_scopes(client):
+ var args := McpCliStrategy.format_args(
+ client.cli_unregister_template, server_name, server_url, {}, pre_scope
+ )
+ var parts: Array[String] = [short_name]
+ for arg in args:
+ parts.append(String(arg))
+ lines.append(_render_command_line(parts, shell_kind))
+ return lines
+
+
+## The one thing the commands themselves cannot show: that the project pass
+## resolves `.mcp.json` against the CLI's cwd, so it can delete an entry from a
+## repository that has nothing to do with this project. Only for `{scope}`
+## descriptors — a single implicit-scope pass removes exactly the entry the
+## register is about to rewrite, which needs no warning. Kept outside the shell
+## block so pasting the block never picks up prose.
+static func _sweep_caveat(client: McpClient) -> String:
+ if client.cli_unregister_template.is_empty() or not McpCliStrategy.uses_scope_token(client):
+ return ""
+ return (
+ "\n\nThe project-scope remove rewrites the .mcp.json in the directory the"
+ + " editor was launched from — not necessarily this project — so it drops a"
+ + " hand-maintained godot-ai entry there. Other servers in that file are left"
+ + " alone."
+ )
+
+
+static func _shell_kind_for_platform() -> String:
+ return SHELL_POWERSHELL if OS.get_name() == "Windows" else SHELL_POSIX
+
+
+## Render a command for one explicitly named shell. The label is load-bearing:
+## POSIX and PowerShell use different escaping for embedded single quotes, so
+## presenting the command without its target shell invites a bad copy/paste.
+static func _format_shell_command(parts: Array[String], shell_kind: String) -> String:
+ return _format_shell_block([_render_command_line(parts, shell_kind)], shell_kind)
+
+
+## One label over N command lines. The label is per-block, not per-line:
+## repeating "Run in PowerShell:" four times would bury the single line that
+## actually registers.
+static func _format_shell_block(lines: Array[String], shell_kind: String) -> String:
+ var label := "Run in PowerShell:" if shell_kind == SHELL_POWERSHELL else "Run in a POSIX shell:"
+ return "%s\n%s" % [label, "\n".join(lines)]
+
+
+static func _render_command_line(parts: Array[String], shell_kind: String) -> String:
+ var rendered: Array[String] = []
+ for part in parts:
+ rendered.append(_shell_display_arg(part, shell_kind))
+ return " ".join(rendered)
+
+
+## Quote one argv element for the paste-into-terminal hint. Single-quoted
+## strings are literal in both supported shells, but embedded single quotes
+## have shell-specific spellings. Backslashes, double quotes, dollar signs,
+## and PowerShell backticks remain byte-for-byte unchanged inside the quotes.
+static func _shell_display_arg(arg: String, shell_kind: String) -> String:
+ if arg.is_empty():
+ return "''"
+ var stays_bare := true
+ for index in range(arg.length()):
+ if _SHELL_BARE_SAFE_CHARS.find(arg.substr(index, 1)) < 0:
+ stays_bare = false
+ break
+ if stays_bare:
+ return arg
+ if shell_kind == SHELL_POWERSHELL:
+ return "'%s'" % arg.replace("'", "''")
+ return "'%s'" % arg.replace("'", "'\"'\"'")
+
+
+static func _build_json(
+ client: McpClient,
+ server_name: String,
+ server_url: String,
+ resolved_path: String,
+ launch: Dictionary = {},
+ project_roots: PackedStringArray = PackedStringArray(),
+) -> String:
+ var target := McpJsonStrategy.manual_target_details(client, server_name, resolved_path, project_roots)
+ var target_note := ""
+ if not target.get("ok", false):
+ target_note = "Target inspection failed: %s" % str(target.get("error", "cannot inspect config"))
+ target = {"path": resolved_path, "key_path": client.server_key_path}
+ var target_path := str(target.get("path", resolved_path))
+ var key_path: PackedStringArray = target.get("key_path", client.server_key_path)
+ var key := key_path[0] if key_path.size() > 0 else "mcpServers"
+ if client.command_shape != McpClient.CommandShape.NONE:
+ var lines: Array[String] = []
+ var launch_error := McpJsonStrategy.command_launch_error(client, launch)
+ if launch_error.is_empty():
+ var command_entry := McpJsonStrategy.build_entry(client, server_url, null, launch)
+ lines.append("Edit %s and add under \"%s\":" % [target_path, key])
+ lines.append(" \"%s\": %s" % [server_name, _format_entry_inline(command_entry)])
+ else:
+ lines.append("Attach launch command unavailable: %s" % launch_error)
+ if not target_note.is_empty():
+ lines.append("")
+ lines.append(target_note)
+ return "\n".join(lines)
+ var entry := McpJsonStrategy.build_entry(client, server_url)
+ var instructions := "Edit %s and add under \"%s\":\n \"%s\": %s" % [target_path, key, server_name, _format_entry_inline(entry)]
+ if not target_note.is_empty():
+ instructions += "\n\n" + target_note
+ return instructions
+
+
+static func _build_toml(
+ client: McpClient,
+ _server_name: String,
+ server_url: String,
+ resolved_path: String,
+ launch: Dictionary = {},
+) -> String:
+ var header := _toml_header(client)
+ if client.command_shape != McpClient.CommandShape.NONE:
+ var lines: Array[String] = []
+ var rendered := McpTomlStrategy.render_body(client, server_url, launch)
+ if bool(rendered.get("ok", false)):
+ lines.append("Edit %s and add:" % resolved_path)
+ lines.append(" %s" % header)
+ for body_line in rendered.get("lines", []):
+ lines.append(" %s" % str(body_line))
+ else:
+ lines.append("Attach launch command unavailable: %s" % str(rendered.get("error", "no compatible launcher found")))
+ return "\n".join(lines)
+ var body := McpTomlStrategy.format_body(client.toml_body_template, server_url)
+ var lines: Array[String] = ["Edit %s and add:" % resolved_path, " %s" % header]
+ for b in body:
+ lines.append(" %s" % String(b))
+ return "\n".join(lines)
+
+
+static func _build_yaml(
+ client: McpClient,
+ server_name: String,
+ server_url: String,
+ resolved_path: String,
+ launch: Dictionary = {},
+) -> String:
+ var key := client.server_key_path[0] if client.server_key_path.size() > 0 else "mcp_servers"
+ if client.command_shape != McpClient.CommandShape.NONE:
+ var lines: Array[String] = []
+ var launch_error := McpYamlStrategy.command_launch_error(client, launch)
+ if launch_error.is_empty():
+ var command_entry := McpYamlStrategy.build_entry(client, server_url, null, launch)
+ lines.append("Edit %s and add under '%s':" % [resolved_path, key])
+ for entry_line in McpYamlStrategy.render_entry_lines(server_name, command_entry):
+ lines.append(String(entry_line))
+ else:
+ lines.append("Attach launch command unavailable: %s" % launch_error)
+ return "\n".join(lines)
+ var entry := McpYamlStrategy.build_entry(client, server_url)
+ var lines: Array[String] = [
+ "Edit %s and add under '%s':" % [resolved_path, key],
+ " %s:" % server_name,
+ ]
+ for k in entry:
+ lines.append(" %s: %s" % [k, str(entry[k])])
+ return "\n".join(lines)
+
+
+## DeepSeek Harness: the user pastes a loader `insert` row into the home
+## patch layer (`$DSH_HOME/cordis.patch.yml`). Auto-configure writes exactly
+## these lines, so the manual text matches the file Configure would produce
+## byte-for-byte.
+static func _build_dsh(
+ client: McpClient,
+ server_name: String,
+ server_url: String,
+ resolved_path: String,
+ launch: Dictionary = {},
+) -> String:
+ var lines: Array[String] = ["Edit %s and add this loader entry (an `insert` row; a plain `- id:` row only overrides an existing bundle id and would be skipped):" % resolved_path]
+ var entry_id_value := McpDshStrategy.entry_id(server_name)
+ if client.command_shape != McpClient.CommandShape.NONE:
+ var launch_error := McpDshStrategy.command_launch_error(client, launch)
+ if launch_error.is_empty():
+ var command_entry := McpDshStrategy.build_entry(client, server_name, server_url, null, launch)
+ for row_line in McpDshStrategy.render_insert_row(entry_id_value, command_entry):
+ lines.append(String(row_line))
+ else:
+ lines.append("Attach launch command unavailable: %s" % launch_error)
+ return "\n".join(lines)
+ var url_entry := McpDshStrategy.build_url_entry(client, server_name, server_url)
+ for row_line in McpDshStrategy.render_insert_row(entry_id_value, url_entry):
+ lines.append(String(row_line))
+ return "\n".join(lines)
+
+
+## Mirrors the [section."name"] header `_toml_strategy._primary_header`
+## emits, kept here so the manual-command text matches the file we'd write.
+static func _toml_header(client: McpClient) -> String:
+ var parts := client.toml_section_path
+ if parts.size() < 2:
+ return "[%s]" % ".".join(parts)
+ var section := ".".join(McpClient._array_from_packed(McpClient._packed_slice(parts, 0, parts.size() - 1)))
+ var name := parts[parts.size() - 1]
+ return "[%s.\"%s\"]" % [section, name]
+
+
+## Format an entry dict as a single inline JSON-ish string, matching the
+## pre-refactor manual-command style: `{ "k": v, "k": v }` with spaces.
+## Pre-existing manual-command tests assert the exact substring shape; this
+## keeps them stable.
+##
+## Uses `JSON.stringify` for every leaf String (key OR value) so paths
+## containing backslashes / quotes / newlines render as syntactically valid
+## JSON. A Windows uvx path like `C:\Users\foo\uvx.exe` would otherwise be
+## emitted as `"C:\Users\foo\uvx.exe"` — invalid JSON, unsafe to paste.
+static func _format_entry_inline(entry: Dictionary) -> String:
+ var parts: Array[String] = []
+ for k in entry:
+ parts.append("%s: %s" % [JSON.stringify(String(k)), _format_value(entry[k])])
+ if parts.is_empty():
+ return "{}"
+ return "{ %s }" % ", ".join(parts)
+
+
+static func _format_value(value: Variant) -> String:
+ # Strings, bools, numbers, null all round-trip correctly through JSON.stringify
+ # without spurious quoting of non-string scalars (true → `true`, 5 → `5`).
+ # Arrays and Dictionaries are formatted manually so the inline ` { k: v } `
+ # spacing matches the pre-refactor manual-command output shape that tests
+ # pin with assert_contains.
+ if value is Array:
+ var arr_parts: Array[String] = []
+ for v in value:
+ arr_parts.append(_format_value(v))
+ return "[%s]" % ", ".join(arr_parts)
+ if value is Dictionary:
+ var d_parts: Array[String] = []
+ for k in value:
+ d_parts.append("%s: %s" % [JSON.stringify(String(k)), _format_value(value[k])])
+ if d_parts.is_empty():
+ return "{}"
+ return "{ %s }" % ", ".join(d_parts)
+ return JSON.stringify(value)
diff --git a/addons/godot_ai/clients/_manual_command.gd.uid b/addons/godot_ai/clients/_manual_command.gd.uid
new file mode 100644
index 0000000..07a96f4
--- /dev/null
+++ b/addons/godot_ai/clients/_manual_command.gd.uid
@@ -0,0 +1 @@
+uid://ct1wmgfk408x0
diff --git a/addons/godot_ai/clients/_path_template.gd b/addons/godot_ai/clients/_path_template.gd
new file mode 100644
index 0000000..8fc9753
--- /dev/null
+++ b/addons/godot_ai/clients/_path_template.gd
@@ -0,0 +1,242 @@
+@tool
+class_name McpPathTemplate
+extends RefCounted
+
+## Expands ~ / $HOME / $APPDATA / $XDG_CONFIG_HOME / $LOCALAPPDATA / $USERPROFILE
+## inside path templates so per-client descriptors can declare paths declaratively
+## without hand-rolling per-OS lookups.
+
+## #691: dock worker threads (client-status refresh, configure/remove
+## actions) and the #678 startup walk's discovery worker expand these
+## templates off the main thread, while the spawn step mutates the
+## process-global environment around `OS.create_process`
+## (`GODOT_AI_OWNER_PID`, `GODOT_AI_PLUGIN_SPAWNED`, `PYTHONPATH`,
+## `GODOT_AI_DISABLE_TELEMETRY`). A glibc `getenv` racing a concurrent
+## `setenv` can return a freed pointer — rare but process-fatal. All env
+## reads in this layer therefore go through `env_lookup`: on the MAIN
+## thread it reads live and refreshes a mutex-guarded snapshot; off the
+## main thread it serves from the snapshot, so no `OS.get_environment`
+## runs concurrently with the spawn window's mutations. Callers pre-warm
+## every var their workers can touch via `warm_env_snapshot` (plugin
+## `_enter_tree` and the dock's phase-1 refresh prep, both main-thread,
+## both before any worker starts).
+static var _env_snapshot := {}
+static var _env_snapshot_mutex := Mutex.new()
+
+## Every var this layer and its sibling consumers (`_base.gd`
+## `config_file_override_details`, `config_home_override_details`, `_cli_finder.gd` lookups,
+## `client_configurator.gd` mode/trace reads) can touch off-main.
+## Descriptor-declared config-file/config-home env names are passed as extras
+## by the warm callers.
+const _BASE_ENV_VARS: Array[String] = [
+ "HOME",
+ "USERPROFILE",
+ "XDG_CONFIG_HOME",
+ "APPDATA",
+ "LOCALAPPDATA",
+ "SHELL",
+ "ProgramFiles",
+ "GODOT_AI_MODE",
+ "GODOT_AI_STARTUP_TRACE",
+ ## #804 (#752 adoption): _find_venv_python reads this via env_lookup on
+ ## the dock's worker path; without pre-warming, a set override reads as
+ ## empty there and is silently ignored — the exact misconfiguration the
+ ## push_warning in client_configurator.gd exists to surface.
+ "GODOT_AI_VENV_PYTHON",
+]
+
+
+## Thread-safe env read (#691). Main thread: live read + snapshot refresh.
+## Worker thread: snapshot only, so it can never race a main-thread
+## setenv/unsetenv. A worker read of a never-warmed var returns "" — the
+## same value an unset var reads as — never a live OS.get_environment,
+## which would reintroduce the race for exactly the vars nobody thought
+## to warm. Missing warm-up degrades resolution; it must not touch the
+## process-global environment off-main.
+static func env_lookup(name: String) -> String:
+ if OS.get_thread_caller_id() == OS.get_main_thread_id():
+ var live := OS.get_environment(name)
+ _env_snapshot_mutex.lock()
+ _env_snapshot[name] = live
+ _env_snapshot_mutex.unlock()
+ return live
+ _env_snapshot_mutex.lock()
+ var cached: Variant = _env_snapshot.get(name, null)
+ _env_snapshot_mutex.unlock()
+ if cached != null:
+ return str(cached)
+ return ""
+
+
+## Main-thread pre-warm so subsequent worker reads never touch the real
+## environment. Idempotent; safe to call before every worker dispatch.
+static func warm_env_snapshot(extra_vars: PackedStringArray = PackedStringArray()) -> void:
+ for var_name in _BASE_ENV_VARS:
+ env_lookup(var_name)
+ for var_name in extra_vars:
+ if not String(var_name).is_empty():
+ env_lookup(String(var_name))
+
+
+## Pick the right entry from a {"darwin": ..., "windows": ..., "linux": ...} map.
+static func resolve(template_map: Dictionary) -> String:
+ var key := platform_key(template_map)
+ if key.is_empty():
+ return ""
+ var template: String = template_map[key]
+ return expand(template)
+
+
+## Return the platform-specific key present in a descriptor map. `unix` is a
+## shorthand for macOS and Linux. Public so descriptors can use the same
+## platform selection for ordered path-candidate arrays as for one path.
+static func platform_key(template_map: Dictionary) -> String:
+ var key := _os_key()
+ if template_map.has(key):
+ return key
+ if (key == "darwin" or key == "linux") and template_map.has("unix"):
+ return "unix"
+ return ""
+
+
+## Expand one path template into zero or more concrete paths. A single `*` is
+## allowed inside one DIRECTORY segment (for example `Packages/Claude_*`). The
+## wildcard is resolved by enumerating that segment's parent; the remaining
+## suffix may name a file that does not exist yet, which lets callers derive a
+## deterministic create target for a fresh packaged-app install.
+##
+## Multiple wildcards fail closed and return no candidates. A wildcard final
+## segment may identify an installation directory for `detect_paths`. Returned
+## paths are sorted for deterministic tests and diagnostics; callers still
+## reject ambiguous config groups rather than picking one.
+static func expand_path_candidates(template: String) -> PackedStringArray:
+ var expanded := expand(template)
+ if expanded.is_empty():
+ return PackedStringArray()
+ var star := expanded.find("*")
+ if star < 0:
+ return PackedStringArray([expanded])
+ if expanded.find("*", star + 1) >= 0:
+ return PackedStringArray()
+
+ var slash_before := maxi(expanded.rfind("/", star), expanded.rfind("\\", star))
+ var forward_after := expanded.find("/", star)
+ var backward_after := expanded.find("\\", star)
+ var slash_after := forward_after
+ if slash_after < 0 or (backward_after >= 0 and backward_after < slash_after):
+ slash_after = backward_after
+ if slash_before < 0:
+ return PackedStringArray()
+
+ var parent := expanded.substr(0, slash_before)
+ var pattern := (
+ expanded.substr(slash_before + 1)
+ if slash_after < 0
+ else expanded.substr(slash_before + 1, slash_after - slash_before - 1)
+ )
+ var suffix := "" if slash_after < 0 else expanded.substr(slash_after + 1)
+ var pattern_star := pattern.find("*")
+ if pattern_star < 0:
+ return PackedStringArray()
+ var prefix := pattern.substr(0, pattern_star)
+ var ending := pattern.substr(pattern_star + 1)
+ var dir := DirAccess.open(parent)
+ if dir == null:
+ return PackedStringArray()
+
+ var matches := PackedStringArray()
+ for child in dir.get_directories():
+ if _wildcard_segment_matches(String(child), prefix, ending):
+ var matched_path := parent.path_join(String(child))
+ matches.append(matched_path if suffix.is_empty() else matched_path.path_join(suffix))
+ matches.sort()
+ return matches
+
+
+## Substitute env vars and ~ in a single template string.
+##
+## A token that cannot be resolved — the var is unset and no home-derived
+## fallback applies, or a dock worker's env snapshot was never warmed with it —
+## is LEFT IN PLACE rather than replaced with "". Substituting "" is what turns
+## `$USERPROFILE/godot` into `/godot`: a root-relative path that
+## `is_absolute_path()` accepts, so every fail-closed guard downstream waves it
+## through and the caller reads or writes a directory the user never named.
+## `$USERPROFILE` is the easy repro (unset off Windows, and the only one of
+## these vars with no fallback), but `$HOME`/`$XDG_CONFIG_HOME`/`$APPDATA`/
+## `$LOCALAPPDATA` reach the same state whenever `_home()` is empty, which the
+## missing-warm-up degradation above makes reachable on a worker thread.
+##
+## The surviving token keeps the result non-absolute, so those guards catch it,
+## and the unexpanded path they report still shows what failed to resolve.
+## `~` is left alone for the same reason: collapsing `~/godot` to the relative
+## `godot` aims it at the editor's own working directory.
+static func expand(template: String) -> String:
+ if template.is_empty():
+ return ""
+ var out := template
+ if out.begins_with("~/") or out == "~":
+ var home := _home()
+ if not home.is_empty():
+ out = home if out == "~" else home.path_join(out.substr(2))
+ # $HOME, $APPDATA, $LOCALAPPDATA, $USERPROFILE, $XDG_CONFIG_HOME
+ for var_name in ["XDG_CONFIG_HOME", "LOCALAPPDATA", "USERPROFILE", "APPDATA", "HOME"]:
+ var token := "$%s" % var_name
+ if out.find(token) < 0:
+ continue
+ var value := env_lookup(var_name)
+ if value.is_empty():
+ value = _home_fallback(var_name)
+ if value.is_empty():
+ continue
+ out = out.replace(token, value)
+ return out
+
+
+## Home-derived default for a var the environment does not define. Returns ""
+## when home itself is unresolvable so `expand` leaves the token alone: rooting
+## these at an empty home yields a RELATIVE path, not an absent one —
+## `"".path_join(".config")` is `.config`, which reads against the editor's
+## working directory rather than the user's config dir. `USERPROFILE` has no
+## fallback (it is the thing `_home()` itself falls back to).
+static func _home_fallback(var_name: String) -> String:
+ var home := _home()
+ if home.is_empty():
+ return ""
+ match var_name:
+ "XDG_CONFIG_HOME":
+ return home.path_join(".config")
+ "APPDATA":
+ return home.path_join("AppData/Roaming")
+ "LOCALAPPDATA":
+ return home.path_join("AppData/Local")
+ "HOME":
+ return home
+ return ""
+
+
+static func _os_key() -> String:
+ match OS.get_name():
+ "macOS":
+ return "darwin"
+ "Windows":
+ return "windows"
+ _:
+ return "linux"
+
+
+static func _wildcard_segment_matches(value: String, prefix: String, ending: String) -> bool:
+ # Prefix/suffix tests alone allow the two fixed portions to overlap inside a
+ # too-short value. Glob semantics require room for both portions even when
+ # `*` matches an empty string.
+ if value.length() < prefix.length() + ending.length():
+ return false
+ if OS.get_name() == "Windows":
+ return value.to_lower().begins_with(prefix.to_lower()) and value.to_lower().ends_with(ending.to_lower())
+ return value.begins_with(prefix) and value.ends_with(ending)
+
+
+static func _home() -> String:
+ var h := env_lookup("HOME")
+ if h.is_empty():
+ h = env_lookup("USERPROFILE")
+ return h
diff --git a/addons/godot_ai/clients/_path_template.gd.uid b/addons/godot_ai/clients/_path_template.gd.uid
new file mode 100644
index 0000000..f2403d7
--- /dev/null
+++ b/addons/godot_ai/clients/_path_template.gd.uid
@@ -0,0 +1 @@
+uid://5pd418va35ms
diff --git a/addons/godot_ai/clients/_registry.gd b/addons/godot_ai/clients/_registry.gd
new file mode 100644
index 0000000..000317b
--- /dev/null
+++ b/addons/godot_ai/clients/_registry.gd
@@ -0,0 +1,156 @@
+@tool
+class_name McpClientRegistry
+extends RefCounted
+
+## Central enumeration of every supported MCP client. Adding a new client
+## means: drop a file in clients/, then append one path below.
+##
+## Paths, not preloads (#736): a preload array pulled all client descriptor
+## scripts into the boot-time compile closure of everything that preloads
+## this registry (plugin.gd via client_configurator.gd and mcp_dock.gd),
+## stalling "Initializing plugins" on every editor boot. Descriptors are
+## only needed when the dock refreshes client statuses or a client_*
+## command runs, so they load lazily on first registry access.
+
+const _CLIENT_SCRIPT_PATHS := [
+ "res://addons/godot_ai/clients/claude_code.gd",
+ "res://addons/godot_ai/clients/claude_desktop.gd",
+ "res://addons/godot_ai/clients/codex.gd",
+ "res://addons/godot_ai/clients/codebuddy.gd",
+ "res://addons/godot_ai/clients/grok.gd",
+ "res://addons/godot_ai/clients/antigravity.gd",
+ "res://addons/godot_ai/clients/cursor.gd",
+ "res://addons/godot_ai/clients/windsurf.gd",
+ "res://addons/godot_ai/clients/vscode.gd",
+ "res://addons/godot_ai/clients/vscode_insiders.gd",
+ "res://addons/godot_ai/clients/zed.gd",
+ "res://addons/godot_ai/clients/gemini_cli.gd",
+ "res://addons/godot_ai/clients/cline.gd",
+ "res://addons/godot_ai/clients/kilo_code.gd",
+ "res://addons/godot_ai/clients/roo_code.gd",
+ "res://addons/godot_ai/clients/zoo_code.gd",
+ "res://addons/godot_ai/clients/kiro.gd",
+ "res://addons/godot_ai/clients/trae.gd",
+ "res://addons/godot_ai/clients/opencode.gd",
+ "res://addons/godot_ai/clients/qwen_code.gd",
+ "res://addons/godot_ai/clients/kimi_code.gd",
+ "res://addons/godot_ai/clients/hermes.gd",
+ "res://addons/godot_ai/clients/pi.gd",
+ "res://addons/godot_ai/clients/deepseek_harness.gd",
+]
+
+static var _instances: Array[McpClient] = []
+static var _by_id: Dictionary = {}
+## First registry access can come from the dock's client-status refresh
+## worker thread while the main thread hits it via a client_* command —
+## serialize the one-time load so a racing thread can never observe a
+## half-built registry. load() itself is thread-safe via ResourceLoader.
+static var _load_mutex := Mutex.new()
+## True when even a fresh rebuild yields instances missing base-schema
+## fields — the deep stale-script state after an in-session self-update
+## (#850; docs/releasing.md release-shape rules). Only an editor restart
+## heals it; callers surface RESTART_TO_FINISH_UPDATE instead of erroring
+## per client. Never reset within a session: rebuilding again cannot help,
+## it would only repeat the load work and warning on every dock sweep.
+static var _stale_session := false
+
+const RESTART_TO_FINISH_UPDATE := (
+ "Godot AI was updated in this editor session. Restart the editor to finish the update."
+)
+
+
+static func all() -> Array[McpClient]:
+ _ensure_loaded()
+ return _instances
+
+
+static func get_by_id(id: String) -> McpClient:
+ _ensure_loaded()
+ return _by_id.get(id, null)
+
+
+static func ids() -> PackedStringArray:
+ var out := PackedStringArray()
+ for c in all():
+ out.append(c.id)
+ return out
+
+
+static func has_id(id: String) -> bool:
+ _ensure_loaded()
+ return _by_id.has(id)
+
+
+## True when this editor session is running a self-update whose script
+## reloads left descriptor state unusable. Client operations short-circuit
+## with RESTART_TO_FINISH_UPDATE rather than spamming per-field errors.
+static func stale_session_detected() -> bool:
+ _ensure_loaded()
+ return _stale_session
+
+
+## An instance is coherent when fields added to the CURRENT McpClient schema
+## read back with their declared types. After an in-session self-update,
+## hot-patched or pre-update instances answer Nil for vars the update added
+## (#850: `config_path_candidates` and
+## `config_file_env` read as Nil, crashing platform_key / String()). The
+## reflected `get()` avoids typed-access errors on such instances.
+static func _instance_is_coherent(inst: Object) -> bool:
+ if inst == null:
+ return false
+ return (
+ inst.get("config_path_candidates") is Dictionary
+ and inst.get("config_file_env") is String
+ and inst.get("path_template") is Dictionary
+ and inst.get("server_key_path_aliases") is Array
+ and inst.get("config_merge_path_templates") is Dictionary
+ and inst.get("config_merge_project_paths") is PackedStringArray
+ )
+
+
+static func _cache_is_coherent() -> bool:
+ return not _instances.is_empty() and _instance_is_coherent(_instances[0])
+
+
+static func _ensure_loaded() -> void:
+ if _stale_session:
+ return
+ if _cache_is_coherent():
+ return
+ _load_mutex.lock()
+ ## Re-check under the lock: another thread may have rebuilt (or concluded
+ ## staleness) while this one waited.
+ if not _stale_session and not _cache_is_coherent():
+ ## Covers both the first load and the post-self-update rebuild: this
+ ## registry file can survive an update unchanged, so its statics keep
+ ## serving pre-update instances to freshly reloaded callers. A rebuild
+ ## instantiates from the reloaded descriptor scripts, which repairs
+ ## every case except a stale base Script object itself.
+ _load()
+ if not _instances.is_empty() and not _cache_is_coherent():
+ _stale_session = true
+ push_warning("MCP | %s" % RESTART_TO_FINISH_UPDATE)
+ _load_mutex.unlock()
+
+
+static func _load() -> void:
+ ## Build into locals and publish whole containers last, so the lock-free
+ ## fast path in _ensure_loaded can never see a partially-filled registry.
+ var instances: Array[McpClient] = []
+ var by_id: Dictionary = {}
+ for path in _CLIENT_SCRIPT_PATHS:
+ var script := load(path) as GDScript
+ if script == null:
+ push_warning("MCP | failed to load client descriptor %s" % path)
+ continue
+ var inst: McpClient = script.new()
+ if inst.id.is_empty():
+ push_warning("MCP | client descriptor %s has empty id" % path)
+ continue
+ if by_id.has(inst.id):
+ push_warning("MCP | duplicate client id: %s" % inst.id)
+ continue
+ instances.append(inst)
+ by_id[inst.id] = inst
+ _by_id = by_id
+ _instances = instances
diff --git a/addons/godot_ai/clients/_registry.gd.uid b/addons/godot_ai/clients/_registry.gd.uid
new file mode 100644
index 0000000..57edcf7
--- /dev/null
+++ b/addons/godot_ai/clients/_registry.gd.uid
@@ -0,0 +1 @@
+uid://bxougoq8xwg1
diff --git a/addons/godot_ai/clients/_toml_strategy.gd b/addons/godot_ai/clients/_toml_strategy.gd
new file mode 100644
index 0000000..063f387
--- /dev/null
+++ b/addons/godot_ai/clients/_toml_strategy.gd
@@ -0,0 +1,754 @@
+@tool
+class_name McpTomlStrategy
+extends RefCounted
+
+## TOML upsert for URL entries and client-owned command entries.
+##
+## This remains deliberately smaller than a general TOML parser, but the
+## parts that affect migration are semantic: assignments retain their whole
+## value span (including multiline arrays/strings), and command/args status
+## verification decodes TOML strings and arrays rather than comparing text.
+
+
+static func configure(
+ client: McpClient,
+ _server_name: String,
+ server_url: String,
+ launch: Dictionary = {},
+) -> Dictionary:
+ var resolution := client.resolved_config_path_details()
+ var path := str(resolution.get("path", ""))
+ var path_error := str(resolution.get("error", ""))
+ if not path_error.is_empty():
+ return {"status": "error", "message": path_error}
+ if path.is_empty():
+ return {"status": "error", "message": "Could not resolve config path for %s" % client.display_name}
+
+ var seed_path := str(resolution.get("seed_path", ""))
+ var read_path := seed_path if not FileAccess.file_exists(path) and not seed_path.is_empty() else path
+ var read := _read_or_init(read_path)
+ if not read["ok"]:
+ return {"status": "error", "message": "Refusing to overwrite %s: %s. Fix or move the file, then re-run Configure." % [read_path, read["error"]]}
+
+ var rendered := render_body(client, server_url, launch)
+ if not bool(rendered.get("ok", false)):
+ return {"status": "error", "message": str(rendered.get("error", "Could not build the TOML entry."))}
+
+ var lines: Array[String] = _split_lines(String(read["data"]))
+ var body: Array[String] = rendered["lines"]
+ var pinned_keys: Dictionary = rendered["pinned_keys"]
+ var initial_keys: Dictionary = rendered["initial_keys"]
+ var removed_keys: Dictionary = rendered["removed_keys"]
+
+ var section := _find_section(lines, _all_headers(client))
+ var header := _primary_header(client)
+ var new_lines: Array[String] = [header]
+
+ if section.is_empty():
+ new_lines.append_array(body)
+ var output_fresh: Array[String] = []
+ output_fresh.append_array(lines)
+ if not output_fresh.is_empty() and not output_fresh[-1].strip_edges().is_empty():
+ output_fresh.append("")
+ output_fresh.append_array(new_lines)
+ if not McpAtomicWrite.write(path, _join_lines(output_fresh)):
+ return {"status": "error", "message": "Cannot write to %s" % path}
+ return {"status": "ok", "message": McpClient.configured_message(client, server_url)}
+
+ var old_items := _value_items(lines, int(section["start"]) + 1, int(section["end"]))
+ var old_by_key := {}
+ for item in old_items:
+ var old_key := str(item.get("key", ""))
+ if not old_key.is_empty() and not old_by_key.has(old_key):
+ old_by_key[old_key] = item
+
+ var body_items := _value_items(body, 0, body.size())
+ var emitted_keys := {}
+ for item in body_items:
+ var key := str(item.get("key", ""))
+ if key.is_empty():
+ new_lines.append_array(item["lines"])
+ continue
+ emitted_keys[key] = true
+ if initial_keys.has(key) and old_by_key.has(key):
+ new_lines.append_array(old_by_key[key]["lines"])
+ else:
+ ## Pinned keys always use the freshly rendered span. A generated key
+ ## that is neither pinned nor initial is also rendered deterministically.
+ new_lines.append_array(item["lines"])
+
+ ## Carry unknown/user-owned assignments and standalone comments verbatim.
+ ## Whole item spans prevent multiline arrays/strings from being truncated.
+ for item in old_items:
+ var key := str(item.get("key", ""))
+ if not key.is_empty():
+ if emitted_keys.has(key) or pinned_keys.has(key) or removed_keys.has(key):
+ continue
+ new_lines.append_array(item["lines"])
+ continue
+ var item_lines: Array = item.get("lines", [])
+ for line in item_lines:
+ if not str(line).strip_edges().is_empty():
+ new_lines.append(str(line))
+
+ var output: Array[String] = []
+ output.append_array(_slice(lines, 0, int(section["start"])))
+ output.append_array(new_lines)
+ output.append_array(_slice(lines, int(section["end"]), lines.size()))
+ output = _rewrite_legacy_descendant_headers(output, client)
+
+ if not McpAtomicWrite.write(path, _join_lines(output)):
+ return {"status": "error", "message": "Cannot write to %s" % path}
+ return {"status": "ok", "message": McpClient.configured_message(client, server_url)}
+
+
+static func check_status(
+ client: McpClient,
+ server_name: String,
+ server_url: String,
+ launch: Dictionary = {},
+) -> McpClient.Status:
+ return check_status_details(client, server_name, server_url, launch).get("status", McpClient.Status.NOT_CONFIGURED)
+
+
+static func check_status_details(
+ client: McpClient,
+ _server_name: String,
+ server_url: String,
+ launch: Dictionary = {},
+) -> Dictionary:
+ var resolution := client.resolved_config_path_details()
+ var path := str(resolution.get("path", ""))
+ var path_error := str(resolution.get("error", ""))
+ if not path_error.is_empty():
+ return {"status": McpClient.Status.ERROR, "error_msg": path_error}
+ if path.is_empty() or not FileAccess.file_exists(path):
+ return {"status": McpClient.Status.NOT_CONFIGURED, "error_msg": ""}
+ var read := _read_or_init(path)
+ if not read["ok"]:
+ return {"status": McpClient.Status.ERROR, "error_msg": String(read["error"])}
+ var lines: Array[String] = _split_lines(String(read["data"]))
+ var section := _find_section(lines, _all_headers(client))
+ if section.is_empty():
+ return {"status": McpClient.Status.NOT_CONFIGURED, "error_msg": ""}
+
+ var items := _value_items(lines, int(section["start"]) + 1, int(section["end"]))
+ var by_key := {}
+ for item in items:
+ var key := str(item.get("key", ""))
+ if not key.is_empty() and not by_key.has(key):
+ by_key[key] = item
+ ## Whether the existing entry launches Godot AI at all: a major migration
+ ## may rewrite such an entry, never a foreign one (client_job_owner.gd).
+ ## Only the launch values count; the section header carries our name.
+ var launch_tokens := PackedStringArray()
+ for launch_key in ["command", "url"]:
+ if by_key.has(launch_key):
+ var decoded := _decode_toml_string(_item_value(by_key[launch_key]))
+ if bool(decoded.get("ok", false)):
+ launch_tokens.append(str(decoded.get("value", "")))
+ if by_key.has("args"):
+ var decoded_args := _decode_toml_string_array(_item_value(by_key["args"]))
+ if bool(decoded_args.get("ok", false)):
+ for argument in decoded_args.get("value", []):
+ launch_tokens.append(str(argument))
+ var mismatch := {
+ "status": McpClient.Status.CONFIGURED_MISMATCH,
+ "error_msg": "",
+ "owned": McpClient.launch_values_mention_godot_ai(launch_tokens),
+ }
+
+ if client.command_shape != McpClient.CommandShape.NONE:
+ if not bool(launch.get("ok", false)):
+ return {
+ "status": McpClient.Status.ERROR,
+ "error_msg": str(launch.get("error", "No compatible attach launcher was found.")),
+ }
+ for legacy_key in client.command_legacy_keys:
+ if by_key.has(String(legacy_key)):
+ return mismatch
+ if not by_key.has("command") or not by_key.has("args"):
+ return mismatch
+ var command_value := _decode_toml_string(_item_value(by_key["command"]))
+ var args_value := _decode_toml_string_array(_item_value(by_key["args"]))
+ if not bool(command_value.get("ok", false)) or not bool(args_value.get("ok", false)):
+ return mismatch
+ if str(command_value.get("value", "")) != str(launch.get("command", "")):
+ return mismatch
+ if not _string_arrays_equal(args_value.get("value", []), launch.get("args", [])):
+ return mismatch
+ if not client.command_transport_key.is_empty():
+ var transport_key := client.command_transport_key
+ if not by_key.has(transport_key):
+ return mismatch
+ var decoded_transport := _decode_toml_scalar(_item_value(by_key[transport_key]))
+ if not bool(decoded_transport.get("ok", false)) or decoded_transport.get("value") != client.command_transport_value:
+ return mismatch
+ return {"status": McpClient.Status.CONFIGURED, "error_msg": ""}
+
+ if not by_key.has("url"):
+ return mismatch
+ var url_value := _decode_toml_string(_item_value(by_key["url"]))
+ if not bool(url_value.get("ok", false)) or str(url_value.get("value", "")) != server_url:
+ return mismatch
+ return {"status": McpClient.Status.CONFIGURED, "error_msg": ""}
+
+
+static func remove(client: McpClient, _server_name: String) -> Dictionary:
+ var resolution := client.resolved_config_path_details()
+ var path := str(resolution.get("path", ""))
+ var path_error := str(resolution.get("error", ""))
+ if not path_error.is_empty():
+ return {"status": "error", "message": path_error}
+ if path.is_empty() or not FileAccess.file_exists(path):
+ return {"status": "ok", "message": "Not configured"}
+ var read := _read_or_init(path)
+ if not read["ok"]:
+ return {"status": "error", "message": "Refusing to rewrite %s: %s." % [path, read["error"]]}
+ var lines: Array[String] = _split_lines(String(read["data"]))
+ var headers := _all_headers(client)
+ var subtable_prefixes := _subtable_prefixes(headers)
+
+ var output: Array[String] = []
+ var i := 0
+ while i < lines.size():
+ if _matches_any_header(lines[i], headers) or _matches_subtable_prefix(lines[i], subtable_prefixes):
+ i += 1
+ while i < lines.size() and not _is_any_section_header(lines[i]):
+ i += 1
+ continue
+ output.append(lines[i])
+ i += 1
+
+ if not McpAtomicWrite.write(path, _join_lines(output)):
+ return {"status": "error", "message": "Cannot write to %s" % path}
+ return {"status": "ok", "message": "%s configuration removed" % client.display_name}
+
+
+## Substitute `{url}` in every legacy URL body-template line.
+static func format_body(template: PackedStringArray, server_url: String) -> PackedStringArray:
+ var out := PackedStringArray()
+ for line in template:
+ out.append(String(line).replace("{url}", server_url))
+ return out
+
+
+## Encode a TOML basic string. This is intentionally public for the
+## cross-language fixture test that parses the rendered sample with tomllib.
+static func encode_basic_string(value: String) -> String:
+ return '"%s"' % value.replace("\\", "\\\\").replace('"', '\\"').replace("\b", "\\b").replace("\t", "\\t").replace("\n", "\\n").replace("\f", "\\f").replace("\r", "\\r")
+
+
+## Multi-line string-array encoding used by command-shape entries.
+static func encode_string_array(values: Variant) -> Array[String]:
+ var out: Array[String] = ["["]
+ for value in values:
+ out.append(" %s," % encode_basic_string(str(value)))
+ out.append("]")
+ return out
+
+
+static func render_body(client: McpClient, server_url: String, launch: Dictionary) -> Dictionary:
+ if client.command_shape == McpClient.CommandShape.NONE:
+ if client.toml_body_template.is_empty():
+ return {"ok": false, "error": "%s descriptor missing toml_body_template" % client.display_name}
+ var legacy_body := format_body(client.toml_body_template, server_url)
+ var legacy_lines: Array[String] = []
+ var pinned := {}
+ var initial := {}
+ for idx in range(legacy_body.size()):
+ legacy_lines.append(String(legacy_body[idx]))
+ var key := _line_key(String(client.toml_body_template[idx]))
+ if key.is_empty():
+ continue
+ if String(client.toml_body_template[idx]).contains("{url}"):
+ pinned[key] = true
+ else:
+ initial[key] = true
+ return {
+ "ok": true,
+ "lines": legacy_lines,
+ "pinned_keys": pinned,
+ "initial_keys": initial,
+ "removed_keys": {},
+ }
+
+ if client.command_shape != McpClient.CommandShape.COMMAND_ARRAY:
+ return {"ok": false, "error": "%s uses a command shape not supported by TOML yet" % client.display_name}
+ if not bool(launch.get("ok", false)):
+ return {"ok": false, "error": str(launch.get("error", "No compatible attach launcher was found."))}
+
+ var command_lines: Array[String] = [
+ "command = %s" % encode_basic_string(str(launch.get("command", ""))),
+ ]
+ command_lines.append_array(_encode_assignment("args", launch.get("args", [])))
+
+ var pinned_keys := {"command": true, "args": true}
+ if not client.command_transport_key.is_empty():
+ var encoded_transport := _encode_scalar(client.command_transport_value)
+ if encoded_transport.is_empty():
+ return {
+ "ok": false,
+ "error": "Unsupported TOML transport `%s` for %s" % [
+ client.command_transport_key, client.display_name,
+ ],
+ }
+ command_lines.append("%s = %s" % [client.command_transport_key, encoded_transport])
+ pinned_keys[client.command_transport_key] = true
+
+ var initial_keys := {}
+ for key in client.command_initial_fields:
+ var encoded := _encode_assignment(str(key), client.command_initial_fields[key])
+ if encoded.is_empty():
+ return {"ok": false, "error": "Unsupported TOML default `%s` for %s" % [key, client.display_name]}
+ command_lines.append_array(encoded)
+ initial_keys[str(key)] = true
+
+ var removed_keys := {}
+ for key in client.command_legacy_keys:
+ removed_keys[String(key)] = true
+ return {
+ "ok": true,
+ "lines": command_lines,
+ "pinned_keys": pinned_keys,
+ "initial_keys": initial_keys,
+ "removed_keys": removed_keys,
+ }
+
+
+static func _encode_assignment(key: String, value: Variant) -> Array[String]:
+ if value is Array or value is PackedStringArray:
+ var lines := encode_string_array(value)
+ lines[0] = "%s = %s" % [key, lines[0]]
+ return lines
+ var encoded := _encode_scalar(value)
+ var lines: Array[String] = []
+ if not encoded.is_empty():
+ lines.append("%s = %s" % [key, encoded])
+ return lines
+
+
+static func _encode_scalar(value: Variant) -> String:
+ if value is String:
+ return encode_basic_string(value)
+ if value is bool:
+ return "true" if value else "false"
+ if value is int or value is float:
+ return str(value)
+ return ""
+
+
+# --- span-aware merge helpers -------------------------------------------
+
+static func _value_items(lines: Array[String], from: int, to: int) -> Array[Dictionary]:
+ var out: Array[Dictionary] = []
+ var i := from
+ while i < to:
+ var key := _line_key(lines[i])
+ if key.is_empty():
+ out.append({"key": "", "lines": [lines[i]]})
+ i += 1
+ continue
+ var end := _value_span_end(lines, i, to)
+ out.append({"key": key, "lines": _slice(lines, i, end)})
+ i = end
+ return out
+
+
+static func _value_span_end(lines: Array[String], start: int, limit: int) -> int:
+ var state := {"quote": "", "square": 0, "curly": 0, "escaped": false}
+ for i in range(start, limit):
+ var begin := 0
+ if i == start:
+ var eq := _assignment_equal(lines[i])
+ begin = eq + 1 if eq >= 0 else 0
+ _scan_toml_value_line(lines[i], begin, state)
+ if str(state["quote"]).is_empty() and int(state["square"]) == 0 and int(state["curly"]) == 0:
+ return i + 1
+ return limit
+
+
+static func _scan_toml_value_line(line: String, begin: int, state: Dictionary) -> void:
+ var i := begin
+ while i < line.length():
+ var quote := str(state["quote"])
+ if quote == '"""' or quote == "'''":
+ if line.substr(i).begins_with(quote):
+ state["quote"] = ""
+ i += 3
+ continue
+ if quote == '"""' and line.unicode_at(i) == 92 and not bool(state["escaped"]):
+ state["escaped"] = true
+ i += 1
+ continue
+ state["escaped"] = false
+ i += 1
+ continue
+ if quote == '"' or quote == "'":
+ var c := line.unicode_at(i)
+ if quote == '"' and c == 92 and not bool(state["escaped"]):
+ state["escaped"] = true
+ i += 1
+ continue
+ if c == quote.unicode_at(0) and not bool(state["escaped"]):
+ state["quote"] = ""
+ state["escaped"] = false
+ i += 1
+ continue
+
+ if line.substr(i).begins_with('"""'):
+ state["quote"] = '"""'
+ i += 3
+ continue
+ if line.substr(i).begins_with("'''"):
+ state["quote"] = "'''"
+ i += 3
+ continue
+ var c := line.unicode_at(i)
+ if c == 34:
+ state["quote"] = '"'
+ elif c == 39:
+ state["quote"] = "'"
+ elif c == 35:
+ break
+ elif c == 91:
+ state["square"] = int(state["square"]) + 1
+ elif c == 93:
+ state["square"] = maxi(0, int(state["square"]) - 1)
+ elif c == 123:
+ state["curly"] = int(state["curly"]) + 1
+ elif c == 125:
+ state["curly"] = maxi(0, int(state["curly"]) - 1)
+ i += 1
+
+
+static func _line_key(line: String) -> String:
+ var eq := _assignment_equal(line)
+ if eq <= 0:
+ return ""
+ return line.substr(0, eq).strip_edges()
+
+
+static func _assignment_equal(line: String) -> int:
+ var quote := 0
+ var escaped := false
+ for i in range(line.length()):
+ var c := line.unicode_at(i)
+ if quote != 0:
+ if quote == 34 and c == 92 and not escaped:
+ escaped = true
+ continue
+ if c == quote and not escaped:
+ quote = 0
+ escaped = false
+ continue
+ if c == 34 or c == 39:
+ quote = c
+ elif c == 35:
+ return -1
+ elif c == 61:
+ return i
+ return -1
+
+
+# --- semantic value decoding --------------------------------------------
+
+static func _item_value(item: Dictionary) -> String:
+ var item_lines: Array = item.get("lines", [])
+ if item_lines.is_empty():
+ return ""
+ var first := str(item_lines[0])
+ var eq := _assignment_equal(first)
+ if eq < 0:
+ return ""
+ var parts: Array[String] = [first.substr(eq + 1)]
+ for i in range(1, item_lines.size()):
+ parts.append(str(item_lines[i]))
+ return "\n".join(parts)
+
+
+static func _decode_toml_scalar(raw: String) -> Dictionary:
+ var cleaned := _without_comments(raw).strip_edges()
+ if cleaned == "true":
+ return {"ok": true, "value": true}
+ if cleaned == "false":
+ return {"ok": true, "value": false}
+ var string_value := _decode_toml_string(cleaned)
+ if bool(string_value.get("ok", false)):
+ return string_value
+ if cleaned.is_valid_int():
+ return {"ok": true, "value": cleaned.to_int()}
+ if cleaned.is_valid_float():
+ return {"ok": true, "value": cleaned.to_float()}
+ return {"ok": false}
+
+
+static func _decode_toml_string(raw: String) -> Dictionary:
+ var cleaned := _without_comments(raw).strip_edges()
+ if cleaned.length() >= 2 and cleaned.begins_with("'") and cleaned.ends_with("'"):
+ return {"ok": true, "value": cleaned.substr(1, cleaned.length() - 2)}
+ if cleaned.length() < 2 or not cleaned.begins_with('"') or not cleaned.ends_with('"'):
+ return {"ok": false}
+ var parsed: Variant = JSON.parse_string(cleaned)
+ if parsed is String:
+ return {"ok": true, "value": parsed}
+ return {"ok": false}
+
+
+static func _decode_toml_string_array(raw: String) -> Dictionary:
+ var cleaned := _without_comments(raw).strip_edges()
+ if not cleaned.begins_with("["):
+ return {"ok": false}
+ var i := 1
+ var values: Array[String] = []
+ while true:
+ i = _skip_space(cleaned, i)
+ if i >= cleaned.length():
+ return {"ok": false}
+ if cleaned.unicode_at(i) == 93:
+ i = _skip_space(cleaned, i + 1)
+ return {"ok": i == cleaned.length(), "value": values}
+ var parsed := _parse_string_at(cleaned, i)
+ if not bool(parsed.get("ok", false)):
+ return {"ok": false}
+ values.append(str(parsed.get("value", "")))
+ i = _skip_space(cleaned, int(parsed.get("next", i)))
+ if i >= cleaned.length():
+ return {"ok": false}
+ var c := cleaned.unicode_at(i)
+ if c == 44:
+ i += 1
+ continue
+ if c == 93:
+ continue
+ return {"ok": false}
+ return {"ok": false} # Unreachable; keeps GDScript's return analysis explicit.
+
+
+static func _parse_string_at(text: String, start: int) -> Dictionary:
+ if start >= text.length():
+ return {"ok": false}
+ var quote := text.unicode_at(start)
+ if quote != 34 and quote != 39:
+ return {"ok": false}
+ var i := start + 1
+ var escaped := false
+ while i < text.length():
+ var c := text.unicode_at(i)
+ if quote == 34 and c == 92 and not escaped:
+ escaped = true
+ i += 1
+ continue
+ if c == quote and not escaped:
+ var raw := text.substr(start, i - start + 1)
+ if quote == 39:
+ return {"ok": true, "value": raw.substr(1, raw.length() - 2), "next": i + 1}
+ var parsed: Variant = JSON.parse_string(raw)
+ if parsed is String:
+ return {"ok": true, "value": parsed, "next": i + 1}
+ return {"ok": false}
+ escaped = false
+ i += 1
+ return {"ok": false}
+
+
+static func _without_comments(raw: String) -> String:
+ var out: Array[String] = []
+ for line in raw.split("\n"):
+ var quote := 0
+ var escaped := false
+ var kept := ""
+ for i in range(line.length()):
+ var c := line.unicode_at(i)
+ if quote != 0:
+ kept += line.substr(i, 1)
+ if quote == 34 and c == 92 and not escaped:
+ escaped = true
+ continue
+ if c == quote and not escaped:
+ quote = 0
+ escaped = false
+ continue
+ if c == 34 or c == 39:
+ quote = c
+ kept += line.substr(i, 1)
+ elif c == 35:
+ break
+ else:
+ kept += line.substr(i, 1)
+ out.append(kept)
+ return "\n".join(out)
+
+
+static func _skip_space(text: String, start: int) -> int:
+ var i := start
+ while i < text.length() and text.substr(i, 1) in [" ", "\t", "\r", "\n"]:
+ i += 1
+ return i
+
+
+static func _string_arrays_equal(left: Variant, right: Variant) -> bool:
+ if not (left is Array or left is PackedStringArray):
+ return false
+ if not (right is Array or right is PackedStringArray):
+ return false
+ if left.size() != right.size():
+ return false
+ for i in range(left.size()):
+ if str(left[i]) != str(right[i]):
+ return false
+ return true
+
+
+# --- file / section helpers ---------------------------------------------
+
+static func _read_or_init(path: String) -> Dictionary:
+ if not FileAccess.file_exists(path):
+ return {"ok": true, "data": ""}
+ var f := FileAccess.open(path, FileAccess.READ)
+ if f == null:
+ var err := FileAccess.get_open_error()
+ return {"ok": false, "error": "could not open for reading (error %d)" % err}
+ var text := f.get_as_text()
+ f.close()
+ return {"ok": true, "data": text}
+
+
+static func _join_lines(lines: Array[String]) -> String:
+ var joined := "\n".join(lines)
+ return joined if joined.is_empty() or joined.ends_with("\n") else joined + "\n"
+
+
+static func _split_lines(content: String) -> Array[String]:
+ var out: Array[String] = []
+ for line in content.split("\n"):
+ out.append(line)
+ return out
+
+
+static func _slice(lines: Array[String], from: int, to: int) -> Array[String]:
+ var out: Array[String] = []
+ for i in range(from, to):
+ out.append(lines[i])
+ return out
+
+
+static func _primary_header(client: McpClient) -> String:
+ var parts := client.toml_section_path
+ if parts.size() < 2:
+ return "[%s]" % ".".join(parts)
+ var section := ".".join(McpClient._packed_slice(parts, 0, parts.size() - 1))
+ var name := parts[parts.size() - 1]
+ return "[%s.\"%s\"]" % [section, name]
+
+
+static func _all_headers(client: McpClient) -> Array[String]:
+ var primary := _primary_header(client)
+ var out: Array[String] = [primary]
+ var bare := _bare_key_header(client)
+ if not bare.is_empty() and bare != primary:
+ out.append(bare)
+ for legacy in client.toml_legacy_section_aliases:
+ out.append("[%s]" % legacy)
+ return out
+
+
+static func _bare_key_header(client: McpClient) -> String:
+ var parts := client.toml_section_path
+ if parts.is_empty():
+ return ""
+ for part in parts:
+ if not _is_bare_key(String(part)):
+ return ""
+ return "[%s]" % ".".join(parts)
+
+
+static func _is_bare_key(value: String) -> bool:
+ if value.is_empty():
+ return false
+ for i in range(value.length()):
+ var c := value.unicode_at(i)
+ var alpha := (c >= 65 and c <= 90) or (c >= 97 and c <= 122)
+ var digit := c >= 48 and c <= 57
+ if not (alpha or digit or c == 45 or c == 95):
+ return false
+ return true
+
+
+static func _subtable_prefixes(headers: Array[String]) -> Array[String]:
+ var out: Array[String] = []
+ for header in headers:
+ if header.length() > 2 and header.ends_with("]"):
+ out.append(header.substr(0, header.length() - 1) + ".")
+ return out
+
+
+static func _matches_subtable_prefix(line: String, prefixes: Array[String]) -> bool:
+ var trimmed := line.strip_edges()
+ for prefix in prefixes:
+ if not trimmed.begins_with(prefix):
+ continue
+ var rest := trimmed.substr(prefix.length())
+ var bracket := rest.find("]")
+ if bracket < 0:
+ continue
+ var remainder := rest.substr(bracket + 1).strip_edges()
+ if remainder.is_empty() or remainder.begins_with("#"):
+ return true
+ return false
+
+
+static func _matches_any_header(line: String, headers: Array[String]) -> bool:
+ var trimmed := line.strip_edges()
+ for header in headers:
+ if not trimmed.begins_with(header):
+ continue
+ var remainder := trimmed.substr(header.length()).strip_edges()
+ if remainder.is_empty() or remainder.begins_with("#"):
+ return true
+ return false
+
+
+static func _find_section(lines: Array[String], headers: Array[String]) -> Dictionary:
+ for i in range(lines.size()):
+ if _matches_any_header(lines[i], headers):
+ var end := lines.size()
+ for j in range(i + 1, lines.size()):
+ if _is_any_section_header(lines[j]):
+ end = j
+ break
+ return {"start": i, "end": end}
+ return {}
+
+
+static func _is_any_section_header(line: String) -> bool:
+ var trimmed := line.strip_edges()
+ if not trimmed.begins_with("["):
+ return false
+ var bracket := trimmed.find("]")
+ if bracket < 0:
+ return false
+ var remainder := trimmed.substr(bracket + 1).strip_edges()
+ return remainder.is_empty() or remainder.begins_with("#")
+
+
+static func _rewrite_legacy_descendant_headers(
+ lines: Array[String], client: McpClient
+) -> Array[String]:
+ if client.toml_legacy_section_aliases.is_empty():
+ return lines
+ var primary := _primary_header(client)
+ var primary_prefix := primary.substr(0, primary.length() - 1) + "."
+ var out: Array[String] = []
+ for line in lines:
+ var rewritten := line
+ var trimmed := line.strip_edges()
+ var indent_length := line.find("[")
+ var indent := line.substr(0, indent_length) if indent_length >= 0 else ""
+ for alias in client.toml_legacy_section_aliases:
+ var legacy_prefix := "[%s." % String(alias)
+ if trimmed.begins_with(legacy_prefix):
+ rewritten = indent + primary_prefix + trimmed.substr(legacy_prefix.length())
+ break
+ out.append(rewritten)
+ return out
diff --git a/addons/godot_ai/clients/_toml_strategy.gd.uid b/addons/godot_ai/clients/_toml_strategy.gd.uid
new file mode 100644
index 0000000..723cb79
--- /dev/null
+++ b/addons/godot_ai/clients/_toml_strategy.gd.uid
@@ -0,0 +1 @@
+uid://cwdvxgn0aurqv
diff --git a/addons/godot_ai/clients/_yaml_strategy.gd b/addons/godot_ai/clients/_yaml_strategy.gd
new file mode 100644
index 0000000..becf4ac
--- /dev/null
+++ b/addons/godot_ai/clients/_yaml_strategy.gd
@@ -0,0 +1,553 @@
+@tool
+class_name McpYamlStrategy
+extends RefCounted
+
+## Minimal YAML upsert for Hermes Agent MCP config.
+##
+## Hermes reads MCP servers from ~/.hermes/config.yaml under the
+## `mcp_servers` key (snake_case, YAML). HTTP entries are transport-inferred:
+## just `url` (plus optional `headers`), no `type` field. We only parse the
+## `mcp_servers` block and re-emit it; other top-level keys in the user's
+## config.yaml are preserved verbatim by round-tripping the raw lines around
+## that block. No general YAML parser — Godot has none in stdlib, and Hermes
+## only needs this one shape. See issue #640.
+
+const INDENT := " " # YAML forbids tab indentation; match the 2-space style of ~/.hermes/config.yaml
+
+
+static func configure(
+ client: McpClient,
+ server_name: String,
+ server_url: String,
+ launch: Dictionary = {},
+) -> Dictionary:
+ var resolution := client.resolved_config_path_details()
+ var path := str(resolution.get("path", ""))
+ var path_error := str(resolution.get("error", ""))
+ if not path_error.is_empty():
+ return {"status": "error", "message": path_error}
+ if path.is_empty():
+ return {"status": "error", "message": "Could not resolve config path for %s on this OS" % client.display_name}
+ ## Fail closed before touching the file — same contract as JSON/TOML.
+ var launch_error := command_launch_error(client, launch)
+ if not launch_error.is_empty():
+ return {"status": "error", "message": launch_error}
+
+ var seed_path := str(resolution.get("seed_path", ""))
+ var read_path := seed_path if not FileAccess.file_exists(path) and not seed_path.is_empty() else path
+ var read := _read(read_path)
+ if not read["ok"]:
+ return {"status": "error", "message": "Refusing to overwrite %s: %s. Fix or move the file, then re-run Configure." % [read_path, read["error"]]}
+
+ var text: String = read["data"]
+ var block := _extract_block(text)
+ var entries: Dictionary = block["entries"]
+
+ # Preserve existing entry's user-mutable keys; force the transport keys.
+ var existing: Dictionary = entries.get(server_name, {})
+ var new_entry := build_entry(client, server_url, existing, launch)
+ entries[server_name] = new_entry
+
+ var out := _assemble(text, block["prefix_lines"], entries, block["suffix_lines"])
+ if not McpAtomicWrite.write(path, out):
+ return {"status": "error", "message": "Cannot write to %s" % path}
+ return {"status": "ok", "message": McpClient.configured_message(client, server_url)}
+
+
+static func check_status(
+ client: McpClient, server_name: String, server_url: String, launch: Dictionary = {}
+) -> McpClient.Status:
+ return check_status_details(client, server_name, server_url, launch)["status"]
+
+
+## Same contract as the JSON/TOML strategies (#711): {status, error_msg}.
+## An existing-but-unreadable config is ERROR with the diagnostic — not
+## NOT_CONFIGURED — so the dock row can tell "no config" from "config the
+## editor can't read" instead of offering a Configure that would fail.
+static func check_status_details(
+ client: McpClient, server_name: String, server_url: String, launch: Dictionary = {}
+) -> Dictionary:
+ var resolution := client.resolved_config_path_details()
+ var path := str(resolution.get("path", ""))
+ var path_error := str(resolution.get("error", ""))
+ if not path_error.is_empty():
+ return {"status": McpClient.Status.ERROR, "error_msg": path_error}
+ if path.is_empty() or not FileAccess.file_exists(path):
+ return {"status": McpClient.Status.NOT_CONFIGURED, "error_msg": ""}
+ var read := _read(path)
+ if not read["ok"]:
+ return {
+ "status": McpClient.Status.ERROR,
+ "error_msg": "Cannot read %s: %s" % [path, read["error"]],
+ }
+ var block := _extract_block(String(read["data"]))
+ var entries: Dictionary = block["entries"]
+ if not entries.has(server_name):
+ return {"status": McpClient.Status.NOT_CONFIGURED, "error_msg": ""}
+ var entry: Variant = entries[server_name]
+ if not (entry is Dictionary):
+ return {"status": McpClient.Status.NOT_CONFIGURED, "error_msg": ""}
+ ## An entry exists but no verified launcher does — mirror JSON/TOML: this
+ ## is an environment ERROR, not entry drift.
+ var launch_error := command_launch_error(client, launch)
+ if not launch_error.is_empty():
+ return {"status": McpClient.Status.ERROR, "error_msg": launch_error}
+ if verify_entry(client, entry, server_url, launch):
+ return {"status": McpClient.Status.CONFIGURED, "error_msg": ""}
+ return {
+ "status": McpClient.Status.CONFIGURED_MISMATCH,
+ "error_msg": "",
+ "owned": McpClient.launch_values_mention_godot_ai(McpClient.entry_launch_values(entry)),
+ }
+
+
+static func remove(client: McpClient, server_name: String) -> Dictionary:
+ var resolution := client.resolved_config_path_details()
+ var path := str(resolution.get("path", ""))
+ var path_error := str(resolution.get("error", ""))
+ if not path_error.is_empty():
+ return {"status": "error", "message": path_error}
+ if path.is_empty() or not FileAccess.file_exists(path):
+ return {"status": "ok", "message": "Not configured"}
+ var read := _read(path)
+ if not read["ok"]:
+ return {"status": "error", "message": "Refusing to rewrite %s: %s." % [path, read["error"]]}
+ var text: String = read["data"]
+ var block := _extract_block(text)
+ var entries: Dictionary = block["entries"]
+ if not entries.has(server_name):
+ return {"status": "ok", "message": "%s configuration removed" % client.display_name}
+ entries.erase(server_name)
+ var out := _assemble(text, block["prefix_lines"], entries, block["suffix_lines"])
+ if not McpAtomicWrite.write(path, out):
+ return {"status": "error", "message": "Cannot write to %s" % path}
+ return {"status": "ok", "message": "%s configuration removed" % client.display_name}
+
+
+## Build the entry dict written under mcp_servers[server_name].
+##
+## URL mode (`CommandShape.NONE`): Hermes HTTP entries are transport-inferred
+## — { url: } plus whatever user-mutable keys (headers, enabled, tools,
+## ...) the existing entry carries. Stdio-bridge keys are the one exception
+## (see _STDIO_BRIDGE_KEYS). No `type` field.
+##
+## Command mode (`CommandShape.FLAT`, #838): flat `command` + `args` keys,
+## transport inferred the same way — which is exactly why the descriptor's
+## `command_legacy_keys` (url, headers) must be scrubbed: a Hermes entry with
+## both a url and a command picks the wrong transport. User keys survive.
+static func build_entry(
+ client: McpClient,
+ server_url: String,
+ existing: Variant = null,
+ launch: Dictionary = {},
+) -> Dictionary:
+ if client.command_shape == McpClient.CommandShape.FLAT:
+ var command_entry: Dictionary = (existing as Dictionary).duplicate(true) if existing is Dictionary else {}
+ command_entry["command"] = str(launch.get("command", ""))
+ command_entry["args"] = _array_copy(launch.get("args", []))
+ if not client.command_transport_key.is_empty():
+ command_entry[client.command_transport_key] = client.command_transport_value
+ for key in client.command_initial_fields:
+ if not command_entry.has(key):
+ command_entry[key] = client.command_initial_fields[key]
+ for key in client.command_legacy_keys:
+ command_entry.erase(String(key))
+ return command_entry
+ if client.command_shape != McpClient.CommandShape.NONE:
+ ## Every production caller checks command_launch_error first. Keep this
+ ## builder defensive too so a future unsupported shape cannot silently
+ ## degrade into a flat YAML entry.
+ return {}
+ var entry: Dictionary = {}
+ if existing is Dictionary:
+ ## User-mutable keys (headers, enabled, tools, ...) survive a
+ ## reconfigure — the same preservation contract the JSON strategy's
+ ## entry_initial_fields split implements. Only the stdio-bridge keys
+ ## are scrubbed (see _STDIO_BRIDGE_KEYS), then the url is repointed.
+ entry = (existing as Dictionary).duplicate(true)
+ for stale_key in _STDIO_BRIDGE_KEYS:
+ entry.erase(stale_key)
+ entry[client.entry_url_field] = server_url
+ return entry
+
+
+## Keys a prior stdio-bridge entry (e.g. `command: uvx mcp-proxy`) may carry.
+## These must NOT survive a URL reconfigure: a Hermes entry with both a url
+## and a command picks the wrong transport.
+const _STDIO_BRIDGE_KEYS := ["command", "args", "env"]
+
+
+## Empty string when this client's launch requirements are satisfied.
+## Mirrors `McpJsonStrategy.command_launch_error`; YAML supports FLAT only.
+static func command_launch_error(client: McpClient, launch: Dictionary) -> String:
+ if client.command_shape == McpClient.CommandShape.NONE:
+ return ""
+ if client.command_shape != McpClient.CommandShape.FLAT:
+ return "%s uses a command shape not supported by YAML yet" % client.display_name
+ if not bool(launch.get("ok", false)):
+ return str(launch.get("error", "No compatible attach launcher was found."))
+ return ""
+
+
+## Verify a stored entry matches.
+##
+## URL mode: Hermes entries have no transport type pin, so verification is:
+## url matches. Extra keys (headers, enabled, tools) are user-mutable and
+## intentionally NOT checked (mirrors json entry_initial_fields).
+##
+## Command mode: every launch-affecting value must match exactly and legacy
+## URL-transport keys must be gone — their presence is migration drift.
+static func verify_entry(
+ client: McpClient,
+ entry: Dictionary,
+ server_url: String,
+ launch: Dictionary = {},
+) -> bool:
+ if client.command_shape != McpClient.CommandShape.NONE:
+ if client.command_shape != McpClient.CommandShape.FLAT or not bool(launch.get("ok", false)):
+ return false
+ for key in client.command_legacy_keys:
+ if entry.has(String(key)):
+ return false
+ if entry.get("command") != launch.get("command"):
+ return false
+ if not _arrays_equal(entry.get("args", null), launch.get("args", null)):
+ return false
+ if not client.command_transport_key.is_empty():
+ if entry.get(client.command_transport_key, null) != client.command_transport_value:
+ return false
+ return true
+ return entry.get(client.entry_url_field, "") == server_url
+
+
+static func _array_copy(value: Variant) -> Array:
+ if value is Array:
+ return (value as Array).duplicate(true)
+ if value is PackedStringArray:
+ return McpClient._array_from_packed(value)
+ return []
+
+
+static func _arrays_equal(left: Variant, right: Variant) -> bool:
+ if not (left is Array or left is PackedStringArray):
+ return false
+ if not (right is Array or right is PackedStringArray):
+ return false
+ var left_array := _array_copy(left)
+ var right_array := _array_copy(right)
+ if left_array.size() != right_array.size():
+ return false
+ for i in range(left_array.size()):
+ if left_array[i] != right_array[i]:
+ return false
+ return true
+
+
+# --- YAML block handling (scoped to mcp_servers) -------------------------
+
+## Parse the file into three regions:
+## prefix_lines — everything before `mcp_servers:` (may be empty)
+## entries — the map of server_name -> {url, ...} under mcp_servers
+## suffix_lines — everything after the mcp_servers block (may be empty)
+## This lets us rewrite only the mcp_servers block and keep the rest of the
+## user's config.yaml byte-for-byte intact.
+static func _extract_block(text: String) -> Dictionary:
+ ## allow_empty must stay true: dropping empty splits would silently strip
+ ## the user's blank lines from the preserved prefix/suffix regions on
+ ## every rewrite. The parse loops below already skip blank lines.
+ var lines := text.split("\n")
+ var prefix: PackedStringArray = []
+ var entries: Dictionary = {}
+ var suffix: PackedStringArray = []
+ var header_idx := -1
+ for i in range(lines.size()):
+ if lines[i].strip_edges().begins_with("mcp_servers:"):
+ header_idx = i
+ break
+ if header_idx < 0:
+ # No mcp_servers yet — whole file is prefix; block will be appended.
+ prefix = lines.duplicate()
+ return {"prefix_lines": prefix, "entries": entries, "suffix_lines": [], "header_idx": -1}
+
+ for i in range(0, header_idx):
+ prefix.append(lines[i])
+
+ # Determine the indent of the first entry so we can tell sibling
+ # entries (same indent) apart from nested keys (deeper indent) and
+ # parent-level keys (less indent). All server entries under
+ # `mcp_servers:` share one indent level; breaking on 0-indent alone
+ # mis-nests 2-space-indented siblings under the first entry.
+ var entry_indent := -1
+ var probe := header_idx + 1
+ while probe < lines.size() and _is_blank_or_comment(lines[probe]):
+ probe += 1
+ if probe < lines.size():
+ entry_indent = _indent_of(lines[probe])
+
+ # Empty block guard: the first nonblank line after the header must sit
+ # DEEPER than the header itself to be an entry. At or above the header's
+ # indent it is a sibling/parent key — parsing it as an entry would
+ # swallow the user's next top-level key and re-emit it nested under
+ # mcp_servers, corrupting the file.
+ if probe < lines.size() and entry_indent <= _indent_of(lines[header_idx]):
+ for j in range(header_idx + 1, lines.size()):
+ suffix.append(lines[j])
+ return {"prefix_lines": prefix, "entries": entries, "suffix_lines": suffix, "header_idx": header_idx}
+
+ var i := header_idx + 1
+ while i < lines.size():
+ var raw := lines[i]
+ ## Comment-only lines inside the block are skipped like blanks —
+ ## treating one as an entry header would re-emit it as a bogus
+ ## `# comment:` server on rewrite. (Comments INSIDE the rewritten
+ ## block are consequently dropped; comments outside the block live
+ ## in prefix/suffix and survive verbatim.)
+ if _is_blank_or_comment(raw):
+ i += 1
+ continue
+ # Stop at any line indented less than a sibling entry (parent key
+ # or a new top-level section), or at the header's own level.
+ if _indent_of(raw) < entry_indent:
+ break
+ var entry := _parse_entry(raw, lines, i, entry_indent)
+ if not entry["name"].is_empty():
+ entries[entry["name"]] = entry["data"]
+ i = entry["next_idx"]
+
+ for j in range(i, lines.size()):
+ suffix.append(lines[j])
+
+ return {"prefix_lines": prefix, "entries": entries, "suffix_lines": suffix, "header_idx": header_idx}
+
+
+## Parse one ` name:` entry starting at `lines[start]`. Consumes all deeper-
+## indented sublines (url, headers, etc.) and returns the next sibling index.
+static func _parse_entry(raw: String, lines: PackedStringArray, start: int, entry_indent: int) -> Dictionary:
+ var name := raw.strip_edges().trim_suffix(":").strip_edges()
+ var data: Dictionary = {}
+ var i := start + 1
+ while i < lines.size():
+ var l := lines[i]
+ ## Comments inside an entry (e.g. ` # auth for CI`) would parse
+ ## as a `# auth for CI` key — skip them like blanks.
+ if _is_blank_or_comment(l):
+ i += 1
+ continue
+ # A line at or above the entry's indent is a sibling/parent key.
+ if _indent_of(l) <= entry_indent:
+ break
+ var stripped := l.strip_edges()
+ var colon := stripped.find(":")
+ if colon < 0:
+ i += 1
+ continue
+ var key := stripped.substr(0, colon).strip_edges()
+ var val := stripped.substr(colon + 1).strip_edges()
+ if val.is_empty():
+ # Nested block (e.g. headers:). Parse as raw sub-dict lines for
+ # preservation; we don't introspect deeper than url at the top.
+ var sub := _parse_subblock(lines, i + 1, entry_indent)
+ data[key] = sub["value"]
+ i = sub["next_idx"]
+ else:
+ data[key] = coerce_scalar(val)
+ i += 1
+ return {"name": name, "data": data, "next_idx": i}
+
+
+## Parse a nested block (e.g. headers:) as a preserved sub-dictionary of
+## scalar key/values. Deeper nesting is flattened into scalar strings — fine
+## for Hermes' known shape (headers are flat key: value).
+static func _parse_subblock(lines: PackedStringArray, start: int, entry_indent: int) -> Dictionary:
+ var sub: Dictionary = {}
+ var i := start
+ while i < lines.size():
+ var l := lines[i]
+ if _is_blank_or_comment(l):
+ i += 1
+ continue
+ # A line at or above the parent entry's indent ends the nested block.
+ if _indent_of(l) <= entry_indent:
+ break
+ var stripped := l.strip_edges()
+ var colon := stripped.find(":")
+ if colon < 0:
+ i += 1
+ continue
+ var key := stripped.substr(0, colon).strip_edges()
+ var val := stripped.substr(colon + 1).strip_edges()
+ if val.is_empty():
+ i += 1
+ continue
+ sub[key] = coerce_scalar(val)
+ i += 1
+ return {"value": sub, "next_idx": i}
+
+
+## Reassemble the full file text from prefix + a freshly built mcp_servers
+## block + suffix. If the block didn't exist before, it is appended.
+static func _assemble(_text: String, prefix: PackedStringArray, entries: Dictionary, suffix: PackedStringArray) -> String:
+ var out: PackedStringArray = []
+ for l in prefix:
+ out.append(l)
+ # Trim trailing blank lines from prefix so we don't stack double blanks.
+ while out.size() > 0 and out[out.size() - 1].strip_edges().is_empty():
+ out.remove_at(out.size() - 1)
+
+ if not _text.contains("mcp_servers:"):
+ # File existed but had no mcp_servers block — append it.
+ if out.size() > 0:
+ out.append("")
+ out.append("mcp_servers:")
+ for name in entries:
+ out.append_array(_emit_entry(name, entries[name]))
+ else:
+ out.append("mcp_servers:")
+ for name in entries:
+ out.append_array(_emit_entry(name, entries[name]))
+
+ # Suffix: keep as-is.
+ for l in suffix:
+ out.append(l)
+ return "\n".join(out)
+
+
+## Public rendering seam for the dock's manual-instruction text, so the
+## pasted YAML matches what Configure would write byte-for-byte.
+static func render_entry_lines(name: String, data: Dictionary) -> PackedStringArray:
+ return _emit_entry(name, data)
+
+
+## Emit one ` name:` entry with its scalar keys (top level only; headers
+## sub-dict is re-emitted as nested scalars; arrays — the command entry's
+## `args` — are emitted in flow style on one line).
+static func _emit_entry(name: String, data: Dictionary) -> PackedStringArray:
+ var lines: PackedStringArray = []
+ lines.append(INDENT + "%s:" % name)
+ for key in data:
+ var val = data[key]
+ if val is Dictionary:
+ lines.append(INDENT + INDENT + "%s:" % key)
+ for sk in val:
+ lines.append(INDENT + INDENT + INDENT + "%s: %s" % [sk, emit_scalar(val[sk])])
+ elif val is Array or val is PackedStringArray:
+ lines.append(INDENT + INDENT + "%s: %s" % [key, emit_flow_array(_array_copy(val))])
+ else:
+ lines.append(INDENT + INDENT + "%s: %s" % [key, emit_scalar(val)])
+ return lines
+
+
+## Flow-style sequence with every item double-quoted. JSON string quoting is
+## valid YAML double-quote style (shared escape set), so the same encoding
+## both writes the file and — via JSON.parse_string in `coerce_scalar` —
+## reads it back for verification. Shared with `McpDshStrategy` for the
+## DeepSeek Harness patch-list `args` lines.
+static func emit_flow_array(values: Array) -> String:
+ var parts: Array[String] = []
+ for v in values:
+ parts.append(JSON.stringify(str(v)))
+ return "[%s]" % ", ".join(parts)
+
+
+static func emit_scalar(v: Variant) -> String:
+ match typeof(v):
+ TYPE_BOOL:
+ return "true" if bool(v) else "false"
+ TYPE_INT:
+ return str(int(v))
+ TYPE_FLOAT:
+ return str(float(v))
+ _:
+ return emit_string_scalar(str(v))
+
+
+## Plain YAML scalars cannot safely carry ": ", " #", quotes, flow
+## indicators, or leading indicator characters — a Windows launcher path with
+## spaces would silently corrupt the entry. Quote exactly when needed so
+## existing plain values (urls, bools-as-strings) keep their current
+## byte-shape on rewrite. Backslashes are quoted too: a Windows path parses
+## either way, but quoting makes the written shape unambiguous for any
+## consumer. Shared with `McpDshStrategy` for DeepSeek Harness patch entries.
+static func emit_string_scalar(s: String) -> String:
+ if s.is_empty():
+ return "\"\""
+ var needs_quote := s.begins_with(" ") or s.ends_with(" ")
+ if not needs_quote:
+ for needle in [": ", " #", "\"", "'", "\n", "\t", "\\", "{", "}", "[", "]", ","]:
+ if s.contains(needle):
+ needs_quote = true
+ break
+ if not needs_quote:
+ for prefix in ["#", "-", "?", "&", "*", "!", "|", ">", "%", "@", "`"]:
+ if s.begins_with(prefix):
+ needs_quote = true
+ break
+ return JSON.stringify(s) if needs_quote else s
+
+
+## Blank and comment-only lines carry no structure — every scan loop skips
+## them the same way so a `# comment` can never be mistaken for an entry
+## header or a key/value line.
+static func _is_blank_or_comment(line: String) -> bool:
+ var stripped := line.strip_edges()
+ return stripped.is_empty() or stripped.begins_with("#")
+
+
+## Returns the leading-whitespace indent width of a line (spaces + tabs
+## counted as 1 each). Used to distinguish sibling entries (same indent)
+## from nested keys (deeper indent) and parent-level keys (less indent).
+static func _indent_of(line: String) -> int:
+ var n := 0
+ while n < line.length() and (line[n] == " " or line[n] == "\t"):
+ n += 1
+ return n
+
+
+## Minimal scalar coercion for parsed YAML values. Quotes are stripped;
+## bare true/false/numbers are typed; double-quoted flow sequences (the
+## command entry's `args`) parse back into an Array. Good enough for Hermes'
+## url/headers/command/args. A hand-edited args in block style or with
+## unquoted items doesn't JSON-parse — it stays a raw string, compares
+## unequal, and surfaces as CONFIGURED_MISMATCH, which Reconfigure
+## normalizes back to the flow form. Public: the dsh strategy parses its
+## patch-list scalars with the same rules, so both YAML dialects share one
+## coercion (#867 review).
+static func coerce_scalar(s: String) -> Variant:
+ var t := s.strip_edges()
+ if t.begins_with("[") and t.ends_with("]"):
+ var parsed_array: Variant = JSON.parse_string(t)
+ if parsed_array is Array:
+ return parsed_array
+ return t
+ if t.begins_with("\"") and t.ends_with("\""):
+ var parsed_string: Variant = JSON.parse_string(t)
+ if parsed_string is String:
+ return parsed_string
+ return t.substr(1, t.length() - 2)
+ if t.begins_with("'") and t.ends_with("'"):
+ return t.substr(1, t.length() - 2)
+ if t == "true":
+ return true
+ if t == "false":
+ return false
+ if t.is_valid_int():
+ return t.to_int()
+ if t.is_valid_float():
+ return t.to_float()
+ return t
+
+
+## Returns {"ok": true, "data": String} when the file is absent or readable,
+## and {"ok": false, "error": String} when unreadable. Callers must NOT fall
+## back to an empty string on the error path — doing so blows away the user's
+## other config.yaml entries on the next write.
+static func _read(path: String) -> Dictionary:
+ if not FileAccess.file_exists(path):
+ return {"ok": true, "data": ""}
+ var f := FileAccess.open(path, FileAccess.READ)
+ if f == null:
+ var err := FileAccess.get_open_error()
+ return {"ok": false, "error": "could not open for reading (%s)" % error_string(err)}
+ var t := f.get_as_text()
+ f.close()
+ return {"ok": true, "data": t}
diff --git a/addons/godot_ai/clients/_yaml_strategy.gd.uid b/addons/godot_ai/clients/_yaml_strategy.gd.uid
new file mode 100644
index 0000000..d8adce8
--- /dev/null
+++ b/addons/godot_ai/clients/_yaml_strategy.gd.uid
@@ -0,0 +1 @@
+uid://cfnw4oe71ra1l
diff --git a/addons/godot_ai/clients/antigravity.gd b/addons/godot_ai/clients/antigravity.gd
new file mode 100644
index 0000000..ce82681
--- /dev/null
+++ b/addons/godot_ai/clients/antigravity.gd
@@ -0,0 +1,38 @@
+@tool
+extends McpClient
+
+
+func _init() -> void:
+ id = "antigravity"
+ display_name = "Antigravity"
+ config_type = "json"
+ ## Antigravity moved its shared MCP config from `~/.gemini/antigravity/`
+ ## to `~/.gemini/config/` (IDE + CLI now read the same file there); the
+ ## old path is left in `detect_paths` below so an existing install is
+ ## still recognized, but new/updated entries write to the current path.
+ path_template = {
+ "unix": "~/.gemini/config/mcp_config.json",
+ "windows": "$USERPROFILE/.gemini/config/mcp_config.json",
+ }
+ server_key_path = PackedStringArray(["mcpServers"])
+ entry_url_field = "serverUrl"
+ ## `disabled` is user-state (they may have flipped the entry off in the
+ ## UI); seeded on first Configure but preserved across reconfigure.
+ entry_initial_fields = {"disabled": false}
+ ## Attach migration (#838). Antigravity stdio entries are flat
+ ## command/args/env with no type discriminator — transport is inferred
+ ## from `command` vs `serverUrl` presence (antigravity.google/docs/mcp),
+ ## so the legacy `serverUrl` must not survive next to a command.
+ command_shape = McpClient.CommandShape.FLAT
+ command_legacy_keys = PackedStringArray(["serverUrl"])
+ command_initial_fields = {"disabled": false}
+ command_user_fields = PackedStringArray(["disabled", "disabledTools", "authProviderType", "env"])
+ ## Antigravity's spawner hangs stdio tool calls when the entry launches a
+ ## GUI-subsystem pythonw.exe (#863), and it hides child console windows
+ ## itself, so the visible-terminal problem the bootstrap solves (#827)
+ ## never applies. Write the plain console launcher on Windows.
+ needs_consoleless_launcher = false
+ detect_paths = PackedStringArray(path_template.values() + [
+ "~/.gemini/antigravity/mcp_config.json",
+ "$USERPROFILE/.gemini/antigravity/mcp_config.json",
+ ])
diff --git a/addons/godot_ai/clients/antigravity.gd.uid b/addons/godot_ai/clients/antigravity.gd.uid
new file mode 100644
index 0000000..0721a2f
--- /dev/null
+++ b/addons/godot_ai/clients/antigravity.gd.uid
@@ -0,0 +1 @@
+uid://b4l1g0apa2hch
diff --git a/addons/godot_ai/clients/claude_code.gd b/addons/godot_ai/clients/claude_code.gd
new file mode 100644
index 0000000..de4a5db
--- /dev/null
+++ b/addons/godot_ai/clients/claude_code.gd
@@ -0,0 +1,81 @@
+@tool
+extends McpClient
+
+
+func _init() -> void:
+ id = "claude_code"
+ display_name = "Claude Code"
+ config_type = "cli"
+ cli_names = PackedStringArray(["claude", "claude.exe"] if OS.get_name() == "Windows" else ["claude"])
+ ## Stdio registration through the client-owned `godot-ai attach` bridge
+ ## (#838). `--` stops claude's own flag parsing so the attach argv passes
+ ## through verbatim; stdio is the CLI's default transport. Scope comes from
+ ## the `godot_ai/mcp_client_scope` EditorSetting via the `{scope}` token; it
+ ## defaults to `user` — the same ~/.claude.json the pre-attach HTTP entry
+ ## lived in — and can be set to `project` to write /.mcp.json so the
+ ## server is not loaded in every unrelated workspace.
+ cli_register_template = PackedStringArray(
+ ["mcp", "add", "--scope", "{scope}", "{name}", "--", "{command}", "{args...}"]
+ )
+ ## Explicit scope: an unscoped `mcp remove` deletes from whichever scope
+ ## matches first, so the flag keeps each removal aimed at one known place.
+ ## Note what that does and does not protect (#877): the dock's **Remove**
+ ## button runs this once, at the selected scope only. **Configure** runs it
+ ## once per scope in `CLIENT_SCOPES` before registering, so a Configure at
+ ## any setting does delete a `godot-ai` entry from /.mcp.json if one is
+ ## there — see the cwd caveat below for which directory that actually is.
+ cli_unregister_template = PackedStringArray(["mcp", "remove", "--scope", "{scope}", "{name}"])
+ ## Scope caveat: `--scope project` resolves `.mcp.json` against the
+ ## *spawned CLI's* working directory, which is whatever the editor process
+ ## inherited at launch — not necessarily `res://`. Godot has no API to set
+ ## a child's cwd (`OS.execute_with_pipe` takes none), so this is an
+ ## accepted limitation rather than something the descriptor can pin. It is
+ ## self-consistent: `cli_status_args` runs from the same cwd, so the
+ ## read-back finds whatever the register wrote (see
+ ## `_scope_diverges_from_json_fallback` in client_configurator.gd).
+ cli_status_args = PackedStringArray(["mcp", "list"])
+ ## `mcp list` prints the resolved entry for the cwd without saying which
+ ## scope won, so it cannot tell "registered at project scope" from "an old
+ ## user-scope entry is shadowing an empty project scope". `mcp get` prints
+ ## both the command and a `Scope: …` line, in the same single subprocess,
+ ## so scope-token clients probe with it instead (#872). Verified against
+ ## claude 2.1.241, which prints one of:
+ ## Scope: User config (available in all your projects)
+ ## Scope: Project config (shared via .mcp.json)
+ ## Scope: Local config (private to you in this project)
+ ## and exits 1 with "No MCP server named …" when the entry is absent.
+ cli_scope_status_template = PackedStringArray(["mcp", "get", "{name}"])
+ ## #463: JSON fallback for when the `claude` binary isn't on PATH — e.g.
+ ## Claude Code installed only as a VS Code / Cursor extension. The CLI is
+ ## still preferred for Configure whenever it resolves; this is what gets
+ ## written otherwise. `claude mcp add --scope user -- `
+ ## produces exactly this shape under `mcpServers` in ~/.claude.json
+ ## (verified live against claude CLI in an isolated CLAUDE_CONFIG_DIR):
+ ## "godot-ai": { "type": "stdio", "command": "", "args": [...], "env": {} }
+ ## The fallback writer omits the empty `env`; the verifier accepts both.
+ ## Status reads this file only while the selected scope is still `user` —
+ ## it is the CLI's own store for that scope, and file reads give exact
+ ## launch-drift detection that `mcp list` stdout scanning cannot. At
+ ## `project`/`local` the entry is somewhere `path_template` cannot see, so
+ ## `_scope_diverges_from_json_fallback` (client_configurator.gd) routes
+ ## status to `cli_scope_status_template` instead and this file is not read.
+ path_template = {"unix": "~/.claude.json", "windows": "~/.claude.json"}
+ server_key_path = PackedStringArray(["mcpServers"])
+ ## Legacy HTTP entries used this shape; command rendering repins it to stdio.
+ entry_extra_fields = {"type": "http"}
+ command_shape = McpClient.CommandShape.FLAT
+ command_transport_key = "type"
+ command_transport_value = "stdio"
+ ## Legacy HTTP entries carried a `url`; Claude Code rejects an entry mixing
+ ## url with command fields, and the stale `type: "http"` is repinned to
+ ## "stdio" by the transport key above.
+ command_legacy_keys = PackedStringArray(["url"])
+ command_user_fields = PackedStringArray(["env"])
+ ## Documented: $CLAUDE_CONFIG_DIR relocates Claude Code's config home,
+ ## including .claude.json ($CLAUDE_CONFIG_DIR/.claude.json). The preferred
+ ## CLI path needs no help — the spawned `claude` binary inherits the
+ ## editor's environment and resolves the dir itself — but the JSON
+ ## fallback above would otherwise write ~/.claude.json that a relocated
+ ## install never reads (#617).
+ config_home_env = "CLAUDE_CONFIG_DIR"
+ config_home_env_subpath = ".claude.json"
diff --git a/addons/godot_ai/clients/claude_code.gd.uid b/addons/godot_ai/clients/claude_code.gd.uid
new file mode 100644
index 0000000..3d3335f
--- /dev/null
+++ b/addons/godot_ai/clients/claude_code.gd.uid
@@ -0,0 +1 @@
+uid://cp1u1hdpa6f8d
diff --git a/addons/godot_ai/clients/claude_desktop.gd b/addons/godot_ai/clients/claude_desktop.gd
new file mode 100644
index 0000000..c1aa9bb
--- /dev/null
+++ b/addons/godot_ai/clients/claude_desktop.gd
@@ -0,0 +1,38 @@
+@tool
+extends McpClient
+
+## Claude Desktop's mcpServers entries launch a local stdio process. The
+## client-owned `godot-ai attach` bridge keeps that stdio session stable while
+## adopting or starting the shared HTTP backend as Godot editors come and go.
+
+
+func _init() -> void:
+ id = "claude_desktop"
+ display_name = "Claude Desktop"
+ config_type = "json"
+ path_template = {
+ "darwin": "~/Library/Application Support/Claude/claude_desktop_config.json",
+ "windows": "$APPDATA/Claude/claude_desktop_config.json",
+ "linux": "$XDG_CONFIG_HOME/Claude/claude_desktop_config.json",
+ }
+ ## Store-installed Claude runs inside MSIX AppData virtualization. Godot is
+ ## outside that container, so `%APPDATA%` names a different physical file
+ ## once Claude has created its private copy. A unique Store package root is
+ ## authoritative even before the config leaf exists: create the private file
+ ## directly so a later copy-on-write cannot hide an entry written to roaming.
+ ## With no Store package, use the conventional roaming path. The wildcard
+ ## avoids coupling to the publisher-hash suffix.
+ config_path_candidates = {
+ "windows": [
+ "$LOCALAPPDATA/Packages/Claude_*/LocalCache/Roaming/Claude/claude_desktop_config.json",
+ "$APPDATA/Claude/claude_desktop_config.json",
+ ],
+ }
+ detect_paths = PackedStringArray([
+ "$LOCALAPPDATA/Packages/Claude_*",
+ ])
+ server_key_path = PackedStringArray(["mcpServers"])
+ command_shape = McpClient.CommandShape.FLAT
+ command_legacy_keys = PackedStringArray(["url"])
+ command_env_legacy_keys = PackedStringArray(["UV_LINK_MODE"])
+ command_user_fields = PackedStringArray(["env", "disabled"])
diff --git a/addons/godot_ai/clients/claude_desktop.gd.uid b/addons/godot_ai/clients/claude_desktop.gd.uid
new file mode 100644
index 0000000..9759a1a
--- /dev/null
+++ b/addons/godot_ai/clients/claude_desktop.gd.uid
@@ -0,0 +1 @@
+uid://bilntn5n8oqe3
diff --git a/addons/godot_ai/clients/cline.gd b/addons/godot_ai/clients/cline.gd
new file mode 100644
index 0000000..9e827b6
--- /dev/null
+++ b/addons/godot_ai/clients/cline.gd
@@ -0,0 +1,42 @@
+@tool
+extends McpClient
+
+## Cline is a VS Code extension. Its MCP settings live in VS Code's
+## globalStorage under the extension id `saoudrizwan.claude-dev`.
+
+
+func _init() -> void:
+ id = "cline"
+ display_name = "Cline"
+ config_type = "json"
+ path_template = {
+ "darwin": "~/Library/Application Support/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json",
+ "windows": "$APPDATA/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json",
+ "linux": "$XDG_CONFIG_HOME/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json",
+ }
+ server_key_path = PackedStringArray(["mcpServers"])
+ ## Cline (like Roo) defaults a typeless entry to SSE transport, which
+ ## returns HTTP 400 against our streamable-http endpoint on `/mcp`. Pin
+ ## the type explicitly. Cline's schema uses "streamableHttp" (camelCase,
+ ## see src/services/mcp/schemas.ts in the cline repo) — distinct from
+ ## Roo's "streamable-http" string. Parallel to the Roo fix in #190.
+ entry_extra_fields = {"type": "streamableHttp"}
+ ## `disabled` and `autoApprove` are user-state (they may have flipped the
+ ## entry off, or auto-approved specific tools). Seed on first Configure
+ ## but preserve across reconfigure — see `entry_initial_fields` in `_base.gd`.
+ entry_initial_fields = {"disabled": false, "autoApprove": []}
+ ## Attach migration (#838). Cline stdio entries are flat command/args/env;
+ ## its schema accepts `type: "stdio"` and normalizes typeless command
+ ## entries to it (apps/vscode/src/services/mcp/schemas.ts), so pin the
+ ## type — that also repins the legacy "streamableHttp" value instead of
+ ## letting it survive the deep-copy and misroute the transport.
+ command_shape = McpClient.CommandShape.FLAT
+ command_transport_key = "type"
+ command_transport_value = "stdio"
+ command_legacy_keys = PackedStringArray(["url", "headers"])
+ command_initial_fields = {"disabled": false, "autoApprove": []}
+ command_user_fields = PackedStringArray([
+ "disabled", "autoApprove", "timeout", "oauth", "metadata",
+ "remoteConfigured", "env", "cwd",
+ ])
+ command_timeout_fields = PackedStringArray(["timeout"])
diff --git a/addons/godot_ai/clients/cline.gd.uid b/addons/godot_ai/clients/cline.gd.uid
new file mode 100644
index 0000000..95e20f1
--- /dev/null
+++ b/addons/godot_ai/clients/cline.gd.uid
@@ -0,0 +1 @@
+uid://d36nywn2nkgts
diff --git a/addons/godot_ai/clients/codebuddy.gd b/addons/godot_ai/clients/codebuddy.gd
new file mode 100644
index 0000000..3d043f0
--- /dev/null
+++ b/addons/godot_ai/clients/codebuddy.gd
@@ -0,0 +1,25 @@
+@tool
+extends McpClient
+
+## CodeBuddy IDE: https://www.codebuddy.ai/docs/zh/ide/User-guide/MCP
+## Official docs specify mcpServers + type:stdio + command/args/env.
+## Global ~/.codebuddy/mcp.json was verified by the reporter in #941.
+## This descriptor uses the global user scope,
+## matching the registry's absolute user-path contract; project configuration
+## remains available manually in CodeBuddy's MCP settings.
+
+
+func _init() -> void:
+ id = "codebuddy"
+ display_name = "CodeBuddy"
+ config_type = "json"
+ path_template = {
+ "unix": "~/.codebuddy/mcp.json",
+ "windows": "$USERPROFILE/.codebuddy/mcp.json",
+ }
+ server_key_path = PackedStringArray(["mcpServers"])
+ command_shape = McpClient.CommandShape.FLAT
+ command_transport_key = "type"
+ command_transport_value = "stdio"
+ command_legacy_keys = PackedStringArray(["url", "headers"])
+ command_user_fields = PackedStringArray(["env", "description"])
diff --git a/addons/godot_ai/clients/codebuddy.gd.uid b/addons/godot_ai/clients/codebuddy.gd.uid
new file mode 100644
index 0000000..88506e6
--- /dev/null
+++ b/addons/godot_ai/clients/codebuddy.gd.uid
@@ -0,0 +1 @@
+uid://cutvvrwaef0rk
diff --git a/addons/godot_ai/clients/codex.gd b/addons/godot_ai/clients/codex.gd
new file mode 100644
index 0000000..c80f7f3
--- /dev/null
+++ b/addons/godot_ai/clients/codex.gd
@@ -0,0 +1,44 @@
+@tool
+extends McpClient
+
+
+func _init() -> void:
+ id = "codex"
+ display_name = "Codex"
+ config_type = "toml"
+ path_template = {"unix": "~/.codex/config.toml", "windows": "$USERPROFILE/.codex/config.toml"}
+ ## Documented: when $CODEX_HOME is set, Codex reads config.toml directly
+ ## from it instead of ~/.codex (#617).
+ config_home_env = "CODEX_HOME"
+ config_home_env_subpath = "config.toml"
+ toml_section_path = PackedStringArray(["mcp_servers", "godot-ai"])
+ # Older Codex builds used the unquoted form with underscore-substituted ids.
+ toml_legacy_section_aliases = PackedStringArray(["mcp_servers.godot_ai"])
+ command_shape = McpClient.CommandShape.COMMAND_ARRAY
+ command_legacy_keys = PackedStringArray(["url"])
+ ## Initial-only: users may disable the entry or tune either timeout and
+ ## Configure preserves that choice. Codex currently defaults to 10s for
+ ## startup and 60s per tool; test_run legitimately has a 300s server
+ ## budget, so the generated config leaves transport margin at the client.
+ command_initial_fields = {
+ "enabled": true,
+ "startup_timeout_sec": 60,
+ "tool_timeout_sec": 360,
+ }
+ command_timeout_fields = PackedStringArray([
+ "startup_timeout_sec",
+ "tool_timeout_sec",
+ ])
+ command_user_fields = PackedStringArray([
+ "enabled",
+ "required",
+ "startup_timeout_sec",
+ "tool_timeout_sec",
+ "enabled_tools",
+ "disabled_tools",
+ "default_tools_approval_mode",
+ "env",
+ "env_vars",
+ "cwd",
+ ])
+ detect_paths = PackedStringArray(path_template.values())
diff --git a/addons/godot_ai/clients/codex.gd.uid b/addons/godot_ai/clients/codex.gd.uid
new file mode 100644
index 0000000..1e1c3ae
--- /dev/null
+++ b/addons/godot_ai/clients/codex.gd.uid
@@ -0,0 +1 @@
+uid://hdlwcfdr8mdk
diff --git a/addons/godot_ai/clients/cursor.gd b/addons/godot_ai/clients/cursor.gd
new file mode 100644
index 0000000..9dd4ea0
--- /dev/null
+++ b/addons/godot_ai/clients/cursor.gd
@@ -0,0 +1,20 @@
+@tool
+extends McpClient
+
+
+func _init() -> void:
+ id = "cursor"
+ display_name = "Cursor"
+ config_type = "json"
+ path_template = {"unix": "~/.cursor/mcp.json", "windows": "$USERPROFILE/.cursor/mcp.json"}
+ server_key_path = PackedStringArray(["mcpServers"])
+ ## Attach migration (#838). Cursor's stdio entries are flat command/args/env
+ ## (cursor.com/docs/context/mcp). The docs' reference table documents
+ ## `type: "stdio"`; pinning it also repins any hand-added `type: "http"`
+ ## left on the legacy URL entry, which would otherwise survive the
+ ## deep-copy migration and misroute the transport.
+ command_shape = McpClient.CommandShape.FLAT
+ command_transport_key = "type"
+ command_transport_value = "stdio"
+ command_legacy_keys = PackedStringArray(["url"])
+ command_user_fields = PackedStringArray(["env", "envFile"])
diff --git a/addons/godot_ai/clients/cursor.gd.uid b/addons/godot_ai/clients/cursor.gd.uid
new file mode 100644
index 0000000..e0c7ddf
--- /dev/null
+++ b/addons/godot_ai/clients/cursor.gd.uid
@@ -0,0 +1 @@
+uid://bvpbssfanukef
diff --git a/addons/godot_ai/clients/deepseek_harness.gd b/addons/godot_ai/clients/deepseek_harness.gd
new file mode 100644
index 0000000..db11ffc
--- /dev/null
+++ b/addons/godot_ai/clients/deepseek_harness.gd
@@ -0,0 +1,44 @@
+@tool
+extends McpClient
+
+
+func _init() -> void:
+ id = "deepseek_harness"
+ display_name = "DeepSeek Harness"
+ ## DeepSeek Harness (dsh) has no `mcp` CLI verb — MCP servers register as
+ ## `@deepseek-ai/dsh-mcp-client` plugin entries in the HOME patch layer
+ ## `$DSH_HOME/cordis.patch.yml`, which dsh applies over every profile's
+ ## own patch layer (the web GUI included). config_type "dsh" routes to
+ ## `McpDshStrategy`. Verified live against dsh 0.1.0-rc.6: new entries
+ ## must be `insert` rows; plain rows only override existing bundle ids.
+ config_type = "dsh"
+ ## Home patch layer, not a per-profile file: one entry covers the web
+ ## GUI, headless, tui, and any custom profile.
+ path_template = {
+ "unix": "~/.dsh/cordis.patch.yml",
+ "windows": "~/.dsh/cordis.patch.yml",
+ }
+ ## Documented: `$DSH_HOME` relocates the whole harness home, and the
+ ## loader reads `$DSH_HOME/cordis.patch.yml` (homePatchPath) — same
+ ## false-success-write class as CODEX_HOME (#617), so the override must
+ ## be declared.
+ config_home_env = "DSH_HOME"
+ config_home_env_subpath = "cordis.patch.yml"
+ ## Installed detection: the harness home directory itself (dsh GUI and
+ ## CLI both live there), or the config file once one exists.
+ detect_paths = PackedStringArray(["~/.dsh"])
+ ## Attach migration (#838). The loader entry nests the launch under
+ ## `config`; the mcp-client plugin requires `transport` next to
+ ## command/args, and a `url` must never coexist with command fields.
+ command_shape = McpClient.CommandShape.FLAT
+ command_transport_key = "transport"
+ command_transport_value = "stdio"
+ command_legacy_keys = PackedStringArray(["url"])
+ ## Fields the mcp-client plugin documents as user-owned; preserved on
+ ## reconfigure and never drift-checked.
+ command_user_fields = PackedStringArray([
+ "env", "cwd", "toolCallTimeoutMs", "failOnStartupError", "reconnect",
+ ])
+ command_timeout_fields = PackedStringArray(["toolCallTimeoutMs"])
+ ## The bridge accepts `transport: streamable-http` with a `url`, so the
+ ## manual instructions may offer the HTTP alternative.
diff --git a/addons/godot_ai/clients/deepseek_harness.gd.uid b/addons/godot_ai/clients/deepseek_harness.gd.uid
new file mode 100644
index 0000000..8cc2ed3
--- /dev/null
+++ b/addons/godot_ai/clients/deepseek_harness.gd.uid
@@ -0,0 +1 @@
+uid://cw3pghlevtql8
diff --git a/addons/godot_ai/clients/gemini_cli.gd b/addons/godot_ai/clients/gemini_cli.gd
new file mode 100644
index 0000000..34257b6
--- /dev/null
+++ b/addons/godot_ai/clients/gemini_cli.gd
@@ -0,0 +1,24 @@
+@tool
+extends McpClient
+
+
+func _init() -> void:
+ id = "gemini_cli"
+ display_name = "Gemini CLI"
+ config_type = "json"
+ path_template = {
+ "unix": "~/.gemini/settings.json",
+ "windows": "$USERPROFILE/.gemini/settings.json",
+ }
+ server_key_path = PackedStringArray(["mcpServers"])
+ entry_url_field = "httpUrl"
+ ## Attach migration (#838). Gemini CLI stdio entries are flat
+ ## command/args/env(+cwd); the config is one-of `command` | `url` (SSE) |
+ ## `httpUrl` (docs/tools/mcp-server.md), so BOTH URL keys are legacy next
+ ## to a command. `trust` bypasses tool confirmations — never seed it.
+ command_shape = McpClient.CommandShape.FLAT
+ command_legacy_keys = PackedStringArray(["httpUrl", "url"])
+ command_user_fields = PackedStringArray([
+ "timeout", "trust", "includeTools", "excludeTools", "env", "cwd",
+ ])
+ command_timeout_fields = PackedStringArray(["timeout"])
diff --git a/addons/godot_ai/clients/gemini_cli.gd.uid b/addons/godot_ai/clients/gemini_cli.gd.uid
new file mode 100644
index 0000000..2d4e85a
--- /dev/null
+++ b/addons/godot_ai/clients/gemini_cli.gd.uid
@@ -0,0 +1 @@
+uid://b8288pxninajy
diff --git a/addons/godot_ai/clients/grok.gd b/addons/godot_ai/clients/grok.gd
new file mode 100644
index 0000000..4c54216
--- /dev/null
+++ b/addons/godot_ai/clients/grok.gd
@@ -0,0 +1,34 @@
+@tool
+extends McpClient
+
+
+func _init() -> void:
+ id = "grok"
+ display_name = "Grok Build"
+ config_type = "toml"
+ # Grok Build reads MCP servers from ~/.grok/config.toml
+ # (https://x.ai / Grok user guide: MCP servers section).
+ path_template = {
+ "unix": "~/.grok/config.toml",
+ "windows": "$USERPROFILE/.grok/config.toml",
+ }
+ toml_section_path = PackedStringArray(["mcp_servers", "godot-ai"])
+ # Some docs / older notes used an underscore form.
+ toml_legacy_section_aliases = PackedStringArray(["mcp_servers.godot_ai"])
+ ## Attach migration (#838). Grok's stdio sections are flat command/args/env
+ ## with no type discriminator (docs.x.ai/build/features/mcp-servers);
+ ## url/headers are the HTTP form and must not survive next to a command.
+ ## Docs default startup_timeout_sec to 30 — a cold `uvx` install of the
+ ## pinned package can exceed that, so new entries seed 60 (preserved once
+ ## the user tunes it). tool_timeout_sec's documented 6000s default already
+ ## clears test_run's 300s budget, so it is left alone. The old body
+ ## template's `enabled = true` line was never documented for Grok — it is
+ ## no longer seeded, and an existing value survives as a user key.
+ command_shape = McpClient.CommandShape.COMMAND_ARRAY
+ command_legacy_keys = PackedStringArray(["url", "headers"])
+ command_initial_fields = {"startup_timeout_sec": 60}
+ command_user_fields = PackedStringArray([
+ "env", "enabled", "startup_timeout_sec", "tool_timeout_sec",
+ ])
+ command_timeout_fields = PackedStringArray(["startup_timeout_sec", "tool_timeout_sec"])
+ detect_paths = PackedStringArray(path_template.values())
diff --git a/addons/godot_ai/clients/grok.gd.uid b/addons/godot_ai/clients/grok.gd.uid
new file mode 100644
index 0000000..f2d12c2
--- /dev/null
+++ b/addons/godot_ai/clients/grok.gd.uid
@@ -0,0 +1 @@
+uid://ckchsj5s3q1b0
diff --git a/addons/godot_ai/clients/hermes.gd b/addons/godot_ai/clients/hermes.gd
new file mode 100644
index 0000000..2e9f7bc
--- /dev/null
+++ b/addons/godot_ai/clients/hermes.gd
@@ -0,0 +1,45 @@
+@tool
+extends McpClient
+
+func _init() -> void:
+ id = "hermes"
+ display_name = "Hermes Agent"
+ config_type = "yaml"
+
+ # Hermes reads MCP config from ~/.hermes/config.yaml (YAML), NOT mcp.json.
+ # Verified against the official docs:
+ # https://hermes-agent.nousresearch.com/docs/user-guide/features/mcp
+ # Windows: Hermes stores config under $LOCALAPPDATA/hermes (NOT $APPDATA,
+ # which is Roaming) — confirmed by where the running Hermes process reads.
+ # NOTE: _path_template.expand() only substitutes $VAR tokens, not %VAR%.
+ path_template = {
+ "unix": "~/.hermes/config.yaml",
+ "windows": "$LOCALAPPDATA/hermes/config.yaml"
+ }
+
+ # Hermes uses the snake_case `mcp_servers` key (not `mcpServers`).
+ # PackedStringArray explicitly, matching every other descriptor — an
+ # untyped Array literal relies on implicit conversion that newer Godot
+ # builds enforce more strictly (the #722 CI lesson for Array[String]).
+ server_key_path = PackedStringArray(["mcp_servers"])
+
+ # HTTP entries use `url` (+ optional `headers`); transport is inferred —
+ # there is no `type` field in Hermes MCP config.
+ entry_url_field = "url"
+
+ # No transport pin: Hermes infers streamable-http from the URL.
+ entry_extra_fields = {}
+ entry_initial_fields = {}
+
+ ## Attach migration (#838). Hermes stdio entries are flat command/args/env
+ ## (hermes-agent.nousresearch.com/docs/user-guide/features/mcp), transport
+ ## inferred exactly like the URL form — which is why `url` and the
+ ## HTTP-only `headers` must not survive next to a command: an entry with
+ ## both picks the wrong transport. `enabled`/`tools`/`env` stay user-owned.
+ command_shape = McpClient.CommandShape.FLAT
+ command_legacy_keys = PackedStringArray(["url", "headers"])
+ command_user_fields = PackedStringArray(["enabled", "tools", "env"])
+
+ # Hermes is "installed" wherever the config.yaml lives; presence of the
+ # file is sufficient for the dock's installed badge.
+ detect_paths = PackedStringArray()
diff --git a/addons/godot_ai/clients/hermes.gd.uid b/addons/godot_ai/clients/hermes.gd.uid
new file mode 100644
index 0000000..14cabd8
--- /dev/null
+++ b/addons/godot_ai/clients/hermes.gd.uid
@@ -0,0 +1 @@
+uid://ewmadhrvs5d7
diff --git a/addons/godot_ai/clients/kilo_code.gd b/addons/godot_ai/clients/kilo_code.gd
new file mode 100644
index 0000000..b904837
--- /dev/null
+++ b/addons/godot_ai/clients/kilo_code.gd
@@ -0,0 +1,37 @@
+@tool
+extends McpClient
+
+
+func _init() -> void:
+ id = "kilo_code"
+ display_name = "Kilo Code"
+ config_type = "json"
+ path_template = {
+ "darwin": "~/Library/Application Support/Code/User/globalStorage/kilocode.kilo-code/settings/mcp_settings.json",
+ "windows": "$APPDATA/Code/User/globalStorage/kilocode.kilo-code/settings/mcp_settings.json",
+ "linux": "$XDG_CONFIG_HOME/Code/User/globalStorage/kilocode.kilo-code/settings/mcp_settings.json",
+ }
+ server_key_path = PackedStringArray(["mcpServers"])
+ ## Kilo Code (like Roo) defaults a typeless entry to SSE transport, which
+ ## returns HTTP 400 against our streamable-http endpoint on `/mcp`. Pin
+ ## the type explicitly. Parallel to the Roo fix in #190.
+ entry_extra_fields = {"type": "streamable-http"}
+ ## `disabled` and `alwaysAllow` are user-state (they may have flipped the
+ ## entry off, or auto-approved specific tools). Seed on first Configure
+ ## but preserve across reconfigure — see `entry_initial_fields` in `_base.gd`.
+ entry_initial_fields = {"disabled": false, "alwaysAllow": []}
+ ## Attach migration (#838). UNLIKE its Roo siblings the stdio entry must be
+ ## TYPELESS: Kilo's v7 platform treats this file as a migration source and
+ ## routes on `type` — any http type value sends the entry down the remote
+ ## branch where it is dropped for lack of a url, and only a bare
+ ## command/args/env entry is verified to work in BOTH the legacy extension
+ ## and the v7 migrator (packages/opencode/src/kilocode/mcp-migrator.ts).
+ ## `type` therefore joins the legacy keys instead of being repinned.
+ command_shape = McpClient.CommandShape.FLAT
+ command_legacy_keys = PackedStringArray(["url", "type", "headers"])
+ command_initial_fields = {"disabled": false, "alwaysAllow": []}
+ command_user_fields = PackedStringArray([
+ "disabled", "alwaysAllow", "timeout", "cwd", "watchPaths",
+ "disabledTools", "env",
+ ])
+ command_timeout_fields = PackedStringArray(["timeout"])
diff --git a/addons/godot_ai/clients/kilo_code.gd.uid b/addons/godot_ai/clients/kilo_code.gd.uid
new file mode 100644
index 0000000..3ee5152
--- /dev/null
+++ b/addons/godot_ai/clients/kilo_code.gd.uid
@@ -0,0 +1 @@
+uid://dc1x77i1cmb6w
diff --git a/addons/godot_ai/clients/kimi_code.gd b/addons/godot_ai/clients/kimi_code.gd
new file mode 100644
index 0000000..04354a4
--- /dev/null
+++ b/addons/godot_ai/clients/kimi_code.gd
@@ -0,0 +1,33 @@
+@tool
+extends McpClient
+
+
+func _init() -> void:
+ id = "kimi_code"
+ display_name = "Kimi Code"
+ ## Kimi Code has no `mcp` CLI subcommand (verified against v0.28.1 —
+ ## `kimi mcp` falls through to the root --help, and the docs at
+ ## moonshotai.github.io/kimi-code/en/customization/mcp confirm servers are
+ ## managed via ~/.kimi-code/mcp.json, not a CLI verb). JSON is therefore
+ ## the only working config method, not a fallback.
+ config_type = "json"
+ path_template = {"unix": "~/.kimi-code/mcp.json", "windows": "~/.kimi-code/mcp.json"}
+ server_key_path = PackedStringArray(["mcpServers"])
+ entry_extra_fields = {"transport": "http"}
+ ## Documented: `$KIMI_CODE_HOME/mcp.json` relocates the config
+ ## (moonshotai.github.io/kimi-code/en/customization/mcp) — same
+ ## false-success-write class as CODEX_HOME (#617).
+ config_home_env = "KIMI_CODE_HOME"
+ config_home_env_subpath = "mcp.json"
+ ## Attach migration (#838). Kimi Code stdio entries are flat
+ ## command/args/env(+cwd): "Entries with a `command` field are stdio
+ ## servers". `transport` is only defined for SSE-with-url, so the legacy
+ ## `transport: "http"` must be removed alongside `url`. Timeout fields are
+ ## camelCase, unlike Codex's snake_case.
+ command_shape = McpClient.CommandShape.FLAT
+ command_legacy_keys = PackedStringArray(["url", "transport"])
+ command_user_fields = PackedStringArray([
+ "enabled", "startupTimeoutMs", "toolTimeoutMs", "enabledTools",
+ "disabledTools", "env", "cwd",
+ ])
+ command_timeout_fields = PackedStringArray(["startupTimeoutMs", "toolTimeoutMs"])
diff --git a/addons/godot_ai/clients/kimi_code.gd.uid b/addons/godot_ai/clients/kimi_code.gd.uid
new file mode 100644
index 0000000..5a05a0e
--- /dev/null
+++ b/addons/godot_ai/clients/kimi_code.gd.uid
@@ -0,0 +1 @@
+uid://d2whd6a5fofhg
diff --git a/addons/godot_ai/clients/kiro.gd b/addons/godot_ai/clients/kiro.gd
new file mode 100644
index 0000000..d7d6b72
--- /dev/null
+++ b/addons/godot_ai/clients/kiro.gd
@@ -0,0 +1,22 @@
+@tool
+extends McpClient
+
+
+func _init() -> void:
+ id = "kiro"
+ display_name = "Kiro"
+ config_type = "json"
+ path_template = {
+ "unix": "~/.kiro/settings/mcp.json",
+ "windows": "$USERPROFILE/.kiro/settings/mcp.json",
+ }
+ server_key_path = PackedStringArray(["mcpServers"])
+ ## `disabled` is user-state — preserved across reconfigure.
+ entry_initial_fields = {"disabled": false}
+ ## Attach migration (#838). Kiro stdio entries are flat command/args/env
+ ## with no type discriminator (kiro.dev/docs/mcp/configuration).
+ ## `autoApprove` is user-state, same contract as the URL entry's fields.
+ command_shape = McpClient.CommandShape.FLAT
+ command_legacy_keys = PackedStringArray(["url"])
+ command_initial_fields = {"disabled": false}
+ command_user_fields = PackedStringArray(["disabled", "autoApprove", "disabledTools", "env"])
diff --git a/addons/godot_ai/clients/kiro.gd.uid b/addons/godot_ai/clients/kiro.gd.uid
new file mode 100644
index 0000000..dde3e87
--- /dev/null
+++ b/addons/godot_ai/clients/kiro.gd.uid
@@ -0,0 +1 @@
+uid://dqdmd2jw5qen7
diff --git a/addons/godot_ai/clients/opencode.gd b/addons/godot_ai/clients/opencode.gd
new file mode 100644
index 0000000..eab6518
--- /dev/null
+++ b/addons/godot_ai/clients/opencode.gd
@@ -0,0 +1,54 @@
+@tool
+extends McpClient
+
+## OpenCode stores MCP servers under `mcp.` (not the typical mcpServers
+## map) and uses `type: "remote"` for HTTP servers.
+
+
+func _init() -> void:
+ id = "opencode"
+ display_name = "OpenCode"
+ config_type = "json"
+ ## `$HOME` on Windows is deliberate: OpenCode reads ~/.config/... on ALL
+ ## platforms (verified via `opencode debug paths`), and
+ ## McpPathTemplate._home() falls back to USERPROFILE when HOME is unset —
+ ## pinned by test_opencode_client_uses_home_config_on_windows. The documented
+ ## `OPENCODE_CONFIG` override names an exact file and must win over this
+ ## default for configure, status, remove, and manual instructions.
+ path_template = {
+ "unix": "~/.config/opencode/opencode.json",
+ "windows": "$HOME/.config/opencode/opencode.json",
+ }
+ config_file_env = "OPENCODE_CONFIG"
+ ## OpenCode loads BOTH opencode.json and opencode.jsonc from its config
+ ## directory and merges them, later file winning per key
+ ## (anomalyco/opencode#32447). Writing only opencode.json therefore
+ ## leaves a stale `godot-ai` entry in an existing opencode.jsonc in
+ ## charge (#1011). Declaring the merge order makes Configure update the
+ ## effective last definition, status verify it, and Remove clear both.
+ config_merge_path_templates = {
+ "unix": PackedStringArray([
+ "~/.config/opencode/opencode.json",
+ "~/.config/opencode/opencode.jsonc",
+ ]),
+ "windows": PackedStringArray([
+ "$HOME/.config/opencode/opencode.json",
+ "$HOME/.config/opencode/opencode.jsonc",
+ ]),
+ }
+ server_key_path = PackedStringArray(["mcp"])
+ entry_extra_fields = {"type": "remote"}
+ ## `enabled` is user-state (they may have toggled the server off).
+ entry_initial_fields = {"enabled": true}
+ ## Attach migration (#838). OpenCode local entries carry the launch as ONE
+ ## argv array — `"command": ["uvx", …]` with no separate args key — plus a
+ ## schema-REQUIRED `type: "local"` (McpLocalConfig in opencode.ai/config.json;
+ ## env lives under `environment`, not `env`). The pin rewrites the legacy
+ ## `type: "remote"` in place; url/headers are remote-only and must go.
+ command_shape = McpClient.CommandShape.COMMAND_ARRAY
+ command_transport_key = "type"
+ command_transport_value = "local"
+ command_legacy_keys = PackedStringArray(["url", "headers"])
+ command_initial_fields = {"enabled": true}
+ command_user_fields = PackedStringArray(["enabled", "timeout", "environment", "cwd"])
+ command_timeout_fields = PackedStringArray(["timeout"])
diff --git a/addons/godot_ai/clients/opencode.gd.uid b/addons/godot_ai/clients/opencode.gd.uid
new file mode 100644
index 0000000..dc2ad00
--- /dev/null
+++ b/addons/godot_ai/clients/opencode.gd.uid
@@ -0,0 +1 @@
+uid://s8n0vfirf2pj
diff --git a/addons/godot_ai/clients/pi.gd b/addons/godot_ai/clients/pi.gd
new file mode 100644
index 0000000..0862cc6
--- /dev/null
+++ b/addons/godot_ai/clients/pi.gd
@@ -0,0 +1,61 @@
+@tool
+extends McpClient
+
+
+func _init() -> void:
+ id = "pi"
+ display_name = "Pi Agent"
+ config_type = "json"
+ # pi-codemode-mcp reads MCP server definitions from ~/.pi/agent/mcp.json
+ # (first merge tier; ~/.pi/agent/.mcp.json, .pi/mcp.json, and
+ # .mcp.json merge after, project-scope last). Documented at
+ # github.com/mitsuhiko/pi-codemode-mcp README "Configuration files"
+ # section. Windows path mirrors antigravity's $USERPROFILE choice so
+ # the descriptor round-trips on every supported platform.
+ path_template = {
+ "unix": "~/.pi/agent/mcp.json",
+ "windows": "$USERPROFILE/.pi/agent/mcp.json",
+ }
+ # Pi merges these files in order; later definitions of the same server win.
+ # Keep this separate from config_path_candidates, whose entries are alternatives
+ # rather than merge tiers.
+ config_merge_path_templates = {
+ "unix": PackedStringArray([
+ "~/.pi/agent/mcp.json",
+ "~/.pi/agent/.mcp.json",
+ ]),
+ "windows": PackedStringArray([
+ "$USERPROFILE/.pi/agent/mcp.json",
+ "$USERPROFILE/.pi/agent/.mcp.json",
+ ]),
+ }
+ config_merge_project_paths = PackedStringArray([".pi/mcp.json", ".mcp.json"])
+ server_key_path = PackedStringArray(["mcpServers"])
+ # pi-codemode-mcp resolves these with nullish precedence:
+ # mcpServers ?? mcp-servers ?? servers. Reuse whichever map already exists
+ # so Configure cannot shadow and deactivate the user's existing servers.
+ var aliases: Array[PackedStringArray] = [
+ PackedStringArray(["mcp-servers"]),
+ PackedStringArray(["servers"]),
+ ]
+ server_key_path_aliases = aliases
+ entry_url_field = "url"
+ # pi-codemode-mcp selects stdio from `command` and remote transport from
+ # `url`; it ignores `type`. Keep generated stdio entries minimal and typeless.
+ entry_extra_fields = {}
+ entry_initial_fields = {}
+ # Attach migration (#838). Pi stdio entries are flat command/args/env.
+ # When Configure replaces a legacy URL entry, remove `url`, `headers`, and
+ # any redundant `type` field so the generated stdio entry stays canonical.
+ command_shape = McpClient.CommandShape.FLAT
+ command_legacy_keys = PackedStringArray(["url", "headers", "type"])
+ # `env` is the only user-mutable field pi's docs surface. Future
+ # fields still survive — the strategy deep-copies unknown keys.
+ command_user_fields = PackedStringArray(["env"])
+ # Pi's loader also accepts pure-URL entries (the example mcp.json in
+ # the pi-codemode-mcp repo ships stdio + URL entries side-by-side).
+ # ~/.pi/agent/mcp.json is created by the first pi launch, so its
+ # presence is the strongest install signal. Mirror antigravity's
+ # `detect_paths = path_template.values()` pattern so the dock shows
+ # the installed badge before Configure has run.
+ detect_paths = PackedStringArray(path_template.values())
diff --git a/addons/godot_ai/clients/pi.gd.uid b/addons/godot_ai/clients/pi.gd.uid
new file mode 100644
index 0000000..53aeb7c
--- /dev/null
+++ b/addons/godot_ai/clients/pi.gd.uid
@@ -0,0 +1 @@
+uid://it00ohwkm2nd
diff --git a/addons/godot_ai/clients/qwen_code.gd b/addons/godot_ai/clients/qwen_code.gd
new file mode 100644
index 0000000..bed4492
--- /dev/null
+++ b/addons/godot_ai/clients/qwen_code.gd
@@ -0,0 +1,25 @@
+@tool
+extends McpClient
+
+
+func _init() -> void:
+ id = "qwen_code"
+ display_name = "Qwen Code"
+ config_type = "json"
+ path_template = {
+ "unix": "~/.qwen/settings.json",
+ "windows": "$USERPROFILE/.qwen/settings.json",
+ }
+ server_key_path = PackedStringArray(["mcpServers"])
+ entry_url_field = "httpUrl"
+ ## Attach migration (#838). Qwen Code is a gemini-cli fork with the same
+ ## flat stdio shape and one-of `command` | `url` | `httpUrl` rule
+ ## (docs/users/features/mcp.md). Qwen adds `discoveryTimeoutMs` (stdio
+ ## discovery handshake cap, default 30s).
+ command_shape = McpClient.CommandShape.FLAT
+ command_legacy_keys = PackedStringArray(["httpUrl", "url"])
+ command_user_fields = PackedStringArray([
+ "timeout", "trust", "includeTools", "excludeTools", "env", "cwd",
+ "discoveryTimeoutMs",
+ ])
+ command_timeout_fields = PackedStringArray(["timeout", "discoveryTimeoutMs"])
diff --git a/addons/godot_ai/clients/qwen_code.gd.uid b/addons/godot_ai/clients/qwen_code.gd.uid
new file mode 100644
index 0000000..5f2eb1a
--- /dev/null
+++ b/addons/godot_ai/clients/qwen_code.gd.uid
@@ -0,0 +1 @@
+uid://qwb5udkf423q
diff --git a/addons/godot_ai/clients/roo_code.gd b/addons/godot_ai/clients/roo_code.gd
new file mode 100644
index 0000000..5a1e697
--- /dev/null
+++ b/addons/godot_ai/clients/roo_code.gd
@@ -0,0 +1,41 @@
+@tool
+extends McpClient
+
+
+func _init() -> void:
+ id = "roo_code"
+ display_name = "Roo Code"
+ config_type = "json"
+ path_template = {
+ "darwin": "~/Library/Application Support/Code/User/globalStorage/rooveterinaryinc.roo-cline/settings/mcp_settings.json",
+ "windows": "$APPDATA/Code/User/globalStorage/rooveterinaryinc.roo-cline/settings/mcp_settings.json",
+ "linux": "$XDG_CONFIG_HOME/Code/User/globalStorage/rooveterinaryinc.roo-cline/settings/mcp_settings.json",
+ }
+ server_key_path = PackedStringArray(["mcpServers"])
+ ## Roo defaults an entry with no "type" to SSE transport — which returns
+ ## HTTP 400 against our streamable-http endpoint on `/mcp`. Pin the type
+ ## explicitly so Roo negotiates streamable-http (the current MCP spec's
+ ## recommended remote transport). See issue #189. The default verifier
+ ## requires every entry_extra_fields key to match, so a pre-#189 typeless
+ ## entry surfaces as drift instead of silently passing as configured.
+ entry_extra_fields = {"type": "streamable-http"}
+ ## `disabled` and `alwaysAllow` are user-state (they may have flipped the
+ ## entry off, or auto-approved specific tools like `session_manage`).
+ ## Seed on first Configure but preserve across reconfigure — without this
+ ## split, the Configure-All-Mismatched sweep silently wipes the user's
+ ## auto-approval list every time the type pin or URL drifts.
+ entry_initial_fields = {"disabled": false, "alwaysAllow": []}
+ ## Attach migration (#838). Roo stdio entries are flat command/args/env
+ ## (+cwd); docs state `type` defaults to "stdio" for command configs and
+ ## the stdio schema forbids url/headers. Pin type=stdio — it is documented
+ ## and repins the legacy "streamable-http" value in place.
+ command_shape = McpClient.CommandShape.FLAT
+ command_transport_key = "type"
+ command_transport_value = "stdio"
+ command_legacy_keys = PackedStringArray(["url", "headers"])
+ command_initial_fields = {"disabled": false, "alwaysAllow": []}
+ command_user_fields = PackedStringArray([
+ "disabled", "alwaysAllow", "timeout", "disabledTools", "watchPaths",
+ "env", "cwd",
+ ])
+ command_timeout_fields = PackedStringArray(["timeout"])
diff --git a/addons/godot_ai/clients/roo_code.gd.uid b/addons/godot_ai/clients/roo_code.gd.uid
new file mode 100644
index 0000000..1dcae84
--- /dev/null
+++ b/addons/godot_ai/clients/roo_code.gd.uid
@@ -0,0 +1 @@
+uid://denjdf50qrf66
diff --git a/addons/godot_ai/clients/trae.gd b/addons/godot_ai/clients/trae.gd
new file mode 100644
index 0000000..68f66de
--- /dev/null
+++ b/addons/godot_ai/clients/trae.gd
@@ -0,0 +1,22 @@
+@tool
+extends McpClient
+
+
+func _init() -> void:
+ id = "trae"
+ display_name = "Trae"
+ config_type = "json"
+ path_template = {
+ "darwin": "~/Library/Application Support/Trae/User/mcp.json",
+ "windows": "$APPDATA/Trae/User/mcp.json",
+ "linux": "$XDG_CONFIG_HOME/Trae/User/mcp.json",
+ }
+ server_key_path = PackedStringArray(["mcpServers"])
+ ## Attach migration (#838). Trae stdio entries are flat command/args/env
+ ## with no type discriminator (docs.trae.cn/ide/add-mcp-servers); transport
+ ## is inferred from `command` vs `url`, so url/headers are legacy next to a
+ ## command. Entry stays minimal — Trae manages enable/disable in its UI,
+ ## and its startup/run timeouts ride user-owned env vars, which survive.
+ command_shape = McpClient.CommandShape.FLAT
+ command_legacy_keys = PackedStringArray(["url", "headers"])
+ command_user_fields = PackedStringArray(["env"])
diff --git a/addons/godot_ai/clients/trae.gd.uid b/addons/godot_ai/clients/trae.gd.uid
new file mode 100644
index 0000000..f10046e
--- /dev/null
+++ b/addons/godot_ai/clients/trae.gd.uid
@@ -0,0 +1 @@
+uid://cwpu48772vfj1
diff --git a/addons/godot_ai/clients/vscode.gd b/addons/godot_ai/clients/vscode.gd
new file mode 100644
index 0000000..9b97c53
--- /dev/null
+++ b/addons/godot_ai/clients/vscode.gd
@@ -0,0 +1,29 @@
+@tool
+extends McpClient
+
+## VS Code (stable) reads MCP servers from per-user mcp.json under
+## `servers.` with `{ "type": "http", "url": ... }`.
+
+
+func _init() -> void:
+ id = "vscode"
+ display_name = "VS Code"
+ config_type = "json"
+ path_template = {
+ "darwin": "~/Library/Application Support/Code/User/mcp.json",
+ "windows": "$APPDATA/Code/User/mcp.json",
+ "linux": "$XDG_CONFIG_HOME/Code/User/mcp.json",
+ }
+ server_key_path = PackedStringArray(["servers"])
+ entry_extra_fields = {"type": "http"}
+ ## Attach migration (#838). VS Code stdio entries are flat command/args/env
+ ## under `servers` with a documented `type: "stdio"` discriminator
+ ## (code.visualstudio.com/docs/agents/reference/mcp-configuration). The
+ ## stdio schema is `additionalProperties: false` (mcpConfiguration.ts), so
+ ## removing the legacy url/headers is load-bearing — leftovers invalidate
+ ## the whole entry, and the pin flips the legacy `type: "http"` in place.
+ command_shape = McpClient.CommandShape.FLAT
+ command_transport_key = "type"
+ command_transport_value = "stdio"
+ command_legacy_keys = PackedStringArray(["url", "headers"])
+ command_user_fields = PackedStringArray(["env", "envFile", "cwd", "sandboxEnabled", "dev"])
diff --git a/addons/godot_ai/clients/vscode.gd.uid b/addons/godot_ai/clients/vscode.gd.uid
new file mode 100644
index 0000000..1c79881
--- /dev/null
+++ b/addons/godot_ai/clients/vscode.gd.uid
@@ -0,0 +1 @@
+uid://dl6cm044pihub
diff --git a/addons/godot_ai/clients/vscode_insiders.gd b/addons/godot_ai/clients/vscode_insiders.gd
new file mode 100644
index 0000000..0fc9427
--- /dev/null
+++ b/addons/godot_ai/clients/vscode_insiders.gd
@@ -0,0 +1,22 @@
+@tool
+extends McpClient
+
+
+func _init() -> void:
+ id = "vscode_insiders"
+ display_name = "VS Code Insiders"
+ config_type = "json"
+ path_template = {
+ "darwin": "~/Library/Application Support/Code - Insiders/User/mcp.json",
+ "windows": "$APPDATA/Code - Insiders/User/mcp.json",
+ "linux": "$XDG_CONFIG_HOME/Code - Insiders/User/mcp.json",
+ }
+ server_key_path = PackedStringArray(["servers"])
+ entry_extra_fields = {"type": "http"}
+ ## Attach migration (#838). Identical format to vscode.gd — Insiders ships
+ ## the same mcpConfiguration.ts schema; see that descriptor for citations.
+ command_shape = McpClient.CommandShape.FLAT
+ command_transport_key = "type"
+ command_transport_value = "stdio"
+ command_legacy_keys = PackedStringArray(["url", "headers"])
+ command_user_fields = PackedStringArray(["env", "envFile", "cwd", "sandboxEnabled", "dev"])
diff --git a/addons/godot_ai/clients/vscode_insiders.gd.uid b/addons/godot_ai/clients/vscode_insiders.gd.uid
new file mode 100644
index 0000000..c763703
--- /dev/null
+++ b/addons/godot_ai/clients/vscode_insiders.gd.uid
@@ -0,0 +1 @@
+uid://cad5w4ofyg8a2
diff --git a/addons/godot_ai/clients/windsurf.gd b/addons/godot_ai/clients/windsurf.gd
new file mode 100644
index 0000000..e069557
--- /dev/null
+++ b/addons/godot_ai/clients/windsurf.gd
@@ -0,0 +1,28 @@
+@tool
+extends McpClient
+
+
+func _init() -> void:
+ # #623: Windsurf was rebranded to Devin Desktop by Cognition (June 2026).
+ # The id stays "windsurf" — it is the stable registry key used for
+ # configured-status lookups. The MCP config path is unchanged by the
+ # rebrand: per the official docs (docs.devin.ai/desktop/cascade/mcp) the
+ # global config still lives under the platform's `.codeium/windsurf/`
+ # directory (~/.codeium/windsurf/ on unix, $USERPROFILE/.codeium/windsurf/
+ # on Windows), and migrated installs carry their settings over in place.
+ id = "windsurf"
+ display_name = "Devin Desktop (Windsurf)"
+ config_type = "json"
+ path_template = {
+ "unix": "~/.codeium/windsurf/mcp_config.json",
+ "windows": "$USERPROFILE/.codeium/windsurf/mcp_config.json",
+ }
+ server_key_path = PackedStringArray(["mcpServers"])
+ entry_url_field = "serverUrl"
+ ## Attach migration (#838). Stdio entries are flat command/args/env
+ ## (docs.devin.ai/desktop/cascade/mcp); transport is inferred from
+ ## `command` vs `serverUrl` presence and no type field is documented —
+ ## do not write one.
+ command_shape = McpClient.CommandShape.FLAT
+ command_legacy_keys = PackedStringArray(["serverUrl"])
+ command_user_fields = PackedStringArray(["env"])
diff --git a/addons/godot_ai/clients/windsurf.gd.uid b/addons/godot_ai/clients/windsurf.gd.uid
new file mode 100644
index 0000000..af34b60
--- /dev/null
+++ b/addons/godot_ai/clients/windsurf.gd.uid
@@ -0,0 +1 @@
+uid://b6pqiok2mlsmg
diff --git a/addons/godot_ai/clients/zed.gd b/addons/godot_ai/clients/zed.gd
new file mode 100644
index 0000000..e189064
--- /dev/null
+++ b/addons/godot_ai/clients/zed.gd
@@ -0,0 +1,35 @@
+@tool
+extends McpClient
+
+## Zed registers MCP servers under `context_servers.` and supports both
+## stdio and streamable http transports.
+
+
+func _init() -> void:
+ id = "zed"
+ display_name = "Zed"
+ config_type = "json"
+ ## Zed settings are JSONC. Owning a second JSON tokenizer solely to preserve
+ ## comments would make the shared JSON strategy much harder to trust. Keep
+ ## status checks read-only and give users the exact manual entry instead.
+ automatic_config_edits = false
+ ## Reads still have to cope with the `//` header Zed ships, or a stock
+ ## install shows a parse error in the row instead of a status.
+ config_allows_comments = true
+ path_template = {
+ "darwin": "~/.config/zed/settings.json",
+ "linux": "$XDG_CONFIG_HOME/zed/settings.json",
+ "windows": "$APPDATA/Zed/settings.json",
+ }
+ server_key_path = PackedStringArray(["context_servers"])
+ ## Attach migration (#838). Current Zed's context_servers entries are an
+ ## untagged serde enum discriminated by shape: `command` (string) + args/env
+ ## → stdio, `url` → HTTP (zed.dev/docs/ai/mcp; settings_content/project.rs).
+ ## BECAUSE the enum is untagged, HTTP-only keys left next to `command`
+ ## (`url`, `headers`, `oauth`) make the entry match no variant and break it —
+ ## removing them on migration is load-bearing, not cosmetic. `enabled`,
+ ## `remote`, and `timeout` are user-state on the stdio variant.
+ command_shape = McpClient.CommandShape.FLAT
+ command_legacy_keys = PackedStringArray(["url", "headers", "oauth"])
+ command_user_fields = PackedStringArray(["enabled", "remote", "timeout", "env"])
+ command_timeout_fields = PackedStringArray(["timeout"])
diff --git a/addons/godot_ai/clients/zed.gd.uid b/addons/godot_ai/clients/zed.gd.uid
new file mode 100644
index 0000000..b9b313a
--- /dev/null
+++ b/addons/godot_ai/clients/zed.gd.uid
@@ -0,0 +1 @@
+uid://d152l0u0r6fsc
diff --git a/addons/godot_ai/clients/zoo_code.gd b/addons/godot_ai/clients/zoo_code.gd
new file mode 100644
index 0000000..85529bf
--- /dev/null
+++ b/addons/godot_ai/clients/zoo_code.gd
@@ -0,0 +1,35 @@
+@tool
+extends McpClient
+
+
+func _init() -> void:
+ id = "zoo_code"
+ display_name = "Zoo Code"
+ config_type = "json"
+ path_template = {
+ "darwin": "~/Library/Application Support/Code/User/globalStorage/zoocodeorganization.zoo-code/settings/mcp_settings.json",
+ "windows": "$APPDATA/Code/User/globalStorage/zoocodeorganization.zoo-code/settings/mcp_settings.json",
+ "linux": "$XDG_CONFIG_HOME/Code/User/globalStorage/zoocodeorganization.zoo-code/settings/mcp_settings.json",
+ }
+ server_key_path = PackedStringArray(["mcpServers"])
+ ## Local validation against the installed extension shows Zoo stores MCP
+ ## entries in `settings/mcp_settings.json` under `mcpServers`, matching Roo's
+ ## shape. Its changelog also references Streamable HTTP support, so pin the
+ ## transport explicitly to avoid any typeless entry falling back to SSE.
+ entry_extra_fields = {"type": "streamable-http"}
+ ## Preserve user-controlled state across reconfigure, parallel to Roo/Kilo.
+ entry_initial_fields = {"disabled": false, "alwaysAllow": []}
+ ## Attach migration (#838). Zoo's stdio zod schema is Roo's: flat
+ ## command/args/env(+cwd), `type: z.enum(["stdio"]).optional()`, and
+ ## url/headers explicitly forbidden on stdio entries (McpHub.ts) — so both
+ ## are legacy keys and the documented type pin repins "streamable-http".
+ command_shape = McpClient.CommandShape.FLAT
+ command_transport_key = "type"
+ command_transport_value = "stdio"
+ command_legacy_keys = PackedStringArray(["url", "headers"])
+ command_initial_fields = {"disabled": false, "alwaysAllow": []}
+ command_user_fields = PackedStringArray([
+ "disabled", "alwaysAllow", "timeout", "watchPaths", "disabledTools",
+ "env", "cwd",
+ ])
+ command_timeout_fields = PackedStringArray(["timeout"])
diff --git a/addons/godot_ai/clients/zoo_code.gd.uid b/addons/godot_ai/clients/zoo_code.gd.uid
new file mode 100644
index 0000000..dd3713e
--- /dev/null
+++ b/addons/godot_ai/clients/zoo_code.gd.uid
@@ -0,0 +1 @@
+uid://fmp0nlzcm3ukl
diff --git a/addons/godot_ai/connection.gd b/addons/godot_ai/connection.gd
new file mode 100644
index 0000000..5877356
--- /dev/null
+++ b/addons/godot_ai/connection.gd
@@ -0,0 +1,1321 @@
+@tool
+class_name McpConnection
+extends Node
+
+## WebSocket transport to the Godot AI Python server.
+## Only handles connect, reconnect, send, and receive.
+## Command dispatch is owned by McpDispatcher.
+
+const RECONNECT_DELAYS: Array[float] = [1.0, 2.0, 4.0, 8.0, 16.0, 30.0, 60.0]
+const RECONNECT_VERBOSE_ATTEMPTS := 5
+const RECONNECT_LOG_HEARTBEAT_MSEC := 60_000
+## Backpressure policy: do not queue responses once the WebSocket's current
+## outbound buffer plus the next payload would exceed this cap. Command
+## responses get a compact structured error when that can still be sent;
+## state events report failure so their callers can retry on a later tick.
+const OUTBOUND_BUFFER_LIMIT_BYTES := 4 * 1024 * 1024
+## Cap the inbound packet drain per `_process` tick. A flooding peer or a
+## fast batch could otherwise saturate `_handle_message` in one frame and
+## blow the documented 4ms budget. Packets beyond this cap spill to the
+## next frame; the cumulative spill counter is logged so flood patterns
+## are observable in `logs_read`. See audit-v2 finding #12 (issue #356).
+const PACKET_DRAIN_CAP_PER_TICK := 32
+const MAX_QUEUED_PACKETS := 64
+const WS_PROTOCOL_VERSION := 2
+const HANDSHAKE_TIMEOUT_MSEC := 5000
+const MAX_HANDSHAKE_FRAME_BYTES := 8 * 1024
+const CLOSE_CODE_DUPLICATE_SESSION := 4001
+const CLOSE_CODE_PROTOCOL_MISMATCH := 4002
+const CLOSE_CODE_AUTH_FAILED := 4003
+const _SERVER_PROOF_DOMAIN := "godot-ai-ws-v2/server-proof"
+const _CLIENT_PROOF_DOMAIN := "godot-ai-ws-v2/client-proof"
+const ClientConfigurator := preload("res://addons/godot_ai/client_configurator.gd")
+const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
+
+## Emitted whenever the authenticated v4 editor channel becomes usable/unusable.
+## Subscribers (e.g. the plugin-side telemetry helper) use this to drain
+## events that were enqueued before authentication completed.
+signal connection_state_changed(is_open: bool)
+
+var _peer := WebSocketPeer.new()
+## Seeded by plugin.gd from the configured EditorSettings port before the
+## first dial, then republished with the fully resolved port once the
+## deferred startup walk (#678) finishes resolving/spawning. Each connect
+## attempt recomputes the URL from the latest value, so reconnects keep
+## dialing the port the Python server was asked to bind.
+var ws_port := ClientConfigurator.DEFAULT_WS_PORT
+## Per-launch editor capability. Root supplies the copied value only after
+## lifecycle proof has read the child-published private capability record.
+## It never crosses the wire or enters EditorSettings; empty/malformed values
+## fail closed and require a fresh lifecycle episode.
+var auth_token := ""
+var _url := ""
+var _connected := false
+var _reconnect_attempt := 0
+var _reconnect_timer := 0.0
+## Pull-based reconnect observability. The peer owns CONNECTING/CLOSING
+## timing; tracking entry time here lets logs and the dock distinguish those
+## phases from the plugin-owned CLOSED-state backoff without changing policy.
+var _observed_peer_state := WebSocketPeer.STATE_CLOSED
+var _peer_state_entered_msec := 0
+var _last_reconnect_transition_log_msec := -1
+var _transient_diagnostic: Dictionary = {}
+## One pre-OPEN failure diagnostic per WebSocketPeer. Without this guard the
+## CLOSED state is polled every frame and would flood the editor log.
+var _preopen_failure_logged_for_peer := false
+var _session_id := ""
+var _handshake_started_msec := 0
+var _client_nonce := ""
+var _server_nonce := ""
+var _challenged_server_version := ""
+var _server_verified := false
+var _auth_response_sent := false
+var _handshake_complete := false
+## Godot-AI Python package version reported by the server in its `handshake_ack`
+## reply. Empty until the authenticated v4 handshake completes.
+var server_version := ""
+
+var dispatcher
+var log_buffer
+var surfaced_error_tracker
+## Set by plugin.gd. Lets the per-frame play-state poll end game-run
+## bookkeeping when the game exits on its own (self-quit, crash) — the
+## debugger session's stopped signal is not reliably connected, and no MCP
+## stop op runs in that path (#642).
+var debugger_plugin
+## Set by plugin.gd when the HTTP port is occupied by an incompatible or
+## unverified server. Keeping the Connection node alive lets handlers and the
+## dock share one object, but no WebSocket is opened to the wrong server.
+var connect_blocked := false
+var connect_block_reason := ""
+var _blocked_notice_logged := false
+## Compatibility property used by existing handlers. Setting true increments
+## the pause depth; setting false decrements it. Processing stays paused until
+## every nested pause has resumed.
+var pause_processing: bool:
+ get: return _pause_depth > 0
+ set(value):
+ if value:
+ pause()
+ else:
+ resume()
+var _pause_depth := 0
+## Cumulative count of inbound packets that didn't fit in their tick's drain
+## budget and got deferred to a subsequent tick. Reset on disconnect so each
+## connection starts with a clean spillover history. Logged whenever new
+## spillover occurs so flood patterns surface in `logs_read`.
+var _packet_spillover_total := 0
+
+
+func _ready() -> void:
+ _session_id = _make_session_id(ProjectSettings.globalize_path("res://"))
+ ## Increase outbound buffer for large messages (e.g. screenshot base64).
+ ## Default is 64 KB; screenshots can be several MB.
+ _peer.outbound_buffer_size = OUTBOUND_BUFFER_LIMIT_BYTES
+ ## Stay under the pre-auth byte ceiling until the server proof validates;
+ ## only then may the peer allocate the normal 4 MiB command buffer.
+ _peer.inbound_buffer_size = MAX_HANDSHAKE_FRAME_BYTES
+ _peer.max_queued_packets = MAX_QUEUED_PACKETS
+ if connect_blocked:
+ _log_blocked_notice_once()
+ set_process(false)
+ return
+ _connect_to_server()
+ _hook_editor_signals()
+
+
+func _process(delta: float) -> void:
+ if pause_processing:
+ return
+ _peer.poll()
+ ## Run-stop bookkeeping must not wait behind the socket-state machine:
+ ## if the game stops while disconnected, the first command drained on
+ ## reconnect would still observe stale "live" state (PR #642 review).
+ _check_game_run_play_state(EditorInterface.is_playing_scene())
+
+ var peer_state := _peer.get_ready_state()
+ var transition := _observe_peer_state(peer_state, Time.get_ticks_msec())
+ match peer_state:
+ WebSocketPeer.STATE_OPEN:
+ if not _connected:
+ _connected = true
+ if log_buffer:
+ log_buffer.log("connected to server; authenticating")
+ _send_auth_hello()
+
+ _drain_inbound_packets(_peer)
+ if not _handshake_complete:
+ if (
+ _handshake_started_msec > 0
+ and Time.get_ticks_msec() - _handshake_started_msec >= HANDSHAKE_TIMEOUT_MSEC
+ ):
+ _fail_handshake(CLOSE_CODE_AUTH_FAILED, "v4 editor handshake timed out")
+ return
+
+ _check_state_changes()
+
+ if dispatcher:
+ for response in dispatcher.tick():
+ _send_json(response)
+
+ WebSocketPeer.STATE_CLOSED:
+ if _connected:
+ _connected = false
+ var was_authenticated := _handshake_complete
+ var server_was_verified := _server_verified
+ ## This peer reached OPEN, so its one close diagnostic is the
+ ## post-OPEN line below. Mark the peer consumed; otherwise a
+ ## stale reconnect delay leaves it in CLOSED for another frame
+ ## and the pre-OPEN branch emits a mislabeled duplicate.
+ _preopen_failure_logged_for_peer = true
+ _clear_on_disconnect()
+ var code := _peer.get_close_code()
+ var reason := _peer.get_close_reason()
+ if _should_regenerate_session_id(code, server_was_verified, was_authenticated):
+ _session_id = _make_session_id(ProjectSettings.globalize_path("res://"))
+ var open_elapsed_sec := float(transition.get("previous_elapsed_sec", 0.0))
+ var close_diagnostic := _handshake_close_diagnostic(code, was_authenticated)
+ if close_diagnostic.is_empty():
+ close_diagnostic = {
+ "reason_code": "connection_lost",
+ "reason": _close_reason_text(code, reason),
+ }
+ _transient_diagnostic = close_diagnostic
+ _log_reconnect_transition(
+ _postopen_close_diagnostic(
+ open_elapsed_sec,
+ code,
+ reason,
+ _url,
+ ),
+ maxi(1, _reconnect_attempt),
+ true,
+ )
+ if was_authenticated:
+ connection_state_changed.emit(false)
+ elif not _preopen_failure_logged_for_peer:
+ _preopen_failure_logged_for_peer = true
+ ## A failed attempt never reached OPEN, so any post-OPEN reason
+ ## belongs to the previous peer and must not describe this one.
+ _transient_diagnostic.clear()
+ ## Initial failure is attempt 1 for diagnostics. Later transition
+ ## summaries are time-throttled so a missing listener stays
+ ## observable without tying log volume to attempt duration.
+ var failed_attempt := maxi(1, _reconnect_attempt)
+ var connecting_elapsed_sec := float(
+ transition.get("previous_elapsed_sec", 0.0)
+ )
+ _log_reconnect_transition(
+ _preopen_failure_diagnostic(
+ failed_attempt,
+ connecting_elapsed_sec,
+ _reconnect_timer,
+ _peer.get_close_code(),
+ _peer.get_close_reason(),
+ _url
+ ),
+ failed_attempt,
+ )
+ _reconnect_timer -= delta
+ if _reconnect_timer <= 0.0:
+ _attempt_reconnect()
+
+ WebSocketPeer.STATE_CLOSING:
+ pass
+ WebSocketPeer.STATE_CONNECTING:
+ pass
+
+func get_session_id() -> String:
+ return _session_id
+
+## Drain up to PACKET_DRAIN_CAP_PER_TICK inbound packets and dispatch each
+## via `_handle_message`. Anything past the cap stays in the peer's queue
+## and gets picked up next tick. The cumulative spillover count is logged
+## (via `log_buffer`) only when the cap was actually hit AND packets remain
+## — sustained flood thus emits one log line per tick with the running
+## total, while a normal-traffic frame stays silent.
+##
+## `peer` is untyped (Variant) so tests can inject a duck-typed fake with
+## `get_available_packet_count()` + `get_packet()`. Production passes the
+## real `_peer: WebSocketPeer`.
+func _drain_inbound_packets(peer) -> Dictionary:
+ var drained := 0
+ while peer.get_available_packet_count() > 0 and drained < PACKET_DRAIN_CAP_PER_TICK:
+ var raw: String = peer.get_packet().get_string_from_utf8()
+ _handle_message(raw)
+ drained += 1
+
+ var spilled := 0
+ if drained >= PACKET_DRAIN_CAP_PER_TICK and peer.get_available_packet_count() > 0:
+ spilled = peer.get_available_packet_count()
+ _packet_spillover_total += spilled
+ if log_buffer:
+ log_buffer.log(
+ (
+ "[backpressure] inbound drain capped at %d/tick;"
+ + " %d packets spilled to next frame (cumulative %d)"
+ )
+ % [PACKET_DRAIN_CAP_PER_TICK, spilled, _packet_spillover_total]
+ )
+
+ return {"drained": drained, "spilled": spilled}
+
+
+var is_connected: bool:
+ get: return _connected and _handshake_complete
+
+
+func disconnect_from_server(reason := "Plugin unloading") -> void:
+ var was_authenticated := _handshake_complete
+ if _peer.get_ready_state() != WebSocketPeer.STATE_CLOSED:
+ _peer.close(1000, reason)
+ ## Drop the peer reference immediately instead of depending on a future
+ ## `_process()` poll to finish a closing handshake. Authority revocation
+ ## disables processing, so retaining a CLOSING peer here would keep the
+ ## underlying channel alive beyond the revocation boundary.
+ _peer = WebSocketPeer.new()
+ _observed_peer_state = WebSocketPeer.STATE_CLOSED
+ _peer_state_entered_msec = Time.get_ticks_msec()
+ _connected = false
+ ## A deliberate close owns all cleanup synchronously. Waiting for a later
+ ## CLOSED poll would leave authenticated commands dispatchable after root
+ ## revoked the transport authority.
+ _preopen_failure_logged_for_peer = true
+ _clear_on_disconnect()
+ if was_authenticated:
+ connection_state_changed.emit(false)
+
+
+## Revoke a copied lifecycle authority immediately. This is stronger than
+## `connect_blocked`: it also terminates an existing channel and erases every
+## queued/deferred command before returning to the caller.
+func revoke_transport(reason: String) -> void:
+ auth_token = ""
+ connect_blocked = true
+ connect_block_reason = reason
+ set_process(false)
+ disconnect_from_server("Transport authority revoked")
+
+
+## Apply one proven lifecycle grant and dial immediately. Keeping the copied
+## authority transition here makes revoke/authorize symmetric and avoids
+## depending on a later CLOSED-peer frame after a plugin reload.
+func authorize_transport(p_ws_port: int, p_auth_token: String) -> void:
+ ws_port = p_ws_port
+ auth_token = p_auth_token
+ connect_blocked = false
+ connect_block_reason = ""
+ server_version = ""
+ _reconnect_timer = 0.0
+ set_process(true)
+ if is_inside_tree():
+ _attempt_reconnect()
+
+
+## Reset per-connection state that was filled in by the previous server
+## and must NOT bleed into the next one. `force_restart_server` swaps
+## servers without reloading the plugin, so without this reset the dock
+## would keep showing the killed server's version until the next ack.
+## Also fires on plain reconnect-loop drops — correct either way.
+func _clear_on_disconnect() -> void:
+ server_version = ""
+ _handshake_started_msec = 0
+ _client_nonce = ""
+ _server_nonce = ""
+ _challenged_server_version = ""
+ _server_verified = false
+ _auth_response_sent = false
+ _handshake_complete = false
+ ## Reset the spillover counter so a flood pattern from the previous
+ ## connection doesn't pollute the next one's `logs_read` baseline.
+ _packet_spillover_total = 0
+ if dispatcher:
+ dispatcher.clear_deferred_responses()
+ ## Queued-but-unexecuted commands from the dead connection must not
+ ## run under the next one (#712): their requester's futures were
+ ## already failed server-side, so executing them after reconnect is
+ ## an uncorrelatable surprise write.
+ dispatcher.clear_command_queue()
+
+
+## Full pre-free cleanup for plugin unload: stop _process, close the
+## socket, and drop dispatcher/log_buffer refs so their Callable-held
+## RefCounted handlers decref before plugin.gd clears _handlers.
+## See issue #46 and plugin.gd::_exit_tree.
+func teardown() -> void:
+ set_process(false)
+ disconnect_from_server()
+ dispatcher = null
+ log_buffer = null
+
+
+func _connect_to_server() -> void:
+ _url = "ws://127.0.0.1:%d" % ws_port
+ var err := _peer.connect_to_url(_url)
+ if err != OK:
+ log_buffer.log("failed to initiate connection (error %d)" % err)
+ _observed_peer_state = _peer.get_ready_state()
+ _peer_state_entered_msec = Time.get_ticks_msec()
+
+
+func _attempt_reconnect() -> void:
+ if connect_blocked:
+ _log_blocked_notice_once()
+ set_process(false)
+ return
+ var delay := _reconnect_delay_for_attempt(_reconnect_attempt)
+ _reconnect_attempt += 1
+ _reconnect_timer = delay
+ _log_reconnect_transition(
+ "connecting to server (attempt %d)" % _reconnect_attempt,
+ _reconnect_attempt,
+ )
+ ## Always create a fresh WebSocketPeer before reconnecting. A peer that has
+ ## reached STATE_CLOSED is terminal; reusing it can leave the editor stuck in
+ ## a quiet reconnect loop after the Python server restarts.
+ _peer = WebSocketPeer.new()
+ _preopen_failure_logged_for_peer = false
+ _peer.outbound_buffer_size = OUTBOUND_BUFFER_LIMIT_BYTES
+ _peer.inbound_buffer_size = MAX_HANDSHAKE_FRAME_BYTES
+ _peer.max_queued_packets = MAX_QUEUED_PACKETS
+ _connect_to_server()
+
+
+func pause() -> void:
+ _pause_depth += 1
+
+
+func resume() -> void:
+ _pause_depth = maxi(0, _pause_depth - 1)
+
+
+func pause_depth() -> int:
+ return _pause_depth
+
+
+static func _reconnect_delay_for_attempt(attempt_index: int) -> float:
+ var delay_idx := mini(attempt_index, RECONNECT_DELAYS.size() - 1)
+ return RECONNECT_DELAYS[delay_idx]
+
+
+static func _should_log_reconnect_transition(
+ attempt_number: int,
+ now_msec: int,
+ last_log_msec: int
+) -> bool:
+ ## Keep the first few transitions visible, then emit at most one summary per
+ ## minute. Attempt-number throttling goes quiet for minutes when the engine
+ ## spends a long time in CONNECTING, which is the state this log explains.
+ return (
+ attempt_number <= RECONNECT_VERBOSE_ATTEMPTS
+ or last_log_msec < 0
+ or now_msec - last_log_msec >= RECONNECT_LOG_HEARTBEAT_MSEC
+ )
+
+
+func _log_reconnect_transition(message: String, attempt_number: int, force := false) -> void:
+ if not log_buffer:
+ return
+ var now_msec := Time.get_ticks_msec()
+ if not force and not _should_log_reconnect_transition(
+ attempt_number,
+ now_msec,
+ _last_reconnect_transition_log_msec,
+ ):
+ return
+ _last_reconnect_transition_log_msec = now_msec
+ log_buffer.log(message)
+
+
+static func _preopen_failure_diagnostic(
+ attempt_number: int,
+ connecting_elapsed_sec: float,
+ retry_in_sec: float,
+ code: int,
+ reason: String,
+ url: String
+) -> String:
+ var retry_text := "retrying now"
+ if retry_in_sec > 0.0:
+ retry_text = "retrying in %.0fs" % retry_in_sec
+ return (
+ "connection attempt %d failed before OPEN after %.1fs; %s"
+ + " (code %d, reason %s, url %s)"
+ ) % [
+ attempt_number,
+ maxf(0.0, connecting_elapsed_sec),
+ retry_text,
+ code,
+ _sanitized_close_reason(reason),
+ url,
+ ]
+
+
+static func _postopen_close_diagnostic(
+ open_elapsed_sec: float,
+ code: int,
+ reason: String,
+ url: String
+) -> String:
+ return (
+ "connection lost after being open for %.1fs (code %d, reason %s, url %s); reconnecting"
+ % [maxf(0.0, open_elapsed_sec), code, _sanitized_close_reason(reason), url]
+ )
+
+
+static func _sanitized_close_reason(reason: String) -> String:
+ var reason_label := reason.strip_edges()
+ if reason_label.is_empty():
+ return ""
+ return reason_label.replace("\r", "\\r").replace("\n", "\\n")
+
+
+static func _close_reason_text(code: int, reason: String) -> String:
+ return "Close code %d: %s" % [code, _sanitized_close_reason(reason)]
+
+
+func _handshake_close_diagnostic(code: int, authenticated := false) -> Dictionary:
+ if code == CLOSE_CODE_PROTOCOL_MISMATCH:
+ return {
+ "reason_code": "ws_protocol_mismatch",
+ "reason": "The editor and server use different Godot AI major protocols; update/restart both sides.",
+ }
+ if code == CLOSE_CODE_AUTH_FAILED:
+ return {
+ "reason_code": "ws_auth_failed",
+ "reason": "The v4 editor capability was rejected; restart the managed server from this Godot editor.",
+ }
+ if not authenticated:
+ return {
+ "reason_code": "ws_handshake_failed",
+ "reason": "The v4 handshake closed before authentication; update/restart both editor and server.",
+ }
+ return {}
+
+
+## Record one peer-state transition and return the duration of the state that
+## just ended. Kept separate from `_transport_status_snapshot` so tests can
+## exercise the status contract with injected values and no live socket.
+func _observe_peer_state(state: int, now_msec: int) -> Dictionary:
+ if _peer_state_entered_msec <= 0:
+ _observed_peer_state = state
+ _peer_state_entered_msec = now_msec
+ return {"changed": false, "previous_elapsed_sec": 0.0}
+ if state == _observed_peer_state:
+ return {"changed": false, "previous_elapsed_sec": 0.0}
+ var previous_elapsed_sec := maxf(
+ 0.0,
+ (now_msec - _peer_state_entered_msec) / 1000.0,
+ )
+ var previous_state := _observed_peer_state
+ _observed_peer_state = state
+ _peer_state_entered_msec = now_msec
+ return {
+ "changed": true,
+ "previous_state": previous_state,
+ "previous_elapsed_sec": previous_elapsed_sec,
+ }
+
+
+## Pure transport-status contract shared by the connection log and dock. It
+## intentionally knows nothing about lifecycle diagnoses; the thin public
+## wrapper below applies generic `blocked`, while server_lifecycle.gd remains
+## authoritative for exact terminal states such as incompatible/foreign port.
+static func _transport_status_snapshot(
+ state: int,
+ state_elapsed_sec: float,
+ attempt: int,
+ retry_timer: float
+) -> Dictionary:
+ var phase := "closing"
+ match state:
+ WebSocketPeer.STATE_OPEN:
+ phase = "connected"
+ WebSocketPeer.STATE_CONNECTING:
+ phase = "connecting"
+ WebSocketPeer.STATE_CLOSED:
+ phase = "retrying"
+ WebSocketPeer.STATE_CLOSING:
+ phase = "closing"
+ var snapshot := {
+ "phase": phase,
+ "attempt": maxi(0, attempt),
+ "state_elapsed_sec": maxf(0.0, state_elapsed_sec),
+ }
+ ## `retry_in_sec` is deliberately unrepresentable outside CLOSED-state
+ ## backoff. CONNECTING is an in-flight attempt, never "retrying in 0s".
+ if phase == "retrying":
+ snapshot["retry_in_sec"] = maxf(0.0, retry_timer)
+ return snapshot
+
+
+func get_transport_status() -> Dictionary:
+ var now_msec := Time.get_ticks_msec()
+ var elapsed_sec := 0.0
+ if _peer_state_entered_msec > 0:
+ elapsed_sec = maxf(0.0, (now_msec - _peer_state_entered_msec) / 1000.0)
+ var snapshot := _transport_status_snapshot(
+ _peer.get_ready_state(),
+ elapsed_sec,
+ _reconnect_attempt,
+ _reconnect_timer,
+ )
+ if connect_blocked:
+ snapshot["phase"] = "blocked"
+ snapshot.erase("retry_in_sec")
+ snapshot["reason_code"] = "connection_blocked"
+ if not connect_block_reason.is_empty():
+ snapshot["reason"] = connect_block_reason
+ elif _connected and not _handshake_complete:
+ snapshot["phase"] = "authenticating"
+ snapshot.erase("retry_in_sec")
+ elif not _transient_diagnostic.is_empty():
+ for key in _transient_diagnostic:
+ snapshot[key] = _transient_diagnostic[key]
+ return snapshot
+
+
+func _log_blocked_notice_once() -> void:
+ if _blocked_notice_logged:
+ return
+ _blocked_notice_logged = true
+ if log_buffer and not connect_block_reason.is_empty():
+ log_buffer.log(connect_block_reason)
+
+
+func _send_auth_hello() -> void:
+ _handshake_started_msec = maxi(1, Time.get_ticks_msec())
+ if not _is_lower_hex_64(auth_token):
+ _fail_handshake(
+ CLOSE_CODE_AUTH_FAILED,
+ "missing v4 editor capability; restart the managed server from Godot",
+ )
+ return
+ var nonce_bytes := Crypto.new().generate_random_bytes(32)
+ if nonce_bytes.size() != 32:
+ _fail_handshake(CLOSE_CODE_AUTH_FAILED, "could not generate v4 client nonce")
+ return
+ _client_nonce = nonce_bytes.hex_encode()
+ if not _send_json(_build_auth_hello(_client_nonce), true):
+ _fail_handshake(CLOSE_CODE_AUTH_FAILED, "could not send v4 auth hello")
+
+
+static func _build_auth_hello(client_nonce: String) -> Dictionary:
+ return {
+ "type": "auth_hello",
+ "protocol_version": WS_PROTOCOL_VERSION,
+ "client_nonce": client_nonce,
+ }
+
+
+func _handle_auth_challenge(parsed: Dictionary) -> void:
+ if _server_verified or parsed.get("protocol_version") != WS_PROTOCOL_VERSION:
+ _fail_handshake(
+ CLOSE_CODE_PROTOCOL_MISMATCH,
+ "Godot AI v4 WebSocket protocol %d required" % WS_PROTOCOL_VERSION,
+ )
+ return
+ var expected_keys: Array[String] = [
+ "type",
+ "protocol_version",
+ "client_nonce",
+ "server_nonce",
+ "server_version",
+ "server_proof",
+ ]
+ if (
+ not _has_exact_keys(parsed, expected_keys)
+ or parsed.get("type") != "auth_challenge"
+ or parsed.get("client_nonce") != _client_nonce
+ or not _is_lower_hex_64(parsed.get("server_nonce"))
+ or not _is_lower_hex_64(parsed.get("server_proof"))
+ or not _is_bounded_text(parsed.get("server_version"), 64)
+ ):
+ _fail_handshake(CLOSE_CODE_AUTH_FAILED, "invalid v4 server challenge")
+ return
+ var expected_proof := _server_proof(
+ auth_token,
+ _client_nonce,
+ str(parsed["server_nonce"]),
+ str(parsed["server_version"]),
+ )
+ if not _constant_time_equal(str(parsed["server_proof"]), expected_proof):
+ _fail_handshake(CLOSE_CODE_AUTH_FAILED, "v4 server proof failed")
+ return
+
+ ## Only this proven transition unlocks project/session metadata.
+ _server_nonce = str(parsed["server_nonce"])
+ _challenged_server_version = str(parsed["server_version"])
+ _server_verified = true
+ _peer.inbound_buffer_size = OUTBOUND_BUFFER_LIMIT_BYTES
+ _last_readiness = get_readiness()
+ var response := _build_auth_response()
+ if response.is_empty() or not _send_json(response, true):
+ _fail_handshake(CLOSE_CODE_AUTH_FAILED, "could not send v4 editor proof")
+ return
+ _auth_response_sent = true
+
+
+func _build_auth_response() -> Dictionary:
+ var godot_version := str(Engine.get_version_info().get("string", "unknown"))
+ var project_path := ProjectSettings.globalize_path("res://")
+ var plugin_version := ClientConfigurator.get_plugin_version()
+ var launch_mode := ClientConfigurator.get_server_launch_mode()
+ var editor_pid := OS.get_process_id()
+ if (
+ not _is_bounded_text(_session_id, 128)
+ or not _is_bounded_text(godot_version, 64)
+ or not _is_bounded_text(project_path, 4096)
+ or not _is_bounded_text(plugin_version, 64)
+ or not _is_bounded_text(launch_mode, 32)
+ or _last_readiness not in ["ready", "importing", "playing", "no_scene"]
+ or editor_pid < 0
+ ):
+ return {}
+ var client_proof := _client_proof(
+ auth_token,
+ _client_nonce,
+ _server_nonce,
+ _challenged_server_version,
+ _session_id,
+ godot_version,
+ project_path,
+ plugin_version,
+ _last_readiness,
+ editor_pid,
+ launch_mode,
+ )
+ if not _is_lower_hex_64(client_proof):
+ return {}
+ return {
+ "type": "auth_response",
+ "protocol_version": WS_PROTOCOL_VERSION,
+ "client_nonce": _client_nonce,
+ "server_nonce": _server_nonce,
+ "client_proof": client_proof,
+ "session_id": _session_id,
+ "godot_version": godot_version,
+ "project_path": project_path,
+ "plugin_version": plugin_version,
+ "readiness": _last_readiness,
+ "editor_pid": editor_pid,
+ "server_launch_mode": launch_mode,
+ }
+
+
+## Classify one raw inbound frame. Shared by the normal dispatch path
+## (`_handle_message`, which enqueues commands) and the exclusive-run
+## service path (`_service_handle_message`, which rejects them) — one
+## parser, two sinks, so the paths can't drift. `kind` is one of:
+## "challenge", "ack", "command", "malformed_command", "ignore".
+func _classify_message(raw: String) -> Dictionary:
+ var parsed = JSON.parse_string(raw)
+ if parsed == null:
+ push_warning("MCP: failed to parse websocket JSON")
+ return {"kind": "ignore", "parsed": null}
+ if not (parsed is Dictionary):
+ return {"kind": "ignore", "parsed": null}
+ if parsed.get("type", "") == "auth_challenge":
+ return {"kind": "challenge", "parsed": parsed}
+ if parsed.get("type", "") == "handshake_ack":
+ return {"kind": "ack", "parsed": parsed}
+ if parsed.has("request_id") and parsed.has("command"):
+ if (
+ parsed.get("request_id") is String
+ and parsed.get("command") is String
+ and (not parsed.has("params") or parsed.get("params") is Dictionary)
+ ):
+ return {"kind": "command", "parsed": parsed}
+ return {"kind": "malformed_command", "parsed": parsed}
+ return {"kind": "ignore", "parsed": parsed}
+
+
+func _handle_message(raw: String) -> void:
+ if not _handshake_complete and raw.to_utf8_buffer().size() > MAX_HANDSHAKE_FRAME_BYTES:
+ _fail_handshake(CLOSE_CODE_PROTOCOL_MISMATCH, "v4 handshake frame too large")
+ return
+ var classified := _classify_message(raw)
+ if not _handshake_complete:
+ match classified["kind"]:
+ "challenge":
+ _handle_auth_challenge(classified["parsed"])
+ "ack":
+ _handle_handshake_ack(classified["parsed"])
+ _:
+ _fail_handshake(
+ CLOSE_CODE_PROTOCOL_MISMATCH,
+ "Godot AI v4 expected auth_challenge or handshake_ack",
+ )
+ return
+ match classified["kind"]:
+ "challenge", "ack":
+ _fail_handshake(CLOSE_CODE_PROTOCOL_MISMATCH, "unexpected v4 handshake frame")
+ "command":
+ if dispatcher:
+ dispatcher.enqueue(classified["parsed"])
+ "malformed_command":
+ _reply_malformed_command(classified["parsed"])
+
+
+func _handle_handshake_ack(parsed: Dictionary) -> void:
+ var expected_keys: Array[String] = ["type", "protocol_version", "server_version"]
+ if (
+ _handshake_complete
+ or not _server_verified
+ or not _auth_response_sent
+ or not _has_exact_keys(parsed, expected_keys)
+ or parsed.get("type") != "handshake_ack"
+ or parsed.get("protocol_version") != WS_PROTOCOL_VERSION
+ or parsed.get("server_version") != _challenged_server_version
+ ):
+ _fail_handshake(CLOSE_CODE_AUTH_FAILED, "invalid v4 handshake ACK")
+ return
+ server_version = _challenged_server_version
+ _handshake_complete = true
+ _reconnect_attempt = 0
+ ## Re-emit non-default scene/play state only after the server has published
+ ## this authenticated peer and normal frames are legal.
+ _last_scene_path = ""
+ _last_play_state = false
+ _transient_diagnostic.clear()
+ if log_buffer:
+ log_buffer.log("authenticated with server")
+ connection_state_changed.emit(true)
+
+
+func _fail_handshake(code: int, reason: String) -> void:
+ if log_buffer:
+ log_buffer.log("v4 websocket handshake failed: %s" % reason)
+ _peer.close(code, reason)
+
+
+static func _has_exact_keys(value: Dictionary, expected: Array[String]) -> bool:
+ if value.size() != expected.size():
+ return false
+ for key in expected:
+ if not value.has(key):
+ return false
+ return true
+
+
+static func _is_bounded_text(value: Variant, max_length: int) -> bool:
+ return value is String and not String(value).is_empty() and String(value).length() <= max_length
+
+
+static func _is_lower_hex_64(value: Variant) -> bool:
+ if not (value is String) or String(value).length() != 64:
+ return false
+ for character in String(value):
+ if not (character >= "0" and character <= "9") and not (
+ character >= "a" and character <= "f"
+ ):
+ return false
+ return true
+
+
+static func _proof_message(domain: String, values: Array) -> PackedByteArray:
+ var message := domain.to_utf8_buffer()
+ for value in values:
+ var encoded := str(value).to_utf8_buffer()
+ message.append_array(("\n%d:" % encoded.size()).to_utf8_buffer())
+ message.append_array(encoded)
+ return message
+
+
+static func _hmac_proof(capability: String, domain: String, values: Array) -> String:
+ if not _is_lower_hex_64(capability):
+ return ""
+ var context := HMACContext.new()
+ if context.start(HashingContext.HASH_SHA256, capability.to_utf8_buffer()) != OK:
+ return ""
+ if context.update(_proof_message(domain, values)) != OK:
+ return ""
+ return context.finish().hex_encode()
+
+
+static func _server_proof(
+ capability: String,
+ client_nonce: String,
+ server_nonce: String,
+ server_version_value: String,
+) -> String:
+ return _hmac_proof(
+ capability,
+ _SERVER_PROOF_DOMAIN,
+ [WS_PROTOCOL_VERSION, client_nonce, server_nonce, server_version_value],
+ )
+
+
+static func _client_proof(
+ capability: String,
+ client_nonce: String,
+ server_nonce: String,
+ server_version_value: String,
+ session_id: String,
+ godot_version: String,
+ project_path: String,
+ plugin_version: String,
+ readiness: String,
+ editor_pid: int,
+ server_launch_mode: String,
+) -> String:
+ return _hmac_proof(
+ capability,
+ _CLIENT_PROOF_DOMAIN,
+ [
+ WS_PROTOCOL_VERSION,
+ client_nonce,
+ server_nonce,
+ server_version_value,
+ session_id,
+ godot_version,
+ project_path,
+ plugin_version,
+ readiness,
+ editor_pid,
+ server_launch_mode,
+ ],
+ )
+
+
+static func _constant_time_equal(left: String, right: String) -> bool:
+ var difference := left.length() ^ right.length()
+ var length := maxi(left.length(), right.length())
+ for index in length:
+ var left_code := left.unicode_at(index) if index < left.length() else 0
+ var right_code := right.unicode_at(index) if index < right.length() else 0
+ difference |= left_code ^ right_code
+ return difference == 0
+
+
+## Never enqueue a malformed command frame: the dispatcher's typed casts
+## would error on the queue head every tick, wedging every later command
+## behind it. Reply with an error when the request_id is usable so the
+## server's pending future resolves instead of waiting out the full
+## command timeout.
+func _reply_malformed_command(parsed: Dictionary) -> void:
+ push_warning("MCP: dropping malformed command frame (request_id/command must be String, params a Dictionary)")
+ var rid: Variant = parsed.get("request_id")
+ if rid is String and not String(rid).is_empty():
+ var response := ErrorCodes.make(
+ ErrorCodes.INVALID_PARAMS,
+ "Malformed command frame: request_id/command must be strings and params a dict"
+ )
+ response["request_id"] = rid
+ response["readiness"] = get_readiness()
+ _stamp_error_watermark(response)
+ _send_json(response)
+
+
+## Send a state event to the server (not a command response).
+func send_event(event_name: String, data: Dictionary = {}) -> bool:
+ return _send_json({"type": "event", "event": event_name, "data": data})
+
+
+## Push a command response for a request_id whose handler deferred its reply
+## (see McpDispatcher.DEFERRED_RESPONSE). `payload` must carry either a `data`
+## or `error` field in the same shape handlers normally return.
+func send_deferred_response(request_id: String, payload: Dictionary) -> void:
+ if dispatcher != null and not dispatcher.has_pending_deferred_response(request_id):
+ if log_buffer:
+ log_buffer.log("[defer] dropped late response for expired request %s" % request_id)
+ return
+ var response := payload.duplicate()
+ response["request_id"] = request_id
+ if not response.has("status"):
+ response["status"] = "ok" if payload.has("data") else "error"
+ ## Symmetric with McpDispatcher::_dispatch — stamp live readiness on the
+ ## deferred reply so the server's session cache self-heals from any
+ ## response, not just the synchronous ones. Lets `project_stop` (the
+ ## main deferred-response producer) stay correct even if its bespoke
+ ## `readiness_after` payload field were ever dropped.
+ if not response.has("readiness"):
+ response["readiness"] = get_readiness()
+ if not response.has("error_watermark"):
+ _stamp_error_watermark(response)
+ if _send_json(response) and dispatcher != null:
+ dispatcher.complete_deferred_response(request_id)
+
+
+## Result of one cooperative transport-servicing pass during an exclusive
+## synchronous run (currently only the test runner). PAUSED is an
+## invariant violation for callers, not a healthy state: a pause held
+## across servicing checkpoints would silently starve the heartbeat.
+enum ServiceStatus { SERVICED, DISCONNECTED, PAUSED, BLOCKED }
+
+## Cumulative cap on application packets processed across ONE exclusive
+## run. Counts every drained packet — valid command, malformed frame, or
+## ack-like — so no frame kind evades it. Past the cap the connection is
+## closed (1013): bounded rejects, never unbounded stale buffering. 2048
+## leaves headroom under Godot's default max_queued_packets (4096) and
+## sits above stormtest's ~1000-call default workload; tune with
+## telemetry/benchmarks if rejection traffic ever extends a checkpoint.
+const EXCLUSIVE_RUN_PACKET_CAP := 2048
+const CLOSE_CODE_EXCLUSIVE_RUN_FLOOD := 1013
+## Reject-log throttle: first few rejects verbatim, then periodic totals.
+const _SERVICE_REJECT_LOG_FIRST := 5
+const _SERVICE_REJECT_LOG_EVERY := 100
+
+## Service the WebSocket transport from inside a long synchronous handler
+## (an "exclusive run" — the test runner). The editor main thread is
+## blocked, so `_process` cannot poll; without this the server keepalive
+## (`DEFAULT_KEEPALIVE_PING_INTERVAL_SECONDS` / `_PING_TIMEOUT_SECONDS` in
+## `transport/websocket.py` — 20s interval, 60s pong deadline since #958)
+## closes the session mid-run. See docs/test-run-transport-starvation-plan.md.
+##
+## Contract — do NOT extend this method to dispatch:
+## - `WebSocketPeer.poll()` has no heartbeat-only mode; it also buffers
+## application frames. Buffering them past this call would replay them
+## STALE after their server-side futures expire (the #712 hazard), so
+## every drained command frame is REJECTED immediately with a retryable
+## EDITOR_NOT_READY / EDITOR_TEST_RUNNING error instead.
+## - Drains to quiescence: poll → drain everything available → poll again,
+## until no packets remain. A full packet queue could hide a ping deeper
+## in the TCP stream, so nothing may spill to a later checkpoint.
+## - `run_state` is caller-owned mutable state carrying the cumulative
+## packet counter under "packets_serviced" — no connection-global
+## lifecycle that could leak if the run dies.
+func service_transport_during_exclusive_run(run_state: Dictionary) -> ServiceStatus:
+ if connect_blocked:
+ return ServiceStatus.BLOCKED
+ if pause_processing:
+ return ServiceStatus.PAUSED
+ while true:
+ _peer.poll()
+ if _peer.get_ready_state() != WebSocketPeer.STATE_OPEN:
+ return ServiceStatus.DISCONNECTED
+ if _peer.get_available_packet_count() == 0:
+ return ServiceStatus.SERVICED
+ while _peer.get_available_packet_count() > 0:
+ var raw: String = _peer.get_packet().get_string_from_utf8()
+ if _service_note_packet(run_state):
+ if log_buffer:
+ log_buffer.log(
+ "[busy] packet flood during test run (%d > cap %d) — closing connection"
+ % [int(run_state.get("packets_serviced", 0)), EXCLUSIVE_RUN_PACKET_CAP]
+ )
+ _peer.close(CLOSE_CODE_EXCLUSIVE_RUN_FLOOD, "command flood during test run")
+ return ServiceStatus.DISCONNECTED
+ _service_handle_message(raw, int(run_state.get("packets_serviced", 0)))
+ ## Unreachable: every exit above returns. Keeps the typed signature happy.
+ return ServiceStatus.SERVICED
+
+
+## Shared between-phase checkpoint for exclusive runs: deadline first
+## (cheap), then transport servicing via `service_cb`. Returns "" to
+## continue, or a terminal outcome: "timeout" | "transport_lost" |
+## "paused". Static and stateless so the runner's between-test checkpoints
+## and the handler's discovery checkpoints share ONE outcome mapping —
+## PAUSED is abort-worthy (a held pause would silently skip every later
+## poll and starve the heartbeat), and DISCONNECTED/BLOCKED both mean "no
+## live transport".
+static func exclusive_run_checkpoint(
+ service_cb: Callable, deadline_ticks_ms: int, run_state: Dictionary
+) -> String:
+ if deadline_ticks_ms > 0 and Time.get_ticks_msec() >= deadline_ticks_ms:
+ return "timeout"
+ if not service_cb.is_valid():
+ return ""
+ var status: int = service_cb.call(run_state)
+ if status == ServiceStatus.SERVICED:
+ return ""
+ if status == ServiceStatus.PAUSED:
+ return "paused"
+ return "transport_lost"
+
+
+## Count one application packet against the exclusive-run cap. Counts EVERY
+## drained packet regardless of kind (valid command, malformed, ack-like)
+## so no frame kind can evade the flood limit. Returns true once the cap is
+## exceeded — the caller closes the connection.
+static func _service_note_packet(run_state: Dictionary) -> bool:
+ var count: int = int(run_state.get("packets_serviced", 0)) + 1
+ run_state["packets_serviced"] = count
+ return count > EXCLUSIVE_RUN_PACKET_CAP
+
+
+## Exclusive-run sink for `_classify_message`: acks are still processed,
+## malformed frames keep their normal reply, and valid commands are
+## rejected without touching the dispatcher.
+func _service_handle_message(raw: String, packets_serviced: int) -> void:
+ var classified := _classify_message(raw)
+ match classified["kind"]:
+ "challenge", "ack":
+ _fail_handshake(CLOSE_CODE_PROTOCOL_MISMATCH, "unexpected v4 handshake frame")
+ "command":
+ _service_reject_command(classified["parsed"], packets_serviced)
+ "malformed_command":
+ _reply_malformed_command(classified["parsed"])
+
+
+func _service_reject_command(parsed: Dictionary, packets_serviced: int) -> void:
+ _send_json(_build_service_reject(parsed))
+ if log_buffer and (
+ packets_serviced <= _SERVICE_REJECT_LOG_FIRST
+ or packets_serviced % _SERVICE_REJECT_LOG_EVERY == 0
+ ):
+ ## Ring-buffer only (echo=false): a flood must not bury the console.
+ log_buffer.log(
+ "[busy] rejected '%s' during test run (packet %d)"
+ % [parsed.get("command", ""), packets_serviced],
+ false,
+ )
+
+
+## Build the busy-reject response for a valid command frame that arrived
+## mid-run. Split from the send so tests can assert the exact wire shape.
+func _build_service_reject(parsed: Dictionary) -> Dictionary:
+ var command: String = parsed.get("command", "")
+ var response := ErrorCodes.make_not_ready(
+ ErrorCodes.SUB_EDITOR_TEST_RUNNING,
+ (
+ "A test run is in progress on this editor — '%s' was not executed. "
+ + "Retry when the run completes, or fetch results afterward with "
+ + "test_manage(op=\"results_get\")."
+ ) % command,
+ true,
+ )
+ response["request_id"] = parsed.get("request_id", "")
+ response["readiness"] = get_readiness()
+ _stamp_error_watermark(response)
+ return response
+
+
+func _hook_editor_signals() -> void:
+ # Scene change: poll in _process since there's no direct signal for scene switch
+ # Play state: EditorInterface signals
+ EditorInterface.get_editor_settings() # ensure interface is ready
+ _last_scene_path = _get_current_scene_path()
+ _last_play_state = EditorInterface.is_playing_scene()
+ _last_play_state_for_run = _last_play_state
+
+
+var _last_scene_path := ""
+var _last_play_state := false
+## Separate edge tracker for game-run bookkeeping: _last_play_state only
+## advances when the play_state_changed event sends successfully, but ending
+## run tracking must not depend on the websocket being up.
+var _last_play_state_for_run := false
+var _last_readiness := ""
+
+
+## Compute current editor readiness from live Godot state.
+static func get_readiness() -> String:
+ if EditorInterface.get_resource_filesystem().is_scanning():
+ return "importing"
+ if EditorInterface.is_playing_scene():
+ return "playing"
+ if EditorInterface.get_edited_scene_root() == null:
+ return "no_scene"
+ return "ready"
+
+
+## Check for scene/play state changes each frame (lightweight polling).
+func _check_state_changes() -> void:
+ var scene_path := _get_current_scene_path()
+ if scene_path != _last_scene_path:
+ if send_event("scene_changed", {"current_scene": scene_path}):
+ _last_scene_path = scene_path
+ if log_buffer:
+ log_buffer.log("[event] scene_changed -> %s" % scene_path)
+
+ var playing := EditorInterface.is_playing_scene()
+ if playing != _last_play_state:
+ var state := "playing" if playing else "stopped"
+ if send_event("play_state_changed", {"play_state": state}):
+ _last_play_state = playing
+ if log_buffer:
+ log_buffer.log("[event] play_state_changed -> %s" % state)
+
+ var readiness := get_readiness()
+ if readiness != _last_readiness:
+ if send_event("readiness_changed", {"readiness": readiness}):
+ _last_readiness = readiness
+ if log_buffer:
+ ## echo=false: readiness flips on every filesystem scan
+ ## (each import cycles importing -> ready), so echoing to
+ ## console spams every install during normal editing (#626).
+ ## The line stays in the ring for the dock's log panel.
+ log_buffer.log("[event] readiness -> %s" % readiness, false)
+
+
+## Stopped↔playing edge handler for game-run bookkeeping. Handles both the
+## stopped→playing edge (adopting editor-started runs) and the playing→stopped
+## edge (ending runs). Runs every process tick (any socket state) so a
+## self-quit game's run ends even while the transport is down or reconnecting.
+func _check_game_run_play_state(playing: bool) -> void:
+ if playing == _last_play_state_for_run:
+ return
+ if debugger_plugin != null:
+ if playing:
+ ## #891: adopt a play the user started from the editor. The
+ ## debugger session's own adoption in `_setup_session` only fires
+ ## when `is_playing_scene()` is already true when the session
+ ## attaches, which an F5/F6 launch routinely misses; this edge is
+ ## the other half of that race. Polled every tick regardless of
+ ## socket state, same as the stop edge below.
+ debugger_plugin.note_editor_play_started()
+ else:
+ debugger_plugin.note_editor_play_stopped()
+ _last_play_state_for_run = playing
+
+
+func _get_current_scene_path() -> String:
+ var scene_root := EditorInterface.get_edited_scene_root()
+ return scene_root.scene_file_path if scene_root else ""
+
+
+func _send_json(data: Dictionary, allow_pre_auth := false) -> bool:
+ if not _connected or (not _handshake_complete and not allow_pre_auth):
+ return false
+ var text := JSON.stringify(data)
+ if allow_pre_auth and text.to_utf8_buffer().size() > MAX_HANDSHAKE_FRAME_BYTES:
+ return false
+ var buffered_bytes := _peer.get_current_outbound_buffered_amount()
+ ## `send_text` encodes the string to UTF-8 internally, so an exact
+ ## `to_utf8_buffer().size()` here would encode every payload twice. Almost
+ ## all payloads sit far below the limit, so gate on a cheap upper bound
+ ## (<= 4 UTF-8 bytes per code point) and only pay for the exact count when
+ ## the estimate lands near the backpressure ceiling.
+ if _might_exceed_outbound_backpressure(buffered_bytes, text.length()):
+ var message_bytes := text.to_utf8_buffer().size()
+ if _would_exceed_outbound_backpressure(buffered_bytes, message_bytes):
+ return _handle_outbound_backpressure(data, buffered_bytes, message_bytes)
+ var err := _peer.send_text(text)
+ if err != OK:
+ if log_buffer:
+ log_buffer.log("[send] websocket send_text failed: %s" % error_string(err))
+ return false
+ return true
+
+
+static func _would_exceed_outbound_backpressure(buffered_bytes: int, message_bytes: int) -> bool:
+ return buffered_bytes + message_bytes > OUTBOUND_BUFFER_LIMIT_BYTES
+
+
+## Cheap pre-check on the code-point count: UTF-8 uses at most 4 bytes per code
+## point, so `char_count * 4` upper-bounds the encoded size. When even that
+## upper bound fits under the ceiling the payload is definitely safe and we can
+## skip the exact encode; only a positive here warrants `to_utf8_buffer()`.
+static func _might_exceed_outbound_backpressure(buffered_bytes: int, char_count: int) -> bool:
+ return buffered_bytes + char_count * 4 > OUTBOUND_BUFFER_LIMIT_BYTES
+
+
+func _handle_outbound_backpressure(
+ data: Dictionary,
+ buffered_bytes: int,
+ message_bytes: int,
+) -> bool:
+ var request_id: String = data.get("request_id", "")
+ if request_id.is_empty():
+ if log_buffer:
+ log_buffer.log(
+ "[send] requestless payload blocked by websocket backpressure "
+ + "(buffered=%d, message=%d, limit=%d)"
+ % [buffered_bytes, message_bytes, OUTBOUND_BUFFER_LIMIT_BYTES]
+ )
+ return false
+
+ var err_response := _make_backpressure_error(request_id, buffered_bytes, message_bytes)
+ _stamp_error_watermark(err_response)
+ var err_text := JSON.stringify(err_response)
+ var err_bytes := err_text.to_utf8_buffer().size()
+ if _would_exceed_outbound_backpressure(buffered_bytes, err_bytes):
+ if log_buffer:
+ log_buffer.log(
+ "[send] dropped response for request %s due to websocket backpressure "
+ + "(buffered=%d, message=%d, limit=%d)"
+ % [request_id, buffered_bytes, message_bytes, OUTBOUND_BUFFER_LIMIT_BYTES]
+ )
+ return false
+
+ var send_err := _peer.send_text(err_text)
+ if send_err != OK:
+ if log_buffer:
+ log_buffer.log("[send] websocket backpressure error send failed: %s" % error_string(send_err))
+ return false
+ if log_buffer:
+ log_buffer.log(
+ "[send] %s -> error: outbound websocket backpressure"
+ % data.get("command", "response")
+ )
+ return true
+
+
+static func _make_backpressure_error(
+ request_id: String,
+ buffered_bytes: int,
+ message_bytes: int,
+) -> Dictionary:
+ return {
+ "request_id": request_id,
+ "status": "error",
+ "data": {},
+ ## Stamp readiness on the backpressure error too — the server's
+ ## per-response self-heal applies to every response shape the
+ ## plugin emits, and the next legitimate reply may already be
+ ## queued behind this one.
+ "readiness": get_readiness(),
+ "error": {
+ "code": ErrorCodes.INTERNAL_ERROR,
+ "message": (
+ "Outbound WebSocket buffer is full; dropped response before queueing "
+ + "more data. Retry with a smaller payload (for screenshots, lower "
+ + "max_resolution or set include_image=false)."
+ ),
+ "data": {
+ "buffered_bytes": buffered_bytes,
+ "message_bytes": message_bytes,
+ "limit_bytes": OUTBOUND_BUFFER_LIMIT_BYTES,
+ },
+ },
+ }
+
+
+func _stamp_error_watermark(response: Dictionary) -> void:
+ McpSurfacedErrorTracker.stamp_watermark(response, surfaced_error_tracker)
+
+
+## Build a human-readable session ID of form "@<16hex>" from the project path.
+## The slug is derived from the project directory name so agents can recognize
+## which editor they're targeting; the hex suffix disambiguates same-project twins.
+static func _make_session_id(project_path: String) -> String:
+ var base := project_path.rstrip("/\\").get_file()
+ if base == "":
+ base = "project"
+ var slug := _slugify(base)
+ if slug == "":
+ slug = "project"
+ var random_bytes := Crypto.new().generate_random_bytes(8)
+ if random_bytes.size() != 8:
+ return ""
+ var suffix := random_bytes.hex_encode()
+ return "%s@%s" % [slug, suffix]
+
+
+static func _slugify(s: String) -> String:
+ var out := ""
+ var prev_dash := false
+ for c in s.to_lower():
+ if (c >= "a" and c <= "z") or (c >= "0" and c <= "9"):
+ out += c
+ prev_dash = false
+ elif not prev_dash and out != "":
+ out += "-"
+ prev_dash = true
+ return out.trim_suffix("-")
+
+
+static func _should_regenerate_session_id(
+ close_code: int,
+ server_verified: bool,
+ handshake_complete: bool,
+) -> bool:
+ ## A duplicate rejection arrives after the server proved possession of the
+ ## capability but before its final ACK. Ignore unauthenticated close codes so
+ ## an unrelated listener cannot churn the editor identity.
+ return (
+ close_code == CLOSE_CODE_DUPLICATE_SESSION
+ and server_verified
+ and not handshake_complete
+ )
diff --git a/addons/godot_ai/connection.gd.uid b/addons/godot_ai/connection.gd.uid
new file mode 100644
index 0000000..ff78405
--- /dev/null
+++ b/addons/godot_ai/connection.gd.uid
@@ -0,0 +1 @@
+uid://bmnk8rsotiks2
diff --git a/addons/godot_ai/custom_tools/custom_tool_wrapper.gd b/addons/godot_ai/custom_tools/custom_tool_wrapper.gd
new file mode 100644
index 0000000..b4e13c7
--- /dev/null
+++ b/addons/godot_ai/custom_tools/custom_tool_wrapper.gd
@@ -0,0 +1,80 @@
+@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
+
+
+func quiesce_for_script_swap() -> Dictionary:
+ if _handler_instance == null:
+ return {"ok": true}
+ if not _handler_instance.has_method("quiesce_for_script_swap"):
+ return {
+ "ok": false,
+ "error": "Custom tool '%s' must provide quiesce_for_script_swap() before hot updates." % _spec.name,
+ }
+ var result: Variant = _handler_instance.call("quiesce_for_script_swap")
+ if not result is Dictionary or result.get("ok") != true:
+ return {"ok": false, "error": "Custom tool '%s' still owns active work." % _spec.name}
+ return {"ok": true}
+
+## 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
diff --git a/addons/godot_ai/custom_tools/custom_tool_wrapper.gd.uid b/addons/godot_ai/custom_tools/custom_tool_wrapper.gd.uid
new file mode 100644
index 0000000..ff02cb9
--- /dev/null
+++ b/addons/godot_ai/custom_tools/custom_tool_wrapper.gd.uid
@@ -0,0 +1 @@
+uid://b68uel6lsb7jr
diff --git a/addons/godot_ai/custom_tools/mcp_call_context.gd b/addons/godot_ai/custom_tools/mcp_call_context.gd
new file mode 100644
index 0000000..6e40d86
--- /dev/null
+++ b/addons/godot_ai/custom_tools/mcp_call_context.gd
@@ -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)
diff --git a/addons/godot_ai/custom_tools/mcp_call_context.gd.uid b/addons/godot_ai/custom_tools/mcp_call_context.gd.uid
new file mode 100644
index 0000000..e37765b
--- /dev/null
+++ b/addons/godot_ai/custom_tools/mcp_call_context.gd.uid
@@ -0,0 +1 @@
+uid://bdgqe4gyian2o
diff --git a/addons/godot_ai/custom_tools/mcp_custom_tool_spec.gd b/addons/godot_ai/custom_tools/mcp_custom_tool_spec.gd
new file mode 100644
index 0000000..8c5a551
--- /dev/null
+++ b/addons/godot_ai/custom_tools/mcp_custom_tool_spec.gd
@@ -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_") 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:"]
+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
diff --git a/addons/godot_ai/custom_tools/mcp_custom_tool_spec.gd.uid b/addons/godot_ai/custom_tools/mcp_custom_tool_spec.gd.uid
new file mode 100644
index 0000000..42fe408
--- /dev/null
+++ b/addons/godot_ai/custom_tools/mcp_custom_tool_spec.gd.uid
@@ -0,0 +1 @@
+uid://h7k8j5tpscwy
diff --git a/addons/godot_ai/custom_tools/mcp_service_locator.gd b/addons/godot_ai/custom_tools/mcp_service_locator.gd
new file mode 100644
index 0000000..4e50ac5
--- /dev/null
+++ b/addons/godot_ai/custom_tools/mcp_service_locator.gd
@@ -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
diff --git a/addons/godot_ai/custom_tools/mcp_service_locator.gd.uid b/addons/godot_ai/custom_tools/mcp_service_locator.gd.uid
new file mode 100644
index 0000000..1815007
--- /dev/null
+++ b/addons/godot_ai/custom_tools/mcp_service_locator.gd.uid
@@ -0,0 +1 @@
+uid://dfvphmvo7en3i
diff --git a/addons/godot_ai/custom_tools/mcp_tool_registry.gd b/addons/godot_ai/custom_tools/mcp_tool_registry.gd
new file mode 100644
index 0000000..407d405
--- /dev/null
+++ b/addons/godot_ai/custom_tools/mcp_tool_registry.gd
@@ -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:" — 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:"; 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
diff --git a/addons/godot_ai/custom_tools/mcp_tool_registry.gd.uid b/addons/godot_ai/custom_tools/mcp_tool_registry.gd.uid
new file mode 100644
index 0000000..23b9822
--- /dev/null
+++ b/addons/godot_ai/custom_tools/mcp_tool_registry.gd.uid
@@ -0,0 +1 @@
+uid://bq2btxtm8flhb
diff --git a/addons/godot_ai/debugger/mcp_debugger_plugin.gd b/addons/godot_ai/debugger/mcp_debugger_plugin.gd
new file mode 100644
index 0000000..0bf9320
--- /dev/null
+++ b/addons/godot_ai/debugger/mcp_debugger_plugin.gd
@@ -0,0 +1,2088 @@
+@tool
+class_name McpDebuggerPlugin
+extends EditorDebuggerPlugin
+
+const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
+const ScriptWork := preload("res://addons/godot_ai/utils/script_work.gd")
+
+## Editor-side half of the game-process capture bridge.
+##
+## The game-side counterpart (`plugin/addons/godot_ai/runtime/game_helper.gd`,
+## registered as autoload `_mcp_game_helper`) listens on EngineDebugger's
+## message channel. This plugin sends "mcp:take_screenshot" requests and
+## routes the replies back through the WebSocket McpConnection using the
+## request_id the MCP dispatcher threaded through params.
+##
+## Why this exists: the game always runs as a separate OS process. Even
+## "Embed Game Mode" on Windows/Linux (and macOS 4.5+) just reparents the
+## game's window into the editor — the game's framebuffer is never reachable
+## from the editor's Viewport. The debugger channel is the engine's own
+## supported IPC and works identically regardless of embed mode.
+
+const CAPTURE_PREFIX := "mcp"
+## CI runners under xvfb can be slow to spin up the game subprocess and
+## register the autoload's capture. 8s keeps the message responsive for
+## interactive users while still covering slow-CI startup.
+const DEFAULT_TIMEOUT_SEC := 8.0
+## How long to wait for the game-side autoload to beacon mcp:hello
+## before sending the screenshot request. Godot's debugger drops
+## messages whose prefix has no registered capture, so sending
+## take_screenshot before the game registers its "mcp" capture is a
+## silent black hole. On CI the game subprocess has been observed
+## taking ~15s to boot + register.
+const GAME_READY_WAIT_SEC := 20.0
+## #500: how long to wait for the game-side autoload to beacon mcp:hello before
+## issuing a game_eval. This is deliberately MUCH shorter than the 20s
+## screenshot wait above: the eval path's total editor-side budget is this wait
+## plus the 250ms liveness probe and 10s eval backstop
+## (request_game_eval's timeout_sec), and that total MUST stay below the 15s
+## game_eval timeout enforced at two layers: the Python server's send_command
+## budget (src/godot_ai/handlers/editor.py::game_eval) and this plugin's own
+## deferred budget (dispatcher.gd's 15000ms game_eval entry, editor/plugin-side
+## — not server-side). Either firing produces the opaque tail.
+## With the 20s screenshot wait, a not-yet-ready game made the editor poll past
+## the 15s deadline, so the server gave up first with an opaque
+## ~15s TimeoutError instead of the actionable "Is the game actually running?"
+## error below ever reaching the client (#500's residual TimeoutError bucket).
+## 3s wait + 0.25s probe + 10s backstop = 13.25s, comfortably under the 15s
+## server timeout, so the actionable error always wins. A game launched moments
+## before the eval still has the 3s grace to register; if it needs longer, the
+## user gets a fast, clear "is it running?" rather than a 15s hang.
+const EVAL_READY_WAIT_SEC := 3.0
+## #859: once the helper has registered, confirm its game loop is still
+## advancing before dispatching an eval. The helper replies synchronously from
+## the debugger capture, so 250ms is ample while keeping not-ready failures
+## below the telemetry target of 0.5s.
+const EVAL_LIVENESS_WAIT_SEC := 0.25
+## #490: how long to wait for the game's mcp:eval_compiled beacon before
+## concluding the eval source failed to compile. A parse error aborts the
+## game-side handler before it can reply, so without this we'd wait the
+## full eval timeout for a syntax mistake. reload() of valid source is
+## sub-millisecond, so 3s is comfortably clear of false positives.
+const EVAL_COMPILE_GRACE_SEC := 3.0
+## #490: once an eval compiles, the editor polls the game every this many
+## seconds with mcp:eval_check. A backgrounded play-in-editor game has a
+## frozen idle loop (no _process / SceneTreeTimer ticks) so it can't
+## self-report a runtime error that aborted the eval — but its debugger
+## capture callback still answers a probe. The editor's own loop keeps
+## ticking, so it drives the poll. 0.35s keeps detection well under a second
+## without flooding the channel; most evals reply before the first probe.
+const EVAL_PROBE_INTERVAL_SEC := 0.35
+const GAME_DEBUG_CONTROL_TIMEOUT_SEC := 5.0
+const EMBED_SUSPEND_TOGGLE := 0
+const EMBED_NEXT_FRAME := 1
+
+const VisionRoutingScript := preload("res://addons/godot_ai/vision_routing.gd")
+
+var _log_buffer: McpLogBuffer
+var _game_log_buffer: McpGameLogBuffer
+var _editor_log_buffer: McpEditorLogBuffer
+var _surfaced_error_tracker
+var vision_routing: VisionRoutingScript = null
+
+## Pending request_id -> phase-specific data. Every eval phase carries kind
+## and connection; timer-backed phases also retain their bound timeout lambda
+## so `_clear_pending` can disconnect it on success/error. Otherwise the
+## SceneTreeTimer pins the captured request_id until its timeout elapses.
+var _pending: Dictionary = {}
+
+## Testing seam for the readiness wait below. Production leaves this invalid
+## and awaits SceneTree.process_frame; editor tests inject a synchronous
+## callable because their runner does not pump frames while a test is running.
+var _eval_ready_frame_waiter: Callable = Callable()
+
+## Flipped true when the game-side autoload sends its "mcp:hello" boot
+## beacon for the current project_run. Reset as soon as a new run is
+## requested, before Godot has attached the fresh debugger session, so
+## editor_state cannot leak readiness from the previous game process.
+var _game_ready := false
+var _game_run_token := 0
+var _ready_run_token := -1
+var _game_session_id := -1
+var _game_run_active := false
+## Debugger session id of an `mcp:hello` that arrived before the run was
+## adopted, or -1 when none is held (#891). A manually started play can beat
+## its own adoption: the helper's boot beacon is a one-shot, so discarding it
+## left the runtime bridge dead for the whole run with no retry. Buffer it and
+## replay on adoption instead. Cleared whenever a run ends, so a beacon can
+## never carry across into a later run.
+var _pending_hello_session_id := -1
+## True when holding a beacon already rotated the game-log run id, so the
+## adoption that consumes it must not rotate again (#891 review).
+var _pre_adoption_log_rotated := false
+var _manual_run_armed := false
+var _game_run_started_msec := 0
+var _game_run_started_editor_cursor := 0
+var _game_run_started_debugger_cursor := 0
+var _game_helper_expected := true
+
+## #645: a GDScript parse error hit while an editor-launched game boots calls
+## GDScriptLanguage::debug_break_parse — the game parks in a remote-debugger
+## break BEFORE the helper's mcp:hello and before any record reaches the
+## Errors tab, the editor Logger, or the game log. The only editor-side traces
+## are the debugger break signals; the stack frames land in the Stack Trace
+## panel a few frames later. Track the break here so game_status can report
+## status="break" and a synthesized error record can name the failure.
+var _break_active := false
+var _break_can_debug := false
+var _break_reason := ""
+var _break_pre_live := false
+var _break_run_token := -1
+var _break_record_synthesized := false
+
+## #645: how long after the break signal to scrape the Stack Trace panel for
+## frames. The editor requests the stack dump from the game separately, so the
+## panel is empty at signal time; ~0.5s has it populated. The late tick
+## synthesizes with whatever is available so a scrape failure still yields a
+## record carrying the break reason.
+const BREAK_FRAME_SCRAPE_DELAYS_SEC: Array[float] = [0.5, 2.0]
+
+
+
+func _init(log_buffer: McpLogBuffer = null, game_log_buffer: McpGameLogBuffer = null, editor_log_buffer: McpEditorLogBuffer = null, surfaced_error_tracker = null, vision_routing: VisionRoutingScript = null) -> void:
+ _log_buffer = log_buffer
+ _game_log_buffer = game_log_buffer
+ _editor_log_buffer = editor_log_buffer
+ _surfaced_error_tracker = surfaced_error_tracker
+ self.vision_routing = vision_routing
+
+
+func quiesce_for_script_swap() -> Dictionary:
+ if not _pending.is_empty():
+ return {"ok": false, "error": "Wait for pending game debugger requests before updating."}
+ return ScriptWork.quiescence()
+
+
+func _has_capture(prefix: String) -> bool:
+ return prefix == CAPTURE_PREFIX
+
+
+## Fires when a debugger session attaches — once for the editor's own
+## self-session at startup, and again each time the user hits Play and a
+## new game subprocess connects. Reset _game_ready so the next capture
+## request waits for the (new) game's mcp:hello beacon before sending,
+## avoiding stale-flag timeouts across Play→Stop→Play cycles.
+##
+## Do NOT log here: add_debugger_plugin() triggers this virtual before
+## plugin.gd's _enter_tree logs "plugin loaded", and ci-reload-test
+## asserts "plugin loaded" is the first line after a plugin reload.
+func _setup_session(session_id: int) -> void:
+ _connect_session_stopped(session_id)
+ _connect_session_break_signals(session_id)
+ if EditorInterface.is_playing_scene() and not _game_run_active:
+ _begin_game_run_tracking(_editor_log_cursor(), true, true, true, true, true)
+ else:
+ _game_ready = false
+ _ready_run_token = -1
+ _game_session_id = session_id
+
+
+func begin_game_run(editor_log_cursor: int = 0, helper_expected: bool = true) -> void:
+ _begin_game_run_tracking(editor_log_cursor, helper_expected, true, true)
+
+
+func _begin_game_run_tracking(
+ editor_log_cursor: int = 0,
+ helper_expected: bool = true,
+ rotate_game_log: bool = true,
+ sticky_debugger_scan: bool = true,
+ quiet: bool = false,
+ manual_armed: bool = false,
+ keep_session_id: int = -1,
+) -> void:
+ _game_run_token += 1
+ _game_run_active = true
+ _manual_run_armed = manual_armed
+ _game_ready = false
+ _ready_run_token = -1
+ ## Adopting a run whose game is ALREADY attached keeps that session id
+ ## (#891 review): clearing it would leave every staleness guard comparing
+ ## against -1, so a foreign session's `stopped` could end this live run —
+ ## `_on_debugger_session_stopped` lets a manual-armed run end on any
+ ## session while the id is unknown. Spawn-first callers pass -1 and keep
+ ## the historical clear, since their game has not attached yet.
+ _game_session_id = keep_session_id
+ clear_debug_break()
+ _game_run_started_msec = Time.get_ticks_msec()
+ _game_run_started_editor_cursor = maxi(0, editor_log_cursor)
+ if _surfaced_error_tracker != null:
+ _surfaced_error_tracker.note_game_run_started(sticky_debugger_scan)
+ _game_run_started_debugger_cursor = _surfaced_error_tracker.debugger_promoted_total()
+ else:
+ _game_run_started_debugger_cursor = 0
+ _game_helper_expected = helper_expected
+ var run_id := ""
+ if _game_log_buffer and rotate_game_log:
+ if _pre_adoption_log_rotated:
+ ## Already rotated when this game's beacon was held, so its boot
+ ## output is tagged with the run id this adoption is about to
+ ## announce. Rotating again here would orphan those lines under a
+ ## superseded id and `logs_read(source="game")` — which returns the
+ ## CURRENT run only — would report an empty log for a game whose
+ ## only output happens in `_ready()` (#891 review).
+ run_id = _game_log_buffer.run_id()
+ else:
+ run_id = _game_log_buffer.clear_for_new_run()
+ _pre_adoption_log_rotated = false
+ if _log_buffer and not quiet:
+ var log_text := "[debug] game capture pending run token %d" % _game_run_token
+ if not run_id.is_empty():
+ log_text += " (run %s)" % run_id
+ _log_buffer.log(log_text)
+ ## #891: a beacon that beat this adoption is held rather than dropped —
+ ## consume it now so the bridge comes up without waiting for a second
+ ## hello the game will never send.
+ _replay_pending_hello()
+
+
+func _editor_log_cursor() -> int:
+ return _editor_log_buffer.appended_total() if _editor_log_buffer != null else 0
+
+
+func end_game_run() -> void:
+ _fail_pending_evals_not_ready(
+ "The game run ended before game_eval completed — the game stopped, crashed, or is restarting. Confirm it is running and retry."
+ )
+ _fail_pending_game_debug_controls_not_ready(
+ "The game run ended before runtime control completed — confirm it is running and retry."
+ )
+ ## Never carry a held beacon across a run boundary (#891).
+ _pending_hello_session_id = -1
+ _pre_adoption_log_rotated = false
+ _game_run_active = false
+ _manual_run_armed = false
+ _game_ready = false
+ _ready_run_token = -1
+ _game_session_id = -1
+ clear_debug_break()
+ if _surfaced_error_tracker != null:
+ _surfaced_error_tracker.note_game_run_stopped()
+
+
+## Editor play-state edge, stopped -> playing (#891). `_setup_session` adopts a
+## manually started play only when `EditorInterface.is_playing_scene()` is
+## ALREADY true at the instant Godot attaches the debugger session — an F5/F6
+## launch routinely loses that race, and the run was then never adopted at all,
+## leaving game_eval, game logs and runtime inspection silently dead for its
+## whole lifetime. This closes the race from the other side: whichever of the
+## two edges lands second performs the adoption. No-op for MCP-started runs,
+## which `project_run` already adopted via `begin_game_run`.
+func note_editor_play_started() -> void:
+ if _game_run_active:
+ return
+ ## State belonging to the game that is already talking must survive the
+ ## adoption that is about to reset run bookkeeping (#891 review).
+ var attached_session := _game_session_id
+ var had_break := _break_active
+ var break_can_debug := _break_can_debug
+ var break_reason := _break_reason
+ _begin_game_run_tracking(
+ _editor_log_cursor(), true, true, true, true, true, attached_session
+ )
+ if had_break:
+ ## A boot parse error can break the game before the play-state edge
+ ## lands. `_begin_game_run_tracking` clears break state, which would
+ ## drop the actionable #645 diagnosis and leave game_status reporting
+ ## "not_live" instead of "break". Re-record it against the new run so
+ ## `_break_pre_live` and the synthetic-error scan are computed for THIS
+ ## run — the beacon will never arrive for a game parked at a break.
+ note_debug_break(break_can_debug, break_reason)
+
+
+## Authoritative fallback for runs whose debugger `stopped` signal never
+## fired or was never connected: the editor's play state falling to stopped
+## means the game process is gone. A game that exits on its own
+## (get_tree().quit(), crash) has no MCP stop op to run the bookkeeping, and
+## without this game_status stayed "live" until the next run (#642 smoke).
+## Called on the playing→stopped edge only, so the pre-play launch window
+## (run tracking begun, is_playing_scene() not yet true) is never clipped.
+func note_editor_play_stopped() -> void:
+ if not _game_run_active:
+ ## A beacon held for an adoption that never happened must not survive
+ ## into the next run (#891).
+ _pending_hello_session_id = -1
+ _pre_adoption_log_rotated = false
+ return
+ end_game_run()
+
+
+## Apply the game helper's boot beacon: the game has registered its "mcp"
+## capture and is safe to send take_screenshot / eval requests to — before
+## this, Godot's debugger drops our messages silently. Shared by the live
+## `mcp:hello` branch in `_capture` and by the buffered replay in
+## `_replay_pending_hello`, so a held beacon produces exactly the same state
+## and side effects as one that arrived after adoption (#891).
+func _accept_game_hello(session_id: int) -> void:
+ _pending_hello_session_id = -1
+ ## Bind the run to the game that actually announced itself. Adoption
+ ## clears `_game_session_id`, so without this a replayed beacon would
+ ## leave the run bound to nothing and the staleness checks toothless
+ ## until Godot's next `_setup_session` (#891 review).
+ if session_id != -1:
+ _game_session_id = session_id
+ _game_ready = true
+ _ready_run_token = _game_run_token
+ ## #641: boot-time parse errors race the hello beacon — both ride
+ ## the same debugger channel, and the editor inserts Errors-tab
+ ## rows with a per-frame throttle, so rows can land moments after
+ ## the run is declared live. Arm forced scans so those rows get
+ ## promoted into the watermark even if no tool call follows.
+ if _surfaced_error_tracker != null:
+ _surfaced_error_tracker.schedule_deferred_scans()
+ if _log_buffer:
+ if _game_log_buffer:
+ _log_buffer.log("[debug] <- mcp:hello from game_helper (run %s)" % _game_log_buffer.run_id())
+ else:
+ _log_buffer.log("[debug] <- mcp:hello from game_helper")
+
+
+## Consume a beacon that arrived before this run was adopted. Called at the end
+## of run-tracking setup; no-op when nothing is held (#891).
+func _replay_pending_hello() -> void:
+ if _pending_hello_session_id == -1:
+ return
+ var held := _pending_hello_session_id
+ if _game_session_id != -1 and held != _game_session_id:
+ ## Belongs to a different debugger session than the one this run is
+ ## bound to — drop it rather than declare the wrong game live.
+ _pending_hello_session_id = -1
+ if _log_buffer:
+ _log_buffer.log("[debug] dropped held mcp:hello from debugger session %d (current %d)" % [held, _game_session_id])
+ return
+ if _log_buffer:
+ _log_buffer.log("[debug] replaying held mcp:hello from debugger session %d" % held)
+ _accept_game_hello(held)
+
+
+func _connect_session_stopped(session_id: int) -> void:
+ var session = get_session(session_id)
+ if session == null:
+ return
+ var stopped := Callable(self, "_on_debugger_session_stopped").bind(session_id)
+ if not session.stopped.is_connected(stopped):
+ session.stopped.connect(stopped)
+
+
+func _on_debugger_session_stopped(session_id: int) -> void:
+ if _game_session_id != -1 and session_id != _game_session_id:
+ return
+ ## MCP-started runs normally end via project_manage(op="stop"), but a game
+ ## that exits on its own (get_tree().quit(), crash) emits only this signal.
+ ## Without ending the run here, game_status stays "live" until the next
+ ## run's bookkeeping rewrites it (#642 live smoke). Before the game session
+ ## attaches (_game_session_id == -1) only manual runs may end on this
+ ## signal — a foreign session's stop must not cancel a launching MCP run.
+ if not _manual_run_armed and _game_session_id == -1:
+ return
+ end_game_run()
+
+
+## #859: a closed debugger session is authoritative. Do not leave evals on
+## their 10s backstop after the game process has already gone away. Other
+## pending request kinds keep their existing ownership and timeout behavior.
+func _fail_pending_evals_not_ready(message: String) -> void:
+ for request_id in _pending.keys():
+ var pending: Dictionary = _pending.get(request_id, {})
+ if str(pending.get("kind", "")) not in [
+ "eval_ready_wait", "eval_liveness", "eval"
+ ]:
+ continue
+ var connection: McpConnection = pending.get("connection")
+ _clear_pending(str(request_id))
+ if connection != null and is_instance_valid(connection):
+ _send_error(connection, str(request_id), ErrorCodes.EVAL_GAME_NOT_READY, message)
+
+
+func _is_current_game_run(run_token: int) -> bool:
+ return _game_run_active and _game_run_token == run_token
+
+
+func _fail_eval_not_ready(request_id: String, message: String) -> void:
+ var pending: Dictionary = _pending.get(request_id, {})
+ var connection: McpConnection = pending.get("connection")
+ _clear_pending(request_id)
+ if connection != null and is_instance_valid(connection):
+ _send_error(connection, request_id, ErrorCodes.EVAL_GAME_NOT_READY, message)
+
+
+## --- #645: boot-time debugger breaks ---------------------------------------
+
+func _connect_session_break_signals(session_id: int) -> void:
+ var session = get_session(session_id)
+ if session != null:
+ var breaked_cb := Callable(self, "_on_debugger_session_breaked").bind(session_id)
+ if not session.breaked.is_connected(breaked_cb):
+ session.breaked.connect(breaked_cb)
+ var continued_cb := Callable(self, "_on_debugger_session_continued").bind(session_id)
+ if not session.continued.is_connected(continued_cb):
+ session.continued.connect(continued_cb)
+ _connect_script_debugger_breaked()
+
+
+## The session-level `breaked` signal carries only can_debug; the underlying
+## ScriptEditorDebugger's own `breaked` also carries the human-readable break
+## reason ("Parser Error: ..."), which is otherwise visible only in the
+## Debugger UI. EditorDebuggerSession does not expose its debugger node, so
+## locate ScriptEditorDebugger instances by walking the editor UI — the same
+## approach as the tracker's Errors-tab scrape.
+func _connect_script_debugger_breaked() -> void:
+ var base := EditorInterface.get_base_control()
+ if base == null:
+ return
+ var debuggers: Array[Node] = []
+ _collect_nodes_of_class(base, "ScriptEditorDebugger", debuggers)
+ for dbg in debuggers:
+ if not dbg.has_signal("breaked"):
+ continue
+ var cb := Callable(self, "_on_script_debugger_breaked")
+ if not dbg.is_connected("breaked", cb):
+ dbg.connect("breaked", cb)
+
+
+static func _collect_nodes_of_class(node: Node, klass: String, out: Array[Node]) -> void:
+ if node.get_class() == klass:
+ out.append(node)
+ for child in node.get_children():
+ _collect_nodes_of_class(child, klass, out)
+
+
+func _on_debugger_session_breaked(can_debug: bool, session_id: int) -> void:
+ if _game_session_id != -1 and session_id != _game_session_id:
+ return
+ note_debug_break(can_debug, "")
+
+
+func _on_debugger_session_continued(session_id: int) -> void:
+ if _game_session_id != -1 and session_id != _game_session_id:
+ return
+ clear_debug_break()
+
+
+## reallydid=false is Godot's "left the break" notification (debug_exit).
+func _on_script_debugger_breaked(reallydid: bool, can_debug: bool, reason: String, _has_stackdump: bool) -> void:
+ if not reallydid:
+ clear_debug_break()
+ return
+ note_debug_break(can_debug, reason)
+
+
+## Record that the game process is parked in a remote-debugger break. Fires
+## once per break from the session signal (no reason text) and again moments
+## later from the ScriptEditorDebugger signal (with reason) — the notices
+## merge into one break. Public so tests can drive break state directly.
+func note_debug_break(can_debug: bool, reason: String) -> void:
+ var first_notice := not _break_active
+ _break_active = true
+ _break_can_debug = can_debug
+ if not reason.is_empty():
+ _break_reason = reason
+ if not first_notice:
+ return
+ _break_run_token = _game_run_token
+ _break_pre_live = _game_run_active and not is_game_capture_ready()
+ _break_record_synthesized = false
+ if _log_buffer:
+ _log_buffer.log("[debug] debugger break (pre_live=%s can_debug=%s)" % [str(_break_pre_live), str(can_debug)])
+ if _break_pre_live:
+ _schedule_break_record_synthesis()
+
+
+func clear_debug_break() -> void:
+ _break_active = false
+ _break_can_debug = false
+ _break_reason = ""
+ _break_pre_live = false
+ _break_run_token = -1
+ _break_record_synthesized = false
+
+
+## Stack frames land in the Stack Trace panel a few frames after the break
+## signal (the editor requests the stack dump separately), so the record is
+## synthesized on short timers rather than at signal time.
+func _schedule_break_record_synthesis() -> void:
+ var tree := Engine.get_main_loop() as SceneTree
+ if tree == null:
+ return
+ var token := _break_run_token
+ for i in BREAK_FRAME_SCRAPE_DELAYS_SEC.size():
+ var final := i == BREAK_FRAME_SCRAPE_DELAYS_SEC.size() - 1
+ var timer := tree.create_timer(BREAK_FRAME_SCRAPE_DELAYS_SEC[i])
+ var work := ScriptWork.begin("debugger_break_scrape")
+ timer.timeout.connect(func() -> void:
+ _on_break_scrape_tick(token, final)
+ ScriptWork.finish(work))
+
+
+func _on_break_scrape_tick(run_token: int, final: bool) -> void:
+ if not _break_active or _break_record_synthesized or run_token != _break_run_token:
+ return
+ var frames := _scrape_break_stack_frames()
+ if frames.is_empty() and not final:
+ return
+ synthesize_break_error_record(frames)
+
+
+## Read the debugger's Stack Trace panel rows. Row metadata is a Dictionary
+## {frame, file, function, line} regardless of editor locale, so stack trees
+## are identified by metadata shape rather than the translated column title.
+func _scrape_break_stack_frames() -> Array[Dictionary]:
+ var base := EditorInterface.get_base_control()
+ if base == null:
+ return []
+ var debuggers: Array[Node] = []
+ _collect_nodes_of_class(base, "ScriptEditorDebugger", debuggers)
+ for dbg in debuggers:
+ var trees: Array[Node] = []
+ _collect_nodes_of_class(dbg, "Tree", trees)
+ for t in trees:
+ var frames := _frames_from_stack_tree(t as Tree)
+ if not frames.is_empty():
+ return frames
+ return []
+
+
+static func _frames_from_stack_tree(tree: Tree) -> Array[Dictionary]:
+ var frames: Array[Dictionary] = []
+ var root := tree.get_root()
+ if root == null:
+ return frames
+ var item := root.get_first_child()
+ while item != null:
+ var meta = item.get_metadata(0)
+ if not (meta is Dictionary and meta.has("file") and meta.has("frame")):
+ return []
+ frames.append({
+ "path": str(meta.get("file", "")),
+ "line": int(meta.get("line", 0)),
+ "function": str(meta.get("function", "")),
+ })
+ item = item.get_next()
+ return frames
+
+
+## Build the Errors-tab-shaped record for a boot-time break and promote it via
+## the tracker so recent_editor_errors_since / logs_read / the watermark all
+## surface it — the break itself produces no record anywhere else (#645).
+## Public so tests can synthesize without waiting on scrape timers.
+func synthesize_break_error_record(frames: Array[Dictionary]) -> void:
+ if _break_record_synthesized:
+ return
+ _break_record_synthesized = true
+ if _surfaced_error_tracker == null:
+ return
+ var reason := _break_reason
+ if reason.is_empty():
+ reason = "Game process broke into the debugger during startup (script parse/load error; reason not captured)"
+ var top: Dictionary = frames[0] if not frames.is_empty() else {}
+ var location := {
+ "path": str(top.get("path", "")),
+ "line": int(top.get("line", 0)),
+ "function": str(top.get("function", "")),
+ }
+ var entry := {
+ "source": "editor",
+ "level": "error",
+ "text": reason,
+ "path": location["path"],
+ "line": location["line"],
+ "function": location["function"],
+ "details": {
+ "debugger_tab": "Stack Trace",
+ "message": reason,
+ "error_type_name": "debugger_break",
+ "source": location.duplicate(true),
+ "resolved": location.duplicate(true),
+ "frames": frames.duplicate(true),
+ },
+ }
+ _surfaced_error_tracker.record_synthetic_error(entry)
+ if _log_buffer:
+ _log_buffer.log("[debug] synthesized boot-break error record: %s" % McpSurfacedErrorTracker.format_editor_error_summary(entry))
+
+
+## --- end #645 ---------------------------------------------------------------
+
+
+func is_game_capture_ready() -> bool:
+ return _game_run_active and _game_ready and _ready_run_token == _game_run_token
+
+
+static func with_liveness_flags(status: Dictionary) -> Dictionary:
+ var enriched := status.duplicate(true)
+ var state := str(enriched.get("status", "stopped"))
+ enriched["helper_live"] = state == "live"
+ enriched["session_active"] = not state in ["not_live", "stopped"]
+ return enriched
+
+
+func get_game_status(now_msec: int = -1, ready_wait_sec: float = GAME_READY_WAIT_SEC) -> Dictionary:
+ var resolved_now := Time.get_ticks_msec() if now_msec < 0 else now_msec
+ var ready_wait_msec := maxi(0, int(ready_wait_sec * 1000.0))
+ var elapsed_msec := maxi(0, resolved_now - _game_run_started_msec) if _game_run_active else 0
+ ## "stopped" also covers idle/never-ran; no game run is currently active.
+ var status := "stopped"
+ if _game_run_active:
+ ## #645: a parked process takes precedence over "live" — a game frozen
+ ## in a remote-debugger break cannot service game-side tools even when
+ ## its helper registered before the break.
+ if _break_active:
+ status = "break"
+ elif is_game_capture_ready():
+ status = "live"
+ elif not _game_helper_expected:
+ status = "no_helper"
+ elif elapsed_msec >= ready_wait_msec:
+ status = "not_live"
+ else:
+ status = "launching"
+ var out := {
+ "status": status,
+ "run_token": _game_run_token,
+ "active": _game_run_active,
+ "ready": is_game_capture_ready(),
+ "helper_expected": _game_helper_expected,
+ "run_started_msec": _game_run_started_msec,
+ "elapsed_msec": elapsed_msec,
+ "ready_wait_msec": ready_wait_msec,
+ "editor_log_cursor": _game_run_started_editor_cursor,
+ }
+ if status == "break":
+ out["break"] = {
+ "reason": _break_reason,
+ "can_debug": _break_can_debug,
+ "pre_live": _break_pre_live,
+ }
+ return with_liveness_flags(out)
+
+
+func _explain_not_live(status: Dictionary, code: String = ErrorCodes.INTERNAL_ERROR) -> Dictionary:
+ var state := str(status.get("status", "stopped"))
+ var errors_info := recent_editor_errors_since(int(status.get("editor_log_cursor", 0)))
+ var recent_errors: Array = errors_info.get("errors", [])
+ var recent_errors_scope := str(errors_info.get("scope", "none"))
+ var truncated := bool(errors_info.get("truncated", false))
+ var data := {
+ "game_status": status.duplicate(true),
+ "recent_errors": recent_errors,
+ "recent_errors_scope": recent_errors_scope,
+ "recent_errors_may_predate_run": recent_errors_scope == "retained_recent",
+ "recent_errors_truncated": truncated,
+ }
+ data.merge(split_errors_by_scope(recent_errors, recent_errors_scope), true)
+ var message := ""
+ match state:
+ "not_live":
+ if not recent_errors.is_empty() and recent_errors_scope == "run":
+ message = "The game failed to load or crashed before the Godot AI game helper registered: %s. Check logs_read(source='editor', include_details=true)." % _format_editor_error_summary(recent_errors[0])
+ if truncated:
+ message += " Editor logs since this run may be truncated; showing retained errors."
+ elif not recent_errors.is_empty():
+ message = "The game is not responding and reported no load errors during this run. A recent editor error may be related, but may predate this run: %s. Check logs_read(source='editor', include_details=true)." % _format_editor_error_summary(recent_errors[0])
+ else:
+ message = "The game is not responding and reported no load errors before the helper-ready window elapsed. It may still be booting or may have failed silently; check logs_read(source='editor', include_details=true) and retry."
+ "break":
+ var break_info: Dictionary = status.get("break", {})
+ var break_reason := str(break_info.get("reason", ""))
+ var reason_suffix := (": %s" % break_reason) if not break_reason.is_empty() else ""
+ if bool(break_info.get("pre_live", true)):
+ message = "The game hit a script error during startup and is frozen at a debugger break%s. It cannot become live; call project_manage(op='stop') to end the run, fix the error, and relaunch. Check logs_read(source='editor', include_details=true)." % reason_suffix
+ else:
+ message = "The game is paused at a debugger break%s. Resume it from the editor's Debugger panel or call project_manage(op='stop')." % reason_suffix
+ "no_helper":
+ message = "The running game has no _mcp_game_helper autoload, so game-side tools cannot connect. If this is a headless or custom-main-loop project, use editor_screenshot(source='viewport') where applicable. Otherwise, re-enable the plugin and relaunch the game."
+ "launching":
+ message = "The game is still starting (%.1fs elapsed); the Godot AI game helper has not registered yet. Retry shortly." % (float(status.get("elapsed_msec", 0)) / 1000.0)
+ "stopped":
+ message = "The game is not running. Start the project and retry the game-side tool."
+ _:
+ message = "The game-side tool could not confirm the game is live (status=%s). Check logs_read(source='editor', include_details=true) and retry." % state
+ var err := ErrorCodes.make(code, message)
+ var inner: Dictionary = err.get("error", {})
+ inner["data"] = data
+ err["error"] = inner
+ return err
+
+
+static func split_errors_by_scope(recent_errors: Array, scope: String) -> Dictionary:
+ var current_run_errors: Array = []
+ var retained_errors: Array = []
+ if scope == "run":
+ current_run_errors = recent_errors
+ elif scope == "retained_recent":
+ retained_errors = recent_errors
+ return {
+ "current_run_errors": current_run_errors,
+ "retained_errors": retained_errors,
+ }
+
+
+## `force_debugger_scan` bypasses the tracker's scan gate for one read. Keep it
+## false on per-frame polling paths (the run-liveness loop) — a forced scan
+## walks the Debugger dock UI — and pass true only for one-shot reads that must
+## see rows which landed after the last gated scan (#641).
+func recent_editor_errors_since(cursor: int, force_debugger_scan: bool = false) -> Dictionary:
+ return _recent_editor_errors_since(cursor, force_debugger_scan)
+
+
+func _recent_editor_errors_since(cursor: int, force_debugger_scan: bool = false) -> Dictionary:
+ var out: Array[Dictionary] = []
+ var truncated := false
+ if _surfaced_error_tracker != null:
+ var captured_by_tracker: Dictionary = _surfaced_error_tracker.editor_entries_since(
+ maxi(0, cursor),
+ _game_run_started_debugger_cursor,
+ force_debugger_scan,
+ )
+ truncated = bool(captured_by_tracker.get("truncated", false))
+ for raw_entry in captured_by_tracker.get("entries", []):
+ var compact := _compact_editor_error(raw_entry)
+ if compact.is_empty():
+ continue
+ out.append(compact)
+ if out.size() >= 5:
+ break
+ if not out.is_empty():
+ return {"errors": out, "truncated": truncated, "scope": "run"}
+ for raw_entry in _surfaced_error_tracker.retained_recent_editor_entries():
+ var compact := _compact_editor_error(raw_entry, true)
+ if compact.is_empty():
+ continue
+ out.append(compact)
+ if out.size() >= 5:
+ break
+ if not out.is_empty():
+ return {"errors": out, "truncated": false, "scope": "retained_recent"}
+ return {"errors": out, "truncated": false, "scope": "none"}
+ if _editor_log_buffer == null:
+ return {"errors": out, "truncated": false, "scope": "none"}
+ var captured: Dictionary = _editor_log_buffer.get_since(maxi(0, cursor), -1)
+ truncated = bool(captured.get("truncated", false))
+ for raw_entry in captured.get("entries", []):
+ var compact := _compact_editor_error(raw_entry)
+ if compact.is_empty():
+ continue
+ out.append(compact)
+ if out.size() >= 5:
+ break
+ if not out.is_empty():
+ return {"errors": out, "truncated": truncated, "scope": "run"}
+
+ for raw_entry in _reversed_entries(_editor_log_buffer.get_recent(McpEditorLogBuffer.MAX_LINES)):
+ var compact := _compact_editor_error(raw_entry, true)
+ if compact.is_empty():
+ continue
+ out.append(compact)
+ if out.size() >= 5:
+ break
+ if not out.is_empty():
+ return {"errors": out, "truncated": false, "scope": "retained_recent"}
+ return {"errors": out, "truncated": false, "scope": "none"}
+
+
+func _compact_editor_error(raw_entry: Variant, fallback_recent: bool = false) -> Dictionary:
+ if not raw_entry is Dictionary:
+ return {}
+ var entry := raw_entry as Dictionary
+ if str(entry.get("level", "info")) != "error":
+ return {}
+ var path := str(entry.get("path", ""))
+ if fallback_recent and _is_diagnostic_noise_path(path):
+ return {}
+ var compact := {
+ "source": "editor",
+ "level": "error",
+ "text": str(entry.get("text", "")),
+ "path": path,
+ "line": int(entry.get("line", 0)),
+ "function": str(entry.get("function", "")),
+ }
+ if entry.has("details"):
+ compact["details"] = entry["details"].duplicate(true)
+ return compact
+
+
+func _is_diagnostic_noise_path(path: String) -> bool:
+ return path.begins_with("res://addons/godot_ai/") or path.begins_with("res://tests/")
+
+
+func _reversed_entries(entries: Array[Dictionary]) -> Array[Dictionary]:
+ var out: Array[Dictionary] = []
+ for i in range(entries.size() - 1, -1, -1):
+ out.append(entries[i])
+ return out
+
+
+func _format_editor_error_summary(entry: Dictionary) -> String:
+ return McpSurfacedErrorTracker.format_editor_error_summary(entry)
+
+
+func _capture(message: String, data: Array, session_id: int) -> bool:
+ ## Godot passes the full "prefix:tail" string as `message`.
+ match message:
+ "mcp:screenshot_response":
+ _on_screenshot_response(data)
+ return true
+ "mcp:screenshot_error":
+ _on_screenshot_error(data)
+ return true
+ "mcp:log_batch":
+ _on_log_batch(data)
+ return true
+ "mcp:hello":
+ ## Staleness is checked FIRST, before the buffering branch below:
+ ## `_begin_game_run_tracking` clears `_game_session_id`, so a beacon
+ ## validated only at replay time could never be rejected (#891
+ ## review). A beacon from a session other than the attached one is
+ ## some other game's — never hold it, never apply it.
+ if _game_session_id != -1 and session_id != _game_session_id:
+ if _log_buffer:
+ _log_buffer.log("[debug] ignored stale mcp:hello from debugger session %d (current %d)" % [session_id, _game_session_id])
+ return true
+ if not _game_run_active:
+ ## #891: the run is not adopted YET — a manually started play
+ ## whose debugger session attached before the editor flipped
+ ## `is_playing_scene()` lands here. The beacon is a one-shot,
+ ## so hold it for the imminent adoption instead of dropping it
+ ## (which left game_eval waiting forever for a hello that had
+ ## already been thrown away).
+ _pending_hello_session_id = session_id
+ ## The beacon is this game's first word, and its boot output
+ ## follows immediately (the helper flushes on its first
+ ## `_process`). Rotate the run id NOW so those lines are tagged
+ ## with the identity adoption will announce, instead of landing
+ ## under the previous run and vanishing from
+ ## `logs_read(source="game")` (#891 review).
+ if _game_log_buffer and not _pre_adoption_log_rotated:
+ _game_log_buffer.clear_for_new_run()
+ _pre_adoption_log_rotated = true
+ if _log_buffer:
+ _log_buffer.log("[debug] holding mcp:hello from debugger session %d until the run is adopted" % session_id)
+ return true
+ _accept_game_hello(session_id)
+ return true
+ "mcp:eval_liveness_response":
+ _on_eval_liveness_response(data)
+ return true
+ "mcp:debug_status_response":
+ _on_debug_status_response(data)
+ return true
+ "mcp:eval_response":
+ _on_eval_response(data)
+ return true
+ "mcp:eval_error":
+ _on_eval_error(data)
+ return true
+ "mcp:eval_ack":
+ _on_eval_ack(data)
+ return true
+ "mcp:eval_compiled":
+ _on_eval_compiled(data)
+ return true
+ "mcp:eval_runtime_error":
+ _on_eval_runtime_error(data)
+ return true
+ "mcp:game_command_response":
+ _on_game_command_response(data)
+ return true
+ "mcp:game_command_error":
+ _on_game_command_error(data)
+ return true
+ return false
+
+
+func _on_log_batch(data: Array) -> void:
+ if _game_log_buffer == null:
+ return
+ ## data layout: [[[level, text, details?], ...]]
+ if data.is_empty() or not (data[0] is Array):
+ return
+ var entries: Array = data[0]
+ for entry in entries:
+ if entry is Dictionary:
+ var dict_details: Dictionary = {}
+ var raw_dict_details = entry.get("details", {})
+ if raw_dict_details is Dictionary:
+ dict_details = raw_dict_details
+ _game_log_buffer.append(str(entry.get("level", "info")), str(entry.get("text", "")), dict_details)
+ continue
+ if not (entry is Array) or entry.size() < 2:
+ continue
+ var details: Dictionary = {}
+ if entry.size() > 2 and entry[2] is Dictionary:
+ details = entry[2]
+ _game_log_buffer.append(str(entry[0]), str(entry[1]), details)
+
+
+## Request a game-process framebuffer capture over the debugger channel.
+## Reply is pushed back through `connection` out-of-band because the MCP
+## dispatcher has already returned a deferred-response marker for this
+## request_id. Synchronous from the caller's perspective — if the
+## game-side autoload hasn't beaconed yet, the wait + send run as a
+## fire-and-forget coroutine kicked off from here. Structured this way
+## so the call site in EditorHandler stays a plain non-await invocation.
+func request_game_screenshot(
+ request_id: String,
+ max_resolution: int,
+ connection: McpConnection,
+ timeout_sec: float = DEFAULT_TIMEOUT_SEC,
+) -> void:
+ if request_id.is_empty():
+ push_warning("MCP debugger: screenshot request missing request_id")
+ return
+
+ var tree := Engine.get_main_loop() as SceneTree
+ if tree == null:
+ _send_error(connection, request_id, ErrorCodes.INTERNAL_ERROR,
+ "Editor main loop is not a SceneTree — cannot schedule capture")
+ return
+
+ if is_game_capture_ready():
+ _send_take_screenshot(tree, request_id, max_resolution, connection, timeout_sec)
+ return
+
+ ## Not ready yet — run the wait-then-send flow as a detached
+ ## coroutine. It keeps itself alive via the signal subscription on
+ ## tree.process_frame; the caller doesn't need to (and shouldn't)
+ ## await this entrypoint.
+ if _log_buffer:
+ _log_buffer.log("[debug] waiting for game_helper hello (%s)" % request_id)
+ _wait_then_send(tree, request_id, max_resolution, connection, timeout_sec)
+
+
+## Coroutine: poll each editor frame until the mcp:hello beacon arrives
+## (flipping _game_ready true) or the deadline elapses. Once resolved,
+## either dispatch the capture or return an actionable timeout error.
+func _wait_then_send(
+ tree: SceneTree,
+ request_id: String,
+ max_resolution: int,
+ connection: McpConnection,
+ timeout_sec: float,
+) -> void:
+ var work := ScriptWork.begin("game_screenshot_ready")
+ await _settle_capture_ready(tree, request_id, max_resolution, connection, timeout_sec)
+ ScriptWork.finish(work)
+
+
+func _settle_capture_ready(
+ tree: SceneTree, request_id: String, max_resolution: int,
+ connection: McpConnection, timeout_sec: float,
+) -> void:
+ var deadline := Time.get_ticks_msec() + int(GAME_READY_WAIT_SEC * 1000.0)
+ ## #645: always yield at least one frame — the dispatcher registers the
+ ## deferred request only after the handler returns DEFERRED_RESPONSE, so a
+ ## same-frame error reply would be dropped as an expired request. The break
+ ## check then bails out with the actionable break error instead of waiting
+ ## out the full window (a game parked in a debugger break never beacons).
+ await tree.process_frame
+ while not is_game_capture_ready() and not _break_active and Time.get_ticks_msec() < deadline:
+ await tree.process_frame
+ if not is_game_capture_ready():
+ _send_error_response(connection, request_id,
+ _explain_not_live(get_game_status(-1, GAME_READY_WAIT_SEC), ErrorCodes.INTERNAL_ERROR))
+ return
+ _send_take_screenshot(tree, request_id, max_resolution, connection, timeout_sec)
+
+
+## Send the mcp:take_screenshot message and arm the reply timeout.
+## Assumes _game_ready is true.
+func _send_take_screenshot(
+ tree: SceneTree,
+ request_id: String,
+ max_resolution: int,
+ connection: McpConnection,
+ timeout_sec: float,
+) -> void:
+ var session: EditorDebuggerSession = _first_active_session()
+ if session == null:
+ _send_error(connection, request_id, ErrorCodes.INTERNAL_ERROR,
+ "No active debugger session — is the game actually running and started from this editor?")
+ return
+
+ var timer: SceneTreeTimer = tree.create_timer(timeout_sec)
+ var timeout_callable := func() -> void: _on_timeout(request_id)
+ timer.timeout.connect(timeout_callable)
+ _pending[request_id] = {
+ "connection": connection,
+ "timer": timer,
+ "timeout_callable": timeout_callable,
+ }
+
+ session.send_message("mcp:take_screenshot", [request_id, max_resolution])
+ if _log_buffer:
+ _log_buffer.log("[debug] -> mcp:take_screenshot (%s)" % request_id)
+
+
+func _first_active_session() -> EditorDebuggerSession:
+ for s in get_sessions():
+ if s is EditorDebuggerSession and s.is_active():
+ return s
+ return null
+
+
+func _on_screenshot_response(data: Array) -> void:
+ if data.size() < 6:
+ push_warning("MCP debugger: malformed screenshot response (expected 6 fields, got %d)" % data.size())
+ return
+ var request_id: String = data[0]
+ var pending = _pending.get(request_id)
+ if pending == null:
+ ## Timed out or unknown — silently drop.
+ return
+ _clear_pending(request_id)
+
+ var connection: McpConnection = pending.connection
+ if connection == null or not is_instance_valid(connection):
+ return
+
+ var payload := {
+ "source": "game",
+ "width": int(data[2]),
+ "height": int(data[3]),
+ "original_width": int(data[4]),
+ "original_height": int(data[5]),
+ "format": "png",
+ "image_base64": data[1],
+ }
+ ## #777: game helpers append frames_drawn + a stale flag so a capture
+ ## taken while the game's main loop is frozen (backgrounded window) is
+ ## honestly labeled instead of timing out. Older helpers send six fields
+ ## — leave the keys absent rather than guessing.
+ if data.size() >= 8:
+ payload["frames_drawn"] = int(data[6])
+ payload["stale_frame"] = bool(data[7])
+ if bool(data[7]):
+ payload["note"] = ("The game window appears backgrounded or its main loop is "
+ + "stalled; returning the last rendered frame. Focus the game window and "
+ + "retry for a current frame.")
+ ## Vision Routing: when enabled, describe the frame through the configured
+ ## vision provider on a
+ ## worker thread and reply with the text description instead of the raw
+ ## image (see vision_routing.gd). The router replies for us in that case.
+ if vision_routing != null and vision_routing.is_routing_enabled():
+ if vision_routing.route_game_payload(connection, request_id, {"data": payload}):
+ return
+ connection.send_deferred_response(request_id, {"data": payload})
+ if _log_buffer:
+ _log_buffer.log("[debug] <- mcp:screenshot_response (%s)" % request_id)
+
+
+func _on_screenshot_error(data: Array) -> void:
+ if data.size() < 2:
+ return
+ var request_id: String = data[0]
+ var message: String = data[1]
+ var pending = _pending.get(request_id)
+ if pending == null:
+ return
+ _clear_pending(request_id)
+ var connection: McpConnection = pending.connection
+ if connection == null or not is_instance_valid(connection):
+ return
+ _send_error(connection, request_id, ErrorCodes.INTERNAL_ERROR, message)
+
+
+## #777: the 8s reply timer fired — the screenshot request reached (or should
+## have reached) the game helper and no reply came back. Mirror the eval
+## timeout split (#518): a not-live game gets the attributed
+## _explain_not_live payload; a live game gets GAME_HELPER_TIMEOUT instead of
+## the former opaque INTERNAL_ERROR. With the game side's stalled-loop
+## stale-frame fallback, a live game only lands here when it has nothing
+## rendered to fall back on, its debugger servicing is itself wedged, or the
+## helper died mid-run.
+func _on_timeout(request_id: String) -> void:
+ var pending = _pending.get(request_id)
+ if pending == null:
+ return
+ _pending.erase(request_id)
+ var connection: McpConnection = pending.connection
+ if connection == null or not is_instance_valid(connection):
+ return
+ var status := get_game_status(-1, GAME_READY_WAIT_SEC)
+ var err: Dictionary
+ if status.get("status", "") != "live":
+ err = _explain_not_live(status, ErrorCodes.INTERNAL_ERROR)
+ else:
+ err = ErrorCodes.make(ErrorCodes.GAME_HELPER_TIMEOUT,
+ "The game process did not return a frame. The game window may be backgrounded or its main loop blocked — focus the game window and retry, or use game_command to confirm liveness.")
+ _send_error_response(connection, request_id, err)
+ if _log_buffer:
+ _log_buffer.log("[debug] !! screenshot timeout (%s)" % request_id)
+
+
+func _send_error(connection: McpConnection, request_id: String, code: String, message: String) -> void:
+ _send_error_response(connection, request_id, ErrorCodes.make(code, message))
+
+
+func _send_error_response(connection: McpConnection, request_id: String, err: Dictionary) -> void:
+ if connection == null or not is_instance_valid(connection):
+ return
+ connection.send_deferred_response(request_id, err)
+
+
+func _clear_pending(request_id: String) -> void:
+ var pending: Dictionary = _pending.get(request_id, {})
+ var timer: SceneTreeTimer = pending.get("timer")
+ var cb: Callable = pending.get("timeout_callable", Callable())
+ if timer != null and timer.timeout.is_connected(cb):
+ timer.timeout.disconnect(cb)
+ ## #490: eval requests also carry a compile-grace timer and a runtime probe.
+ var grace: SceneTreeTimer = pending.get("grace_timer")
+ var gcb: Callable = pending.get("grace_callable", Callable())
+ if grace != null and grace.timeout.is_connected(gcb):
+ grace.timeout.disconnect(gcb)
+ var probe: SceneTreeTimer = pending.get("probe_timer")
+ var pcb: Callable = pending.get("probe_callable", Callable())
+ if probe != null and probe.timeout.is_connected(pcb):
+ probe.timeout.disconnect(pcb)
+ _pending.erase(request_id)
+
+
+## --- game_eval: execute arbitrary GDScript in the running game ---
+
+## Editor-side fallback timer for game_eval. MUST stay above the game-side
+## EVAL_TIMEOUT_SEC (8.0) in runtime/game_helper.gd and below the dispatcher's
+## game_eval budget (15000 ms) in dispatcher.gd — i.e. game 8s < editor 10s <
+## dispatcher 15s. This timer only fires when the game never replies at all;
+## _on_eval_timeout then attributes the failure (game not live vs never-acked
+## vs started-and-hung, #518). Drop timeout_sec at/below 8s and it pre-empts
+## the game's more specific "Eval exceeded 8s" message — see the TIMEOUT
+## ORDERING note on EVAL_TIMEOUT_SEC.
+##
+## #500/#859: the *not-ready* path adds EVAL_READY_WAIT_SEC (3s) and the
+## EVAL_LIVENESS_WAIT_SEC probe (0.25s) on top of this 10s backstop. That sum
+## (13.25s) must also stay below the dispatcher/server 15s budget, or a
+## not-yet-ready game makes the server time out opaquely before the editor's
+## actionable error returns — exactly the residual ~15s TimeoutError bucket
+## #500 tracked down. The cross-file contract is enforced by
+## tests/unit/test_game_eval_timeout_ordering.py.
+func request_game_eval(
+ code: String,
+ request_id: String,
+ connection: McpConnection,
+ timeout_sec: float = 10.0,
+) -> void:
+ if request_id.is_empty():
+ push_warning("MCP debugger: eval request missing request_id")
+ return
+
+ var tree := Engine.get_main_loop() as SceneTree
+ if tree == null:
+ _send_error(connection, request_id, ErrorCodes.INTERNAL_ERROR,
+ "Editor main loop is not a SceneTree — cannot schedule eval")
+ return
+
+ var run_token := _game_run_token
+ if is_game_capture_ready():
+ _probe_then_eval(tree, code, request_id, connection, timeout_sec, run_token)
+ return
+
+ if _log_buffer:
+ _log_buffer.log("[debug] waiting for game_helper hello before eval (%s)" % request_id)
+ _wait_then_eval(tree, code, request_id, connection, timeout_sec, run_token)
+
+
+func _wait_then_eval(
+ tree: SceneTree,
+ code: String,
+ request_id: String,
+ connection: McpConnection,
+ timeout_sec: float,
+ run_token: int,
+) -> void:
+ var work := ScriptWork.begin("game_eval_ready")
+ await _settle_eval_ready(tree, code, request_id, connection, timeout_sec, run_token)
+ ScriptWork.finish(work)
+
+
+func _settle_eval_ready(
+ tree: SceneTree, code: String, request_id: String,
+ connection: McpConnection, timeout_sec: float, run_token: int,
+) -> void:
+ ## #500: eval uses EVAL_READY_WAIT_SEC (not the 20s GAME_READY_WAIT_SEC) so
+ ## the not-ready path returns its actionable error before the 15s server-side
+ ## command timeout fires an opaque TimeoutError. See EVAL_READY_WAIT_SEC.
+ _pending[request_id] = {
+ "kind": "eval_ready_wait",
+ "connection": connection,
+ "run_token": run_token,
+ }
+ var deadline := Time.get_ticks_msec() + int(EVAL_READY_WAIT_SEC * 1000.0)
+ ## #645: the leading yield guarantees the dispatcher has registered the
+ ## deferred request before any reply (a same-frame reply is dropped as
+ ## expired); the break check bails out early because a parked game never
+ ## registers its capture.
+ if _eval_ready_frame_waiter.is_valid():
+ await _eval_ready_frame_waiter.call()
+ else:
+ await tree.process_frame
+ while (
+ _pending.has(request_id)
+ and _is_current_game_run(run_token)
+ and not is_game_capture_ready()
+ and not _break_active
+ and Time.get_ticks_msec() < deadline
+ ):
+ if _eval_ready_frame_waiter.is_valid():
+ await _eval_ready_frame_waiter.call()
+ else:
+ await tree.process_frame
+ ## end_game_run() owns the reply once it has cleared this tracked wait.
+ if not _pending.has(request_id):
+ return
+ if not _is_current_game_run(run_token):
+ _fail_eval_not_ready(request_id,
+ "The game run changed before game_eval could start — the game stopped or restarted. Retry against the current run.")
+ return
+ if not is_game_capture_ready():
+ ## #518: EVAL_GAME_NOT_READY (not INTERNAL_ERROR) — the play session is up
+ ## but the game-side capture didn't register within the short wait. Fast
+ ## and caller-actionable; classifying it apart from the opaque 10s hang
+ ## keeps the INTERNAL_ERROR telemetry bucket meaning "the eval truly hung".
+ _clear_pending(request_id)
+ _send_error_response(connection, request_id,
+ _explain_not_live(get_game_status(-1, EVAL_READY_WAIT_SEC), ErrorCodes.EVAL_GAME_NOT_READY))
+ return
+ _clear_pending(request_id)
+ _probe_then_eval(tree, code, request_id, connection, timeout_sec, run_token)
+
+
+## #859: registration is run-scoped but not a liveness guarantee. Ping the
+## helper's debugger capture and let it report whether its _process beacon is
+## still advancing before arming the much longer eval backstop.
+func _probe_then_eval(
+ tree: SceneTree,
+ code: String,
+ request_id: String,
+ connection: McpConnection,
+ timeout_sec: float,
+ run_token: int,
+) -> void:
+ if not _is_current_game_run(run_token) or not is_game_capture_ready():
+ _send_error(connection, request_id, ErrorCodes.EVAL_GAME_NOT_READY,
+ "The game run changed before game_eval could be checked — the game stopped or restarted. Retry against the current run.")
+ return
+ var session: EditorDebuggerSession = _first_active_session()
+ if session == null:
+ _send_error(connection, request_id, ErrorCodes.EVAL_GAME_NOT_READY,
+ "Game-side capture registered but its debugger session is no longer active — the game likely just stopped or is restarting. Confirm it is running and retry.")
+ return
+
+ var timer := tree.create_timer(EVAL_LIVENESS_WAIT_SEC)
+ var timeout_callable := func() -> void: _on_eval_liveness_timeout(request_id)
+ timer.timeout.connect(timeout_callable)
+ _pending[request_id] = {
+ "kind": "eval_liveness",
+ "connection": connection,
+ "timer": timer,
+ "timeout_callable": timeout_callable,
+ "code": code,
+ "eval_timeout_sec": timeout_sec,
+ "run_token": run_token,
+ }
+ session.send_message("mcp:eval_liveness", [request_id])
+ if _log_buffer:
+ _log_buffer.log("[debug] -> mcp:eval_liveness (%s)" % request_id)
+
+
+func _on_eval_liveness_timeout(request_id: String) -> void:
+ var pending_entry = _pending.get(request_id)
+ if pending_entry == null or str(pending_entry.get("kind", "")) != "eval_liveness":
+ return
+ var connection: McpConnection = pending_entry.get("connection")
+ _clear_pending(request_id)
+ if connection == null or not is_instance_valid(connection):
+ return
+ _send_error(connection, request_id, ErrorCodes.EVAL_GAME_NOT_READY,
+ ("The game helper did not answer the liveness probe within %.0fms — the game may be backgrounded, frozen, stopped, or restarting. Focus the game window (or relaunch it) and retry."
+ % (EVAL_LIVENESS_WAIT_SEC * 1000.0)))
+
+
+func _on_eval_liveness_response(data: Array) -> void:
+ if data.size() < 2:
+ push_warning("MCP debugger: malformed eval liveness response")
+ return
+ var request_id := str(data[0])
+ var pending_entry = _pending.get(request_id)
+ if pending_entry == null or str(pending_entry.get("kind", "")) != "eval_liveness":
+ return
+ var connection: McpConnection = pending_entry.get("connection")
+ var code := str(pending_entry.get("code", ""))
+ var timeout_sec := float(pending_entry.get("eval_timeout_sec", 10.0))
+ var run_token := int(pending_entry.get("run_token", -1))
+ var loop_live := bool(data[1])
+ _clear_pending(request_id)
+ if connection == null or not is_instance_valid(connection):
+ return
+ if not _is_current_game_run(run_token) or not is_game_capture_ready():
+ _send_error(connection, request_id, ErrorCodes.EVAL_GAME_NOT_READY,
+ "The game run changed while game_eval liveness was being checked — the game stopped or restarted. Retry against the current run.")
+ return
+ if not loop_live:
+ _send_error(connection, request_id, ErrorCodes.EVAL_GAME_NOT_READY,
+ "The game helper is registered but its main loop is not advancing — the game window may be backgrounded or the game may be frozen. Focus the game window (or relaunch it) and retry.")
+ return
+ var current_tree := Engine.get_main_loop() as SceneTree
+ if current_tree == null:
+ _send_error(connection, request_id, ErrorCodes.INTERNAL_ERROR,
+ "Editor main loop is not a SceneTree — cannot schedule eval")
+ return
+ _send_eval(current_tree, code, request_id, connection, timeout_sec)
+
+
+func _send_eval(
+ tree: SceneTree,
+ code: String,
+ request_id: String,
+ connection: McpConnection,
+ timeout_sec: float,
+) -> void:
+ var session: EditorDebuggerSession = _first_active_session()
+ if session == null:
+ ## #518: capture reported ready but the debugger session is no longer live
+ ## (the game just stopped / is restarting) — a not-ready race, so the same
+ ## caller-actionable EVAL_GAME_NOT_READY rather than the opaque hang bucket.
+ _send_error(connection, request_id, ErrorCodes.EVAL_GAME_NOT_READY,
+ "Game-side capture registered but its debugger session is no longer active — the game likely just stopped or is restarting. Confirm it's running and retry.")
+ return
+
+ var timer: SceneTreeTimer = tree.create_timer(timeout_sec)
+ var timeout_callable := func() -> void: _on_eval_timeout(request_id, timeout_sec)
+ timer.timeout.connect(timeout_callable)
+
+ ## #490: arm the compile-grace timer. _on_eval_grace concludes a parse error
+ ## only when the game acked the eval (it received the message and started
+ ## reload()) but never sent mcp:eval_compiled — see there for why a missing
+ ## ack must NOT be read as a compile error.
+ var grace: SceneTreeTimer = tree.create_timer(EVAL_COMPILE_GRACE_SEC)
+ var grace_callable := func() -> void: _on_eval_grace(request_id)
+ grace.timeout.connect(grace_callable)
+
+ _pending[request_id] = {
+ "kind": "eval",
+ "connection": connection,
+ "run_token": _game_run_token,
+ "timer": timer,
+ "timeout_callable": timeout_callable,
+ "grace_timer": grace,
+ "grace_callable": grace_callable,
+ "acked": false,
+ "compiled": false,
+ }
+
+ session.send_message("mcp:eval", [request_id, code])
+ if _log_buffer:
+ _log_buffer.log("[debug] -> mcp:eval (%s)" % request_id)
+
+
+## #518: the 10s editor-side backstop fired — the game never replied at all.
+## Attribute the failure instead of emitting a one-size-fits-all INTERNAL_ERROR:
+##
+## - game not live anymore (parked in a debugger break, stopped, crashed):
+## the eval couldn't run/finish for a *game-state* reason. Reply with the
+## same caller-actionable EVAL_GAME_NOT_READY + `_explain_not_live` payload
+## the pre-hello break path already uses — a break freezes the game's idle
+## loop, so any awaiting eval parks here even though sync evals still work.
+## - game live but never acked the eval: its main thread never serviced the
+## debugger message (long frame/load, CPU-bound prior eval, or a
+## backgrounded window whose idle loop is frozen).
+## - game live, acked, compiled: the eval genuinely started and never
+## finished, and the game couldn't even self-report via its own 8s guard
+## (which needs a ticking idle loop) — hung await, CPU-bound loop, or a
+## reply the debugger channel dropped.
+##
+## The live branches reply EVAL_HUNG: the eval code never finished. That code
+## plus the game-side 8s guard (also EVAL_HUNG, via mcp:eval_error's code
+## element) empties the former INTERNAL_ERROR bucket on this path.
+func _on_eval_timeout(request_id: String, timeout_sec: float) -> void:
+ var pending_entry = _pending.get(request_id)
+ if pending_entry == null:
+ return
+ var run_token := int(pending_entry.get("run_token", _game_run_token))
+ _clear_pending(request_id)
+ var conn: McpConnection = pending_entry.connection
+ if conn == null or not is_instance_valid(conn):
+ return
+ if run_token != _game_run_token:
+ _send_error(conn, request_id, ErrorCodes.EVAL_GAME_NOT_READY,
+ "The game run changed before game_eval completed — the original game stopped or restarted. Retry against the current run.")
+ if _log_buffer:
+ _log_buffer.log("[debug] !! eval timeout from prior run (%s, run=%d, current=%d)"
+ % [request_id, run_token, _game_run_token])
+ return
+ var status := get_game_status(-1, EVAL_READY_WAIT_SEC)
+ if str(status.get("status", "")) != "live":
+ _send_error_response(conn, request_id,
+ _explain_not_live(status, ErrorCodes.EVAL_GAME_NOT_READY))
+ if _log_buffer:
+ _log_buffer.log("[debug] !! eval timeout, game not live (%s, status=%s)"
+ % [request_id, str(status.get("status", ""))])
+ return
+ var message: String
+ if not bool(pending_entry.get("acked", false)):
+ message = ("Game eval was sent but the game never picked it up within %.0fs — "
+ + "its main thread is busy or frozen (a long frame/load, a CPU-bound "
+ + "prior eval, or a backgrounded game window whose loop is throttled). "
+ + "Check logs_read(source='game') and retry.") % timeout_sec
+ else:
+ message = ("Game eval compiled and started running but never returned within "
+ + "%.0fs — the code is likely stuck in an infinite loop or awaiting a "
+ + "signal/timer that never fires (a backgrounded game window also freezes "
+ + "awaits). Check logs_read(source='game').") % timeout_sec
+ _send_error(conn, request_id, ErrorCodes.EVAL_HUNG, message)
+ if _log_buffer:
+ _log_buffer.log("[debug] !! eval timeout (%s)" % request_id)
+
+
+func _on_eval_response(data: Array) -> void:
+ if data.size() < 2:
+ push_warning("MCP debugger: malformed eval response (expected 2 fields, got %d)" % data.size())
+ return
+ var request_id: String = data[0]
+ var pending_entry = _pending.get(request_id)
+ if pending_entry == null:
+ return
+ _clear_pending(request_id)
+
+ var connection: McpConnection = pending_entry.connection
+ if connection == null or not is_instance_valid(connection):
+ return
+
+ var result_json: String = data[1] if data.size() > 1 else "null"
+ var json := JSON.new()
+ var parse_err := json.parse(result_json)
+ connection.send_deferred_response(request_id, {
+ "data": {
+ "result": json.data if parse_err == OK else result_json,
+ "source": "game",
+ }
+ })
+ if _log_buffer:
+ _log_buffer.log("[debug] <- mcp:eval_response (%s)" % request_id)
+
+
+## #518: codes the game side may attach as mcp:eval_error's optional third
+## payload element. Allowlisted so a game process can't mint arbitrary
+## top-level error codes over the debugger channel; anything else (including
+## the legacy two-element payload from an older game helper mid-update)
+## falls back to INTERNAL_ERROR exactly as before.
+const _GAME_EVAL_ERROR_CODES: Array[String] = [
+ ErrorCodes.EVAL_HUNG,
+ ErrorCodes.EVAL_RESULT_TOO_LARGE,
+]
+
+
+func _on_eval_error(data: Array) -> void:
+ if data.size() < 2:
+ return
+ var request_id: String = data[0]
+ var message: String = data[1]
+ var pending_entry = _pending.get(request_id)
+ if pending_entry == null:
+ return
+ _clear_pending(request_id)
+ var connection: McpConnection = pending_entry.connection
+ if connection == null or not is_instance_valid(connection):
+ return
+ var code := ErrorCodes.INTERNAL_ERROR
+ if data.size() > 2 and str(data[2]) in _GAME_EVAL_ERROR_CODES:
+ code = str(data[2])
+ _send_error(connection, request_id, code, message)
+ if _log_buffer:
+ _log_buffer.log("[debug] <- mcp:eval_error (%s): %s" % [request_id, message])
+
+
+## #490: the game sends this at the top of _handle_eval, BEFORE reload() (so it
+## survives a parse-error abort). It positively signals "the game received this
+## eval and started compiling it" — letting _on_eval_grace tell a real parse
+## error (acked, never compiled) apart from a message the game hasn't serviced
+## yet (never acked — main thread blocked by a long frame/load or a CPU-bound
+## prior eval).
+func _on_eval_ack(data: Array) -> void:
+ if data.is_empty():
+ return
+ var request_id: String = data[0]
+ var pending_entry = _pending.get(request_id)
+ if pending_entry == null:
+ return
+ pending_entry["acked"] = true
+ if _log_buffer:
+ _log_buffer.log("[debug] <- mcp:eval_ack (%s)" % request_id)
+
+
+## #490: compile-grace timer fired. Conclude a parse error ONLY when the game
+## acked the eval (started reload()) but never sent mcp:eval_compiled. If it
+## never acked, the game simply hasn't serviced the message yet — NOT a parse
+## error — so leave _pending intact and let the normal eval timeout handle it
+## rather than false-failing a valid eval and dropping its eventual real reply.
+func _on_eval_grace(request_id: String) -> void:
+ var pending_entry = _pending.get(request_id)
+ if pending_entry == null or pending_entry.get("compiled", false):
+ return
+ if not pending_entry.get("acked", false):
+ if _log_buffer:
+ _log_buffer.log("[debug] eval grace: no ack yet, deferring to timeout (%s)" % request_id)
+ return
+ _clear_pending(request_id)
+ var conn: McpConnection = pending_entry.connection
+ if conn == null or not is_instance_valid(conn):
+ return
+ _send_error(conn, request_id, ErrorCodes.EVAL_COMPILE_ERROR,
+ "Game eval failed to compile — likely a GDScript syntax/parse error. The parse error text is in the editor's Output/Debugger panel; it is not capturable from the running game. Check your eval code's syntax.")
+ if _log_buffer:
+ _log_buffer.log("[debug] !! eval compile error (%s)" % request_id)
+
+
+## #490: the game sends this the instant reload() of the eval source
+## succeeds. Flips the pending entry's `compiled` flag so the compile-grace
+## timer won't fire a false EVAL_COMPILE_ERROR.
+func _on_eval_compiled(data: Array) -> void:
+ if data.is_empty():
+ return
+ var request_id: String = data[0]
+ var pending_entry = _pending.get(request_id)
+ if pending_entry == null:
+ return
+ pending_entry["compiled"] = true
+ if _log_buffer:
+ _log_buffer.log("[debug] <- mcp:eval_compiled (%s)" % request_id)
+ ## #490: compiled OK — start polling for a runtime error that may have
+ ## aborted execute(). A backgrounded game can't self-report it, so the
+ ## editor probes via mcp:eval_check until the eval resolves.
+ _arm_eval_probe(request_id)
+
+
+## #490: the game reported a runtime error that aborted the eval — either
+## from its _process fast path (focused game) or in answer to an editor
+## eval_check probe (backgrounded game). Reply fast with the real error text
+## instead of waiting for the hang timeout.
+func _on_eval_runtime_error(data: Array) -> void:
+ if data.size() < 2:
+ return
+ var request_id: String = data[0]
+ var message: String = data[1]
+ var pending_entry = _pending.get(request_id)
+ if pending_entry == null:
+ return
+ _clear_pending(request_id)
+ var connection: McpConnection = pending_entry.connection
+ if connection == null or not is_instance_valid(connection):
+ return
+ var msg := "Game eval raised a runtime error: %s" % message if not message.is_empty() else "Game eval raised a runtime error (no message captured). Check logs_read(source='game')."
+ _send_error(connection, request_id, ErrorCodes.EVAL_RUNTIME_ERROR, msg)
+ if _log_buffer:
+ _log_buffer.log("[debug] <- mcp:eval_runtime_error (%s): %s" % [request_id, message])
+
+
+## #490: arm one probe tick for an in-flight eval. Re-arms itself each tick
+## until the request resolves — eval_response / eval_runtime_error /
+## eval_compile_error / hang-timeout all call _clear_pending, which erases the
+## entry and stops the chain. Uses the editor's own SceneTreeTimer because the
+## editor loop keeps ticking even while a backgrounded game's loop is frozen.
+func _arm_eval_probe(request_id: String) -> void:
+ var pending_entry = _pending.get(request_id)
+ if pending_entry == null:
+ return
+ var tree := Engine.get_main_loop() as SceneTree
+ if tree == null:
+ return
+ var probe_timer: SceneTreeTimer = tree.create_timer(EVAL_PROBE_INTERVAL_SEC)
+ var probe_callable := func() -> void: _on_eval_probe_tick(request_id)
+ pending_entry["probe_timer"] = probe_timer
+ pending_entry["probe_callable"] = probe_callable
+ probe_timer.timeout.connect(probe_callable)
+
+
+## #490: poke the game for a runtime-error verdict, then re-arm. The game's
+## _handle_eval_check answers with mcp:eval_runtime_error if a script error
+## aborted this eval, else stays silent and we poll again next interval.
+func _on_eval_probe_tick(request_id: String) -> void:
+ if not _pending.has(request_id):
+ return ## resolved — stop probing
+ var session: EditorDebuggerSession = _first_active_session()
+ if session != null and session.is_active():
+ session.send_message("mcp:eval_check", [request_id])
+ _arm_eval_probe(request_id)
+
+
+## --- native embedded Game View runtime control (#939) ---
+
+static func game_debug_verification(
+ action: String, state_before: Dictionary, state_after: Dictionary
+) -> Dictionary:
+ var before_ticks := int(state_before.get("process_ticks", -1))
+ var after_ticks := int(state_after.get("process_ticks", -1))
+ var ticks_advanced := after_ticks - before_ticks if before_ticks >= 0 and after_ticks >= 0 else -1
+ match action:
+ "suspend":
+ return {"status": "verified" if bool(state_after.get("suspended", false)) else "pending", "ticks_advanced": ticks_advanced}
+ "resume":
+ return {"status": "verified" if not bool(state_after.get("suspended", true)) else "pending", "ticks_advanced": ticks_advanced}
+ "next_frame":
+ if before_ticks < 0 or after_ticks < 0:
+ return {"status": "failed", "reason": "missing_process_ticks", "ticks_advanced": ticks_advanced}
+ if ticks_advanced > 1:
+ return {"status": "failed", "reason": "multiple_process_ticks", "ticks_advanced": ticks_advanced}
+ if ticks_advanced == 1 and bool(state_after.get("suspended", false)):
+ return {"status": "verified", "ticks_advanced": 1}
+ return {"status": "pending", "ticks_advanced": ticks_advanced}
+ return {"status": "failed", "reason": "unsupported_action", "ticks_advanced": ticks_advanced}
+
+
+func _active_game_debug_mutation() -> Dictionary:
+ for raw_request_id in _pending.keys():
+ var pending_entry: Dictionary = _pending.get(raw_request_id, {})
+ if str(pending_entry.get("kind", "")) != "game_debug_control":
+ continue
+ var active_action := str(pending_entry.get("action", ""))
+ if active_action in ["suspend", "resume", "next_frame"]:
+ return {"request_id": str(raw_request_id), "action": active_action}
+ return {}
+
+
+func request_game_debug_control(
+ action: String,
+ request_id: String,
+ connection: McpConnection,
+ timeout_sec: float = GAME_DEBUG_CONTROL_TIMEOUT_SEC,
+) -> void:
+ if request_id.is_empty():
+ push_warning("MCP debugger: game debug control missing request_id")
+ return
+ ## The dispatcher registers the deferred request only after the handler returns.
+ ## Start the entire guard/control flow deferred so even fast failures cannot race
+ ## that registration and get dropped as an expired response.
+ _begin_game_debug_control.call_deferred(action, request_id, connection, timeout_sec)
+
+
+func _begin_game_debug_control(
+ action: String,
+ request_id: String,
+ connection: McpConnection,
+ timeout_sec: float,
+) -> void:
+ if action not in ["suspend", "resume", "next_frame", "debug_status"]:
+ _send_error(connection, request_id, ErrorCodes.VALUE_OUT_OF_RANGE,
+ "Unsupported game debug action: %s" % action)
+ return
+ var tree := Engine.get_main_loop() as SceneTree
+ if tree == null:
+ _send_error(connection, request_id, ErrorCodes.INTERNAL_ERROR,
+ "Editor main loop is not a SceneTree — cannot schedule game debug control")
+ return
+ if not is_game_capture_ready():
+ _send_error_response(connection, request_id,
+ _explain_not_live(get_game_status(-1, GAME_READY_WAIT_SEC), ErrorCodes.INTERNAL_ERROR))
+ return
+ if action != "debug_status":
+ var active_mutation := _active_game_debug_mutation()
+ if not active_mutation.is_empty():
+ var busy := ErrorCodes.make(
+ ErrorCodes.EDITOR_NOT_READY,
+ "Another game runtime-control mutation is already in flight — retry after it completes."
+ )
+ busy["error"]["data"] = {
+ "action": action,
+ "active_action": str(active_mutation.get("action", "")),
+ "retryable": true,
+ }
+ _send_error_response(connection, request_id, busy)
+ return
+ var session := _first_active_session()
+ if session == null:
+ _send_error(connection, request_id, ErrorCodes.INTERNAL_ERROR,
+ "No active debugger session — is the game actually running?")
+ return
+ var timer := tree.create_timer(timeout_sec)
+ var timeout_callable := func() -> void:
+ var pending_entry: Dictionary = _pending.get(request_id, {})
+ if pending_entry.is_empty():
+ return
+ _pending.erase(request_id)
+ var conn: McpConnection = pending_entry.get("connection")
+ if conn != null and is_instance_valid(conn):
+ var err := ErrorCodes.make(ErrorCodes.INTERNAL_ERROR,
+ "Game debug action '%s' timed out after %.0fs" % [action, timeout_sec])
+ err["error"]["data"] = {
+ "action": action,
+ "state_before": pending_entry.get("state_before", {}),
+ "state_after": pending_entry.get("state_after", {}),
+ "focus_handoff": pending_entry.get("focus_handoff", {}),
+ "path": pending_entry.get("path", ""),
+ "game_view_ui_synced": pending_entry.get("game_view_ui_synced", false),
+ }
+ _send_error_response(conn, request_id, err)
+ timer.timeout.connect(timeout_callable)
+ _pending[request_id] = {
+ "kind": "game_debug_control",
+ "phase": "before",
+ "action": action,
+ "connection": connection,
+ "timer": timer,
+ "timeout_callable": timeout_callable,
+ "run_token": _game_run_token,
+ }
+ session.send_message("mcp:debug_status", [request_id])
+
+
+func _on_debug_status_response(data: Array) -> void:
+ if data.size() < 2 or not (data[1] is Dictionary):
+ push_warning("MCP debugger: malformed debug_status response")
+ return
+ var request_id := str(data[0])
+ var pending_entry: Dictionary = _pending.get(request_id, {})
+ if str(pending_entry.get("kind", "")) != "game_debug_control":
+ return
+ var connection: McpConnection = pending_entry.get("connection")
+ if not _is_current_game_run(int(pending_entry.get("run_token", -1))):
+ _clear_pending(request_id)
+ if connection != null and is_instance_valid(connection):
+ _send_error(connection, request_id, ErrorCodes.EVAL_GAME_NOT_READY,
+ "The game run changed while runtime control was in flight — retry against the current run.")
+ return
+ var state: Dictionary = (data[1] as Dictionary).duplicate(true)
+ pending_entry["state_after"] = state
+ match str(pending_entry.get("phase", "before")):
+ "before":
+ _handle_game_debug_before(request_id, pending_entry, state)
+ "verify":
+ _handle_game_debug_verify(request_id, pending_entry, state)
+ _:
+ _clear_pending(request_id)
+ if connection != null and is_instance_valid(connection):
+ _send_error(connection, request_id, ErrorCodes.INTERNAL_ERROR,
+ "Invalid game debug control phase")
+
+
+func _default_focus_handoff() -> Dictionary:
+ return {"attempted": false, "main_screen": "", "embedded_process_found": false, "focused": false}
+
+
+func _handle_game_debug_before(
+ request_id: String, pending_entry: Dictionary, state: Dictionary
+) -> void:
+ var action := str(pending_entry.get("action", ""))
+ var connection: McpConnection = pending_entry.get("connection")
+ var focus_handoff := _default_focus_handoff()
+ if action == "debug_status":
+ _clear_pending(request_id)
+ if connection != null and is_instance_valid(connection):
+ connection.send_deferred_response(request_id, {"data": {
+ "action": action, "state": state, "focus_handoff": focus_handoff,
+ }})
+ return
+ var suspended := bool(state.get("suspended", false))
+ if action in ["suspend", "resume"]:
+ var desired := action == "suspend"
+ if suspended == desired:
+ _clear_pending(request_id)
+ if connection != null and is_instance_valid(connection):
+ connection.send_deferred_response(request_id, {"data": {
+ "action": action, "changed": false, "verified": true,
+ "state_before": state, "state_after": state,
+ "focus_handoff": focus_handoff,
+ }})
+ return
+ elif action == "next_frame" and not suspended:
+ _clear_pending(request_id)
+ if connection != null and is_instance_valid(connection):
+ _send_error(connection, request_id, ErrorCodes.INVALID_PARAMS,
+ "next_frame requires a suspended game — call suspend first")
+ return
+ if action == "next_frame":
+ focus_handoff = _focus_embedded_game_view()
+ var desired_suspended := action == "suspend"
+ var control := _emit_game_debug_runtime_action(action, desired_suspended)
+ if not bool(control.get("ok", false)):
+ var err := ErrorCodes.make(ErrorCodes.INTERNAL_ERROR,
+ "No active debugger path accepted the native game runtime action")
+ err["error"]["data"] = {
+ "action": action,
+ "reason": control.get("reason", "runtime_control_unavailable"),
+ "focus_handoff": focus_handoff,
+ }
+ _clear_pending(request_id)
+ if connection != null and is_instance_valid(connection):
+ _send_error_response(connection, request_id, err)
+ return
+ pending_entry["phase"] = "verify"
+ pending_entry["state_before"] = state
+ pending_entry["focus_handoff"] = focus_handoff
+ pending_entry["path"] = str(control.get("path", ""))
+ pending_entry["game_view_ui_synced"] = bool(control.get("game_view_ui_synced", false))
+ _request_game_debug_verify.call_deferred(request_id)
+
+
+func _request_game_debug_verify(request_id: String) -> void:
+ var tree := Engine.get_main_loop() as SceneTree
+ if tree == null:
+ return
+ await tree.process_frame
+ var pending_entry: Dictionary = _pending.get(request_id, {})
+ if str(pending_entry.get("phase", "")) != "verify":
+ return
+ var session := _first_active_session()
+ if session == null:
+ var connection: McpConnection = pending_entry.get("connection")
+ _clear_pending(request_id)
+ if connection != null and is_instance_valid(connection):
+ _send_error(connection, request_id, ErrorCodes.EVAL_GAME_NOT_READY,
+ "The debugger session ended before runtime control could be verified")
+ return
+ session.send_message("mcp:debug_status", [request_id])
+
+
+func _handle_game_debug_verify(
+ request_id: String, pending_entry: Dictionary, state_after: Dictionary
+) -> void:
+ var action := str(pending_entry.get("action", ""))
+ var connection: McpConnection = pending_entry.get("connection")
+ var state_before: Dictionary = pending_entry.get("state_before", {})
+ var verdict := game_debug_verification(action, state_before, state_after)
+ match str(verdict.get("status", "failed")):
+ "pending":
+ _request_game_debug_verify.call_deferred(request_id)
+ return
+ "verified":
+ pass
+ _:
+ var err := ErrorCodes.make(ErrorCodes.INTERNAL_ERROR,
+ "Game debug action '%s' violated its verification contract" % action)
+ err["error"]["data"] = {
+ "action": action,
+ "reason": verdict.get("reason", "verification_failed"),
+ "ticks_advanced": verdict.get("ticks_advanced", -1),
+ "state_before": state_before,
+ "state_after": state_after,
+ "focus_handoff": pending_entry.get("focus_handoff", {}),
+ "path": pending_entry.get("path", ""),
+ "game_view_ui_synced": pending_entry.get("game_view_ui_synced", false),
+ }
+ _clear_pending(request_id)
+ if connection != null and is_instance_valid(connection):
+ _send_error_response(connection, request_id, err)
+ return
+ var payload := {
+ "action": action,
+ "changed": true,
+ "verified": true,
+ "state_before": state_before,
+ "state_after": state_after,
+ "focus_handoff": pending_entry.get("focus_handoff", {}),
+ "path": pending_entry.get("path", ""),
+ "game_view_ui_synced": pending_entry.get("game_view_ui_synced", false),
+ }
+ if action == "next_frame":
+ payload["ticks_advanced"] = int(verdict.get("ticks_advanced", -1))
+ _clear_pending(request_id)
+ if connection != null and is_instance_valid(connection):
+ connection.send_deferred_response(request_id, {"data": payload})
+
+
+func _focus_embedded_game_view() -> Dictionary:
+ var result := {
+ "attempted": true,
+ "main_screen": "Game",
+ "embedded_process_found": false,
+ "focused": false,
+ }
+ EditorInterface.set_main_screen_editor("Game")
+ var base := EditorInterface.get_base_control()
+ if base == null:
+ return result
+ var embedded_nodes: Array[Node] = []
+ _collect_nodes_of_class(base, "EmbeddedProcess", embedded_nodes)
+ for node in embedded_nodes:
+ if node is Control:
+ var embedded := node as Control
+ result["embedded_process_found"] = true
+ embedded.grab_focus()
+ result["focused"] = embedded.has_focus()
+ break
+ return result
+
+
+static func direct_game_debug_message(action: String, desired_suspended: bool) -> Dictionary:
+ match action:
+ "suspend", "resume":
+ return {"message": "scene:suspend_changed", "data": [desired_suspended]}
+ "next_frame":
+ return {"message": "scene:next_frame", "data": []}
+ return {}
+
+
+func _emit_game_debug_runtime_action(action: String, desired_suspended: bool) -> Dictionary:
+ var embed_action := EMBED_NEXT_FRAME if action == "next_frame" else EMBED_SUSPEND_TOGGLE
+ var base := EditorInterface.get_base_control()
+ if base != null:
+ var debuggers: Array[Node] = []
+ _collect_nodes_of_class(base, "ScriptEditorDebugger", debuggers)
+ for debugger in debuggers:
+ if not debugger.has_signal("embed_shortcut_requested"):
+ continue
+ if debugger.get_signal_connection_list("embed_shortcut_requested").is_empty():
+ continue
+ debugger.emit_signal("embed_shortcut_requested", embed_action)
+ return {"ok": true, "path": "embed_signal", "game_view_ui_synced": true}
+
+ var session := _first_active_session()
+ if session == null or not session.is_active():
+ return {"ok": false, "reason": "no_active_debugger_session"}
+ var direct := direct_game_debug_message(action, desired_suspended)
+ if direct.is_empty():
+ return {"ok": false, "reason": "unsupported_action"}
+ session.send_message(str(direct.message), direct.data as Array)
+ ## The direct debugger-session path works without embedding, but bypasses
+ ## Game View's suspend button, so its visual pressed state is not synchronized.
+ return {"ok": true, "path": "direct_session", "game_view_ui_synced": false}
+
+
+func _fail_pending_game_debug_controls_not_ready(message: String) -> void:
+ for request_id in _pending.keys():
+ var pending_entry: Dictionary = _pending.get(request_id, {})
+ if str(pending_entry.get("kind", "")) != "game_debug_control":
+ continue
+ var connection: McpConnection = pending_entry.get("connection")
+ _clear_pending(str(request_id))
+ if connection != null and is_instance_valid(connection):
+ _send_error(connection, str(request_id), ErrorCodes.EVAL_GAME_NOT_READY, message)
+
+
+## --- game_command: curated runtime game operations ---
+
+func request_game_command(
+ op: String,
+ params: Dictionary,
+ request_id: String,
+ connection: McpConnection,
+ timeout_sec: float = 10.0,
+) -> void:
+ if request_id.is_empty():
+ push_warning("MCP debugger: game command request missing request_id")
+ return
+
+ var tree := Engine.get_main_loop() as SceneTree
+ if tree == null:
+ _send_error(connection, request_id, ErrorCodes.INTERNAL_ERROR,
+ "Editor main loop is not a SceneTree — cannot schedule game command")
+ return
+
+ if is_game_capture_ready():
+ _send_game_command(tree, op, params, request_id, connection, timeout_sec)
+ return
+
+ if _log_buffer:
+ _log_buffer.log("[debug] waiting for game_helper hello before game_command (%s)" % request_id)
+ _wait_then_game_command(tree, op, params, request_id, connection, timeout_sec)
+
+
+func _wait_then_game_command(
+ tree: SceneTree,
+ op: String,
+ params: Dictionary,
+ request_id: String,
+ connection: McpConnection,
+ timeout_sec: float,
+) -> void:
+ var work := ScriptWork.begin("game_command_ready")
+ await _settle_game_command_ready(tree, op, params, request_id, connection, timeout_sec)
+ ScriptWork.finish(work)
+
+
+func _settle_game_command_ready(
+ tree: SceneTree, op: String, params: Dictionary, request_id: String,
+ connection: McpConnection, timeout_sec: float,
+) -> void:
+ var deadline := Time.get_ticks_msec() + int(GAME_READY_WAIT_SEC * 1000.0)
+ ## #645: the leading yield guarantees the dispatcher has registered the
+ ## deferred request before any reply (a same-frame reply is dropped as
+ ## expired); the break check bails out early because a parked game never
+ ## registers its capture.
+ await tree.process_frame
+ while not is_game_capture_ready() and not _break_active and Time.get_ticks_msec() < deadline:
+ await tree.process_frame
+ if not is_game_capture_ready():
+ _send_error_response(connection, request_id,
+ _explain_not_live(get_game_status(-1, GAME_READY_WAIT_SEC), ErrorCodes.INTERNAL_ERROR))
+ return
+ _send_game_command(tree, op, params, request_id, connection, timeout_sec)
+
+
+func _send_game_command(
+ tree: SceneTree,
+ op: String,
+ params: Dictionary,
+ request_id: String,
+ connection: McpConnection,
+ timeout_sec: float,
+) -> void:
+ var session: EditorDebuggerSession = _first_active_session()
+ if session == null:
+ _send_error(connection, request_id, ErrorCodes.INTERNAL_ERROR,
+ "No active debugger session — is the game actually running?")
+ return
+
+ var timer: SceneTreeTimer = tree.create_timer(timeout_sec)
+ var timeout_callable := func() -> void:
+ var pending_entry = _pending.get(request_id)
+ if pending_entry == null:
+ return
+ _pending.erase(request_id)
+ var conn: McpConnection = pending_entry.connection
+ if conn == null or not is_instance_valid(conn):
+ return
+ _send_error(conn, request_id, ErrorCodes.INTERNAL_ERROR,
+ "Game command '%s' timed out after %.0fs" % [op, timeout_sec])
+ if _log_buffer:
+ _log_buffer.log("[debug] !! game_command timeout (%s)" % request_id)
+ timer.timeout.connect(timeout_callable)
+ _pending[request_id] = {
+ "connection": connection,
+ "timer": timer,
+ "timeout_callable": timeout_callable,
+ }
+
+ session.send_message("mcp:game_command", [request_id, op, JSON.stringify(params)])
+ if _log_buffer:
+ _log_buffer.log("[debug] -> mcp:game_command %s (%s)" % [op, request_id])
+
+
+func _on_game_command_response(data: Array) -> void:
+ if data.size() < 2:
+ push_warning("MCP debugger: malformed game_command response (expected 2 fields, got %d)" % data.size())
+ return
+ var request_id: String = data[0]
+ var pending_entry = _pending.get(request_id)
+ if pending_entry == null:
+ return
+ _clear_pending(request_id)
+
+ var connection: McpConnection = pending_entry.connection
+ if connection == null or not is_instance_valid(connection):
+ return
+
+ var result_json: String = data[1] if data.size() > 1 else "{}"
+ var json := JSON.new()
+ var parse_err := json.parse(result_json)
+ connection.send_deferred_response(request_id, {
+ "data": json.data if parse_err == OK else {"source": "game", "result": result_json}
+ })
+ if _log_buffer:
+ _log_buffer.log("[debug] <- mcp:game_command_response (%s)" % request_id)
+
+
+func _on_game_command_error(data: Array) -> void:
+ if data.size() < 2:
+ return
+ var request_id: String = data[0]
+ var message: String = data[1]
+ var pending_entry = _pending.get(request_id)
+ if pending_entry == null:
+ return
+ _clear_pending(request_id)
+ var connection: McpConnection = pending_entry.connection
+ if connection == null or not is_instance_valid(connection):
+ return
+ _send_error(connection, request_id, ErrorCodes.INTERNAL_ERROR, message)
+ if _log_buffer:
+ _log_buffer.log("[debug] <- mcp:game_command_error (%s): %s" % [request_id, message])
diff --git a/addons/godot_ai/debugger/mcp_debugger_plugin.gd.uid b/addons/godot_ai/debugger/mcp_debugger_plugin.gd.uid
new file mode 100644
index 0000000..1d5c148
--- /dev/null
+++ b/addons/godot_ai/debugger/mcp_debugger_plugin.gd.uid
@@ -0,0 +1 @@
+uid://bd1k63iye1bsl
diff --git a/addons/godot_ai/dispatcher.gd b/addons/godot_ai/dispatcher.gd
new file mode 100644
index 0000000..252fcb6
--- /dev/null
+++ b/addons/godot_ai/dispatcher.gd
@@ -0,0 +1,529 @@
+@tool
+class_name McpDispatcher
+extends RefCounted
+
+## Routes incoming commands to handlers and manages the command queue
+## with a per-frame time budget.
+
+var _command_queue: Array[Dictionary] = []
+var _handlers: Dictionary = {} # command_name -> Callable
+## Lazy handler registration (#736): plugin.gd registers command names
+## against a handler key plus a per-handler script path and constructor
+## args, and the handler script is load()ed and instantiated at the FIRST
+## dispatch of one of its commands. This keeps the ~30 handler scripts
+## (and everything they preload) out of plugin.gd's eager compile
+## closure, which stalled "Initializing plugins" on every editor boot.
+## Materialized commands are promoted into `_handlers`, so the lazy dicts
+## are only consulted on the first call per command.
+var _lazy_handler_specs: Dictionary = {} # handler_key -> {path: String, args: Array}
+var _lazy_handler_cache: Dictionary = {} # handler_key -> handler instance
+var _lazy_commands: Dictionary = {} # command_name -> {handler: String, method: StringName}
+var _pending_deferred: Dictionary = {} # request_id -> {command, started_ms, timeout_ms}
+var _tick_active := false
+var _log_buffer
+var _surfaced_error_tracker
+## The McpConnection whose pause_processing handlers flip around unsafe
+## editor operations (#288 guard). Set by plugin.gd; untyped to honor the
+## self-update field-storage policy. When set, _call_handler restores the
+## pause depth a crashed handler left unbalanced (#712) — without this a
+## single handler crash inside a pause window freezes the transport
+## forever (pause has no watchdog or disconnect reset by design).
+var pause_target
+var mcp_logging := true
+var deferred_timeout_overrides_ms: Dictionary = {}
+
+const DEFAULT_DEFERRED_TIMEOUT_MS := 4500
+const DEFERRED_TIMEOUT_MS_BY_COMMAND := {
+ "create_script": 4500,
+ ## Fresh-`.gd` writes defer through the same import-settle window as
+ ## create_script (#714) — same headroom over IMPORT_SETTLE_MAX_MSEC.
+ "write_file": 4500,
+ "stop_project": 4500,
+ "run_project": 6000,
+ "take_screenshot": 30000,
+ "check_client_status": 30000,
+ "game_eval": 15000,
+ "game_command": 15000,
+ ## The editor-side runtime-control timer owns 5s; keep the dispatcher
+ ## outside it so the actionable control result wins before DEFERRED_TIMEOUT.
+ "game_debug_control": 6500,
+ "scan_filesystem": 30000,
+}
+const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
+const FuzzySuggestions := preload("res://addons/godot_ai/utils/fuzzy_suggestions.gd")
+const PluginReload := preload("res://addons/godot_ai/utils/plugin_reload.gd")
+
+
+func _init(log_buffer: McpLogBuffer, surfaced_error_tracker = null) -> void:
+ _log_buffer = log_buffer
+ _surfaced_error_tracker = surfaced_error_tracker
+
+
+## Register a command handler. The callable receives (params: Dictionary) -> Dictionary.
+func register(command_name: String, handler: Callable) -> void:
+ _handlers[command_name] = handler
+
+
+## Declare a lazily-constructed handler (#736). `script_path` is load()ed
+## and instantiated with `ctor_args` at the first dispatch of any command
+## registered against `handler_key` via register_lazy. `ctor_args` may hold
+## plugin-lifetime objects (connection, buffers, the dispatcher itself for
+## batch); clear() drops them so teardown ordering matches the old eager
+## registration (#46).
+func register_lazy_handler(handler_key: String, script_path: String, ctor_args: Array) -> void:
+ _lazy_handler_specs[handler_key] = {"path": script_path, "args": ctor_args}
+
+
+## Register a command that resolves to `method` on the lazily-constructed
+## handler declared under `handler_key`. Same dispatch semantics as
+## register(); only construction timing differs.
+func register_lazy(command_name: String, handler_key: String, method: StringName) -> void:
+ _lazy_commands[command_name] = {"handler": handler_key, "method": method}
+
+func unregister(command_name: String, handler_key: String) -> void:
+ _handlers.erase(command_name)
+ _lazy_commands.erase(command_name)
+ ## Drop the lazy spec/cache only when no remaining command routes to
+ ## this handler key. Custom tools use 1:1 keys ("custom:") so this
+ ## always erases for them, but built-in keys serve many commands —
+ ## erasing the shared spec would strand the siblings on
+ ## "No lazy handler declared" at dispatch time.
+ for entry in _lazy_commands.values():
+ if entry.get("handler", "") == handler_key:
+ return
+ _lazy_handler_cache.erase(handler_key)
+ _lazy_handler_specs.erase(handler_key)
+
+## Synchronously realize handler-owned work before any add-on script can be
+## replaced. A handler that cannot prove quiescence keeps the dispatcher live
+## and makes the caller fail closed.
+func quiesce_for_script_swap() -> Dictionary:
+ # Deferred timeouts are not proof that the underlying work has returned;
+ # the independent ledger also covers an old composition after a reload.
+ var work: Dictionary = preload("res://addons/godot_ai/utils/script_work.gd").quiescence()
+ if not bool(work.get("ok", false)):
+ return work
+ if not _pending_deferred.is_empty():
+ return {"ok": false, "error": "Wait for pending tool responses before updating."}
+ for handler_key in _lazy_handler_cache:
+ var instance: Variant = _lazy_handler_cache[handler_key]
+ if not is_instance_valid(instance) or not instance.has_method("quiesce_for_script_swap"):
+ return {
+ "ok": false,
+ "error": "Command handler '%s' cannot prove quiescence for script replacement." % handler_key,
+ }
+ var result: Variant = instance.call("quiesce_for_script_swap")
+ if not result is Dictionary or not bool(result.get("ok", false)):
+ return {
+ "ok": false,
+ "error": "Command handler '%s' could not quiesce for script replacement: %s" % [
+ handler_key, result,
+ ],
+ }
+ return {"ok": true}
+
+
+## Drop registered handlers, queued commands, and the log buffer ref so
+## plugin.gd can release RefCounted handlers before Godot reloads their
+## class_name scripts (issue #46). After a successful clear(), the dispatcher
+## is inert. A failed quiesce leaves every reference intact.
+func clear() -> Dictionary:
+ var quiesced := quiesce_for_script_swap()
+ if not bool(quiesced.get("ok", false)):
+ return quiesced
+ release_after_teardown()
+ return {"ok": true}
+
+
+## Ordinary plugin teardown is not permission to replace scripts. The root
+## first stops transport and joins its client/vision workers, then drops this
+## graph even when a handler cannot certify hot script replacement. Requiring
+## that stronger certificate here leaks the dispatcher <-> handler cycles at
+## every editor exit. Hot-update callers must still use clear(), which refuses
+## to release anything until every materialized handler proves quiescence.
+func release_after_teardown() -> void:
+ _handlers.clear()
+ ## Release lazily-constructed handler instances (and the ctor args that
+ ## reference plugin-lifetime objects) at the same teardown point where
+ ## eager handler Callables used to be dropped — their destructors must
+ ## run while their scripts are still loaded (#46). This also breaks the
+ ## dispatcher -> batch handler -> dispatcher ref cycle.
+ _lazy_handler_specs.clear()
+ _lazy_handler_cache.clear()
+ _lazy_commands.clear()
+ _command_queue.clear()
+ _pending_deferred.clear()
+ _log_buffer = null
+ _surfaced_error_tracker = null
+ pause_target = null
+
+
+## Drop queued-but-unexecuted commands. Called by the connection on
+## disconnect (#712): commands queued by the previous connection must not
+## execute under the next one — the requester is gone, its in-flight
+## futures were already failed server-side, and a mutation landing after
+## reconnect is a surprise write nobody can correlate. Deferred bookkeeping
+## has its own reset (clear_deferred_responses).
+func clear_command_queue() -> void:
+ _command_queue.clear()
+
+
+## Invoke a registered handler directly by name. Returns the handler's raw
+## response dict (no request_id or status wrapping). Returns an UNKNOWN_COMMAND
+## error dict if the command is not registered. Used by batch_execute.
+func dispatch_direct(command: String, params: Dictionary) -> Dictionary:
+ if not has_command(command):
+ return ErrorCodes.make(ErrorCodes.UNKNOWN_COMMAND, "Unknown command: %s" % command)
+ ## Strip the reserved deferred-reply key: only _dispatch may thread it.
+ ## A caller-supplied _request_id (e.g. inside a batch_execute
+ ## sub-command's params) would flip a deferred-capable handler into
+ ## deferred mode against a request id the dispatcher never registered —
+ ## the direct caller would get the DEFERRED sentinel instead of a result
+ ## and the out-of-band reply would be dropped as expired.
+ if params.has("_request_id"):
+ params = params.duplicate()
+ params.erase("_request_id")
+ return _call_handler(command, params)
+
+
+## Whether a command is registered (eagerly or lazily).
+func has_command(command: String) -> bool:
+ return _handlers.has(command) or _lazy_commands.has(command)
+
+
+## Rank registered commands by similarity to `cmd_name` and return the top `limit`
+## matches. Uses Godot's built-in String.similarity() (0.0–1.0). Returns an empty
+## array if no candidates clear the threshold. Used by batch_execute to surface
+## "did you mean" suggestions when an unknown command is passed.
+func suggest_similar(cmd_name: String, limit: int = 3, threshold: float = 0.5) -> Array[String]:
+ return FuzzySuggestions.rank(cmd_name, _registered_command_names(), limit, threshold, 0.0, 0.0)
+
+
+## Union of eagerly-registered and lazily-registered command names.
+## Materialized lazy commands live in both dicts, so dedupe via keys.
+func _registered_command_names() -> Array:
+ var names: Dictionary = {}
+ for command in _handlers:
+ names[command] = true
+ for command in _lazy_commands:
+ names[command] = true
+ return names.keys()
+
+
+## Enqueue a raw command dict received from the WebSocket.
+func enqueue(cmd: Dictionary) -> void:
+ _command_queue.append(cmd)
+
+
+func pending_deferred_count() -> int:
+ return _pending_deferred.size()
+
+
+func clear_deferred_responses() -> void:
+ _pending_deferred.clear()
+
+
+func has_pending_deferred_response(request_id: String) -> bool:
+ return request_id.is_empty() or _pending_deferred.has(request_id)
+
+
+func complete_deferred_response(request_id: String) -> bool:
+ if request_id.is_empty():
+ return true
+ if not _pending_deferred.has(request_id):
+ return false
+ _pending_deferred.erase(request_id)
+ return true
+
+
+## Handlers whose response flows out-of-band (e.g. debugger-channel capture)
+## return this marker so tick() skips auto-sending a response. The handler is
+## responsible for pushing the final response via McpConnection._send_json when
+## the async operation completes. The dispatcher tracks the request_id and emits
+## DEFERRED_TIMEOUT if the out-of-band response never arrives. The request_id is
+## threaded through params under the "_request_id" key so the handler can
+## correlate the response.
+const DEFERRED_RESPONSE := {"_deferred": true}
+
+
+## Process queued commands within a frame budget (milliseconds).
+## Returns an array of response dictionaries to send back.
+func tick(budget_ms: float = 4.0) -> Array[Dictionary]:
+ ## Editor filesystem operations can pump another frame before the handler
+ ## returns. That frame must not dispatch the still-queued command again.
+ if _tick_active or PluginReload.is_reload_pending():
+ return []
+ _tick_active = true
+ var responses: Array[Dictionary] = _collect_deferred_timeouts()
+ var start := Time.get_ticks_msec()
+ var idx := 0
+
+ while idx < _command_queue.size() and (Time.get_ticks_msec() - start) < budget_ms:
+ var cmd: Dictionary = _command_queue[idx]
+ var response := _dispatch(cmd)
+ if not response.get("_deferred", false):
+ responses.append(response)
+ idx += 1
+ if PluginReload.is_reload_pending():
+ break
+
+ if idx > 0:
+ _command_queue = _command_queue.slice(idx)
+
+ _tick_active = false
+ return responses
+
+
+func _dispatch(cmd: Dictionary) -> Dictionary:
+ var request_id: String = cmd.get("request_id", "")
+ var command: String = cmd.get("command", "")
+ var raw_params: Dictionary = cmd.get("params", {})
+ ## Duplicate so the internal _request_id key we thread through doesn't
+ ## mutate the queued command's params (which is the same dict we're
+ ## about to JSON-log below, and which later readers like batch_execute
+ ## shouldn't see dispatcher-internal metadata from).
+ var params: Dictionary = raw_params.duplicate()
+ params["_request_id"] = request_id
+
+ if mcp_logging:
+ _log_buffer.log("[recv] %s(%s)" % [command, JSON.stringify(raw_params)])
+
+ var result: Dictionary
+
+ if has_command(command):
+ result = _call_handler(command, params)
+ else:
+ result = ErrorCodes.make(ErrorCodes.UNKNOWN_COMMAND, "Unknown command: %s" % command)
+
+ if result.get("_deferred", false):
+ ## A handler may attach `_deferred_timeout_ms` to its deferred sentinel
+ ## to claim a per-request budget larger than its command's shared entry
+ ## (e.g. game_command's `input_sequence`, which steps frames well past
+ ## the 15s that suits one-shot game ops). 0/absent falls back to the
+ ## per-command table.
+ _register_deferred(request_id, command, int(result.get("_deferred_timeout_ms", 0)))
+ if mcp_logging:
+ _log_buffer.log("[defer] %s (request %s)" % [command, request_id])
+ return result
+
+ result["request_id"] = request_id
+ if not result.has("status"):
+ result["status"] = "ok"
+ ## Stamp live editor readiness onto every command-response envelope so
+ ## the server's `Session.readiness` cache self-heals on the very next
+ ## tool call. Without this, a single dropped `readiness_changed` event
+ ## (or a one-frame race around `pause_processing`) leaves the cache
+ ## stuck at "playing" / "importing" long after the editor has settled,
+ ## and write tools fail with EDITOR_NOT_READY against a writable editor.
+ ## See connection.gd::send_deferred_response for the deferred-response
+ ## counterpart, which stamps the same field.
+ result["readiness"] = McpConnection.get_readiness()
+ _stamp_error_watermark(result)
+
+ if mcp_logging:
+ var status: String = result.get("status", "ok")
+ if status == "ok":
+ _log_buffer.log("[send] %s -> ok" % command)
+ else:
+ var err_msg: String = result.get("error", {}).get("message", "unknown")
+ _log_buffer.log("[send] %s -> error: %s" % [command, err_msg])
+
+ return result
+
+
+## Truncate JSON-stringified args at this many chars when stuffing them into
+## a malformed-result error message — large dicts shouldn't bloat the
+## response, but a few hundred chars usually pinpoints which param was the
+## wrong shape.
+const _MALFORMED_ARGS_MAX := 400
+
+
+func _call_handler(command: String, params: Dictionary) -> Dictionary:
+ if not _handlers.has(command):
+ var materialize_error := _materialize_lazy_command(command)
+ if not materialize_error.is_empty():
+ return materialize_error
+ ## #712: a handler that crashes between pause_processing = true and its
+ ## matching false leaves the pause depth unbalanced — GDScript swallows
+ ## the error, the dispatcher reports "malformed result", and the
+ ## transport stays paused FOREVER (no watchdog, no disconnect reset).
+ ## Restore balance at this boundary: the depth a handler leaves behind
+ ## must equal the depth it started with.
+ var pause_depth_before: int = pause_target.pause_depth() if pause_target != null else 0
+ var result: Dictionary = _handlers[command].call(params)
+ if pause_target != null and pause_target.pause_depth() > pause_depth_before:
+ var leaked: int = pause_target.pause_depth() - pause_depth_before
+ while pause_target.pause_depth() > pause_depth_before:
+ pause_target.resume()
+ if mcp_logging and _log_buffer != null:
+ _log_buffer.log(
+ "[error] %s leaked %d pause_processing level(s) — restored (handler crash?)"
+ % [command, leaked]
+ )
+ ## Handlers must return {"data": ...} on success or {"error": ...} on failure.
+ ## Anything else (null, empty, missing keys) means the handler crashed
+ ## mid-call — GDScript swallows the error and returns an empty dict.
+ if result == null or not (result.has("data") or result.has("error") or result.has("_deferred")):
+ var safe_params := params.duplicate()
+ safe_params.erase("_request_id")
+ var args_json := JSON.stringify(safe_params)
+ if args_json.length() > _MALFORMED_ARGS_MAX:
+ args_json = args_json.substr(0, _MALFORMED_ARGS_MAX) + "..."
+ var backtrace := _capture_compact_backtrace()
+ var msg := (
+ "Handler '%s' returned malformed result — likely a runtime error in the handler "
+ + "(e.g. param type mismatch). Args received: %s"
+ ) % [command, args_json]
+ if not backtrace.is_empty():
+ msg += "\nBacktrace:\n%s" % backtrace
+ if mcp_logging and _log_buffer != null:
+ var compact_backtrace := backtrace.replace("\n", " | ")
+ _log_buffer.log(
+ "[error] %s -> malformed result; args=%s; backtrace=%s"
+ % [command, args_json, compact_backtrace]
+ )
+ return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, msg)
+ return result
+
+
+## Resolve a lazily-registered command into a live Callable in `_handlers`.
+## Loads + constructs the owning handler on first use (cached per handler
+## key, so one load() covers every command the handler serves). Returns an
+## empty dict on success or a protocol error dict on failure — a missing
+## script or method is a plugin packaging bug and must surface loudly, not
+## as a silent no-op.
+func _materialize_lazy_command(command: String) -> Dictionary:
+ var command_spec: Dictionary = _lazy_commands.get(command, {})
+ if command_spec.is_empty():
+ return ErrorCodes.make(ErrorCodes.UNKNOWN_COMMAND, "Unknown command: %s" % command)
+ var handler_key: String = command_spec["handler"]
+ var instance = _lazy_handler_cache.get(handler_key)
+ if instance == null:
+ var handler_spec: Dictionary = _lazy_handler_specs.get(handler_key, {})
+ if handler_spec.is_empty():
+ return ErrorCodes.make(
+ ErrorCodes.INTERNAL_ERROR,
+ "No lazy handler '%s' declared for command '%s'" % [handler_key, command]
+ )
+ ## Existence-check first so a missing script surfaces as one clean
+ ## protocol error instead of also spraying engine load errors.
+ if not ResourceLoader.exists(handler_spec["path"]):
+ return ErrorCodes.make(
+ ErrorCodes.INTERNAL_ERROR,
+ "Missing handler script '%s' for command '%s'" % [handler_spec["path"], command]
+ )
+ var script := load(handler_spec["path"]) as GDScript
+ if script == null:
+ return ErrorCodes.make(
+ ErrorCodes.INTERNAL_ERROR,
+ "Failed to load handler script '%s' for command '%s'" % [handler_spec["path"], command]
+ )
+ instance = script.callv("new", handler_spec["args"])
+ if instance == null:
+ return ErrorCodes.make(
+ ErrorCodes.INTERNAL_ERROR,
+ "Failed to construct handler '%s' for command '%s'" % [handler_key, command]
+ )
+ _lazy_handler_cache[handler_key] = instance
+ var method: StringName = command_spec["method"]
+ if not instance.has_method(method):
+ return ErrorCodes.make(
+ ErrorCodes.INTERNAL_ERROR,
+ "Handler '%s' has no method '%s' for command '%s'" % [handler_key, method, command]
+ )
+ _handlers[command] = Callable(instance, method)
+ return {}
+
+
+func _register_deferred(request_id: String, command: String, timeout_override_ms: int = 0) -> void:
+ if request_id.is_empty():
+ return
+ ## A positive per-request override wins over the per-command table so a
+ ## single deferred call can claim more headroom without globally widening
+ ## the command's budget (see _dispatch: input_sequence needs ~30s, but the
+ ## other game_command ops must keep their tight 15s).
+ var timeout_ms: int = (
+ timeout_override_ms if timeout_override_ms > 0
+ else _deferred_timeout_ms_for_command(command)
+ )
+ _pending_deferred[request_id] = {
+ "command": command,
+ "started_ms": Time.get_ticks_msec(),
+ "timeout_ms": timeout_ms,
+ }
+
+
+func _deferred_timeout_ms_for_command(command: String) -> int:
+ if deferred_timeout_overrides_ms.has(command):
+ return int(deferred_timeout_overrides_ms[command])
+ return int(DEFERRED_TIMEOUT_MS_BY_COMMAND.get(command, DEFAULT_DEFERRED_TIMEOUT_MS))
+
+
+func _collect_deferred_timeouts() -> Array[Dictionary]:
+ var responses: Array[Dictionary] = []
+ if _pending_deferred.is_empty():
+ return responses
+ var now := Time.get_ticks_msec()
+ for request_id in _pending_deferred.keys():
+ var entry: Dictionary = _pending_deferred[request_id]
+ var timeout_ms: int = entry.get("timeout_ms", DEFAULT_DEFERRED_TIMEOUT_MS)
+ var elapsed_ms := now - int(entry.get("started_ms", now))
+ if elapsed_ms < timeout_ms:
+ continue
+ _pending_deferred.erase(request_id)
+ var command: String = entry.get("command", "")
+ var response := ErrorCodes.make(
+ ErrorCodes.DEFERRED_TIMEOUT,
+ "Deferred response for '%s' timed out after %dms" % [command, timeout_ms]
+ )
+ response["request_id"] = request_id
+ response["error"]["data"] = {
+ "command": command,
+ "elapsed_ms": elapsed_ms,
+ "timeout_ms": timeout_ms,
+ }
+ ## Same envelope-level readiness stamp as `_dispatch` — keep the
+ ## self-heal channel symmetric across every reply shape the
+ ## dispatcher emits so the server cache can't drift just because
+ ## the editor happened to time out a deferred command.
+ response["readiness"] = McpConnection.get_readiness()
+ _stamp_error_watermark(response)
+ responses.append(response)
+ if mcp_logging and _log_buffer != null:
+ _log_buffer.log("[defer] %s (request %s) -> timeout" % [command, request_id])
+ return responses
+
+
+func _stamp_error_watermark(response: Dictionary) -> void:
+ McpSurfacedErrorTracker.stamp_watermark(response, _surfaced_error_tracker)
+
+
+static func _capture_compact_backtrace(max_frames: int = 8) -> String:
+ var traces: Array = Engine.capture_script_backtraces(false)
+ for bt in traces:
+ if bt != null and not bt.is_empty():
+ return _trim_backtrace_string(bt.format(0, 2), max_frames)
+ return _format_stack_frames(get_stack(), max_frames)
+
+
+static func _trim_backtrace_string(text: String, max_frames: int) -> String:
+ var lines := text.strip_edges().split("\n")
+ var kept: Array[String] = []
+ for i in range(min(lines.size(), max_frames)):
+ kept.append(lines[i].strip_edges())
+ return "\n".join(kept)
+
+
+static func _format_stack_frames(frames: Array, max_frames: int) -> String:
+ var lines: Array[String] = []
+ for i in range(min(frames.size(), max_frames)):
+ var frame: Dictionary = frames[i]
+ lines.append(
+ "%s:%s in %s"
+ % [
+ frame.get("source", "?"),
+ frame.get("line", 0),
+ frame.get("function", "?"),
+ ]
+ )
+ return "\n".join(lines)
diff --git a/addons/godot_ai/dispatcher.gd.uid b/addons/godot_ai/dispatcher.gd.uid
new file mode 100644
index 0000000..25a05bd
--- /dev/null
+++ b/addons/godot_ai/dispatcher.gd.uid
@@ -0,0 +1 @@
+uid://ctldk7ivsoo3i
diff --git a/addons/godot_ai/dock_panels/log_viewer.gd b/addons/godot_ai/dock_panels/log_viewer.gd
new file mode 100644
index 0000000..150912d
--- /dev/null
+++ b/addons/godot_ai/dock_panels/log_viewer.gd
@@ -0,0 +1,86 @@
+@tool
+extends VBoxContainer
+
+## Dock subpanel — renders the MCP request/response log buffer. Owns its own
+## UI subtree, the line-count cursor, and the display-visibility toggle. It
+## receives copied `{sequence, reset, lines}` values; neither this view nor its
+## parent Dock retains the root-owned log buffer.
+##
+## Extracted from mcp_dock.gd as part of audit-v2 #360 — see the comment at
+## the top of mcp_dock.gd for the broader extraction story.
+
+signal logging_enabled_changed(enabled: bool)
+
+const Dock := preload("res://addons/godot_ai/mcp_dock.gd")
+const Settings := preload("res://addons/godot_ai/utils/settings.gd")
+
+var _log_display: RichTextLabel
+var _log_toggle: CheckButton
+## Last `McpLogBuffer.total_logged()` value painted into the display. Tracking
+## the buffer's monotonic sequence (rather than its bounded `total_count()`)
+## keeps the viewer painting once the ring fills — a size-based cursor would
+## freeze at MAX_LINES on every subsequent append. See PR #392 for the bug.
+var _last_log_seq := 0
+
+
+## Build the UI synchronously here so callers (and detached-tree tests that
+## instantiate the dock with `McpDockScript.new()` and never enter the tree)
+## can interact with the panel's controls right after `setup()`. Mirrors the
+## pre-extraction inline-build behavior that test_dock.gd relies on.
+##
+## Idempotent: `_log_display == null` covers an unlikely double-`setup()` call
+## without rebuilding (which would orphan the prior controls).
+func setup() -> void:
+ if _log_display == null:
+ _build_ui()
+
+
+func _build_ui() -> void:
+ add_child(HSeparator.new())
+
+ var log_header_row := HBoxContainer.new()
+ var log_header := Dock._make_header("MCP Log")
+ log_header.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ log_header_row.add_child(log_header)
+
+ _log_toggle = CheckButton.new()
+ _log_toggle.text = "Log"
+ ## Restore the persisted choice — a hardcoded `true` here meant the
+ ## toggle reset to noisy on every editor restart (#626).
+ _log_toggle.button_pressed = Settings.mcp_logging_enabled()
+ _log_toggle.toggled.connect(_on_log_toggled)
+ log_header_row.add_child(_log_toggle)
+
+ add_child(log_header_row)
+
+ _log_display = RichTextLabel.new()
+ _log_display.custom_minimum_size = Vector2(0, 80)
+ _log_display.scroll_following = true
+ _log_display.bbcode_enabled = false
+ _log_display.selection_enabled = true
+ _log_display.visible = _log_toggle.button_pressed
+ add_child(_log_display)
+
+
+func sequence() -> int:
+ return _last_log_seq
+
+
+func present_snapshot(snapshot: Dictionary) -> void:
+ if _log_display == null:
+ return
+ var seq := int(snapshot.get("sequence", _last_log_seq))
+ if bool(snapshot.get("reset", false)) or seq < _last_log_seq:
+ _log_display.clear()
+ _last_log_seq = 0
+ var lines: Array[String] = []
+ lines.assign(snapshot.get("lines", []))
+ for line in lines:
+ _log_display.add_text(line + "\n")
+ _last_log_seq = seq
+
+
+func _on_log_toggled(enabled: bool) -> void:
+ Settings.set_mcp_logging_enabled(enabled)
+ _log_display.visible = enabled
+ logging_enabled_changed.emit(enabled)
diff --git a/addons/godot_ai/dock_panels/log_viewer.gd.uid b/addons/godot_ai/dock_panels/log_viewer.gd.uid
new file mode 100644
index 0000000..c261627
--- /dev/null
+++ b/addons/godot_ai/dock_panels/log_viewer.gd.uid
@@ -0,0 +1 @@
+uid://cr5nbnd6vj3b8
diff --git a/addons/godot_ai/dock_panels/port_picker_panel.gd b/addons/godot_ai/dock_panels/port_picker_panel.gd
new file mode 100644
index 0000000..ea935b4
--- /dev/null
+++ b/addons/godot_ai/dock_panels/port_picker_panel.gd
@@ -0,0 +1,106 @@
+@tool
+extends VBoxContainer
+
+## Dock subpanel — port-change escape hatch surfaced inside the spawn-failure
+## crash panel when a port is contested or an incompatible server cannot be reclaimed.
+##
+## It moves BOTH ports. A godot-ai server binds its HTTP and WebSocket ports
+## together, so moving only the HTTP port onto a free number lands the next
+## launch on a WebSocket port the previous server still holds, and the server
+## dies at preflight ("WebSocket port 19630 is already in use"). Emits
+## `port_apply_requested(new_http_port, new_ws_port)` after range-validation;
+## the dock writes the EditorSettings and reloads the plugin.
+##
+## Extracted from mcp_dock.gd as part of audit-v2 #360 — see the comment at
+## the top of mcp_dock.gd for the broader extraction story.
+
+const ClientConfigurator := preload("res://addons/godot_ai/client_configurator.gd")
+const PortResolver := preload("res://addons/godot_ai/utils/port_resolver.gd")
+
+signal port_apply_requested(new_http_port: int, new_ws_port: int)
+
+var _spinbox: SpinBox
+var _ws_spinbox: SpinBox
+## Bind probe used when seeding; tests substitute a deterministic one.
+var port_in_use_probe: Callable = Callable(PortResolver, "is_port_in_use")
+
+
+## Build the UI synchronously here so callers (and detached-tree tests that
+## instantiate the dock with `McpDockScript.new()` and never enter the tree)
+## can interact with the panel's controls right after `setup()`. Mirrors the
+## pre-extraction inline-build behavior that test_dock.gd relies on.
+##
+## Idempotent: `_spinbox == null` covers an unlikely double-`setup()` call
+## without rebuilding (which would orphan the prior controls).
+func setup() -> void:
+ if _spinbox == null:
+ _build_ui()
+
+
+func _build_ui() -> void:
+ add_theme_constant_override("separation", 4)
+ visible = false
+
+ var picker_row := HBoxContainer.new()
+ picker_row.add_theme_constant_override("separation", 6)
+
+ _spinbox = _port_spinbox(ClientConfigurator.http_port(), "Effective HTTP port")
+ picker_row.add_child(_spinbox)
+ _ws_spinbox = _port_spinbox(ClientConfigurator.ws_port(), "Effective WebSocket port")
+ picker_row.add_child(_ws_spinbox)
+
+ var apply_btn := Button.new()
+ apply_btn.text = "Apply + Reload"
+ apply_btn.tooltip_text = (
+ "Saves the effective HTTP and WebSocket ports to Editor Settings and reloads"
+ + " the plugin so the server spawns on the new ports. Reconfigure your AI"
+ + " clients afterwards so their bridges use the new ports."
+ )
+ apply_btn.pressed.connect(_on_apply_pressed)
+ picker_row.add_child(apply_btn)
+
+ add_child(picker_row)
+
+
+static func _port_spinbox(value: int, tooltip: String) -> SpinBox:
+ var spinbox := SpinBox.new()
+ spinbox.min_value = ClientConfigurator.MIN_PORT
+ spinbox.max_value = ClientConfigurator.MAX_PORT
+ spinbox.step = 1
+ spinbox.value = value
+ spinbox.tooltip_text = tooltip
+ spinbox.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ return spinbox
+
+
+## Seed both boxes every time the panel surfaces, so a stale value from a
+## previous spawn-failure round cannot carry over. The diagnosed
+## `conflict_port` and any port that is simply in use get a fresh free
+## suggestion; a port that is free keeps its value, so clients whose entries
+## carry it keep working. Note that this OVERWRITES unsaved user input — fine
+## in practice because the dock only calls this on `server_status`
+## transitions (`if server_status == _last_server_status: return`).
+func seed_suggested_ports(conflict_port := 0) -> void:
+ if _spinbox == null:
+ return
+ var http := ClientConfigurator.http_port()
+ var ws := ClientConfigurator.ws_port()
+ if conflict_port == http or bool(port_in_use_probe.call(http)):
+ http = ClientConfigurator.suggest_free_port(http + 1)
+ if conflict_port == ws or ws == http or bool(port_in_use_probe.call(ws)):
+ ws = ClientConfigurator.suggest_free_port(ws + 1)
+ if ws == http:
+ ws = ClientConfigurator.suggest_free_port(ws + 1)
+ _spinbox.value = http
+ _ws_spinbox.value = ws
+
+
+func _on_apply_pressed() -> void:
+ var new_http: int = int(_spinbox.value)
+ var new_ws: int = int(_ws_spinbox.value)
+ for port in [new_http, new_ws]:
+ if port < ClientConfigurator.MIN_PORT or port > ClientConfigurator.MAX_PORT:
+ return
+ if new_http == new_ws:
+ return
+ port_apply_requested.emit(new_http, new_ws)
diff --git a/addons/godot_ai/dock_panels/port_picker_panel.gd.uid b/addons/godot_ai/dock_panels/port_picker_panel.gd.uid
new file mode 100644
index 0000000..38b6320
--- /dev/null
+++ b/addons/godot_ai/dock_panels/port_picker_panel.gd.uid
@@ -0,0 +1 @@
+uid://hlggbo1q65eq
diff --git a/addons/godot_ai/export/mcp_export_plugin.gd b/addons/godot_ai/export/mcp_export_plugin.gd
new file mode 100644
index 0000000..5a8b885
--- /dev/null
+++ b/addons/godot_ai/export/mcp_export_plugin.gd
@@ -0,0 +1,69 @@
+@tool
+extends EditorExportPlugin
+
+## Strips the MCP game-helper autoload from exported builds (#740).
+##
+## plugin.gd writes `autoload/_mcp_game_helper` into project.godot so the
+## editor-spawned game process loads the helper. Exports bake project
+## settings into the pack's project.binary, so without this plugin every
+## export ships the autoload — and users who exclude addons/godot_ai/**
+## in their export preset get three "Failed to instantiate an autoload"
+## errors at game start. Even when the files ARE shipped, the helper is
+## editor-tooling: no exported build should carry it.
+##
+## Strip mechanics: clear the in-memory ProjectSettings entry in
+## _export_begin, restore it in _export_end. The export pipeline reads
+## the live ProjectSettings when it bakes project.binary, which happens
+## after _export_begin — verified end-to-end by
+## script/ci-export-strip-smoke, which exports a real pack and asserts
+## the autoload is absent inside it. We never call ProjectSettings.save()
+## while stripped, so project.godot on disk keeps the autoload
+## throughout; only the export snapshot loses it.
+##
+## Failure containment: if an export aborts so hard that _export_end
+## never fires, the damage is bounded to the editor's in-memory settings
+## — the running game reads project.godot from disk, and plugin.gd's
+## _ensure_game_helper_autoload() re-asserts the entry on the next
+## plugin enable / editor launch.
+
+## Must equal "autoload/" + plugin.gd's GAME_HELPER_AUTOLOAD_NAME.
+## Duplicated (not preloaded from plugin.gd) to avoid a cyclic preload —
+## plugin.gd preloads this script. The pairing is locked by
+## test_export_strip.gd's constants-contract test.
+const AUTOLOAD_KEY := "autoload/_mcp_game_helper"
+
+var _saved_value: Variant = null
+var _stripped := false
+
+
+func _get_name() -> String:
+ return "GodotAIStripAutoload"
+
+
+func _export_begin(_features: PackedStringArray, _is_debug: bool, _path: String, _flags: int) -> void:
+ ## `_stripped` guard: if a previous export died before _export_end,
+ ## don't overwrite the genuinely-saved value with the already-cleared
+ ## state — restore semantics stay anchored to the original value.
+ if _stripped:
+ return
+ if not ProjectSettings.has_setting(AUTOLOAD_KEY):
+ return
+ _saved_value = ProjectSettings.get_setting(AUTOLOAD_KEY)
+ ## Setting a project setting to null erases it.
+ ProjectSettings.set_setting(AUTOLOAD_KEY, null)
+ _stripped = true
+ print("MCP | export: stripping %s from the exported pack (restored in the editor after export)" % AUTOLOAD_KEY)
+
+
+func _export_end() -> void:
+ if not _stripped:
+ return
+ ProjectSettings.set_setting(AUTOLOAD_KEY, _saved_value)
+ ## Mirror _ensure_game_helper_autoload()'s registration shape so the
+ ## restored entry is indistinguishable from the original: initial
+ ## value "" keeps project.godot diff-clean, basic keeps it visible in
+ ## the non-advanced settings view.
+ ProjectSettings.set_initial_value(AUTOLOAD_KEY, "")
+ ProjectSettings.set_as_basic(AUTOLOAD_KEY, true)
+ _saved_value = null
+ _stripped = false
diff --git a/addons/godot_ai/export/mcp_export_plugin.gd.uid b/addons/godot_ai/export/mcp_export_plugin.gd.uid
new file mode 100644
index 0000000..f2a3634
--- /dev/null
+++ b/addons/godot_ai/export/mcp_export_plugin.gd.uid
@@ -0,0 +1 @@
+uid://np0cp6fwpim7
diff --git a/addons/godot_ai/handlers/_node_validator.gd b/addons/godot_ai/handlers/_node_validator.gd
new file mode 100644
index 0000000..61bbe10
--- /dev/null
+++ b/addons/godot_ai/handlers/_node_validator.gd
@@ -0,0 +1,71 @@
+@tool
+class_name McpNodeValidator
+extends RefCounted
+
+## Shared resolve-or-error helper that subsumes the 38+ sites where
+## handlers each rolled their own "is the editor ready, does the path
+## resolve, otherwise return EDITOR_NOT_READY / NODE_NOT_FOUND" guard.
+##
+## audit-v2 #20 (issue #364). Uses the audit-v2 #21 (issue #365) error
+## vocabulary.
+
+## Local const names alias the preloaded scripts. The naming choice is
+## stylistic, not an upgrade-safety boundary: bare `McpErrorCodes.MEMBER`
+## and `ErrorCodes.MEMBER` both depend on the Script object Godot has for
+## `error_codes.gd`. The transient #398 parse errors were caused by the
+## old runner scanning a mixed old/new plugin snapshot and seeing stale
+## Script-object content; the runner now writes one v(N+1) snapshot before
+## its scan.
+const ScenePath := preload("res://addons/godot_ai/utils/scene_path.gd")
+const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
+
+
+## Resolve a scene-relative path to the live Node, or return a structured
+## error dict.
+##
+## Success shape: `{"node": Node, "scene_root": Node, "path": String}`.
+## Error shape: matches `ErrorCodes.make(...)` so callers can
+## `return resolved` to propagate.
+##
+## Errors (in order checked):
+## - `MISSING_REQUIRED_PARAM`: `node_path` is empty
+## - `EDITOR_NOT_READY`: no scene open
+## - `EDITED_SCENE_MISMATCH`: caller pinned `scene_file` and the open
+## scene's path doesn't match
+## - `NODE_NOT_FOUND`: `node_path` doesn't resolve under the scene root
+##
+## `param_name` is the agent-facing name reported in the
+## `MISSING_REQUIRED_PARAM` message — handlers pass "node_path",
+## "player_path", "target_path", etc. so the error reads like the
+## hand-written messages it replaces.
+static func resolve_or_error(
+ node_path: String,
+ param_name: String = "path",
+ scene_file: String = "",
+) -> Dictionary:
+ if node_path.is_empty():
+ return ErrorCodes.make(
+ ErrorCodes.MISSING_REQUIRED_PARAM,
+ "Missing required param: %s" % param_name,
+ )
+ var scene_check := ScenePath.require_edited_scene(scene_file)
+ if scene_check.has("error"):
+ return scene_check
+ var scene_root: Node = scene_check.node
+ var node := ScenePath.resolve(node_path, scene_root)
+ if node == null:
+ return ErrorCodes.make(
+ ErrorCodes.NODE_NOT_FOUND,
+ ScenePath.format_node_error(node_path, scene_root),
+ )
+ return {"node": node, "scene_root": scene_root, "path": node_path}
+
+
+## When the caller needs the scene root but no specific node yet — e.g.
+## handlers that walk children or filter by group. Returns either
+## `{"scene_root": Node}` or an `ErrorCodes.make(...)` error dict.
+static func require_scene_or_error(scene_file: String = "") -> Dictionary:
+ var scene_check := ScenePath.require_edited_scene(scene_file)
+ if scene_check.has("error"):
+ return scene_check
+ return {"scene_root": scene_check.node}
diff --git a/addons/godot_ai/handlers/_node_validator.gd.uid b/addons/godot_ai/handlers/_node_validator.gd.uid
new file mode 100644
index 0000000..b3153a6
--- /dev/null
+++ b/addons/godot_ai/handlers/_node_validator.gd.uid
@@ -0,0 +1 @@
+uid://dn75jifad0ghx
diff --git a/addons/godot_ai/handlers/_param_validators.gd b/addons/godot_ai/handlers/_param_validators.gd
new file mode 100644
index 0000000..a316b32
--- /dev/null
+++ b/addons/godot_ai/handlers/_param_validators.gd
@@ -0,0 +1,30 @@
+@tool
+class_name McpParamValidators
+extends RefCounted
+
+const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
+
+## Type-check a JSON-decoded param Variant before assigning it into a typed
+## GDScript local. The dispatcher only catches handler crashes as an opaque
+## "malformed result" (issue #210), so a typed assignment like
+## var group: String = params.get("group", "")
+## will runtime-error and bubble up without telling the caller which param
+## was the wrong shape. Only string params are guarded — int/bool params
+## can't be: Godot's JSON parser decodes every number as float (a wire `5`
+## arrives as `5.0`), so a strict int check would reject every legitimate
+## integer a client sends, and GDScript's typed assignment already converts
+## numeric Variants safely. Bool params arrive as real bools and a wrong
+## type surfaces through the dispatcher's malformed-result path.
+
+
+## Returns null iff `value` is a String or StringName. On any other type
+## returns an INVALID_PARAMS error dict whose message names both `name` and
+## the actual Variant type (via Godot's built-in `type_string`).
+static func require_string(name: String, value: Variant) -> Variant:
+ var t := typeof(value)
+ if t == TYPE_STRING or t == TYPE_STRING_NAME:
+ return null
+ return ErrorCodes.make(
+ ErrorCodes.WRONG_TYPE,
+ "Param '%s' must be a String, got %s" % [name, type_string(t)],
+ )
diff --git a/addons/godot_ai/handlers/_param_validators.gd.uid b/addons/godot_ai/handlers/_param_validators.gd.uid
new file mode 100644
index 0000000..cd2061e
--- /dev/null
+++ b/addons/godot_ai/handlers/_param_validators.gd.uid
@@ -0,0 +1 @@
+uid://difa877m8dsla
diff --git a/addons/godot_ai/handlers/_property_errors.gd b/addons/godot_ai/handlers/_property_errors.gd
new file mode 100644
index 0000000..316b941
--- /dev/null
+++ b/addons/godot_ai/handlers/_property_errors.gd
@@ -0,0 +1,82 @@
+@tool
+class_name McpPropertyErrors
+extends RefCounted
+
+## Shared helper for building "Property not found" error messages that include
+## "did you mean" suggestions and a tail of available property names. All
+## handlers that validate user-supplied property names against a target Object
+## (Node, Resource, …) should route through build_message() so agents get
+## consistent, actionable errors on typos.
+##
+## Ranking combines Godot's built-in String.similarity() with a substring
+## bonus so both "radus" → "radius" (edit distance) and "top" → "top_radius"
+## (substring) surface naturally.
+
+const _SIMILARITY_THRESHOLD: float = 0.4
+const _SUBSTRING_BONUS: float = 0.5
+const _MAX_SUGGESTIONS: int = 5
+const _MAX_TAIL: int = 10
+
+
+static func build_message(target: Object, bad_name: String) -> String:
+ if target == null:
+ return "Property '%s' not found" % bad_name
+ var class_label := _class_label(target)
+ var available := _available_property_names(target)
+ if available.is_empty():
+ return "Property '%s' not found on %s" % [bad_name, class_label]
+
+ var msg := "Property '%s' not found on %s" % [bad_name, class_label]
+ var suggestions := _rank_suggestions(bad_name, available)
+ if not suggestions.is_empty():
+ msg += ". Did you mean: %s?" % ", ".join(suggestions)
+
+ var tail_names := available.slice(0, min(_MAX_TAIL, available.size()))
+ msg += " (available: %s" % ", ".join(tail_names)
+ if available.size() > tail_names.size():
+ msg += ", ..."
+ msg += ")"
+ return msg
+
+
+## Prefer a scripted class_name if the target has one, else the engine class.
+static func _class_label(target: Object) -> String:
+ var scr := target.get_script()
+ if scr != null and scr.has_method("get_global_name"):
+ var gcn: String = scr.get_global_name()
+ if not gcn.is_empty():
+ return gcn
+ return target.get_class()
+
+
+## Editor-visible properties, alphabetised, with internal/category entries dropped.
+static func _available_property_names(target: Object) -> Array:
+ var names: Array = []
+ for p in target.get_property_list():
+ var usage: int = int(p.get("usage", 0))
+ if (usage & PROPERTY_USAGE_EDITOR) == 0:
+ continue
+ var name: String = p.get("name", "")
+ if name.is_empty() or name.begins_with("_"):
+ continue
+ names.append(name)
+ names.sort()
+ return names
+
+
+static func _rank_suggestions(bad: String, available: Array) -> Array:
+ if bad.is_empty():
+ return []
+ var bad_lower := bad.to_lower()
+ var scored: Array = []
+ for n in available:
+ var score: float = bad.similarity(n)
+ if n.to_lower().find(bad_lower) != -1 or bad_lower.find(n.to_lower()) != -1:
+ score += _SUBSTRING_BONUS
+ if score >= _SIMILARITY_THRESHOLD:
+ scored.append([score, n])
+ scored.sort_custom(func(a, b): return a[0] > b[0])
+ var result: Array = []
+ for i in range(min(_MAX_SUGGESTIONS, scored.size())):
+ result.append(scored[i][1])
+ return result
diff --git a/addons/godot_ai/handlers/_property_errors.gd.uid b/addons/godot_ai/handlers/_property_errors.gd.uid
new file mode 100644
index 0000000..c29d21c
--- /dev/null
+++ b/addons/godot_ai/handlers/_property_errors.gd.uid
@@ -0,0 +1 @@
+uid://c74d560g4l86b
diff --git a/addons/godot_ai/handlers/animation_handler.gd b/addons/godot_ai/handlers/animation_handler.gd
new file mode 100644
index 0000000..13e6777
--- /dev/null
+++ b/addons/godot_ai/handlers/animation_handler.gd
@@ -0,0 +1,825 @@
+@tool
+extends "res://addons/godot_ai/handlers/command_handler.gd"
+
+const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
+
+## Handles AnimationPlayer authoring: creating players, animations, tracks,
+## keyframes, autoplay, and dev-ergonomics playback.
+##
+## Animations live inside an AnimationLibrary attached to an AnimationPlayer
+## node in the scene. They save with the .tscn — no separate resource file
+## needed. Undo callables hold direct Animation references (not paths).
+##
+## Split (issue #342, audit finding #13):
+## - animation_presets.gd → preset_fade / slide / shake / pulse + helpers
+## - animation_values.gd → animation_list / get / validate + shared
+## value coercion / serialization
+## Both submodules hold a WeakRef back to this handler. The handler's
+## preset_* / list / get / validate methods are thin proxies so existing
+## dispatcher registrations and test fixtures don't change.
+
+const AnimationPresets := preload("res://addons/godot_ai/handlers/animation_presets.gd")
+const AnimationValues := preload("res://addons/godot_ai/handlers/animation_values.gd")
+
+var _undo_redo: EditorUndoRedoManager
+var _presets
+var _values
+
+const _LOOP_MODES := {
+ "none": Animation.LOOP_NONE,
+ "linear": Animation.LOOP_LINEAR,
+ "pingpong": Animation.LOOP_PINGPONG,
+}
+
+const _INTERP_MODES := {
+ "nearest": Animation.INTERPOLATION_NEAREST,
+ "linear": Animation.INTERPOLATION_LINEAR,
+ "cubic": Animation.INTERPOLATION_CUBIC,
+}
+
+
+func _init(undo_redo: EditorUndoRedoManager) -> void:
+ _undo_redo = undo_redo
+ _presets = AnimationPresets.new(self)
+ _values = AnimationValues.new(self)
+
+
+# ============================================================================
+# animation_player_create
+# ============================================================================
+
+func create_player(params: Dictionary) -> Dictionary:
+ var parent_path: String = params.get("parent_path", "")
+ var node_name: String = params.get("name", "AnimationPlayer")
+
+ var _scene_check := McpNodeValidator.require_scene_or_error()
+ if _scene_check.has("error"):
+ return _scene_check
+ var scene_root: Node = _scene_check.scene_root
+
+ var parent: Node = scene_root
+ if not parent_path.is_empty():
+ parent = McpScenePath.resolve(parent_path, scene_root)
+ if parent == null:
+ return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, McpScenePath.format_parent_error(parent_path, scene_root))
+
+ var player := AnimationPlayer.new()
+ if not node_name.is_empty():
+ player.name = node_name
+
+ # Attach the default library before adding to tree — it persists on redo.
+ var library := AnimationLibrary.new()
+ player.add_animation_library("", library)
+
+ _undo_redo.create_action("MCP: Create AnimationPlayer %s" % player.name)
+ _undo_redo.add_do_method(parent, "add_child", player, true)
+ _undo_redo.add_do_method(player, "set_owner", scene_root)
+ _undo_redo.add_do_reference(player)
+ _undo_redo.add_do_reference(library)
+ _undo_redo.add_undo_method(parent, "remove_child", player)
+ _undo_redo.commit_action()
+
+ return {
+ "data": {
+ "path": McpScenePath.from_node(player, scene_root),
+ "parent_path": McpScenePath.from_node(parent, scene_root),
+ "name": String(player.name),
+ "undoable": true,
+ }
+ }
+
+
+# ============================================================================
+# animation_create
+# ============================================================================
+
+func create_animation(params: Dictionary) -> Dictionary:
+ var player_path: String = params.get("player_path", "")
+ var anim_name: String = params.get("name", "")
+ var length: float = float(params.get("length", 1.0))
+ var loop_mode_str: String = params.get("loop_mode", "none")
+
+ if player_path.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path")
+ if anim_name.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: name")
+ if length <= 0.0:
+ return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "length must be > 0 (got %s)" % length)
+
+ if not _LOOP_MODES.has(loop_mode_str):
+ return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
+ "Invalid loop_mode '%s'. Valid: %s" % [loop_mode_str, ", ".join(_LOOP_MODES.keys())])
+
+ var resolved := _resolve_player(player_path, true)
+ if resolved.has("error"):
+ return resolved
+ var player: AnimationPlayer = resolved.player
+ var library: AnimationLibrary = resolved.library
+ var created_player: bool = resolved.get("player_created", false)
+ var player_parent: Node = resolved.get("player_parent", null)
+ var created_library := false
+ if library == null:
+ library = AnimationLibrary.new()
+ created_library = true
+
+ var overwrite: bool = params.get("overwrite", false)
+ var old_anim: Animation = null
+ if library.has_animation(anim_name):
+ if not overwrite:
+ return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
+ "Animation '%s' already exists. Pass overwrite=true or delete it first." % anim_name)
+ old_anim = library.get_animation(anim_name)
+
+ var anim := Animation.new()
+ anim.length = length
+ anim.loop_mode = _LOOP_MODES[loop_mode_str]
+
+ _commit_animation_add("MCP: Create animation %s" % anim_name,
+ player, library, created_library, anim_name, anim, old_anim,
+ created_player, player_parent)
+
+ return {
+ "data": {
+ "player_path": player_path,
+ "name": anim_name,
+ "length": length,
+ "loop_mode": loop_mode_str,
+ "library_created": created_library or created_player,
+ "animation_player_created": created_player,
+ "overwritten": old_anim != null,
+ "undoable": true,
+ }
+ }
+
+
+# ============================================================================
+# animation_delete
+# ============================================================================
+
+func delete_animation(params: Dictionary) -> Dictionary:
+ var player_path: String = params.get("player_path", "")
+ var anim_name: String = params.get("animation_name", "")
+
+ if player_path.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path")
+ if anim_name.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: animation_name")
+
+ var resolved := _resolve_player(player_path)
+ if resolved.has("error"):
+ return resolved
+ var player: AnimationPlayer = resolved.player
+
+ # Use _resolve_animation so we can delete from ANY library, not just the
+ # default. Mirrors the read-side symmetry with animation_get / animation_play
+ # which already search all libraries via _resolve_animation.
+ var anim_resolved := _resolve_animation(player, anim_name)
+ if anim_resolved.has("error"):
+ return anim_resolved
+ var old_anim: Animation = anim_resolved.animation
+ var library: AnimationLibrary = anim_resolved.library
+ # Clip key within the owning library — strips the "libname/" prefix if the
+ # caller passed a qualified name.
+ var clip_key: String = anim_name
+ var slash := anim_name.find("/")
+ if slash >= 0:
+ clip_key = anim_name.substr(slash + 1)
+
+ _undo_redo.create_action("MCP: Delete animation %s" % anim_name)
+ _undo_redo.add_do_method(library, "remove_animation", clip_key)
+ _undo_redo.add_undo_method(library, "add_animation", clip_key, old_anim)
+ _undo_redo.add_do_reference(old_anim) # prevent GC so undo→redo works
+ _undo_redo.commit_action()
+
+ return {
+ "data": {
+ "player_path": player_path,
+ "animation_name": anim_name,
+ "library_key": anim_resolved.get("library_key", ""),
+ "undoable": true,
+ }
+ }
+
+
+# ============================================================================
+# animation_add_property_track
+# ============================================================================
+
+func add_property_track(params: Dictionary) -> Dictionary:
+ var player_path: String = params.get("player_path", "")
+ var anim_name: String = params.get("animation_name", "")
+ var track_path: String = params.get("track_path", "")
+ var keyframes = params.get("keyframes", [])
+ var interp_str: String = params.get("interpolation", "linear")
+
+ if player_path.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path")
+ if anim_name.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: animation_name")
+ if track_path.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM,
+ "Missing required param: track_path (format: 'NodeName:property', e.g. 'Panel:modulate')")
+ if not track_path.contains(":"):
+ return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
+ "track_path must include ':property' suffix (e.g. 'Panel:modulate', '.:position')")
+ if not _INTERP_MODES.has(interp_str):
+ return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
+ "Invalid interpolation '%s'. Valid: %s" % [interp_str, ", ".join(_INTERP_MODES.keys())])
+ if typeof(keyframes) != TYPE_ARRAY or keyframes.is_empty():
+ return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "keyframes must be a non-empty array")
+
+ var resolved := _resolve_player(player_path)
+ if resolved.has("error"):
+ return resolved
+ var player: AnimationPlayer = resolved.player
+
+ var anim_resolved := _resolve_animation(player, anim_name)
+ if anim_resolved.has("error"):
+ return anim_resolved
+ var anim: Animation = anim_resolved.animation
+
+ # Validate + pre-coerce keyframes before mutating. Coercion errors
+ # surface as INVALID_PARAMS rather than silently inserting garbage keys.
+ # Resolve the target property's type ONCE — dense clips used to re-walk
+ # get_property_list() per keyframe.
+ var ctx := AnimationValues.resolve_track_prop_context(track_path, player)
+ if ctx.has("error"):
+ return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, ctx.error)
+ var coerced_keyframes: Array = []
+ for kf in keyframes:
+ if typeof(kf) != TYPE_DICTIONARY:
+ return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Each keyframe must be a dictionary")
+ if not "time" in kf:
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Each keyframe must have a 'time' field")
+ if not "value" in kf:
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Each keyframe must have a 'value' field")
+ var coerce_result := AnimationValues.coerce_with_context(kf.get("value"), ctx)
+ if coerce_result.has("error"):
+ return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, coerce_result.error)
+ coerced_keyframes.append({
+ "time": kf.get("time"),
+ "value": coerce_result.ok,
+ "transition": kf.get("transition", "linear"),
+ })
+
+ _create_scene_pinned_action("MCP: Add property track %s to %s" % [track_path, anim_name])
+ _undo_redo.add_do_method(self, "_do_add_property_track", anim, track_path, interp_str, coerced_keyframes)
+ # Undo locates the track by (path, type) at undo time rather than caching
+ # an index captured at do time. Cached indices go stale if any other track
+ # mutation lands between do and undo (Godot editor, another MCP call, etc.)
+ _undo_redo.add_undo_method(self, "_undo_remove_track_by_path", anim, track_path, Animation.TYPE_VALUE)
+ _undo_redo.commit_action()
+
+ return {
+ "data": {
+ "player_path": player_path,
+ "animation_name": anim_name,
+ "track_path": track_path,
+ "interpolation": interp_str,
+ "keyframe_count": keyframes.size(),
+ "undoable": true,
+ }
+ }
+
+
+## Insert a pre-coerced track into the animation. Callers must coerce
+## values against the target property before calling this (see
+## AnimationValues.coerce_value_for_track) — this method runs inside the
+## undo do-method path where error propagation isn't possible.
+func _do_add_property_track(
+ anim: Animation,
+ track_path: String,
+ interp_str: String,
+ keyframes: Array,
+) -> void:
+ var idx := anim.add_track(Animation.TYPE_VALUE)
+ anim.track_set_path(idx, NodePath(track_path))
+ anim.track_set_interpolation_type(idx, _INTERP_MODES.get(interp_str, Animation.INTERPOLATION_LINEAR))
+ for kf in keyframes:
+ var t: float = float(kf.get("time", 0.0))
+ var trans: float = AnimationValues.parse_transition(kf.get("transition", "linear"))
+ anim.track_insert_key(idx, t, kf.get("value"), trans)
+
+
+# ============================================================================
+# animation_add_method_track
+# ============================================================================
+
+func add_method_track(params: Dictionary) -> Dictionary:
+ var player_path: String = params.get("player_path", "")
+ var anim_name: String = params.get("animation_name", "")
+ var target_path: String = params.get("target_node_path", "")
+ var keyframes = params.get("keyframes", [])
+
+ if player_path.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path")
+ if anim_name.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: animation_name")
+ if target_path.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: target_node_path")
+ if target_path.contains(":"):
+ return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
+ "target_node_path is a bare NodePath without ':property' (got '%s'). " % target_path +
+ "Method name goes in each keyframe's 'method' field, not the path.")
+ if typeof(keyframes) != TYPE_ARRAY or keyframes.is_empty():
+ return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "keyframes must be a non-empty array")
+
+ for kf in keyframes:
+ if typeof(kf) != TYPE_DICTIONARY:
+ return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Each keyframe must be a dictionary")
+ if not "time" in kf:
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Each keyframe must have a 'time' field")
+ if not "method" in kf:
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Each keyframe must have a 'method' field")
+ var method_field = kf.get("method")
+ if typeof(method_field) != TYPE_STRING or (method_field as String).is_empty():
+ return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "'method' must be a non-empty string")
+ if kf.has("args") and typeof(kf.get("args")) != TYPE_ARRAY:
+ return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
+ "'args' must be an array if provided (got %s)" % type_string(typeof(kf.get("args"))))
+
+ var resolved := _resolve_player(player_path)
+ if resolved.has("error"):
+ return resolved
+ var player: AnimationPlayer = resolved.player
+
+ var anim_resolved := _resolve_animation(player, anim_name)
+ if anim_resolved.has("error"):
+ return anim_resolved
+ var anim: Animation = anim_resolved.animation
+
+ _create_scene_pinned_action("MCP: Add method track %s to %s" % [target_path, anim_name])
+ _undo_redo.add_do_method(self, "_do_add_method_track", anim, target_path, keyframes)
+ # Undo locates the track by (path, type) at undo time — see add_property_track.
+ _undo_redo.add_undo_method(self, "_undo_remove_track_by_path", anim, target_path, Animation.TYPE_METHOD)
+ _undo_redo.commit_action()
+
+ return {
+ "data": {
+ "player_path": player_path,
+ "animation_name": anim_name,
+ "target_node_path": target_path,
+ "keyframe_count": keyframes.size(),
+ "undoable": true,
+ }
+ }
+
+
+## Remove a track identified by (path, type) at undo time. Robust to
+## history interleaving: if another track was added since the do, the
+## find_track call still resolves to the correct index. Returns silently
+## if the track is no longer present (e.g. a prior undo already removed it).
+func _undo_remove_track_by_path(anim: Animation, track_path: String, track_type: int) -> void:
+ var idx := anim.find_track(NodePath(track_path), track_type)
+ if idx >= 0:
+ anim.remove_track(idx)
+
+
+func _do_add_method_track(anim: Animation, target_path: String, keyframes: Array) -> void:
+ var idx := anim.add_track(Animation.TYPE_METHOD)
+ anim.track_set_path(idx, NodePath(target_path))
+ for kf in keyframes:
+ var t: float = float(kf.get("time", 0.0))
+ var method_name: String = str(kf.get("method", ""))
+ var args: Array = kf.get("args", [])
+ anim.track_insert_key(idx, t, {"method": method_name, "args": args})
+
+
+# ============================================================================
+# animation_set_autoplay
+# ============================================================================
+
+func set_autoplay(params: Dictionary) -> Dictionary:
+ var player_path: String = params.get("player_path", "")
+ var anim_name: String = params.get("animation_name", "")
+
+ if player_path.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path")
+
+ var resolved := _resolve_player(player_path)
+ if resolved.has("error"):
+ return resolved
+ var player: AnimationPlayer = resolved.player
+
+ # Allow empty string to clear autoplay; otherwise validate the name exists.
+ if not anim_name.is_empty() and not player.has_animation(anim_name):
+ return ErrorCodes.make(ErrorCodes.PROPERTY_NOT_ON_CLASS,
+ "Animation '%s' not found on player at %s" % [anim_name, player_path])
+
+ var old_autoplay: String = player.autoplay
+
+ _undo_redo.create_action("MCP: Set autoplay %s on %s" % [anim_name, player_path])
+ _undo_redo.add_do_property(player, "autoplay", anim_name)
+ _undo_redo.add_undo_property(player, "autoplay", old_autoplay)
+ _undo_redo.commit_action()
+
+ return {
+ "data": {
+ "player_path": player_path,
+ "animation_name": anim_name,
+ "previous_autoplay": old_autoplay,
+ "cleared": anim_name.is_empty(),
+ "undoable": true,
+ }
+ }
+
+
+# ============================================================================
+# animation_play (dev ergonomics — not saved with scene)
+# ============================================================================
+
+func play(params: Dictionary) -> Dictionary:
+ var player_path: String = params.get("player_path", "")
+ var anim_name: String = params.get("animation_name", "")
+
+ if player_path.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path")
+
+ var resolved := _resolve_player(player_path)
+ if resolved.has("error"):
+ return resolved
+ var player: AnimationPlayer = resolved.player
+
+ if not anim_name.is_empty() and not player.has_animation(anim_name):
+ return ErrorCodes.make(ErrorCodes.PROPERTY_NOT_ON_CLASS,
+ "Animation '%s' not found on player at %s" % [anim_name, player_path])
+
+ player.play(anim_name)
+
+ return {
+ "data": {
+ "player_path": player_path,
+ "animation_name": anim_name,
+ "undoable": false,
+ "reason": "Runtime playback state — not saved with scene",
+ }
+ }
+
+
+# ============================================================================
+# animation_stop (dev ergonomics — not saved with scene)
+# ============================================================================
+
+func stop(params: Dictionary) -> Dictionary:
+ var player_path: String = params.get("player_path", "")
+
+ if player_path.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path")
+
+ var resolved := _resolve_player(player_path)
+ if resolved.has("error"):
+ return resolved
+ var player: AnimationPlayer = resolved.player
+
+ player.stop()
+
+ return {
+ "data": {
+ "player_path": player_path,
+ "undoable": false,
+ "reason": "Runtime playback state — not saved with scene",
+ }
+ }
+
+
+# ============================================================================
+# animation_create_simple (composer)
+# ============================================================================
+
+func create_simple(params: Dictionary) -> Dictionary:
+ var player_path: String = params.get("player_path", "")
+ var anim_name: String = params.get("name", "")
+ var tweens = params.get("tweens", [])
+ var loop_mode_str: String = params.get("loop_mode", "none")
+
+ if player_path.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path")
+ if anim_name.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: name")
+ if typeof(tweens) != TYPE_ARRAY or tweens.is_empty():
+ return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "tweens must be a non-empty array")
+ if not _LOOP_MODES.has(loop_mode_str):
+ return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
+ "Invalid loop_mode '%s'. Valid: %s" % [loop_mode_str, ", ".join(_LOOP_MODES.keys())])
+
+ # Validate all tween specs before touching the scene.
+ var seen_paths := {}
+ for spec in tweens:
+ if typeof(spec) != TYPE_DICTIONARY:
+ return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Each tween spec must be a dictionary")
+ for field in ["target", "property", "from", "to", "duration"]:
+ if not field in spec:
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM,
+ "Each tween spec must have '%s'" % field)
+ if float(spec.get("duration", 0.0)) <= 0.0:
+ return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
+ "tween 'duration' must be > 0")
+ var dup_key: String = str(spec.target) + ":" + str(spec.property)
+ if seen_paths.has(dup_key):
+ return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
+ "Duplicate tween target '%s' — merge keyframes into a single track " % dup_key +
+ "via animation_add_property_track instead of two separate tweens.")
+ seen_paths[dup_key] = true
+
+ # Compute/validate length before resolving the player — a fresh auto-created
+ # AnimationPlayer is a detached Node that leaks if we return after creation.
+ var has_length: bool = params.has("length") and params.get("length") != null
+ var computed_length: float = 0.0
+ if has_length:
+ computed_length = float(params.get("length"))
+ if computed_length <= 0.0:
+ return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
+ "'length' must be > 0 when provided (got %s)" % str(params.get("length")))
+ else:
+ for spec in tweens:
+ var end_time: float = float(spec.get("delay", 0.0)) + float(spec.get("duration", 0.0))
+ if end_time > computed_length:
+ computed_length = end_time
+ if computed_length <= 0.0:
+ computed_length = 1.0
+
+ var resolved := _resolve_player(player_path, true)
+ if resolved.has("error"):
+ return resolved
+ var player: AnimationPlayer = resolved.player
+ var library: AnimationLibrary = resolved.library
+ var created_player: bool = resolved.get("player_created", false)
+ var player_parent: Node = resolved.get("player_parent", null)
+ var created_library := false
+ if library == null:
+ library = AnimationLibrary.new()
+ created_library = true
+
+ var overwrite: bool = params.get("overwrite", false)
+ var old_anim: Animation = null
+ if library.has_animation(anim_name):
+ if not overwrite:
+ if created_player:
+ player.queue_free()
+ return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
+ "Animation '%s' already exists. Pass overwrite=true or delete it first." % anim_name)
+ old_anim = library.get_animation(anim_name)
+
+ # Pre-coerce all tween values before touching the anim — coercion errors
+ # surface as INVALID_PARAMS, not silent garbage keyframes.
+ # When the player was auto-created, it isn't in the tree yet — pass its
+ # future parent so the coercer can still resolve target property types.
+ var coerce_root: Node = player_parent if created_player else null
+ var per_track_keyframes: Array = []
+ for spec in tweens:
+ var target: String = str(spec.get("target", ""))
+ var property: String = str(spec.get("property", ""))
+ var track_path: String = target + ":" + property
+ var duration: float = float(spec.get("duration", 1.0))
+ var delay: float = float(spec.get("delay", 0.0))
+ var trans_str = spec.get("transition", "linear")
+ var from_result := AnimationValues.coerce_value_for_track(spec.get("from"), track_path, player, coerce_root)
+ if from_result.has("error"):
+ if created_player:
+ player.queue_free()
+ return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "tween '%s': %s" % [track_path, from_result.error])
+ var to_result := AnimationValues.coerce_value_for_track(spec.get("to"), track_path, player, coerce_root)
+ if to_result.has("error"):
+ if created_player:
+ player.queue_free()
+ return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "tween '%s': %s" % [track_path, to_result.error])
+ per_track_keyframes.append({
+ "track_path": track_path,
+ "keyframes": [
+ {"time": delay, "value": from_result.ok, "transition": trans_str},
+ {"time": delay + duration, "value": to_result.ok, "transition": trans_str},
+ ],
+ })
+
+ # Build the animation fully in memory before touching the undo stack.
+ var anim := Animation.new()
+ anim.length = computed_length
+ anim.loop_mode = _LOOP_MODES[loop_mode_str]
+
+ for entry in per_track_keyframes:
+ _do_add_property_track(anim, entry.track_path, "linear", entry.keyframes)
+
+ # One atomic undo action — bundles player creation (if any), library
+ # creation (if any), and the animation add. A single Ctrl-Z rolls back all.
+ _commit_animation_add("MCP: Create animation %s (%d tracks)" % [anim_name, anim.get_track_count()],
+ player, library, created_library, anim_name, anim, old_anim,
+ created_player, player_parent)
+
+ return {
+ "data": {
+ "player_path": player_path,
+ "name": anim_name,
+ "length": computed_length,
+ "loop_mode": loop_mode_str,
+ "track_count": anim.get_track_count(),
+ "library_created": created_library or created_player,
+ "animation_player_created": created_player,
+ "overwritten": old_anim != null,
+ "undoable": true,
+ }
+ }
+
+
+# ============================================================================
+# Proxies — preset_* and read methods live in the submodules. Kept here so
+# the dispatcher registrations and `_handler.method(...)` test fixtures stay
+# unchanged across the split.
+# ============================================================================
+
+func preset_fade(params: Dictionary) -> Dictionary:
+ return _presets.preset_fade(params)
+
+
+func preset_slide(params: Dictionary) -> Dictionary:
+ return _presets.preset_slide(params)
+
+
+func preset_shake(params: Dictionary) -> Dictionary:
+ return _presets.preset_shake(params)
+
+
+func preset_pulse(params: Dictionary) -> Dictionary:
+ return _presets.preset_pulse(params)
+
+
+func list_animations(params: Dictionary) -> Dictionary:
+ return _values.list_animations(params)
+
+
+func get_animation(params: Dictionary) -> Dictionary:
+ return _values.get_animation(params)
+
+
+func validate_animation(params: Dictionary) -> Dictionary:
+ return _values.validate_animation(params)
+
+
+# ============================================================================
+# Helpers — undo
+# ============================================================================
+
+## Shared undo setup for create_animation and create_simple. Handles fresh-
+## create, overwrite, library auto-create, and player auto-create in a single
+## atomic action. When `created_player` is true, the player already has the
+## library attached (eagerly, from `_instantiate_player`) and the library
+## doesn't need its own undo bookkeeping — it rides along with the add_child.
+func _commit_animation_add(
+ action_label: String,
+ player: AnimationPlayer,
+ library: AnimationLibrary,
+ created_library: bool,
+ anim_name: String,
+ anim: Animation,
+ old_anim: Animation, ## null when not overwriting
+ created_player: bool = false,
+ player_parent: Node = null,
+) -> void:
+ _undo_redo.create_action(action_label)
+ if created_player:
+ var scene_root := EditorInterface.get_edited_scene_root()
+ _undo_redo.add_do_method(player_parent, "add_child", player, true)
+ _undo_redo.add_do_method(player, "set_owner", scene_root)
+ _undo_redo.add_do_reference(player)
+ _undo_redo.add_do_reference(library)
+ _undo_redo.add_undo_method(player_parent, "remove_child", player)
+ elif created_library:
+ _undo_redo.add_do_method(player, "add_animation_library", "", library)
+ _undo_redo.add_undo_method(player, "remove_animation_library", "")
+ _undo_redo.add_do_reference(library)
+ if old_anim != null:
+ _undo_redo.add_do_method(library, "remove_animation", anim_name)
+ _undo_redo.add_do_method(library, "add_animation", anim_name, anim)
+ if old_anim != null:
+ _undo_redo.add_undo_method(library, "remove_animation", anim_name)
+ _undo_redo.add_undo_method(library, "add_animation", anim_name, old_anim)
+ _undo_redo.add_do_reference(old_anim)
+ else:
+ _undo_redo.add_undo_method(library, "remove_animation", anim_name)
+ _undo_redo.add_do_reference(anim)
+ _undo_redo.commit_action()
+
+
+## Open a `create_action` pinned to the edited scene's history.
+##
+## Without an explicit context, `add_do_method(self, ...)` against a
+## RefCounted handler lands in GLOBAL_HISTORY while sibling actions whose
+## first do-target is a Resource (e.g. AnimationLibrary) land in the scene's
+## history. Mismatched histories make the test-side `editor_undo` helper
+## (walks scene first) undo the wrong action, and break batch_handler's
+## rollback. Mirrors `camera_handler.gd`'s identical pinning rationale.
+func _create_scene_pinned_action(action_label: String) -> void:
+ _undo_redo.create_action(
+ action_label, UndoRedo.MERGE_DISABLE, EditorInterface.get_edited_scene_root(),
+ )
+
+
+# ============================================================================
+# Helpers — resolution
+# ============================================================================
+
+## Resolve an AnimationPlayer and its default library for write operations.
+## Returns {player, library, player_created, player_parent} on success, or an
+## error dict. library is null if the player exists but has no default library
+## yet — callers bundle an `add_animation_library` step into their undo action.
+##
+## When `create_if_missing` is true and `player_path` resolves to nothing, a
+## fresh AnimationPlayer is instantiated (with an empty default library attached
+## eagerly) but is NOT added to the scene tree — callers must bundle the
+## add_child step into their undo action via `_commit_animation_add`.
+## If the resolved node exists but isn't an AnimationPlayer, that's still an
+## error — we don't clobber an existing node of a different type.
+func _resolve_player(player_path: String, create_if_missing: bool = false) -> Dictionary:
+ var _scene_check := McpNodeValidator.require_scene_or_error()
+ if _scene_check.has("error"):
+ return _scene_check
+ var scene_root: Node = _scene_check.scene_root
+ var node := McpScenePath.resolve(player_path, scene_root)
+ if node == null:
+ if not create_if_missing:
+ return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, McpScenePath.format_node_error(player_path, scene_root))
+ return _instantiate_player(player_path, scene_root)
+ if not node is AnimationPlayer:
+ return ErrorCodes.make(ErrorCodes.WRONG_TYPE,
+ "Node at %s is not an AnimationPlayer (got %s)" % [player_path, node.get_class()])
+ var player := node as AnimationPlayer
+ var lib: AnimationLibrary = null
+ if player.has_animation_library(""):
+ lib = player.get_animation_library("")
+ return {"player": player, "library": lib, "player_created": false, "player_parent": null}
+
+
+## Build a new AnimationPlayer (with empty default library) for insertion under
+## the parent implied by `player_path`. Returns an error dict if the parent
+## can't be resolved or the path has no usable leaf name.
+func _instantiate_player(player_path: String, scene_root: Node) -> Dictionary:
+ var slash := player_path.rfind("/")
+ var parent_path: String
+ var player_name: String
+ if slash < 0:
+ parent_path = ""
+ player_name = player_path
+ else:
+ parent_path = player_path.substr(0, slash)
+ player_name = player_path.substr(slash + 1)
+ if player_name.is_empty():
+ return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
+ "Cannot auto-create AnimationPlayer: player_path '%s' has no leaf name" % player_path)
+ var parent: Node
+ if parent_path.is_empty():
+ parent = scene_root
+ else:
+ parent = McpScenePath.resolve(parent_path, scene_root)
+ if parent == null:
+ return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND,
+ "Cannot auto-create AnimationPlayer at %s: %s" % [
+ player_path, McpScenePath.format_parent_error(parent_path, scene_root)])
+ var new_player := AnimationPlayer.new()
+ new_player.name = player_name
+ var lib := AnimationLibrary.new()
+ new_player.add_animation_library("", lib)
+ return {
+ "player": new_player,
+ "library": lib,
+ "player_created": true,
+ "player_parent": parent,
+ }
+
+
+## Resolve for read operations (no library requirement).
+func _resolve_player_read(player_path: String) -> Dictionary:
+ var resolved := McpNodeValidator.resolve_or_error(player_path, "player_path")
+ if resolved.has("error"):
+ return resolved
+ var node: Node = resolved.node
+ if not node is AnimationPlayer:
+ return ErrorCodes.make(ErrorCodes.WRONG_TYPE,
+ "Node at %s is not an AnimationPlayer (got %s)" % [player_path, node.get_class()])
+ return {"player": node as AnimationPlayer}
+
+
+## Resolve an animation by name, searching all libraries.
+## Accepts bare clip names ("idle") and library-qualified names ("moves/idle")
+## as returned by `list_animations` for non-default libraries.
+func _resolve_animation(player: AnimationPlayer, anim_name: String) -> Dictionary:
+ if not player.has_animation(anim_name):
+ return ErrorCodes.make(ErrorCodes.PROPERTY_NOT_ON_CLASS,
+ "Animation '%s' not found on player. Available: %s" % [
+ anim_name,
+ ", ".join(Array(player.get_animation_list()))
+ ])
+ # If the caller passed "library/clip", look up in that specific library.
+ var slash := anim_name.find("/")
+ if slash >= 0:
+ var lib_key := anim_name.substr(0, slash)
+ var clip_key := anim_name.substr(slash + 1)
+ if player.has_animation_library(lib_key):
+ var lib: AnimationLibrary = player.get_animation_library(lib_key)
+ if lib.has_animation(clip_key):
+ return {"animation": lib.get_animation(clip_key), "library": lib, "library_key": lib_key}
+ # Otherwise scan libraries for a bare clip name.
+ for lib_name in player.get_animation_library_list():
+ var lib2: AnimationLibrary = player.get_animation_library(lib_name)
+ if lib2.has_animation(anim_name):
+ return {"animation": lib2.get_animation(anim_name), "library": lib2, "library_key": lib_name}
+ # Fallback — shouldn't happen if has_animation returned true.
+ return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Animation found by player but not in any library")
diff --git a/addons/godot_ai/handlers/animation_handler.gd.uid b/addons/godot_ai/handlers/animation_handler.gd.uid
new file mode 100644
index 0000000..934f665
--- /dev/null
+++ b/addons/godot_ai/handlers/animation_handler.gd.uid
@@ -0,0 +1 @@
+uid://c0jrius46xsd4
diff --git a/addons/godot_ai/handlers/animation_presets.gd b/addons/godot_ai/handlers/animation_presets.gd
new file mode 100644
index 0000000..40f1ddc
--- /dev/null
+++ b/addons/godot_ai/handlers/animation_presets.gd
@@ -0,0 +1,536 @@
+@tool
+extends RefCounted
+
+## Curated motion presets for the AnimationPlayer surface.
+##
+## Each preset_* method:
+## 1. Validates params + resolves the player (auto-creating its default lib).
+## 2. Resolves the target node + classifies it as control / 2d / 3d.
+## 3. Builds a single-track Animation with shape-appropriate keyframes.
+## 4. Commits the add through the handler's shared `_commit_animation_add`
+## so a single Ctrl-Z rolls back any auto-created library + the animation.
+##
+## Holds a WeakRef back to the AnimationHandler instance so the handler can
+## continue to own this module strongly via `_presets` without forming a
+## RefCounted cycle. Resolution / undo helpers live on the handler — keeping
+## the `_undo_redo` member single-source there avoids drift.
+
+
+const AnimationValues := preload("res://addons/godot_ai/handlers/animation_values.gd")
+const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
+const ScenePath := preload("res://addons/godot_ai/utils/scene_path.gd")
+
+
+var _handler_weak: WeakRef
+
+
+func _init(handler) -> void:
+ _handler_weak = weakref(handler)
+
+
+func _h():
+ return _handler_weak.get_ref()
+
+
+# ============================================================================
+# animation_preset_fade
+# ============================================================================
+
+func preset_fade(params: Dictionary) -> Dictionary:
+ var player_path: String = params.get("player_path", "")
+ var target_path: String = params.get("target_path", "")
+ var mode: String = params.get("mode", "in")
+ var duration: float = float(params.get("duration", 0.5))
+ var anim_name: String = params.get("animation_name", "")
+ var overwrite: bool = params.get("overwrite", false)
+
+ if player_path.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path")
+ if target_path.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: target_path")
+ if mode != "in" and mode != "out":
+ return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
+ "Invalid mode '%s'. Valid: 'in', 'out'" % mode)
+ if duration <= 0.0:
+ return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "'duration' must be > 0")
+
+ var handler = _h()
+ if handler == null:
+ return ErrorCodes.make_not_ready(
+ ErrorCodes.SUB_EDITOR_UNAVAILABLE,
+ "AnimationHandler not available", false)
+ var resolved: Dictionary = handler._resolve_player(player_path)
+ if resolved.has("error"):
+ return resolved
+ var player: AnimationPlayer = resolved.player
+ var library: AnimationLibrary = resolved.library
+ var created_library := false
+ if library == null:
+ library = AnimationLibrary.new()
+ created_library = true
+
+ var target_resolved := _resolve_preset_target(player, target_path)
+ if target_resolved.has("error"):
+ return target_resolved
+ var target: Node = target_resolved.node
+ var track_target: String = target_resolved.track_path_root
+
+ # Fade requires a `modulate` property (CanvasItem/Control/Node2D/Sprite3D/etc).
+ var has_modulate := false
+ for p in target.get_property_list():
+ if p.name == "modulate":
+ has_modulate = true
+ break
+ if not has_modulate:
+ return ErrorCodes.make(ErrorCodes.WRONG_TYPE,
+ "Target '%s' (class %s) has no 'modulate' property — fade requires a CanvasItem, Control, Node2D, or Sprite3D"
+ % [target_path, target.get_class()])
+
+ if anim_name.is_empty():
+ anim_name = "fade_%s" % mode
+
+ var old_anim: Animation = null
+ if library.has_animation(anim_name):
+ if not overwrite:
+ return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
+ "Animation '%s' already exists. Pass overwrite=true or delete it first." % anim_name)
+ old_anim = library.get_animation(anim_name)
+
+ var start_a: float = 0.0 if mode == "in" else 1.0
+ var end_a: float = 1.0 if mode == "in" else 0.0
+
+ var anim := Animation.new()
+ anim.length = duration
+ anim.loop_mode = Animation.LOOP_NONE
+
+ var track_path := "%s:modulate:a" % track_target
+ handler._do_add_property_track(anim, track_path, "linear", [
+ {"time": 0.0, "value": start_a, "transition": "linear"},
+ {"time": duration, "value": end_a, "transition": "linear"},
+ ])
+
+ handler._commit_animation_add(
+ "MCP: Create animation %s" % anim_name,
+ player, library, created_library, anim_name, anim, old_anim,
+ )
+
+ return {
+ "data": {
+ "player_path": player_path,
+ "animation_name": anim_name,
+ "mode": mode,
+ "length": duration,
+ "track_count": anim.get_track_count(),
+ "library_created": created_library,
+ "overwritten": old_anim != null,
+ "undoable": true,
+ }
+ }
+
+
+# ============================================================================
+# animation_preset_slide
+# ============================================================================
+
+func preset_slide(params: Dictionary) -> Dictionary:
+ var player_path: String = params.get("player_path", "")
+ var target_path: String = params.get("target_path", "")
+ var direction: String = params.get("direction", "left")
+ var mode: String = params.get("mode", "in")
+ var duration: float = float(params.get("duration", 0.4))
+ var anim_name: String = params.get("animation_name", "")
+ var overwrite: bool = params.get("overwrite", false)
+
+ if player_path.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path")
+ if target_path.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: target_path")
+ if not ["left", "right", "up", "down"].has(direction):
+ return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
+ "Invalid direction '%s'. Valid: 'left', 'right', 'up', 'down'" % direction)
+ if mode != "in" and mode != "out":
+ return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
+ "Invalid mode '%s'. Valid: 'in', 'out'" % mode)
+ if duration <= 0.0:
+ return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "'duration' must be > 0")
+
+ var handler = _h()
+ if handler == null:
+ return ErrorCodes.make_not_ready(
+ ErrorCodes.SUB_EDITOR_UNAVAILABLE,
+ "AnimationHandler not available", false)
+ var resolved: Dictionary = handler._resolve_player(player_path)
+ if resolved.has("error"):
+ return resolved
+ var player: AnimationPlayer = resolved.player
+ var library: AnimationLibrary = resolved.library
+ var created_library := false
+ if library == null:
+ library = AnimationLibrary.new()
+ created_library = true
+
+ var target_resolved := _resolve_preset_target(player, target_path)
+ if target_resolved.has("error"):
+ return target_resolved
+ var target = target_resolved.node
+ var kind: String = target_resolved.kind
+ var track_target: String = target_resolved.track_path_root
+
+ # Default distance picks 3D units vs screen pixels based on target kind.
+ var default_distance: float = 1.0 if kind == "3d" else 100.0
+ var distance: float = float(params.get("distance", default_distance))
+ if distance == 0.0:
+ return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "'distance' must be non-zero")
+
+ var offset: Variant = _direction_offset(kind, direction, distance)
+ var current_pos: Variant = target.position
+ var start_pos: Variant
+ var end_pos: Variant
+ if mode == "in":
+ start_pos = current_pos + offset
+ end_pos = current_pos
+ else:
+ start_pos = current_pos
+ end_pos = current_pos + offset
+
+ if anim_name.is_empty():
+ anim_name = "slide_%s_%s" % [mode, direction]
+
+ var old_anim: Animation = null
+ if library.has_animation(anim_name):
+ if not overwrite:
+ return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
+ "Animation '%s' already exists. Pass overwrite=true or delete it first." % anim_name)
+ old_anim = library.get_animation(anim_name)
+
+ var anim := Animation.new()
+ anim.length = duration
+ anim.loop_mode = Animation.LOOP_NONE
+
+ var track_path := "%s:position" % track_target
+ handler._do_add_property_track(anim, track_path, "linear", [
+ {"time": 0.0, "value": start_pos, "transition": "linear"},
+ {"time": duration, "value": end_pos, "transition": "linear"},
+ ])
+
+ handler._commit_animation_add(
+ "MCP: Create animation %s" % anim_name,
+ player, library, created_library, anim_name, anim, old_anim,
+ )
+
+ return {
+ "data": {
+ "player_path": player_path,
+ "animation_name": anim_name,
+ "direction": direction,
+ "mode": mode,
+ "distance": distance,
+ "length": duration,
+ "track_count": anim.get_track_count(),
+ "library_created": created_library,
+ "overwritten": old_anim != null,
+ "undoable": true,
+ }
+ }
+
+
+# ============================================================================
+# animation_preset_shake
+# ============================================================================
+
+func preset_shake(params: Dictionary) -> Dictionary:
+ var player_path: String = params.get("player_path", "")
+ var target_path: String = params.get("target_path", "")
+ var duration: float = float(params.get("duration", 0.3))
+ var frequency: float = float(params.get("frequency", 30.0))
+ var rng_seed: int = int(params.get("seed", 0))
+ var anim_name: String = params.get("animation_name", "")
+ var overwrite: bool = params.get("overwrite", false)
+
+ if player_path.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path")
+ if target_path.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: target_path")
+ if duration <= 0.0:
+ return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "'duration' must be > 0")
+ if frequency <= 0.0:
+ return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "'frequency' must be > 0")
+
+ var handler = _h()
+ if handler == null:
+ return ErrorCodes.make_not_ready(
+ ErrorCodes.SUB_EDITOR_UNAVAILABLE,
+ "AnimationHandler not available", false)
+ var resolved: Dictionary = handler._resolve_player(player_path)
+ if resolved.has("error"):
+ return resolved
+ var player: AnimationPlayer = resolved.player
+ var library: AnimationLibrary = resolved.library
+ var created_library := false
+ if library == null:
+ library = AnimationLibrary.new()
+ created_library = true
+
+ var target_resolved := _resolve_preset_target(player, target_path)
+ if target_resolved.has("error"):
+ return target_resolved
+ var target = target_resolved.node
+ var kind: String = target_resolved.kind
+ var track_target: String = target_resolved.track_path_root
+
+ var default_intensity: float = 0.1 if kind == "3d" else 10.0
+ var intensity: float = float(params.get("intensity", default_intensity))
+ if intensity <= 0.0:
+ return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "'intensity' must be > 0")
+
+ if anim_name.is_empty():
+ anim_name = "shake"
+
+ var old_anim: Animation = null
+ if library.has_animation(anim_name):
+ if not overwrite:
+ return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
+ "Animation '%s' already exists. Pass overwrite=true or delete it first." % anim_name)
+ old_anim = library.get_animation(anim_name)
+
+ var rng := RandomNumberGenerator.new()
+ if rng_seed != 0:
+ rng.seed = rng_seed
+ else:
+ rng.randomize()
+
+ # Samples between t=0 and t=duration (exclusive); bookended by at-rest keys.
+ var sample_count: int = int(ceil(frequency * duration))
+ if sample_count < 2:
+ sample_count = 2
+
+ var current_pos: Variant = target.position
+ var kfs: Array = []
+ kfs.append({"time": 0.0, "value": current_pos, "transition": "linear"})
+ for i in range(1, sample_count):
+ var t: float = (float(i) / float(sample_count)) * duration
+ var jx: float = rng.randf_range(-intensity, intensity)
+ var jy: float = rng.randf_range(-intensity, intensity)
+ var jittered: Variant
+ if kind == "3d":
+ var jz: float = rng.randf_range(-intensity, intensity)
+ jittered = current_pos + Vector3(jx, jy, jz)
+ else:
+ jittered = current_pos + Vector2(jx, jy)
+ kfs.append({"time": t, "value": jittered, "transition": "linear"})
+ kfs.append({"time": duration, "value": current_pos, "transition": "linear"})
+
+ var anim := Animation.new()
+ anim.length = duration
+ anim.loop_mode = Animation.LOOP_NONE
+
+ var track_path := "%s:position" % track_target
+ handler._do_add_property_track(anim, track_path, "linear", kfs)
+
+ handler._commit_animation_add(
+ "MCP: Create animation %s" % anim_name,
+ player, library, created_library, anim_name, anim, old_anim,
+ )
+
+ return {
+ "data": {
+ "player_path": player_path,
+ "animation_name": anim_name,
+ "length": duration,
+ "frequency": frequency,
+ "intensity": intensity,
+ "keyframe_count": kfs.size(),
+ "track_count": anim.get_track_count(),
+ "library_created": created_library,
+ "overwritten": old_anim != null,
+ "undoable": true,
+ }
+ }
+
+
+# ============================================================================
+# animation_preset_pulse
+# ============================================================================
+
+func preset_pulse(params: Dictionary) -> Dictionary:
+ var player_path: String = params.get("player_path", "")
+ var target_path: String = params.get("target_path", "")
+ var from_scale: float = float(params.get("from_scale", 1.0))
+ var to_scale: float = float(params.get("to_scale", 1.1))
+ var duration: float = float(params.get("duration", 0.4))
+ var anim_name: String = params.get("animation_name", "")
+ var overwrite: bool = params.get("overwrite", false)
+
+ if player_path.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path")
+ if target_path.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: target_path")
+ if duration <= 0.0:
+ return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "'duration' must be > 0")
+ if from_scale <= 0.0:
+ return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "'from_scale' must be > 0")
+ if to_scale <= 0.0:
+ return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "'to_scale' must be > 0")
+
+ var handler = _h()
+ if handler == null:
+ return ErrorCodes.make_not_ready(
+ ErrorCodes.SUB_EDITOR_UNAVAILABLE,
+ "AnimationHandler not available", false)
+ var resolved: Dictionary = handler._resolve_player(player_path)
+ if resolved.has("error"):
+ return resolved
+ var player: AnimationPlayer = resolved.player
+ var library: AnimationLibrary = resolved.library
+ var created_library := false
+ if library == null:
+ library = AnimationLibrary.new()
+ created_library = true
+
+ var target_resolved := _resolve_preset_target(player, target_path)
+ if target_resolved.has("error"):
+ return target_resolved
+ var kind: String = target_resolved.kind
+ var track_target: String = target_resolved.track_path_root
+
+ if anim_name.is_empty():
+ anim_name = "pulse"
+
+ var old_anim: Animation = null
+ if library.has_animation(anim_name):
+ if not overwrite:
+ return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
+ "Animation '%s' already exists. Pass overwrite=true or delete it first." % anim_name)
+ old_anim = library.get_animation(anim_name)
+
+ var from_vec: Variant
+ var to_vec: Variant
+ if kind == "3d":
+ from_vec = Vector3(from_scale, from_scale, from_scale)
+ to_vec = Vector3(to_scale, to_scale, to_scale)
+ else:
+ from_vec = Vector2(from_scale, from_scale)
+ to_vec = Vector2(to_scale, to_scale)
+
+ var anim := Animation.new()
+ anim.length = duration
+ anim.loop_mode = Animation.LOOP_NONE
+
+ var track_path := "%s:scale" % track_target
+ handler._do_add_property_track(anim, track_path, "linear", [
+ {"time": 0.0, "value": from_vec, "transition": "linear"},
+ {"time": duration * 0.5, "value": to_vec, "transition": "linear"},
+ {"time": duration, "value": from_vec, "transition": "linear"},
+ ])
+
+ handler._commit_animation_add(
+ "MCP: Create animation %s" % anim_name,
+ player, library, created_library, anim_name, anim, old_anim,
+ )
+
+ return {
+ "data": {
+ "player_path": player_path,
+ "animation_name": anim_name,
+ "from_scale": from_scale,
+ "to_scale": to_scale,
+ "length": duration,
+ "track_count": anim.get_track_count(),
+ "library_created": created_library,
+ "overwritten": old_anim != null,
+ "undoable": true,
+ }
+ }
+
+
+# ============================================================================
+# Helpers — preset resolution
+# ============================================================================
+
+## Resolve a preset target node and classify its transform kind.
+##
+## Accepts two `target_path` shapes:
+## * Scene-absolute (starts with "/") — resolved through `ScenePath.resolve`,
+## matching the convention used by every other scene-mutating tool. Targets
+## outside the player's `root_node` subtree are converted to `..`-prefixed
+## paths via `root_node.get_path_to(target)`, mirroring what the relative
+## form accepts and how Godot stores track paths.
+## * Relative — used as-is against the player's `root_node`, matching how
+## animation tracks themselves are stored.
+##
+## Returns `{node, kind, track_path_root}` where `track_path_root` is the path
+## (relative to `root_node`) that callers should embed in the track path. For
+## scene-absolute inputs this is the converted relative path; for relative
+## inputs it equals the input. `kind` ∈ {"control", "2d", "3d"}.
+##
+## Mirrors the same root-node fallback that
+## `AnimationValues.resolve_track_prop_context` uses so tool inputs match how
+## the track path will resolve at playback.
+func _resolve_preset_target(player: AnimationPlayer, target_path: String) -> Dictionary:
+ var root_node := AnimationValues.player_root_node(player)
+ if root_node == null:
+ return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
+ "AnimationPlayer at %s has no resolvable root_node (is the scene open?)" % str(player.get_path()))
+
+ var target: Node = null
+ var track_path_root: String = target_path
+ if target_path.begins_with("/"):
+ var scene_root := EditorInterface.get_edited_scene_root()
+ if scene_root == null:
+ return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
+ "Cannot resolve scene-absolute target_path '%s': no scene open" % target_path)
+ target = ScenePath.resolve(target_path, scene_root)
+ if target == null:
+ return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
+ ScenePath.format_node_error(target_path, scene_root))
+ # Convert to a root_node-relative path. For targets outside the
+ # subtree this yields a `..`-prefixed path, matching what the
+ # relative form already accepts (root_node.get_node_or_null
+ # resolves `..` segments) and what Godot's animation engine
+ # stores natively.
+ track_path_root = str(root_node.get_path_to(target))
+ else:
+ target = root_node.get_node_or_null(target_path)
+ if target == null:
+ # root_node.get_path() leaks the editor's SubViewport-wrapped
+ # path; use the clean scene-relative form so the hint is
+ # actionable.
+ var scene_root := EditorInterface.get_edited_scene_root()
+ var root_hint := ScenePath.from_node(root_node, scene_root) if scene_root != null else str(root_node.name)
+ var abs_example := "/%s/path/to/target" % scene_root.name if scene_root != null else "/SceneRoot/path/to/target"
+ return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
+ ("Target node not found at '%s' (resolved relative to AnimationPlayer's root_node '%s'). "
+ + "Pass a path relative to root_node (e.g. \"path/to/target\") or a scene-absolute path (e.g. \"%s\").")
+ % [target_path, root_hint, abs_example])
+
+ var kind: String
+ if target is Control:
+ kind = "control"
+ elif target is Node2D:
+ kind = "2d"
+ elif target is Node3D:
+ kind = "3d"
+ else:
+ return ErrorCodes.make(ErrorCodes.WRONG_TYPE,
+ "Target '%s' must be a Control, Node2D, or Node3D (got %s)" % [target_path, target.get_class()])
+ return {"node": target, "kind": kind, "track_path_root": track_path_root}
+
+
+## Build a directional offset for slide presets.
+## Axis conventions:
+## Control + Node2D (screen-space, y-down): left/right = ∓x, up = -y, down = +y
+## Node3D (world-up): left/right = ∓x, up = +y, down = -y
+static func _direction_offset(kind: String, direction: String, distance: float) -> Variant:
+ if kind == "3d":
+ match direction:
+ "left": return Vector3(-distance, 0.0, 0.0)
+ "right": return Vector3(distance, 0.0, 0.0)
+ "up": return Vector3(0.0, distance, 0.0)
+ "down": return Vector3(0.0, -distance, 0.0)
+ else:
+ match direction:
+ "left": return Vector2(-distance, 0.0)
+ "right": return Vector2(distance, 0.0)
+ "up": return Vector2(0.0, -distance)
+ "down": return Vector2(0.0, distance)
+ return null
diff --git a/addons/godot_ai/handlers/animation_presets.gd.uid b/addons/godot_ai/handlers/animation_presets.gd.uid
new file mode 100644
index 0000000..f463501
--- /dev/null
+++ b/addons/godot_ai/handlers/animation_presets.gd.uid
@@ -0,0 +1 @@
+uid://c4s3h78bwvr6w
diff --git a/addons/godot_ai/handlers/animation_values.gd b/addons/godot_ai/handlers/animation_values.gd
new file mode 100644
index 0000000..ab6a1e9
--- /dev/null
+++ b/addons/godot_ai/handlers/animation_values.gd
@@ -0,0 +1,446 @@
+@tool
+extends RefCounted
+
+const VariantSerializer := preload("res://addons/godot_ai/utils/variant_serializer.gd")
+
+## Read-only animation introspection + shared value-coercion / serialization.
+##
+## Holds:
+## - Static helpers used by both the write handler (track building, simple
+## composer) and the preset module (target/property resolution).
+## - Instance methods that back the read MCP ops: animation_list,
+## animation_get, animation_validate.
+##
+## The instance methods need the handler to resolve players / animations.
+## To keep that without introducing a RefCounted cycle (the handler holds a
+## strong ref to this module via `_values`), the back-pointer is a WeakRef.
+## When the handler is freed during plugin teardown, _h() returns null and
+## the (no-longer-routable) calls short-circuit to a generic editor-not-ready
+## error — matches the dispatcher already being torn down at that point.
+
+
+const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
+const PropertyErrors := preload("res://addons/godot_ai/handlers/_property_errors.gd")
+
+
+const _NAMED_TRANSITIONS := {
+ "linear": 1.0,
+ "ease_in": 2.0,
+ "ease_out": 0.5,
+ "ease_in_out": -2.0,
+}
+
+## Component letters accepted on each aggregate base type, paired with the
+## scalar Variant type the component resolves to. A subpath like `position:y`
+## on a Vector3 maps to TYPE_FLOAT; on a Vector3i it maps to TYPE_INT.
+const _SUBPATH_COMPONENTS := {
+ TYPE_VECTOR2: ["xy", TYPE_FLOAT],
+ TYPE_VECTOR3: ["xyz", TYPE_FLOAT],
+ TYPE_VECTOR4: ["xyzw", TYPE_FLOAT],
+ TYPE_QUATERNION: ["xyzw", TYPE_FLOAT],
+ TYPE_COLOR: ["rgba", TYPE_FLOAT],
+ TYPE_VECTOR2I: ["xy", TYPE_INT],
+ TYPE_VECTOR3I: ["xyz", TYPE_INT],
+ TYPE_VECTOR4I: ["xyzw", TYPE_INT],
+}
+
+
+var _handler_weak: WeakRef
+
+
+func _init(handler) -> void:
+ _handler_weak = weakref(handler)
+
+
+func _h():
+ return _handler_weak.get_ref()
+
+
+# ============================================================================
+# animation_list (read)
+# ============================================================================
+
+func list_animations(params: Dictionary) -> Dictionary:
+ var player_path: String = params.get("player_path", "")
+
+ if player_path.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path")
+
+ var handler = _h()
+ if handler == null:
+ return ErrorCodes.make_not_ready(
+ ErrorCodes.SUB_EDITOR_UNAVAILABLE,
+ "AnimationHandler not available", false)
+ var resolved: Dictionary = handler._resolve_player_read(player_path)
+ if resolved.has("error"):
+ return resolved
+ var player: AnimationPlayer = resolved.player
+
+ var animations: Array[Dictionary] = []
+ for lib_name in player.get_animation_library_list():
+ var lib: AnimationLibrary = player.get_animation_library(lib_name)
+ for anim_name in lib.get_animation_list():
+ var anim: Animation = lib.get_animation(anim_name)
+ var display_name: String = anim_name if lib_name == "" else "%s/%s" % [lib_name, anim_name]
+ animations.append({
+ "name": display_name,
+ "length": anim.length,
+ "loop_mode": loop_mode_to_string(anim.loop_mode),
+ "track_count": anim.get_track_count(),
+ })
+
+ return {
+ "data": {
+ "player_path": player_path,
+ "animations": animations,
+ "count": animations.size(),
+ }
+ }
+
+
+# ============================================================================
+# animation_get (read)
+# ============================================================================
+
+func get_animation(params: Dictionary) -> Dictionary:
+ var player_path: String = params.get("player_path", "")
+ var anim_name: String = params.get("animation_name", "")
+
+ if player_path.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path")
+ if anim_name.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: animation_name")
+
+ var handler = _h()
+ if handler == null:
+ return ErrorCodes.make_not_ready(
+ ErrorCodes.SUB_EDITOR_UNAVAILABLE,
+ "AnimationHandler not available", false)
+ var resolved: Dictionary = handler._resolve_player_read(player_path)
+ if resolved.has("error"):
+ return resolved
+ var player: AnimationPlayer = resolved.player
+
+ var anim_resolved: Dictionary = handler._resolve_animation(player, anim_name)
+ if anim_resolved.has("error"):
+ return anim_resolved
+ var anim: Animation = anim_resolved.animation
+
+ var tracks: Array[Dictionary] = []
+ for i in anim.get_track_count():
+ var track_type := anim.track_get_type(i)
+ var type_name := track_type_to_string(track_type)
+ var keys: Array[Dictionary] = []
+ for k in anim.track_get_key_count(i):
+ var key_val = anim.track_get_key_value(i, k)
+ keys.append({
+ "time": anim.track_get_key_time(i, k),
+ "value": serialize_value(key_val),
+ "transition": anim.track_get_key_transition(i, k),
+ })
+ tracks.append({
+ "index": i,
+ "type": type_name,
+ "path": str(anim.track_get_path(i)),
+ "interpolation": interp_to_string(anim.track_get_interpolation_type(i)),
+ "key_count": keys.size(),
+ "keys": keys,
+ })
+
+ return {
+ "data": {
+ "player_path": player_path,
+ "name": anim_name,
+ "length": anim.length,
+ "loop_mode": loop_mode_to_string(anim.loop_mode),
+ "track_count": anim.get_track_count(),
+ "tracks": tracks,
+ }
+ }
+
+
+# ============================================================================
+# animation_validate (read-only)
+# ============================================================================
+
+func validate_animation(params: Dictionary) -> Dictionary:
+ var player_path: String = params.get("player_path", "")
+ var anim_name: String = params.get("animation_name", "")
+
+ if player_path.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path")
+ if anim_name.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: animation_name")
+
+ var handler = _h()
+ if handler == null:
+ return ErrorCodes.make_not_ready(
+ ErrorCodes.SUB_EDITOR_UNAVAILABLE,
+ "AnimationHandler not available", false)
+ var resolved: Dictionary = handler._resolve_player_read(player_path)
+ if resolved.has("error"):
+ return resolved
+ var player: AnimationPlayer = resolved.player
+
+ if not player.has_animation(anim_name):
+ return ErrorCodes.make(ErrorCodes.PROPERTY_NOT_ON_CLASS,
+ "Animation '%s' not found on player at %s" % [anim_name, player_path])
+
+ var anim: Animation = player.get_animation(anim_name)
+
+ var root_node := player_root_node(player)
+
+ var broken_tracks: Array[Dictionary] = []
+ var valid_count := 0
+
+ for i in anim.get_track_count():
+ var track_path_str := str(anim.track_get_path(i))
+ # Split on the FIRST colon (node↔property boundary), not the last.
+ # Godot's get_node_or_null strips the ":property" tail natively, so
+ # the valid/broken classification is the same either way — but for
+ # BROKEN tracks the broken_tracks[].node_path field is what callers
+ # read to diagnose the missing node, and rfind would surface
+ # "MissingTarget:modulate" instead of "MissingTarget" for subpath
+ # tracks like the "Target:modulate:a" shape preset_fade emits.
+ var colon := track_path_str.find(":")
+ var node_part: String
+ if colon >= 0:
+ node_part = track_path_str.substr(0, colon)
+ else:
+ node_part = track_path_str
+
+ var target_node: Node = null
+ if root_node != null:
+ target_node = root_node.get_node_or_null(node_part)
+
+ if target_node == null:
+ broken_tracks.append({
+ "index": i,
+ "path": track_path_str,
+ "type": track_type_to_string(anim.track_get_type(i)),
+ "issue": "node_not_found",
+ "node_path": node_part,
+ })
+ else:
+ valid_count += 1
+
+ return {
+ "data": {
+ "player_path": player_path,
+ "animation_name": anim_name,
+ "track_count": anim.get_track_count(),
+ "valid_count": valid_count,
+ "broken_count": broken_tracks.size(),
+ "broken_tracks": broken_tracks,
+ "valid": broken_tracks.is_empty(),
+ }
+ }
+
+
+# ============================================================================
+# Static helpers — shared with handler + presets
+# ============================================================================
+
+## Resolve the effective root node an AnimationPlayer animates against.
+## Falls back to the player's parent when the explicit root_node NodePath is
+## empty or unresolvable. Returns null when the player isn't in the tree.
+##
+## Mirrors the resolution Godot does at playback time so the validator,
+## preset target resolver, and track-property coercer all see the same root.
+static func player_root_node(player: AnimationPlayer) -> Node:
+ if not player.is_inside_tree():
+ return null
+ var rn := player.root_node
+ if rn != NodePath():
+ var n := player.get_node_or_null(rn)
+ if n != null:
+ return n
+ return player.get_parent()
+
+
+## Coerce a JSON value to match the expected Godot type for the given
+## track_path. Returns {"ok": value} or {"error": msg}.
+## Passes the raw value through when the target node isn't in the scene
+## yet (authoring-time path). Errors when the target exists but the
+## property doesn't, or when parsing a typed value (Color/Vector2/Vector3)
+## clearly fails — better to reject than silently store garbage.
+## `override_root_node` lets callers supply the root to resolve target paths
+## against when the player isn't in the tree yet (auto-create flow) — the
+## player's future parent stands in for the root the AnimationPlayer will
+## eventually use.
+static func coerce_value_for_track(value: Variant, track_path: String, player: AnimationPlayer, override_root_node: Node = null) -> Dictionary:
+ var ctx := resolve_track_prop_context(track_path, player, override_root_node)
+ if ctx.has("error"):
+ return {"error": ctx.error}
+ return coerce_with_context(value, ctx)
+
+
+## Resolve a track_path's target property type once, so callers coercing many
+## keyframes avoid walking `get_property_list()` on every one. Returns:
+## {pass_through: true} — no resolution / authoring-time
+## {pass_through: false, prop_type, prop_name} — coerce against this type
+## {error: msg} — property not found on target
+##
+## Supports Godot's native NodePath subpath form `property:sub` (e.g.
+## `position:y`, `modulate:a`) — splits on the FIRST colon (node↔property
+## boundary), resolves the base property on the target, and for known
+## scalar subpaths (x/y/z/w on vectors, r/g/b/a on Color) narrows the
+## coerce target to TYPE_FLOAT so JSON numbers land as floats, not dicts.
+static func resolve_track_prop_context(track_path: String, player: AnimationPlayer, override_root_node: Node = null) -> Dictionary:
+ var colon := track_path.find(":")
+ if colon < 0:
+ return {"pass_through": true}
+
+ var node_part := track_path.substr(0, colon)
+ var prop_full := track_path.substr(colon + 1)
+
+ # Property may include a subpath: "position:y", "modulate:a", etc.
+ var sub_colon := prop_full.find(":")
+ var prop_base := prop_full if sub_colon < 0 else prop_full.substr(0, sub_colon)
+ var prop_sub := "" if sub_colon < 0 else prop_full.substr(sub_colon + 1)
+
+ var root_node: Node = override_root_node
+ if root_node == null:
+ root_node = player_root_node(player)
+ if root_node == null:
+ return {"pass_through": true}
+
+ var target: Node = root_node.get_node_or_null(node_part)
+ if target == null:
+ # Target node isn't in the scene yet — authoring-time path. Pass through.
+ return {"pass_through": true}
+
+ for p in target.get_property_list():
+ if p.name == prop_base:
+ var base_type: int = p.get("type", TYPE_NIL)
+ var coerce_type := base_type
+ if not prop_sub.is_empty():
+ var sub_type := subpath_component_type(base_type, prop_sub)
+ if sub_type == TYPE_NIL:
+ # Unknown subpath component — pass through so Godot's own
+ # NodePath resolution raises at playback if it's truly bogus,
+ # rather than fabricating a coerce error for a valid-but-
+ # uncommon form (e.g. Transform3D subpaths).
+ return {"pass_through": true}
+ coerce_type = sub_type
+ return {
+ "pass_through": false,
+ "prop_type": coerce_type,
+ "prop_name": prop_full,
+ }
+
+ # Target exists but the property doesn't. Reject loudly — silently storing
+ # the raw value here produces garbage keyframes at playback time.
+ return {"error":
+ "%s (target path: '%s')" %
+ [PropertyErrors.build_message(target, prop_base), node_part]}
+
+
+## Map a `property:sub` subpath to its scalar component type. Returns
+## TYPE_NIL when the base type / subkey pair isn't one we recognise —
+## callers pass-through in that case rather than mis-coerce.
+static func subpath_component_type(base_type: int, sub: String) -> int:
+ var entry = _SUBPATH_COMPONENTS.get(base_type)
+ if entry == null or sub.length() != 1:
+ return TYPE_NIL
+ return entry[1] if (entry[0] as String).contains(sub) else TYPE_NIL
+
+
+static func coerce_with_context(value: Variant, ctx: Dictionary) -> Dictionary:
+ if ctx.get("pass_through", false):
+ return {"ok": value}
+ return coerce_for_type(value, ctx.prop_type, ctx.prop_name)
+
+
+## Coerce a single value to the given Godot variant type. Returns
+## {"ok": coerced} or {"error": msg}. Unknown types pass through.
+static func coerce_for_type(value: Variant, prop_type: int, prop_name: String) -> Dictionary:
+ match prop_type:
+ TYPE_COLOR:
+ ## Canonical strict parser (#714): same shapes as every other
+ ## color-accepting handler, including [r,g,b(,a)] arrays.
+ var col = McpJsonValues.parse_color(value)
+ if col != null:
+ return {"ok": col}
+ return {"error": "Cannot coerce value to Color for property '%s' (expected \"#rrggbb(aa)\"/named string, {r,g,b[,a]}, [r,g,b(,a)], or Color)" % prop_name}
+ TYPE_VECTOR2:
+ var v2 = McpJsonValues.parse_vector2(value)
+ if v2 != null:
+ return {"ok": v2}
+ return {"error": "Cannot coerce value to Vector2 for property '%s' (expected {x,y}, [x,y], or Vector2)" % prop_name}
+ TYPE_VECTOR3:
+ var v3 = McpJsonValues.parse_vector3(value)
+ if v3 != null:
+ return {"ok": v3}
+ return {"error": "Cannot coerce value to Vector3 for property '%s' (expected {x,y,z}, [x,y,z], or Vector3)" % prop_name}
+ TYPE_FLOAT:
+ ## Canonical scalar parser (#964): int/float passthrough plus
+ ## strictly-numeric strings. Null falls through to the generic
+ ## passthrough below, matching the old behavior for garbage.
+ var parsed: Variant = McpJsonValues.parse_float(value)
+ if parsed != null:
+ return {"ok": parsed}
+ TYPE_INT:
+ if value is float or value is int:
+ return {"ok": int(value)}
+ TYPE_BOOL:
+ if value is int or value is float or value is bool:
+ return {"ok": bool(value)}
+ return {"ok": value}
+
+
+# ============================================================================
+# Static helpers — parsing + serializing
+# ============================================================================
+
+## Parse a transition value: named string or raw float.
+## Named values live in `_NAMED_TRANSITIONS` so the mapping has a single source.
+static func parse_transition(v: Variant) -> float:
+ if v is float or v is int:
+ return float(v)
+ if v is String:
+ var key: String = (v as String).to_lower()
+ if _NAMED_TRANSITIONS.has(key):
+ return float(_NAMED_TRANSITIONS[key])
+ return 1.0
+
+
+## Map an Animation.TrackType enum to a stable string. Unknown types report
+## as "unknown" rather than being silently coerced to "method" — callers that
+## only produce value/method tracks can ignore the others; clients that want
+## to round-trip bezier/audio/etc. get an honest label to key off.
+static func track_type_to_string(track_type: int) -> String:
+ match track_type:
+ Animation.TYPE_VALUE: return "value"
+ Animation.TYPE_METHOD: return "method"
+ Animation.TYPE_POSITION_3D: return "position_3d"
+ Animation.TYPE_ROTATION_3D: return "rotation_3d"
+ Animation.TYPE_SCALE_3D: return "scale_3d"
+ Animation.TYPE_BLEND_SHAPE: return "blend_shape"
+ Animation.TYPE_BEZIER: return "bezier"
+ Animation.TYPE_AUDIO: return "audio"
+ Animation.TYPE_ANIMATION: return "animation"
+ _: return "unknown"
+
+
+static func loop_mode_to_string(mode: int) -> String:
+ match mode:
+ Animation.LOOP_LINEAR: return "linear"
+ Animation.LOOP_PINGPONG: return "pingpong"
+ _: return "none"
+
+
+static func interp_to_string(mode: int) -> String:
+ match mode:
+ Animation.INTERPOLATION_NEAREST: return "nearest"
+ Animation.INTERPOLATION_CUBIC: return "cubic"
+ _: return "linear"
+
+
+## Convert a Godot Variant to a JSON-safe value.
+static func serialize_value(value: Variant) -> Variant:
+ ## Delegates to the shared serializer (#714) — the drifted private copy
+ ## stringified rotation_3d keyframe Quaternions into opaque text where
+ ## McpVariantSerializer emits the {x,y,z,w} dict callers can round-trip
+ ## (it also NaN/Inf-guards floats, matching the wire contract).
+ return VariantSerializer.serialize(value)
diff --git a/addons/godot_ai/handlers/animation_values.gd.uid b/addons/godot_ai/handlers/animation_values.gd.uid
new file mode 100644
index 0000000..5d2a8b7
--- /dev/null
+++ b/addons/godot_ai/handlers/animation_values.gd.uid
@@ -0,0 +1 @@
+uid://bguta2eb8blgf
diff --git a/addons/godot_ai/handlers/api_handler.gd b/addons/godot_ai/handlers/api_handler.gd
new file mode 100644
index 0000000..35fcd14
--- /dev/null
+++ b/addons/godot_ai/handlers/api_handler.gd
@@ -0,0 +1,89 @@
+@tool
+extends "res://addons/godot_ai/handlers/command_handler.gd"
+
+## Read-only access to version-correct Godot class metadata.
+
+const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
+const ClassIntrospection := preload("res://addons/godot_ai/utils/class_introspection.gd")
+const FuzzySuggestions := preload("res://addons/godot_ai/utils/fuzzy_suggestions.gd")
+
+func get_class_info(params: Dictionary) -> Dictionary:
+ var requested_class: String = params.get("class_name", "")
+ if requested_class.is_empty():
+ return ErrorCodes.make(
+ ErrorCodes.MISSING_REQUIRED_PARAM,
+ "Missing required param: class_name"
+ )
+ if not ClassDB.class_exists(requested_class):
+ var script_class := _global_script_class(requested_class)
+ if not script_class.is_empty():
+ return _script_class_error(requested_class, script_class)
+ return _unknown_class_error(requested_class)
+ if params.has("limit") and int(params.get("limit")) < 0:
+ return ErrorCodes.make(
+ ErrorCodes.INVALID_PARAMS,
+ "limit must be >= 0; use limit=0 only when an unlimited section is needed"
+ )
+ var section_check := ClassIntrospection.validate_sections(
+ params.get("sections", ClassIntrospection.DEFAULT_SECTIONS)
+ )
+ if not section_check.invalid.is_empty():
+ return _invalid_sections_error(section_check.invalid)
+ return {"data": ClassIntrospection.build(requested_class, params)}
+
+
+static func _unknown_class_error(requested_class: String) -> Dictionary:
+ var suggestions := _suggest_classes(requested_class)
+ var message := "Unknown Godot class: %s" % requested_class
+ if not suggestions.is_empty():
+ message += ". Did you mean: %s?" % ", ".join(suggestions)
+ var result := ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, message)
+ result["error"]["data"] = {"suggestions": suggestions}
+ return result
+
+
+static func _suggest_classes(requested_class: String) -> Array[String]:
+ return FuzzySuggestions.rank(requested_class, ClassDB.get_class_list())
+
+
+static func _global_script_class(requested_class: String) -> Dictionary:
+ for raw_info in ProjectSettings.get_global_class_list():
+ var info: Dictionary = raw_info
+ if info.get("class", "") == requested_class:
+ return info
+ return {}
+
+
+static func _script_class_error(requested_class: String, script_class: Dictionary) -> Dictionary:
+ var path := str(script_class.get("path", ""))
+ var base := str(script_class.get("base", ""))
+ var message := (
+ "%s is a project script class, not a ClassDB class. "
+ + "Use script_manage(op=\"find_symbols\", params={\"path\": \"%s\"}) for script symbols."
+ ) % [requested_class, path]
+ var result := ErrorCodes.make(ErrorCodes.WRONG_TYPE, message)
+ result["error"]["data"] = {
+ "script_class": true,
+ "class_name": requested_class,
+ "base_class": base,
+ "path": path,
+ }
+ return result
+
+
+static func _invalid_sections_error(invalid_sections: Array[String]) -> Dictionary:
+ var suggestions := {}
+ for section in invalid_sections:
+ suggestions[section] = FuzzySuggestions.rank(
+ section,
+ ClassIntrospection.SUGGESTABLE_SECTION_TOKENS,
+ 3,
+ 0.3
+ )
+ var message := "Unknown class-info section(s): %s. Valid sections: %s (or \"all\" for all documentation sections; \"inheritors\" must be requested by name)" % [
+ ", ".join(invalid_sections),
+ ", ".join(ClassIntrospection.KNOWN_SECTIONS),
+ ]
+ var result := ErrorCodes.make(ErrorCodes.INVALID_PARAMS, message)
+ result["error"]["data"] = {"suggestions": suggestions}
+ return result
diff --git a/addons/godot_ai/handlers/api_handler.gd.uid b/addons/godot_ai/handlers/api_handler.gd.uid
new file mode 100644
index 0000000..f519c48
--- /dev/null
+++ b/addons/godot_ai/handlers/api_handler.gd.uid
@@ -0,0 +1 @@
+uid://v3rkd7ueunii
diff --git a/addons/godot_ai/handlers/audio_handler.gd b/addons/godot_ai/handlers/audio_handler.gd
new file mode 100644
index 0000000..ba7bd59
--- /dev/null
+++ b/addons/godot_ai/handlers/audio_handler.gd
@@ -0,0 +1,363 @@
+@tool
+extends "res://addons/godot_ai/handlers/command_handler.gd"
+
+const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
+
+## Handles AudioStreamPlayer / 2D / 3D authoring — node creation, stream
+## assignment, playback-property edits, and real editor preview playback.
+##
+## Stream assignment loads a Godot-imported AudioStream resource from
+## res:// (the editor's import step converts .ogg / .wav / .mp3 into a
+## streamable AudioStream subclass before we ever see it).
+##
+## play() / stop() call the live node method directly — no undo, no
+## persistence; they match what the inspector's play button does.
+
+
+const _VALID_TYPES := {
+ "1d": "AudioStreamPlayer",
+ "2d": "AudioStreamPlayer2D",
+ "3d": "AudioStreamPlayer3D",
+}
+
+## Whitelist of playback properties settable via audio_player_set_playback.
+## Each value is the expected Variant type of the param dict value.
+const _PLAYBACK_KEYS := {
+ "volume_db": TYPE_FLOAT,
+ "pitch_scale": TYPE_FLOAT,
+ "autoplay": TYPE_BOOL,
+ "bus": TYPE_STRING,
+}
+
+
+var _undo_redo: EditorUndoRedoManager
+
+
+func _init(undo_redo: EditorUndoRedoManager) -> void:
+ _undo_redo = undo_redo
+
+
+# ============================================================================
+# audio_player_create
+# ============================================================================
+
+func create_player(params: Dictionary) -> Dictionary:
+ var parent_path: String = params.get("parent_path", "")
+ var node_name: String = params.get("name", "AudioStreamPlayer")
+ var type_str: String = params.get("type", "1d")
+
+ if not _VALID_TYPES.has(type_str):
+ return ErrorCodes.make(
+ ErrorCodes.VALUE_OUT_OF_RANGE,
+ "Invalid audio player type '%s'. Valid: %s" % [type_str, ", ".join(_VALID_TYPES.keys())]
+ )
+
+ var _scene_check := McpNodeValidator.require_scene_or_error()
+ if _scene_check.has("error"):
+ return _scene_check
+ var scene_root: Node = _scene_check.scene_root
+
+ var parent: Node = scene_root
+ if not parent_path.is_empty():
+ parent = McpScenePath.resolve(parent_path, scene_root)
+ if parent == null:
+ return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, McpScenePath.format_parent_error(parent_path, scene_root))
+
+ var node := _instantiate_player(type_str)
+ if node == null:
+ return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate audio player")
+ if not node_name.is_empty():
+ node.name = node_name
+
+ _undo_redo.create_action("MCP: Create %s '%s'" % [_VALID_TYPES[type_str], node.name])
+ _undo_redo.add_do_method(parent, "add_child", node, true)
+ _undo_redo.add_do_method(node, "set_owner", scene_root)
+ _undo_redo.add_do_reference(node)
+ _undo_redo.add_undo_method(parent, "remove_child", node)
+ _undo_redo.commit_action()
+
+ return {
+ "data": {
+ "path": McpScenePath.from_node(node, scene_root),
+ "parent_path": McpScenePath.from_node(parent, scene_root),
+ "name": String(node.name),
+ "type": type_str,
+ "class": _VALID_TYPES[type_str],
+ "undoable": true,
+ }
+ }
+
+
+# ============================================================================
+# audio_player_set_stream
+# ============================================================================
+
+func set_stream(params: Dictionary) -> Dictionary:
+ var player_path: String = params.get("player_path", "")
+ var stream_path: String = params.get("stream_path", "")
+
+ if player_path.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path")
+ if stream_path.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: stream_path")
+
+ var stream_path_err = McpPathValidator.loadable_error(stream_path, "stream_path")
+ if stream_path_err != null:
+ return stream_path_err
+
+ var resolved := _resolve_player(player_path)
+ if resolved.has("error"):
+ return resolved
+ var player: Node = resolved.player
+
+ if not ResourceLoader.exists(stream_path):
+ return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "AudioStream not found: %s" % stream_path)
+ var loaded := ResourceLoader.load(stream_path)
+ if loaded == null:
+ return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to load AudioStream: %s" % stream_path)
+ if not (loaded is AudioStream):
+ return ErrorCodes.make(
+ ErrorCodes.WRONG_TYPE,
+ "Resource at %s is not an AudioStream (got %s)" % [stream_path, loaded.get_class()]
+ )
+
+ var old_stream: AudioStream = player.stream
+
+ _undo_redo.create_action("MCP: Set audio stream on %s" % player.name)
+ _undo_redo.add_do_property(player, "stream", loaded)
+ _undo_redo.add_undo_property(player, "stream", old_stream)
+ _undo_redo.commit_action()
+
+ return {
+ "data": {
+ "player_path": player_path,
+ "stream_path": stream_path,
+ "stream_class": loaded.get_class(),
+ "duration_seconds": float(loaded.get_length()),
+ "undoable": true,
+ }
+ }
+
+
+# ============================================================================
+# audio_player_set_playback
+# ============================================================================
+
+func set_playback(params: Dictionary) -> Dictionary:
+ var player_path: String = params.get("player_path", "")
+ if player_path.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path")
+
+ var resolved := _resolve_player(player_path)
+ if resolved.has("error"):
+ return resolved
+ var player: Node = resolved.player
+
+ var updates: Dictionary = {}
+ for key in _PLAYBACK_KEYS:
+ if params.has(key):
+ var expected_type: int = _PLAYBACK_KEYS[key]
+ var value = params.get(key)
+ var coerced = _coerce_playback_value(value, expected_type)
+ if coerced == null:
+ return ErrorCodes.make(
+ ErrorCodes.INVALID_PARAMS,
+ "Invalid value for %s: expected %s, got %s" % [
+ key, type_string(expected_type), type_string(typeof(value))
+ ]
+ )
+ updates[key] = coerced
+
+ if updates.is_empty():
+ return ErrorCodes.make(
+ ErrorCodes.MISSING_REQUIRED_PARAM,
+ "At least one of %s is required" % ", ".join(_PLAYBACK_KEYS.keys())
+ )
+
+ var old_values: Dictionary = {}
+ for key in updates:
+ old_values[key] = player.get(key)
+
+ _undo_redo.create_action("MCP: Update playback on %s" % player.name)
+ for key in updates:
+ _undo_redo.add_do_property(player, key, updates[key])
+ _undo_redo.add_undo_property(player, key, old_values[key])
+ _undo_redo.commit_action()
+
+ return {
+ "data": {
+ "player_path": player_path,
+ "applied": updates.keys(),
+ "values": updates,
+ "undoable": true,
+ }
+ }
+
+
+# ============================================================================
+# audio_play (runtime preview — not saved with scene)
+# ============================================================================
+
+func play(params: Dictionary) -> Dictionary:
+ var player_path: String = params.get("player_path", "")
+ var from_position: float = float(params.get("from_position", 0.0))
+
+ if player_path.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path")
+
+ var resolved := _resolve_player(player_path)
+ if resolved.has("error"):
+ return resolved
+ var player: Node = resolved.player
+
+ if player.stream == null:
+ return ErrorCodes.make(
+ ErrorCodes.MISSING_REQUIRED_PARAM,
+ "Player has no stream assigned — call audio_player_set_stream first"
+ )
+
+ player.play(from_position)
+
+ return {
+ "data": {
+ "player_path": player_path,
+ "from_position": from_position,
+ "playing": bool(player.playing),
+ "undoable": false,
+ "reason": "Runtime playback state — not saved with scene",
+ }
+ }
+
+
+# ============================================================================
+# audio_stop (runtime preview — not saved with scene)
+# ============================================================================
+
+func stop(params: Dictionary) -> Dictionary:
+ var player_path: String = params.get("player_path", "")
+ if player_path.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path")
+
+ var resolved := _resolve_player(player_path)
+ if resolved.has("error"):
+ return resolved
+ var player: Node = resolved.player
+
+ player.stop()
+
+ return {
+ "data": {
+ "player_path": player_path,
+ "playing": bool(player.playing),
+ "undoable": false,
+ "reason": "Runtime playback state — not saved with scene",
+ }
+ }
+
+
+# ============================================================================
+# audio_list (read — scan project for AudioStream resources)
+# ============================================================================
+
+func list_streams(params: Dictionary) -> Dictionary:
+ var root: String = params.get("root", "res://")
+ var include_duration: bool = bool(params.get("include_duration", true))
+
+ var root_err = McpPathValidator.path_error(root, "root")
+ if root_err != null:
+ return root_err
+
+ var efs := EditorInterface.get_resource_filesystem()
+ if efs == null:
+ return ErrorCodes.make_not_ready(
+ ErrorCodes.SUB_EDITOR_UNAVAILABLE,
+ "EditorFileSystem not available", false)
+
+ var results: Array[Dictionary] = []
+ var start_dir := efs.get_filesystem_path(root)
+ if start_dir == null:
+ start_dir = efs.get_filesystem()
+ _scan_audio(start_dir, root, include_duration, results)
+ return {
+ "data": {
+ "root": root,
+ "streams": results,
+ "count": results.size(),
+ }
+ }
+
+
+func _scan_audio(dir: EditorFileSystemDirectory, root: String, include_duration: bool, out: Array[Dictionary]) -> void:
+ if dir == null:
+ return
+ for i in dir.get_file_count():
+ var file_path := dir.get_file_path(i)
+ if not file_path.begins_with(root):
+ continue
+ var file_type := dir.get_file_type(i)
+ var is_audio := file_type == "AudioStream" or ClassDB.is_parent_class(file_type, "AudioStream")
+ if not is_audio:
+ continue
+ var entry: Dictionary = {
+ "path": file_path,
+ "class": file_type,
+ }
+ if include_duration:
+ var res := ResourceLoader.load(file_path)
+ if res is AudioStream:
+ entry["duration_seconds"] = float((res as AudioStream).get_length())
+ else:
+ entry["duration_seconds"] = 0.0
+ out.append(entry)
+ for i in dir.get_subdir_count():
+ _scan_audio(dir.get_subdir(i), root, include_duration, out)
+
+
+# ============================================================================
+# Helpers
+# ============================================================================
+
+static func _instantiate_player(type_str: String) -> Node:
+ match type_str:
+ "1d":
+ return AudioStreamPlayer.new()
+ "2d":
+ return AudioStreamPlayer2D.new()
+ "3d":
+ return AudioStreamPlayer3D.new()
+ return null
+
+
+func _resolve_player(player_path: String) -> Dictionary:
+ var resolved := McpNodeValidator.resolve_or_error(player_path, "player_path")
+ if resolved.has("error"):
+ return resolved
+ var node: Node = resolved.node
+ var is_player := node is AudioStreamPlayer \
+ or node is AudioStreamPlayer2D \
+ or node is AudioStreamPlayer3D
+ if not is_player:
+ return ErrorCodes.make(
+ ErrorCodes.WRONG_TYPE,
+ "Node at %s is not an AudioStreamPlayer/2D/3D (got %s)" % [player_path, node.get_class()]
+ )
+ return {"player": node}
+
+
+## Coerce a playback param value to the expected type. int→float and
+## strictly-numeric strings are allowed so JSON integers and stringified
+## floats (#964) pass through; everything else requires the exact type.
+## Returns the coerced value, or null on type mismatch.
+static func _coerce_playback_value(value: Variant, expected_type: int) -> Variant:
+ match expected_type:
+ TYPE_FLOAT:
+ var parsed: Variant = McpJsonValues.parse_float(value)
+ if parsed != null:
+ return parsed
+ TYPE_BOOL:
+ if value is bool:
+ return value
+ TYPE_STRING:
+ if value is String:
+ return value
+ return null
diff --git a/addons/godot_ai/handlers/audio_handler.gd.uid b/addons/godot_ai/handlers/audio_handler.gd.uid
new file mode 100644
index 0000000..2510ee7
--- /dev/null
+++ b/addons/godot_ai/handlers/audio_handler.gd.uid
@@ -0,0 +1 @@
+uid://cjtvod52xxocs
diff --git a/addons/godot_ai/handlers/autoload_handler.gd b/addons/godot_ai/handlers/autoload_handler.gd
new file mode 100644
index 0000000..790521b
--- /dev/null
+++ b/addons/godot_ai/handlers/autoload_handler.gd
@@ -0,0 +1,91 @@
+@tool
+extends "res://addons/godot_ai/handlers/command_handler.gd"
+
+const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
+
+## Handles autoload listing, adding, and removing via ProjectSettings.
+
+
+func list_autoloads(_params: Dictionary) -> Dictionary:
+ var autoloads: Array[Dictionary] = []
+ for prop in ProjectSettings.get_property_list():
+ var key: String = prop.get("name", "")
+ if not key.begins_with("autoload/"):
+ continue
+ var name := key.substr("autoload/".length())
+ var raw_value: String = ProjectSettings.get_setting(key, "")
+ var is_singleton := raw_value.begins_with("*")
+ var path := raw_value.substr(1) if is_singleton else raw_value
+ autoloads.append({
+ "name": name,
+ "path": path,
+ "singleton": is_singleton,
+ })
+ return {"data": {"autoloads": autoloads, "count": autoloads.size()}}
+
+
+func add_autoload(params: Dictionary) -> Dictionary:
+ var name: String = params.get("name", "")
+ var path: String = params.get("path", "")
+ var singleton: bool = params.get("singleton", true)
+
+ if name.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: name")
+ if path.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: path")
+ var path_err = McpPathValidator.path_error(path, "path")
+ if path_err != null:
+ return path_err
+ if not FileAccess.file_exists(path):
+ return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "File not found: %s" % path)
+
+ var key := "autoload/%s" % name
+ if ProjectSettings.has_setting(key):
+ return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "Autoload '%s' already exists" % name)
+
+ var value := ("*" if singleton else "") + path
+ ProjectSettings.set_setting(key, value)
+ ProjectSettings.set_initial_value(key, "")
+ ProjectSettings.set_as_basic(key, true)
+ var err := ProjectSettings.save()
+ if err != OK:
+ ProjectSettings.clear(key)
+ return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR,
+ "Failed to save project settings while adding autoload '%s': %s (error %d)" % [name, error_string(err), err])
+
+ return {
+ "data": {
+ "name": name,
+ "path": path,
+ "singleton": singleton,
+ "undoable": false,
+ "reason": "Autoload changes are saved to project.godot",
+ }
+ }
+
+
+func remove_autoload(params: Dictionary) -> Dictionary:
+ var name: String = params.get("name", "")
+ if name.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: name")
+
+ var key := "autoload/%s" % name
+ if not ProjectSettings.has_setting(key):
+ return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, "Autoload '%s' not found" % name)
+
+ var old_value: String = ProjectSettings.get_setting(key, "")
+ ProjectSettings.clear(key)
+ var err := ProjectSettings.save()
+ if err != OK:
+ ProjectSettings.set_setting(key, old_value)
+ return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR,
+ "Failed to save project settings while removing autoload '%s': %s (error %d)" % [name, error_string(err), err])
+
+ return {
+ "data": {
+ "name": name,
+ "removed": true,
+ "undoable": false,
+ "reason": "Autoload changes are saved to project.godot",
+ }
+ }
diff --git a/addons/godot_ai/handlers/autoload_handler.gd.uid b/addons/godot_ai/handlers/autoload_handler.gd.uid
new file mode 100644
index 0000000..921ed4e
--- /dev/null
+++ b/addons/godot_ai/handlers/autoload_handler.gd.uid
@@ -0,0 +1 @@
+uid://bb0inov044jn6
diff --git a/addons/godot_ai/handlers/batch_handler.gd b/addons/godot_ai/handlers/batch_handler.gd
new file mode 100644
index 0000000..3db1392
--- /dev/null
+++ b/addons/godot_ai/handlers/batch_handler.gd
@@ -0,0 +1,237 @@
+@tool
+extends "res://addons/godot_ai/handlers/command_handler.gd"
+
+const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
+
+## Executes a list of sub-commands through the dispatcher with stop-on-first-error
+## semantics. When undo=true (default), any successful sub-commands are rolled
+## back via the scene's UndoRedo history if a later sub-command fails.
+
+## Commands that cannot run as batch sub-commands, each with the reason a batch
+## can't host it.
+## - batch_execute: would recurse.
+## - reload_plugin: schedules teardown; later batch operations could pump
+## its callback while the batch handler is still executing.
+## - run_tests: a batch executes synchronously inside one dispatcher tick with
+## NO transport servicing, so a full suite starves the WebSocket heartbeat
+## (the exact disconnect the serviced test_run path exists to prevent) and
+## the Python batch handler only allows 30s anyway — call the test_run tool
+## directly.
+## - game_command: it is deferred — its reply flows out-of-band correlated by a
+## _request_id that dispatch_direct deliberately strips (see
+## McpDispatcher.dispatch_direct), so a game op nested in a batch would have
+## no completion channel and hang or lose its reply. input_sequence made this
+## concrete (#814); the whole game_command surface shares the deferred path.
+const FORBIDDEN_SUBCOMMANDS := {
+ "reload_plugin": "reload_plugin must be called directly so no batch continues during reload",
+ "batch_execute": "batch_execute cannot be nested inside another batch",
+ "run_tests":
+ "run_tests is not allowed as a sub-command — a batch runs synchronously "
+ + "with no transport servicing; call the test_run tool directly",
+ "game_command":
+ "game_command ops are deferred (their reply arrives out-of-band) and "
+ + "have no completion channel inside a batch — run them as their own tool call",
+ "configure_client":
+ "configure_client is deferred and has no completion channel inside a batch — "
+ + "run it as its own tool call",
+ "remove_client":
+ "remove_client is deferred and has no completion channel inside a batch — "
+ + "run it as its own tool call",
+}
+
+## The whole batch executes synchronously inside one dispatcher tick,
+## outside the 4ms frame budget — an unbounded array freezes the editor
+## for the batch's full duration. 500 is far above any legitimate scene
+## edit while keeping worst-case stalls in check.
+const MAX_BATCH_COMMANDS := 500
+
+var _dispatcher: McpDispatcher
+var _undo_redo: EditorUndoRedoManager
+
+
+func _init(dispatcher: McpDispatcher, undo_redo: EditorUndoRedoManager) -> void:
+ _dispatcher = dispatcher
+ _undo_redo = undo_redo
+
+
+func batch_execute(params: Dictionary) -> Dictionary:
+ var commands = params.get("commands", null)
+ if typeof(commands) != TYPE_ARRAY:
+ return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "commands must be a list")
+ if commands.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "commands must not be empty")
+ if commands.size() > MAX_BATCH_COMMANDS:
+ return ErrorCodes.make(
+ ErrorCodes.VALUE_OUT_OF_RANGE,
+ "commands exceeds the %d-command batch cap (got %d) — split into multiple batches" % [MAX_BATCH_COMMANDS, commands.size()]
+ )
+
+ var undo: bool = params.get("undo", true)
+
+ for idx in range(commands.size()):
+ var item = commands[idx]
+ if typeof(item) != TYPE_DICTIONARY:
+ return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "commands[%d] must be a dict" % idx)
+ var cmd_name: String = item.get("command", "")
+ if cmd_name.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "commands[%d] missing 'command' field" % idx)
+ if FORBIDDEN_SUBCOMMANDS.has(cmd_name):
+ return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
+ "commands[%d]: %s" % [idx, FORBIDDEN_SUBCOMMANDS[cmd_name]])
+ if not _dispatcher.has_command(cmd_name):
+ return _unknown_command_error(idx, cmd_name)
+ ## Pre-validate params type: the execution loop's typed Dictionary
+ ## local would hard-error on a non-dict mid-batch, aborting AFTER
+ ## earlier mutations committed. Catching it here keeps the
+ ## all-or-nothing contract for malformed input.
+ if typeof(item.get("params", {})) != TYPE_DICTIONARY:
+ return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "commands[%d].params must be a dict" % idx)
+ if cmd_name.begins_with("custom_tool:"):
+ var _custom_tools := McpToolRegistry.get_instance()
+ if _custom_tools:
+ var spec := _custom_tools.get_spec(cmd_name.trim_prefix("custom_tool:"))
+ ## A deferred tool's real reply arrives via ctx.send_deferred
+ ## AFTER the batch has already returned its synchronous
+ ## results — the sub-command would report an opaque
+ ## "_deferred" placeholder and the actual payload would be
+ ## orphaned. Reject up-front, matching the FORBIDDEN_SUBCOMMANDS
+ ## contract for run_tests.
+ if spec != null and spec.deferred:
+ return ErrorCodes.make(
+ ErrorCodes.VALUE_OUT_OF_RANGE,
+ "commands[%d] custom_tool:%s is deferred — its reply outlives the batch response; call it directly instead" % [idx, cmd_name.trim_prefix("custom_tool:")]
+ )
+ if undo and spec != null and not spec.undoable:
+ return ErrorCodes.make(
+ ErrorCodes.CUSTOM_TOOL_NOT_UNDOABLE,
+ "commands[%d] custom_tool:%s is not undoable" % [idx, cmd_name.trim_prefix("custom_tool:")]
+ )
+
+ var results: Array = []
+ var succeeded := 0
+ var stopped_at = null
+ var all_undoable := true
+ ## One entry per action a sub-command actually committed, in commit order,
+ ## naming the UndoRedo that received it. Rollback replays this in reverse.
+ ##
+ ## Measured rather than inferred. Counting *successes* over-undoes, because
+ ## a sub-command can succeed without committing anything (`create_script`
+ ## and `write_text_file` write straight to disk) — the extra undos then eat
+ ## the user's own pre-batch edits. Trusting the response's `undoable` flag
+ ## instead under-undoes, because `custom_tool:` sub-commands never carry one
+ ## (`custom_tool_wrapper.gd` returns the addon handler's dict verbatim) even
+ ## when their spec declares `undoable = true` and they did commit. Comparing
+ ## `UndoRedo.get_version()` across the call is the only reading that can't
+ ## disagree with what the editor actually recorded.
+ var committed: Array = []
+
+ for idx in range(commands.size()):
+ var item: Dictionary = commands[idx]
+ var cmd_name: String = item["command"]
+ var sub_params: Dictionary = item.get("params", {})
+
+ var tracked := _tracked_histories()
+ var before: Array = []
+ for ur in tracked:
+ before.append(ur.get_version())
+
+ var raw_result: Dictionary = _dispatcher.dispatch_direct(cmd_name, sub_params)
+ ## Record before reading status. A handler can commit_action() and still
+ ## return an error; those actions must be in `committed` so a later
+ ## (or this) failure rolls them back.
+ _record_committed(tracked, before, committed)
+ var status: String = raw_result.get("status", "ok")
+
+ var result_entry: Dictionary = {"command": cmd_name, "status": status}
+ if status == "error":
+ result_entry["error"] = raw_result.get("error", {})
+ results.append(result_entry)
+ stopped_at = idx
+ break
+ else:
+ var data: Dictionary = raw_result.get("data", raw_result)
+ result_entry["data"] = data
+ if typeof(data) == TYPE_DICTIONARY and data.get("undoable", false) != true:
+ all_undoable = false
+ results.append(result_entry)
+ succeeded += 1
+
+ var rolled_back := false
+ if stopped_at != null and undo and not committed.is_empty():
+ rolled_back = _rollback(committed)
+
+ var response_data: Dictionary = {
+ "succeeded": succeeded,
+ "stopped_at": stopped_at,
+ "results": results,
+ "undo": undo,
+ "rolled_back": rolled_back,
+ "undoable": stopped_at == null and all_undoable and not rolled_back,
+ }
+ if stopped_at != null:
+ response_data["error"] = results[-1]["error"]
+ return {"data": response_data}
+
+
+## The histories a batch sub-command can commit into.
+##
+## Most write handlers pin their action to the edited scene, but not all:
+## handlers that `create_action(label)` without a context object bind it to the
+## handler `RefCounted` instead, and it lands in `GLOBAL_HISTORY` (see
+## `animation_handler.gd`'s note on mismatched histories, and
+## `testing/test_suite.gd`, which watches both for the same reason). Watching
+## only the scene history would leave those actions un-rolled-back.
+func _tracked_histories() -> Array:
+ var out: Array = []
+ var scene_root := EditorInterface.get_edited_scene_root()
+ if scene_root != null:
+ var scene_ur := _undo_redo.get_history_undo_redo(
+ _undo_redo.get_object_history_id(scene_root)
+ )
+ if scene_ur != null:
+ out.append(scene_ur)
+ var global_ur := _undo_redo.get_history_undo_redo(EditorUndoRedoManager.GLOBAL_HISTORY)
+ if global_ur != null and not global_ur in out:
+ out.append(global_ur)
+ return out
+
+
+## Append one entry to `committed` per action the just-finished sub-command
+## pushed, by diffing each watched history's version against `before`.
+## `UndoRedo.get_version()` advances once per `commit_action`, so the delta is
+## the action count — a handler that commits twice is recorded twice, and one
+## that commits nothing is recorded not at all.
+func _record_committed(tracked: Array, before: Array, committed: Array) -> void:
+ for i in range(tracked.size()):
+ var delta: int = tracked[i].get_version() - int(before[i])
+ for _n in range(delta):
+ committed.append(tracked[i])
+
+
+## Build the unknown-command error for a sub-command. Clarifies that
+## batch_execute expects plugin command names (not MCP tool names) and
+## surfaces fuzzy suggestions in both the message and structured data.
+func _unknown_command_error(idx: int, cmd_name: String) -> Dictionary:
+ var suggestions := _dispatcher.suggest_similar(cmd_name)
+ var msg := "commands[%d]: unknown plugin command '%s'. batch_execute expects plugin command names (e.g. 'create_node'), not MCP tool names (e.g. 'node_create')." % [idx, cmd_name]
+ if not suggestions.is_empty():
+ msg += " Did you mean: %s?" % ", ".join(suggestions)
+ var err := ErrorCodes.make(ErrorCodes.UNKNOWN_COMMAND, msg)
+ err["error"]["data"] = {"suggestions": suggestions}
+ return err
+
+
+## Undo every action the batch committed, newest first, against the history
+## that actually received it. Returns true iff every undo() reported success.
+##
+## Strict LIFO across histories matters: undoing a scene action before a global
+## one committed after it would replay them out of order. Walking `committed`
+## in reverse preserves commit order regardless of which history each landed in.
+func _rollback(committed: Array) -> bool:
+ if committed.is_empty():
+ return false
+ var ok := true
+ for i in range(committed.size() - 1, -1, -1):
+ if not committed[i].undo():
+ ok = false
+ return ok
diff --git a/addons/godot_ai/handlers/batch_handler.gd.uid b/addons/godot_ai/handlers/batch_handler.gd.uid
new file mode 100644
index 0000000..630a596
--- /dev/null
+++ b/addons/godot_ai/handlers/batch_handler.gd.uid
@@ -0,0 +1 @@
+uid://dt7um75oofdrh
diff --git a/addons/godot_ai/handlers/camera_handler.gd b/addons/godot_ai/handlers/camera_handler.gd
new file mode 100644
index 0000000..5675037
--- /dev/null
+++ b/addons/godot_ai/handlers/camera_handler.gd
@@ -0,0 +1,1131 @@
+@tool
+extends "res://addons/godot_ai/handlers/command_handler.gd"
+
+const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
+
+## Handles Camera2D / Camera3D authoring — create, configure, bounds, damping,
+## node-parent-based follow, presets.
+##
+## All writes are bundled into a single EditorUndoRedoManager action.
+## Setting current=true auto-unmarks previously-current cameras of the same
+## class in the same action so one Ctrl-Z reverts the switch.
+
+const CameraValues := preload("res://addons/godot_ai/handlers/camera_values.gd")
+const CameraPresets := preload("res://addons/godot_ai/handlers/camera_presets.gd")
+
+const _VALID_TYPES := {
+ "2d": "Camera2D",
+ "3d": "Camera3D",
+}
+
+const _KEYS_2D := [
+ "zoom",
+ "offset",
+ "anchor_mode",
+ "ignore_rotation",
+ "enabled",
+ "current",
+ "process_callback",
+ "position_smoothing_enabled",
+ "position_smoothing_speed",
+ "rotation_smoothing_enabled",
+ "rotation_smoothing_speed",
+ "drag_horizontal_enabled",
+ "drag_vertical_enabled",
+ "drag_horizontal_offset",
+ "drag_vertical_offset",
+ "drag_left_margin",
+ "drag_top_margin",
+ "drag_right_margin",
+ "drag_bottom_margin",
+ "limit_left",
+ "limit_right",
+ "limit_top",
+ "limit_bottom",
+ "limit_smoothed",
+]
+
+const _KEYS_3D := [
+ "fov",
+ "near",
+ "far",
+ "size",
+ "projection",
+ "keep_aspect",
+ "cull_mask",
+ "doppler_tracking",
+ "h_offset",
+ "v_offset",
+ "current",
+]
+
+# Transform-shaped keys live on Node2D / Node3D, not in the camera-specific
+# schema — rejecting them without a hint sends agents searching for the wrong
+# tool.
+const _NODE_TRANSFORM_KEYS := [
+ "position", "rotation", "scale", "transform",
+ "global_position", "global_rotation", "global_scale", "global_transform",
+]
+
+const _DAMPING_MARGIN_KEYS := ["left", "top", "right", "bottom"]
+const _CURRENT_SETTLE_ATTEMPTS := 8
+const _CURRENT_SETTLE_DELAY_MSEC := 10
+
+
+var _undo_redo: EditorUndoRedoManager
+
+# Per-scene logical-current bookkeeping. Keys are scene-root InstanceIDs;
+# values are { "2d": NodePath-as-String, "3d": NodePath-as-String } with
+# missing keys meaning "no logical current for that class."
+#
+# Stored on the handler instance (NOT as Node metadata on the scene root)
+# because set_meta() persists into the .tscn on save, contaminating user
+# scene files with MCP-internal sidecar state that lingers across reloads
+# and travels in commits.
+var _logical_current: Dictionary = {}
+
+
+func _init(undo_redo: EditorUndoRedoManager) -> void:
+ _undo_redo = undo_redo
+
+
+# Camera2D doesn't expose `current` as a settable property in Godot 4 —
+# only is_current() / make_current() / clear_current(). Camera3D exposes
+# both, but using methods uniformly avoids per-class branching.
+static func _is_current(cam: Node) -> bool:
+ if cam == null:
+ return false
+ return bool(cam.is_current())
+
+
+static func _viewport_current_camera(scene_root: Node) -> Node:
+ if scene_root == null:
+ return null
+ var viewport := scene_root.get_viewport()
+ if viewport == null:
+ return null
+ var current_2d := viewport.get_camera_2d()
+ if current_2d != null and scene_root.is_ancestor_of(current_2d):
+ return current_2d
+ var current_3d := viewport.get_camera_3d()
+ if current_3d != null and scene_root.is_ancestor_of(current_3d):
+ return current_3d
+ return null
+
+
+static func _is_effective_current(cam: Node) -> bool:
+ if _is_current(cam):
+ return true
+ if cam is Camera2D:
+ var viewport_2d := cam.get_viewport()
+ return viewport_2d != null and viewport_2d.get_camera_2d() == cam
+ if cam is Camera3D:
+ var viewport_3d := cam.get_viewport()
+ return viewport_3d != null and viewport_3d.get_camera_3d() == cam
+ return false
+
+
+# Logical-current bookkeeping. Updated from inside _apply_make_current /
+# _apply_clear_current so DO and UNDO callables stamp the same logical
+# slot they touch in the viewport. Reads consult the logical slot first
+# and treat it as authoritative when set — the viewport read is the
+# fallback for "MCP never touched this scene's cameras."
+
+func _set_logical_current(cam: Node) -> void:
+ if cam == null or not is_instance_valid(cam) or not cam.is_inside_tree():
+ return
+ var type_str := _camera_type_str(cam)
+ if type_str.is_empty():
+ return
+ var scene_root := EditorInterface.get_edited_scene_root()
+ if scene_root == null or not scene_root.is_ancestor_of(cam):
+ return
+ var slot: Dictionary = _logical_current.get(scene_root.get_instance_id(), {})
+ slot[type_str] = McpScenePath.from_node(cam, scene_root)
+ _logical_current[scene_root.get_instance_id()] = slot
+
+
+func _clear_logical_current(cam: Node) -> void:
+ if cam == null:
+ return
+ var type_str := _camera_type_str(cam)
+ if type_str.is_empty():
+ return
+ var scene_root := EditorInterface.get_edited_scene_root()
+ if scene_root == null:
+ return
+ var key := scene_root.get_instance_id()
+ if not _logical_current.has(key):
+ return
+ var slot: Dictionary = _logical_current[key]
+ if not slot.has(type_str):
+ return
+ # Only clear if the logical slot still points at this camera; otherwise
+ # a later make_current already took the slot and we'd stomp it.
+ var current_path := ""
+ if is_instance_valid(cam) and cam.is_inside_tree() and scene_root.is_ancestor_of(cam):
+ current_path = McpScenePath.from_node(cam, scene_root)
+ if String(slot[type_str]) == current_path:
+ slot.erase(type_str)
+ if slot.is_empty():
+ _logical_current.erase(key)
+ else:
+ _logical_current[key] = slot
+
+
+func _logical_current_camera(scene_root: Node, type_str: String = "") -> Node:
+ if scene_root == null:
+ return null
+ var key := scene_root.get_instance_id()
+ if not _logical_current.has(key):
+ return null
+ var slot: Dictionary = _logical_current[key]
+ var types: Array[String] = []
+ if type_str == "2d" or type_str == "3d":
+ types = [type_str]
+ else:
+ types = ["2d", "3d"]
+ for t in types:
+ if not slot.has(t):
+ continue
+ var path := String(slot[t])
+ if path.is_empty():
+ slot.erase(t)
+ continue
+ var node := McpScenePath.resolve(path, scene_root)
+ if node == null or not _is_camera(node) or _camera_type_str(node) != t:
+ slot.erase(t)
+ continue
+ return node
+ if slot.is_empty():
+ _logical_current.erase(key)
+ else:
+ _logical_current[key] = slot
+ return null
+
+
+# Public introspection for tests that need to distinguish "handler has a
+# logical marker" from "handler is falling back to engine state". `get_camera`
+# / `list_cameras` both use `_resolve_current` which falls through to
+# `_is_effective_current` when no marker is set — that's correct for callers
+# but masks the marker presence from anyone trying to gate on
+# "did the handler actually record this state?". Returns the logical-current
+# Camera2D / Camera3D for the given type ("2d" / "3d" / "" for either), or
+# null when no marker is set. See #316 PR #372 review feedback.
+func peek_logical_current(scene_root: Node, type_str: String = "") -> Node:
+ return _logical_current_camera(scene_root, type_str)
+
+
+# Authoritative answer for "is `cam` the current camera of its class?"
+#
+# When a logical marker exists for the camera's class, it is the single
+# source of truth — only the marker's referenced camera reports current,
+# every other camera of that class reports false even if the viewport
+# slot still points at one of them (the headless-CI lag in #140 / #278 /
+# #301). Without a logical marker, fall through to the viewport read so
+# scenes MCP never touched still answer correctly.
+func _resolve_current(scene_root: Node, cam: Node) -> bool:
+ if scene_root == null or cam == null:
+ return false
+ var logical := _logical_current_camera(scene_root, _camera_type_str(cam))
+ if logical != null:
+ return logical == cam
+ return _is_effective_current(cam)
+
+
+# list_cameras pre-fetches the per-class logical pointers once; this
+# variant takes those pointers to avoid an O(n²) walk over the meta
+# bookkeeping for each camera in the scene.
+func _resolve_current_with_logicals(cam: Node, logical_2d: Node, logical_3d: Node) -> bool:
+ if cam == null:
+ return false
+ if cam is Camera2D:
+ if logical_2d != null:
+ return logical_2d == cam
+ elif cam is Camera3D:
+ if logical_3d != null:
+ return logical_3d == cam
+ return _is_effective_current(cam)
+
+
+# Register a current=true switch on `node` in the open undo action,
+# unmarking previously-current siblings of the same class so a single
+# Ctrl-Z reverts the whole switch.
+#
+# Both DO and UNDO route through `_apply_make_current` / `_apply_clear_current`
+# on the handler itself rather than calling Camera.make_current() directly.
+# The helpers do the make_current (or clear_current) call and then verify the
+# viewport slot synchronously. CI used to report `is_current() == false`
+# immediately after a committed make_current (#140 / #278 / #301 / #316); that
+# was never engine lag — Camera2D's current state is a synchronous viewport
+# read — but `Camera2D.make_current()` being silently dropped by the engine's
+# group-call skip set. See `_broadcast_make_current_2d` for the mechanism and
+# the deterministic repair.
+#
+# Because those callables bind to `self` (a RefCounted handler, not a scene
+# node), every action that calls this helper must pin its history via
+# `create_action(name, MERGE_DISABLE, scene_root)` — otherwise the
+# handler-bound ops land in GLOBAL_HISTORY while the scene-node ops land in
+# the scene's history, and a single editor_undo reverts only half the action.
+#
+# Both DO and UNDO use a single make_current() call — never a
+# clear_current() + make_current() pair. make_current() takes over the
+# viewport slot atomically (Godot enforces one current camera per class
+# per viewport), so the displaced camera naturally returns
+# is_current() == false without an explicit clear. The two-step approach
+# leaves the viewport temporarily with no current camera between the
+# clear and the make, which races with editor cleanup on macOS headless
+# (observed flaking CI runs 24674252085, 24675424785).
+func _add_make_current_to_action(node: Node, type_str: String, scene_root: Node) -> void:
+ var prev_current: Node = null
+ for cam in _list_cameras_in_scene(scene_root, type_str):
+ if cam == node:
+ continue
+ if _resolve_current(scene_root, cam):
+ prev_current = cam
+ break
+ _undo_redo.add_do_method(self, "_apply_make_current", node)
+ if prev_current != null:
+ _undo_redo.add_undo_method(self, "_apply_make_current", prev_current)
+ else:
+ _undo_redo.add_undo_method(self, "_apply_clear_current", node)
+
+
+# Apply make_current on `cam` and verify the viewport slot. Registered as the
+# do/undo callable by `_add_make_current_to_action`. Safe against a freed
+# camera node — short-circuits if the node is gone or not in the tree.
+#
+# Everything here is synchronous: `Camera2D.is_current()` is literally
+# `viewport->get_camera_2d() == this` and `Camera3D.is_current()` reads the
+# node's own flag while it is part of the edited scene, so there is no engine
+# state that a delay or a retry could wait for. The one way `make_current()`
+# fails is the group-call skip described on `_broadcast_make_current_2d`, and
+# that is repaired in place.
+func _apply_make_current(cam: Node) -> void:
+ if cam == null or not is_instance_valid(cam) or not cam.is_inside_tree():
+ return
+ _set_logical_current(cam)
+ var scene_root := EditorInterface.get_edited_scene_root()
+ var type_str := _camera_type_str(cam)
+ cam.make_current()
+ if cam is Camera2D and not _is_current_settled(cam):
+ _broadcast_make_current_2d(cam as Camera2D, scene_root)
+ _force_camera_refresh(cam)
+ # In the editor a displaced Camera3D keeps its own `current` flag (the
+ # viewport's LOST_CURRENT notification does not clear it), so it still
+ # answers is_current() == true. Sweep same-class siblings and clear them.
+ _force_clear_other_currents(cam, type_str, scene_root)
+
+
+# `Camera2D.make_current()` does not touch the viewport itself: it calls
+# `SceneTree.call_group("__cameras_", "_make_current", self)` and
+# every camera in the group sets or releases `Viewport.camera_2d` in
+# `_make_current(which)`. `call_group` skips any node whose pointer is in
+# `SceneTree::nodes_removed_on_group_call` — a raw-pointer set of every node
+# removed from the tree since the current `_process()` pass (or the outermost
+# group call) began, cleared only when that pass unwinds. A camera allocated
+# into the address of a node removed and freed earlier in the same frame — a
+# `memdelete`d undo reference, a `free()`d node, an editor Control rebuild —
+# is therefore silently skipped: `make_current()` returns, the slot never
+# changes, and `is_current()` stays false while the handler's own bookkeeping
+# says current. Reproduced deterministically on 4.7.2 (49/50 iterations with
+# a same-frame `free()`); this is the "engine-state lag" behind #140 / #278 /
+# #301 / #316. Camera3D is not affected: it writes the viewport directly.
+#
+# Repair by replicating the broadcast with the same bound `_make_current`
+# method the engine's group call invokes: every other Camera2D in the scene
+# releases the slot, then the target claims it. `_make_current` is engine
+# API, not documented — guard on `has_method` so a future engine that drops
+# it degrades to the plain `make_current()` result instead of erroring.
+func _broadcast_make_current_2d(cam: Camera2D, scene_root: Node) -> void:
+ if not cam.has_method("_make_current"):
+ return
+ if scene_root != null:
+ for other in _list_cameras_in_scene(scene_root, "2d"):
+ if other == cam or not is_instance_valid(other) or not other.is_inside_tree():
+ continue
+ if other.has_method("_make_current"):
+ other.call("_make_current", cam)
+ cam.call("_make_current", cam)
+
+
+# Walk same-class siblings and force-clear any that still report is_current().
+# Best-effort: clear_current errors when called on a non-current camera, so
+# guard. Camera2D's clear_current path also flushes the viewport slot, which
+# is the one we actually care about settling for #301.
+func _force_clear_other_currents(target: Node, type_str: String, scene_root: Node) -> void:
+ if scene_root == null or type_str.is_empty():
+ return
+ for sibling in _list_cameras_in_scene(scene_root, type_str):
+ if sibling == target:
+ continue
+ if not is_instance_valid(sibling) or not sibling.is_inside_tree():
+ continue
+ if not _is_current(sibling):
+ # Even if is_current() reports false, the viewport slot can
+ # still point at this sibling on macOS — re-make target to
+ # take it back. Cheap (idempotent) when the slot is fine.
+ if sibling is Camera2D:
+ var vp_other: Viewport = (sibling as Camera2D).get_viewport()
+ if vp_other != null and vp_other.get_camera_2d() == sibling:
+ target.make_current()
+ _force_camera_refresh(target)
+ continue
+ sibling.clear_current()
+ if sibling is Camera2D:
+ (sibling as Camera2D).force_update_scroll()
+
+
+# Call after commit_action() whenever the action registered a make_current DO.
+# The undo path cannot use a post-undo hook, so it relies on `_apply_make_current`
+# directly; create/configure/apply_preset get this extra post-commit verifier.
+func _verify_current_after_commit(node: Node) -> void:
+ _apply_make_current(node)
+
+
+func _force_camera_refresh(cam: Node) -> void:
+ if cam is Camera2D:
+ (cam as Camera2D).force_update_scroll()
+
+
+func _is_current_settled(cam: Node) -> bool:
+ if not _is_current(cam):
+ return false
+ if cam is Camera2D:
+ var viewport := cam.get_viewport()
+ if viewport != null and viewport.get_camera_2d() != cam:
+ return false
+ return true
+
+
+# Symmetric counterpart to `_apply_make_current` for the "no previous
+# current camera" branch (create_camera with make_current=true and no
+# sibling was current). clear_current errors in Godot if called on a
+# non-current camera, so guard on is_current first.
+func _apply_clear_current(cam: Node) -> void:
+ if cam == null or not is_instance_valid(cam) or not cam.is_inside_tree():
+ return
+ _clear_logical_current(cam)
+ for attempt in range(_CURRENT_SETTLE_ATTEMPTS):
+ if _is_clear_settled(cam):
+ return
+ if _is_current(cam):
+ cam.clear_current()
+ _force_camera_refresh(cam)
+ # Camera2D-only: is_current() may answer false while the viewport
+ # slot still points at cam. Toggle enabled to force the viewport
+ # to release, then restore.
+ if cam is Camera2D:
+ var vp := cam.get_viewport()
+ if vp != null and vp.get_camera_2d() == cam:
+ var was_enabled := (cam as Camera2D).enabled
+ if was_enabled:
+ (cam as Camera2D).enabled = false
+ _force_camera_refresh(cam)
+ if was_enabled:
+ (cam as Camera2D).enabled = true
+ if _is_clear_settled(cam):
+ return
+ if attempt < _CURRENT_SETTLE_ATTEMPTS - 1:
+ OS.delay_msec(_CURRENT_SETTLE_DELAY_MSEC)
+
+
+func _is_clear_settled(cam: Node) -> bool:
+ if cam == null:
+ return true
+ if _is_current(cam):
+ return false
+ if cam is Camera2D:
+ var vp := cam.get_viewport()
+ if vp != null and vp.get_camera_2d() == cam:
+ return false
+ return true
+
+
+# ============================================================================
+# camera_create
+# ============================================================================
+
+func create_camera(params: Dictionary) -> Dictionary:
+ var parent_path: String = params.get("parent_path", "")
+ var node_name: String = params.get("name", "Camera")
+ var type_str: String = params.get("type", "2d")
+ var make_current: bool = bool(params.get("make_current", false))
+
+ if not _VALID_TYPES.has(type_str):
+ return ErrorCodes.make(
+ ErrorCodes.VALUE_OUT_OF_RANGE,
+ "Invalid camera type '%s'. Valid: %s" % [type_str, ", ".join(_VALID_TYPES.keys())]
+ )
+
+ var _scene_check := McpNodeValidator.require_scene_or_error()
+ if _scene_check.has("error"):
+ return _scene_check
+ var scene_root: Node = _scene_check.scene_root
+
+ var parent: Node = scene_root
+ if not parent_path.is_empty():
+ parent = McpScenePath.resolve(parent_path, scene_root)
+ if parent == null:
+ return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, McpScenePath.format_parent_error(parent_path, scene_root))
+
+ var node := _instantiate_camera(type_str)
+ if node == null:
+ return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate camera")
+ if not node_name.is_empty():
+ node.name = node_name
+
+ _undo_redo.create_action(
+ "MCP: Create %s '%s'" % [_VALID_TYPES[type_str], node.name],
+ UndoRedo.MERGE_DISABLE, scene_root
+ )
+ _undo_redo.add_do_method(parent, "add_child", node, true)
+ _undo_redo.add_do_method(node, "set_owner", scene_root)
+ _undo_redo.add_do_reference(node)
+ if make_current:
+ # Must land AFTER add_child: making current before the node is in the
+ # tree is a silent no-op on the viewport.
+ _add_make_current_to_action(node, type_str, scene_root)
+ _undo_redo.add_undo_method(parent, "remove_child", node)
+ _undo_redo.commit_action()
+ if make_current:
+ _verify_current_after_commit(node)
+
+ return {
+ "data": {
+ "path": McpScenePath.from_node(node, scene_root),
+ "parent_path": McpScenePath.from_node(parent, scene_root),
+ "name": String(node.name),
+ "type": type_str,
+ "class": _VALID_TYPES[type_str],
+ "current": bool(make_current),
+ "undoable": true,
+ }
+ }
+
+
+# ============================================================================
+# camera_configure
+# ============================================================================
+
+func configure(params: Dictionary) -> Dictionary:
+ var resolved := _resolve_camera(params)
+ if resolved.has("error"):
+ return resolved
+ var node: Node = resolved.node
+ var node_path: String = resolved.path
+ var type_str: String = resolved.type
+ var scene_root: Node = resolved.scene_root
+
+ var properties: Dictionary = params.get("properties", {})
+ if properties.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "properties dict is empty")
+
+ var valid_keys: Array = _KEYS_2D if type_str == "2d" else _KEYS_3D
+ var prop_types := _property_type_map(node)
+ var coerced: Dictionary = {}
+ var old_values: Dictionary = {}
+ # `current` is special-cased via methods (Camera2D doesn't expose it as a property).
+ var current_request: Variant = null
+
+ for property in properties:
+ var prop_name: String = String(property)
+ if not (prop_name in valid_keys):
+ var msg := "Property '%s' not valid for %s. Valid: %s" % [
+ prop_name, _VALID_TYPES[type_str], ", ".join(valid_keys)
+ ]
+ if prop_name in _NODE_TRANSFORM_KEYS:
+ msg += (
+ ". Transforms live on the Node, not on the camera config — "
+ + "use node_set_property(path=%s, property=\"%s\", value=...)" % [node_path, prop_name]
+ )
+ return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, msg)
+ if prop_name == "current":
+ current_request = bool(properties[prop_name])
+ continue
+ var prop_type: int = prop_types.get(prop_name, TYPE_NIL)
+ if prop_type == TYPE_NIL:
+ return ErrorCodes.make(
+ ErrorCodes.PROPERTY_NOT_ON_CLASS,
+ "Property '%s' not present on %s" % [prop_name, node.get_class()]
+ )
+ var coerce_result := CameraValues.coerce(prop_name, properties[prop_name], prop_type)
+ if not coerce_result.ok:
+ return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, String(coerce_result.error))
+ coerced[prop_name] = coerce_result.value
+ old_values[prop_name] = node.get(prop_name)
+
+ _undo_redo.create_action(
+ "MCP: Configure camera %s" % node.name,
+ UndoRedo.MERGE_DISABLE, scene_root
+ )
+ for prop_name in coerced:
+ _undo_redo.add_do_property(node, prop_name, coerced[prop_name])
+ _undo_redo.add_undo_property(node, prop_name, old_values[prop_name])
+ var verify_current_after := false
+ if current_request != null:
+ var want_on: bool = bool(current_request)
+ var was_on: bool = _resolve_current(scene_root, node)
+ if want_on and not was_on:
+ _add_make_current_to_action(node, type_str, scene_root)
+ verify_current_after = true
+ elif not want_on and was_on:
+ _undo_redo.add_do_method(self, "_apply_clear_current", node)
+ _undo_redo.add_undo_method(self, "_apply_make_current", node)
+ _undo_redo.commit_action()
+ if verify_current_after:
+ _verify_current_after_commit(node)
+
+ var applied: Array[String] = []
+ var serialized: Dictionary = {}
+ for prop_name in coerced:
+ applied.append(prop_name)
+ serialized[prop_name] = CameraValues.serialize(coerced[prop_name])
+ if current_request != null:
+ applied.append("current")
+ serialized["current"] = bool(current_request)
+
+ return {
+ "data": {
+ "path": node_path,
+ "type": type_str,
+ "class": node.get_class(),
+ "applied": applied,
+ "values": serialized,
+ "undoable": true,
+ }
+ }
+
+
+# ============================================================================
+# camera_set_limits_2d
+# ============================================================================
+
+func set_limits_2d(params: Dictionary) -> Dictionary:
+ var resolved := _resolve_camera(params)
+ if resolved.has("error"):
+ return resolved
+ var node: Node = resolved.node
+ var node_path: String = resolved.path
+ var type_str: String = resolved.type
+
+ if type_str != "2d":
+ return ErrorCodes.make(
+ ErrorCodes.WRONG_TYPE,
+ "camera_set_limits_2d requires a Camera2D (got %s)" % node.get_class()
+ )
+
+ var applied: Dictionary = {}
+ var old_values: Dictionary = {}
+ var edges := {
+ "left": "limit_left",
+ "right": "limit_right",
+ "top": "limit_top",
+ "bottom": "limit_bottom",
+ }
+ for edge in edges:
+ var v = params.get(edge)
+ if v != null:
+ var prop_name: String = edges[edge]
+ applied[prop_name] = int(v)
+ old_values[prop_name] = node.get(prop_name)
+
+ var smoothed = params.get("smoothed")
+ if smoothed != null:
+ applied["limit_smoothed"] = bool(smoothed)
+ old_values["limit_smoothed"] = node.get("limit_smoothed")
+
+ if applied.is_empty():
+ return ErrorCodes.make(
+ ErrorCodes.MISSING_REQUIRED_PARAM,
+ "No limits specified; provide at least one of left, right, top, bottom, smoothed"
+ )
+
+ _undo_redo.create_action("MCP: Set camera limits on %s" % node.name)
+ for prop_name in applied:
+ _undo_redo.add_do_property(node, prop_name, applied[prop_name])
+ _undo_redo.add_undo_property(node, prop_name, old_values[prop_name])
+ _undo_redo.commit_action()
+
+ var values: Dictionary = {}
+ for prop_name in applied:
+ values[prop_name] = applied[prop_name]
+
+ return {
+ "data": {
+ "path": node_path,
+ "applied": applied.keys(),
+ "values": values,
+ "undoable": true,
+ }
+ }
+
+
+# ============================================================================
+# camera_set_damping_2d
+# ============================================================================
+
+func set_damping_2d(params: Dictionary) -> Dictionary:
+ var resolved := _resolve_camera(params)
+ if resolved.has("error"):
+ return resolved
+ var node: Node = resolved.node
+ var node_path: String = resolved.path
+ var type_str: String = resolved.type
+
+ if type_str != "2d":
+ return ErrorCodes.make(
+ ErrorCodes.WRONG_TYPE,
+ "camera_set_damping_2d requires a Camera2D (got %s)" % node.get_class()
+ )
+
+ var applied: Dictionary = {}
+ var old_values: Dictionary = {}
+
+ # position_speed: set position_smoothing_speed AND toggle position_smoothing_enabled.
+ var pos_v = params.get("position_speed")
+ if pos_v != null:
+ var pos_speed := float(pos_v)
+ var pos_enable := pos_speed > 0.0
+ applied["position_smoothing_enabled"] = pos_enable
+ old_values["position_smoothing_enabled"] = node.get("position_smoothing_enabled")
+ if pos_enable:
+ applied["position_smoothing_speed"] = pos_speed
+ old_values["position_smoothing_speed"] = node.get("position_smoothing_speed")
+
+ # rotation_speed: same pattern for rotation_smoothing_*.
+ var rot_v = params.get("rotation_speed")
+ if rot_v != null:
+ var rot_speed := float(rot_v)
+ var rot_enable := rot_speed > 0.0
+ applied["rotation_smoothing_enabled"] = rot_enable
+ old_values["rotation_smoothing_enabled"] = node.get("rotation_smoothing_enabled")
+ if rot_enable:
+ applied["rotation_smoothing_speed"] = rot_speed
+ old_values["rotation_smoothing_speed"] = node.get("rotation_smoothing_speed")
+
+ for flag in ["drag_horizontal_enabled", "drag_vertical_enabled"]:
+ var flag_v = params.get(flag)
+ if flag_v != null:
+ applied[flag] = bool(flag_v)
+ old_values[flag] = node.get(flag)
+
+ # drag_margins: dict {left, top, right, bottom} floats in [0,1]; null/missing keys untouched.
+ var margins_v = params.get("drag_margins")
+ if margins_v != null:
+ if not (margins_v is Dictionary):
+ return ErrorCodes.make(
+ ErrorCodes.WRONG_TYPE,
+ "drag_margins must be a dict with optional keys left/top/right/bottom"
+ )
+ var margins: Dictionary = margins_v
+ for edge in _DAMPING_MARGIN_KEYS:
+ var margin_v = margins.get(edge)
+ if margin_v == null:
+ continue
+ var v := float(margin_v)
+ if v < 0.0 or v > 1.0:
+ return ErrorCodes.make(
+ ErrorCodes.INVALID_PARAMS,
+ "drag_margins.%s must be in [0, 1] (got %s)" % [edge, v]
+ )
+ var prop_name: String = "drag_%s_margin" % edge
+ applied[prop_name] = v
+ old_values[prop_name] = node.get(prop_name)
+
+ if applied.is_empty():
+ return ErrorCodes.make(
+ ErrorCodes.MISSING_REQUIRED_PARAM,
+ "No damping params specified; provide at least one of position_speed, rotation_speed, drag_margins, drag_horizontal_enabled, drag_vertical_enabled"
+ )
+
+ _undo_redo.create_action("MCP: Set camera damping on %s" % node.name)
+ for prop_name in applied:
+ _undo_redo.add_do_property(node, prop_name, applied[prop_name])
+ _undo_redo.add_undo_property(node, prop_name, old_values[prop_name])
+ _undo_redo.commit_action()
+
+ return {
+ "data": {
+ "path": node_path,
+ "applied": applied.keys(),
+ "values": applied,
+ "undoable": true,
+ }
+ }
+
+
+# ============================================================================
+# camera_follow_2d
+# ============================================================================
+
+func follow_2d(params: Dictionary) -> Dictionary:
+ var resolved := _resolve_camera(params)
+ if resolved.has("error"):
+ return resolved
+ var node: Node = resolved.node
+ var node_path: String = resolved.path
+ var type_str: String = resolved.type
+ var scene_root: Node = resolved.scene_root
+
+ if type_str != "2d":
+ return ErrorCodes.make(
+ ErrorCodes.WRONG_TYPE,
+ "camera_follow_2d requires a Camera2D (got %s)" % node.get_class()
+ )
+
+ var target_path: String = params.get("target_path", "")
+ if target_path.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: target_path")
+ var target := McpScenePath.resolve(target_path, scene_root)
+ if target == null:
+ return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND,
+ "target_path: %s" % McpScenePath.format_node_error(target_path, scene_root))
+ if not (target is Node2D) and target != scene_root:
+ return ErrorCodes.make(
+ ErrorCodes.WRONG_TYPE,
+ "Follow target must be a Node2D (got %s)" % target.get_class()
+ )
+ if target == node:
+ return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "Camera cannot follow itself")
+ if target.is_ancestor_of(node) and node.get_parent() != target:
+ # A non-parent ancestor — still valid to reparent under (direct parent).
+ pass
+ if node.is_ancestor_of(target):
+ return ErrorCodes.make(
+ ErrorCodes.INVALID_PARAMS,
+ "Cannot follow a descendant of the camera"
+ )
+
+ var smoothing_speed := float(params.get("smoothing_speed", 5.0))
+ var zero_transform: bool = bool(params.get("zero_transform", true))
+
+ var old_parent := node.get_parent()
+ var old_idx: int = node.get_index() if old_parent != null else 0
+ var old_position = node.get("position")
+ var old_rotation = node.get("rotation")
+ var old_smoothing_enabled: bool = bool(node.get("position_smoothing_enabled"))
+ var old_smoothing_speed: float = float(node.get("position_smoothing_speed"))
+
+ var already_child: bool = old_parent == target
+ var reparented: bool = not already_child
+
+ _undo_redo.create_action("MCP: Camera follow %s" % target.name)
+ if reparented:
+ _undo_redo.add_do_method(old_parent, "remove_child", node)
+ _undo_redo.add_do_method(target, "add_child", node, true)
+ _undo_redo.add_do_method(node, "set_owner", scene_root)
+ _undo_redo.add_do_reference(node)
+ if zero_transform:
+ if target is Node2D:
+ _undo_redo.add_do_property(node, "position", Vector2.ZERO)
+ _undo_redo.add_undo_property(node, "position", old_position)
+ _undo_redo.add_do_property(node, "rotation", 0.0)
+ _undo_redo.add_undo_property(node, "rotation", old_rotation)
+ _undo_redo.add_do_property(node, "position_smoothing_enabled", true)
+ _undo_redo.add_undo_property(node, "position_smoothing_enabled", old_smoothing_enabled)
+ if smoothing_speed > 0.0:
+ _undo_redo.add_do_property(node, "position_smoothing_speed", smoothing_speed)
+ _undo_redo.add_undo_property(node, "position_smoothing_speed", old_smoothing_speed)
+ if reparented:
+ _undo_redo.add_undo_method(target, "remove_child", node)
+ _undo_redo.add_undo_method(old_parent, "add_child", node, true)
+ _undo_redo.add_undo_method(old_parent, "move_child", node, old_idx)
+ _undo_redo.add_undo_method(node, "set_owner", scene_root)
+ _undo_redo.add_undo_reference(node)
+ _undo_redo.commit_action()
+
+ return {
+ "data": {
+ "path": McpScenePath.from_node(node, scene_root),
+ "target_path": McpScenePath.from_node(target, scene_root),
+ "reparented": reparented,
+ "smoothing_speed": smoothing_speed,
+ "zero_transform": zero_transform and (target is Node2D),
+ "undoable": true,
+ }
+ }
+
+
+# ============================================================================
+# camera_get
+# ============================================================================
+
+func get_camera(params: Dictionary) -> Dictionary:
+ var _scene_check := McpNodeValidator.require_scene_or_error()
+ if _scene_check.has("error"):
+ return _scene_check
+ var scene_root: Node = _scene_check.scene_root
+
+ var camera_path: String = params.get("camera_path", "")
+ var node: Node = null
+ var resolved_via: String = ""
+ if camera_path.is_empty():
+ # Empty: prefer the viewport's active camera. In headless editor CI,
+ # Camera2D.is_current() can lag make_current() briefly even after the
+ # viewport slot has switched; falling through to "first" during that
+ # window makes camera_get("") nondeterministic.
+ var all_cams := _list_cameras_in_scene(scene_root, "")
+ var logical_current := _logical_current_camera(scene_root)
+ if logical_current != null and all_cams.has(logical_current):
+ node = logical_current
+ resolved_via = "current"
+ var viewport_current := _viewport_current_camera(scene_root)
+ if node == null and viewport_current != null and all_cams.has(viewport_current):
+ node = viewport_current
+ resolved_via = "current"
+ for cam in all_cams:
+ if node != null:
+ break
+ if _is_current(cam):
+ node = cam
+ resolved_via = "current"
+ break
+ if node == null and not all_cams.is_empty():
+ node = all_cams[0]
+ resolved_via = "first"
+ else:
+ node = McpScenePath.resolve(camera_path, scene_root)
+ if node == null:
+ return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, McpScenePath.format_node_error(camera_path, scene_root))
+ if not _is_camera(node):
+ return ErrorCodes.make(
+ ErrorCodes.WRONG_TYPE,
+ "Node %s is not a camera (got %s)" % [camera_path, node.get_class()]
+ )
+ resolved_via = "path"
+
+ if node == null:
+ return {
+ "data": {
+ "path": "",
+ "type": "",
+ "class": "",
+ "current": false,
+ "properties": {},
+ "resolved_via": "not_found",
+ }
+ }
+
+ var type_str := _camera_type_str(node)
+ var keys: Array = _KEYS_2D if type_str == "2d" else _KEYS_3D
+ var prop_types := _property_type_map(node)
+ var props: Dictionary = {}
+ var is_current_effective := _resolve_current(scene_root, node)
+ for key in keys:
+ if key == "current":
+ props[key] = is_current_effective
+ continue
+ if prop_types.has(key):
+ props[key] = CameraValues.serialize(node.get(key))
+
+ return {
+ "data": {
+ "path": McpScenePath.from_node(node, scene_root),
+ "type": type_str,
+ "class": node.get_class(),
+ "current": is_current_effective,
+ "properties": props,
+ "resolved_via": resolved_via,
+ }
+ }
+
+
+# ============================================================================
+# camera_list
+# ============================================================================
+
+func list_cameras(_params: Dictionary) -> Dictionary:
+ var _scene_check := McpNodeValidator.require_scene_or_error()
+ if _scene_check.has("error"):
+ return _scene_check
+ var scene_root: Node = _scene_check.scene_root
+
+ var cams := _list_cameras_in_scene(scene_root, "")
+ var out: Array[Dictionary] = []
+ var logical_2d := _logical_current_camera(scene_root, "2d")
+ var logical_3d := _logical_current_camera(scene_root, "3d")
+ for cam in cams:
+ out.append({
+ "path": McpScenePath.from_node(cam, scene_root),
+ "class": cam.get_class(),
+ "type": _camera_type_str(cam),
+ "current": _resolve_current_with_logicals(cam, logical_2d, logical_3d),
+ })
+ return {"data": {"cameras": out}}
+
+
+# ============================================================================
+# camera_apply_preset
+# ============================================================================
+
+func apply_preset(params: Dictionary) -> Dictionary:
+ var preset_name: String = params.get("preset", "")
+ if preset_name.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: preset")
+
+ var overrides: Dictionary = params.get("overrides", {})
+ var blueprint = CameraPresets.build(preset_name, overrides)
+ if blueprint == null:
+ return ErrorCodes.make(
+ ErrorCodes.VALUE_OUT_OF_RANGE,
+ "Unknown preset '%s'. Valid: %s" % [preset_name, ", ".join(CameraPresets.list_presets())]
+ )
+
+ var parent_path: String = params.get("parent_path", "")
+ var node_name: String = params.get("name", "")
+ var type_str: String = params.get("type", String(blueprint.get("default_type", "2d")))
+ var make_current: bool = bool(params.get("make_current", true))
+ if node_name.is_empty():
+ node_name = preset_name.capitalize()
+ if not _VALID_TYPES.has(type_str):
+ return ErrorCodes.make(
+ ErrorCodes.VALUE_OUT_OF_RANGE,
+ "Invalid camera type '%s'. Valid: %s" % [type_str, ", ".join(_VALID_TYPES.keys())]
+ )
+
+ var _scene_check := McpNodeValidator.require_scene_or_error()
+ if _scene_check.has("error"):
+ return _scene_check
+ var scene_root: Node = _scene_check.scene_root
+
+ var parent: Node = scene_root
+ if not parent_path.is_empty():
+ parent = McpScenePath.resolve(parent_path, scene_root)
+ if parent == null:
+ return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, McpScenePath.format_parent_error(parent_path, scene_root))
+
+ var node := _instantiate_camera(type_str)
+ node.name = node_name
+
+ var preset_props: Dictionary = blueprint.get("properties", {})
+ var valid_keys: Array = _KEYS_2D if type_str == "2d" else _KEYS_3D
+ var prop_types := _property_type_map(node)
+ var applied: Array[String] = []
+ for prop in preset_props:
+ var prop_name := String(prop)
+ if not (prop_name in valid_keys):
+ continue # Silently skip preset keys that don't apply to this camera class.
+ # `current` lives on methods, not as a writable property on Camera2D —
+ # always handled via the make_current path below.
+ if prop_name == "current":
+ continue
+ var prop_type: int = prop_types.get(prop_name, TYPE_NIL)
+ if prop_type == TYPE_NIL:
+ continue
+ var coerce_result := CameraValues.coerce(prop_name, preset_props[prop_name], prop_type)
+ if not coerce_result.ok:
+ return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, String(coerce_result.error))
+ node.set(prop_name, coerce_result.value)
+ applied.append(prop_name)
+
+ _undo_redo.create_action(
+ "MCP: Apply camera preset %s" % preset_name,
+ UndoRedo.MERGE_DISABLE, scene_root
+ )
+ _undo_redo.add_do_method(parent, "add_child", node, true)
+ _undo_redo.add_do_method(node, "set_owner", scene_root)
+ _undo_redo.add_do_reference(node)
+ if make_current:
+ _add_make_current_to_action(node, type_str, scene_root)
+ _undo_redo.add_undo_method(parent, "remove_child", node)
+ _undo_redo.commit_action()
+ if make_current:
+ _verify_current_after_commit(node)
+
+ return {
+ "data": {
+ "path": McpScenePath.from_node(node, scene_root),
+ "parent_path": McpScenePath.from_node(parent, scene_root),
+ "name": node_name,
+ "preset": preset_name,
+ "type": type_str,
+ "class": _VALID_TYPES[type_str],
+ "applied": applied,
+ "current": bool(make_current),
+ "undoable": true,
+ }
+ }
+
+
+# ============================================================================
+# Helpers
+# ============================================================================
+
+static func _instantiate_camera(type_str: String) -> Node:
+ match type_str:
+ "2d":
+ return Camera2D.new()
+ "3d":
+ return Camera3D.new()
+ return null
+
+
+static func _is_camera(node: Node) -> bool:
+ return node is Camera2D or node is Camera3D
+
+
+static func _camera_type_str(node: Node) -> String:
+ if node is Camera2D:
+ return "2d"
+ if node is Camera3D:
+ return "3d"
+ return ""
+
+
+func _resolve_camera(params: Dictionary) -> Dictionary:
+ var resolved := McpNodeValidator.resolve_or_error(
+ params.get("camera_path", ""), "camera_path",
+ )
+ if resolved.has("error"):
+ return resolved
+ var node: Node = resolved.node
+ var node_path: String = resolved.path
+ var scene_root: Node = resolved.scene_root
+ if not _is_camera(node):
+ return ErrorCodes.make(
+ ErrorCodes.WRONG_TYPE,
+ "Node %s is not a camera (got %s)" % [node_path, node.get_class()]
+ )
+ return {
+ "node": node,
+ "path": node_path,
+ "type": _camera_type_str(node),
+ "scene_root": scene_root,
+ }
+
+
+## Walk the edited scene for cameras. class_filter: "2d", "3d", or "" for all.
+static func _list_cameras_in_scene(scene_root: Node, class_filter: String) -> Array:
+ var result: Array = []
+ if scene_root == null:
+ return result
+ _collect_cameras(scene_root, class_filter, result)
+ return result
+
+
+static func _collect_cameras(node: Node, class_filter: String, out: Array) -> void:
+ var matches := false
+ match class_filter:
+ "2d":
+ matches = node is Camera2D
+ "3d":
+ matches = node is Camera3D
+ _:
+ matches = node is Camera2D or node is Camera3D
+ if matches:
+ out.append(node)
+ for child in node.get_children():
+ _collect_cameras(child, class_filter, out)
+
+
+## Build a name -> property-type dict from the object's property list.
+## Single walk of get_property_list() amortizes lookups across a batch of
+## properties in configure / apply_preset.
+static func _property_type_map(obj: Object) -> Dictionary:
+ var out: Dictionary = {}
+ if obj == null:
+ return out
+ for prop in obj.get_property_list():
+ out[prop.name] = int(prop.get("type", TYPE_NIL))
+ return out
diff --git a/addons/godot_ai/handlers/camera_handler.gd.uid b/addons/godot_ai/handlers/camera_handler.gd.uid
new file mode 100644
index 0000000..86d2aae
--- /dev/null
+++ b/addons/godot_ai/handlers/camera_handler.gd.uid
@@ -0,0 +1 @@
+uid://c0lcviccrlrl8
diff --git a/addons/godot_ai/handlers/camera_presets.gd b/addons/godot_ai/handlers/camera_presets.gd
new file mode 100644
index 0000000..a1922c2
--- /dev/null
+++ b/addons/godot_ai/handlers/camera_presets.gd
@@ -0,0 +1,81 @@
+@tool
+extends RefCounted
+
+## Opinionated Camera2D / Camera3D presets.
+##
+## build(preset_name, overrides) -> {default_type, properties} | null
+## properties are merged with caller overrides (overrides win).
+
+
+const _PRESETS := {
+ # Top-down roguelite / arena — damped follow feel, drag deadzone.
+ "topdown_2d": {
+ "default_type": "2d",
+ "properties": {
+ "zoom": {"x": 2.0, "y": 2.0},
+ "anchor_mode": "drag_center",
+ "position_smoothing_enabled": true,
+ "position_smoothing_speed": 5.0,
+ "rotation_smoothing_enabled": false,
+ "drag_horizontal_enabled": true,
+ "drag_vertical_enabled": true,
+ "drag_left_margin": 0.2,
+ "drag_right_margin": 0.2,
+ "drag_top_margin": 0.2,
+ "drag_bottom_margin": 0.2,
+ },
+ },
+ # Platformer — tight horizontal follow, vertical snap with smoothing on.
+ "platformer_2d": {
+ "default_type": "2d",
+ "properties": {
+ "zoom": {"x": 1.5, "y": 1.5},
+ "anchor_mode": "drag_center",
+ "position_smoothing_enabled": true,
+ "position_smoothing_speed": 8.0,
+ "drag_horizontal_enabled": true,
+ "drag_vertical_enabled": false,
+ "drag_left_margin": 0.15,
+ "drag_right_margin": 0.15,
+ },
+ },
+ # Cinematic 3D — narrow FOV, long range. Good for dramatic wide shots.
+ "cinematic_3d": {
+ "default_type": "3d",
+ "properties": {
+ "fov": 40.0,
+ "near": 0.1,
+ "far": 500.0,
+ "projection": "perspective",
+ },
+ },
+ # Action 3D — wider FOV for first/third-person action gameplay.
+ "action_3d": {
+ "default_type": "3d",
+ "properties": {
+ "fov": 70.0,
+ "near": 0.1,
+ "far": 200.0,
+ "projection": "perspective",
+ },
+ },
+}
+
+
+static func list_presets() -> Array:
+ return _PRESETS.keys()
+
+
+## Build a preset blueprint. Returns null if preset_name is unknown.
+## overrides is merged on top of preset defaults (caller values win).
+static func build(preset_name: String, overrides: Dictionary) -> Variant:
+ if not _PRESETS.has(preset_name):
+ return null
+ var preset: Dictionary = _PRESETS[preset_name]
+ var properties: Dictionary = (preset.get("properties", {}) as Dictionary).duplicate(true)
+ for key in overrides:
+ properties[key] = overrides[key]
+ return {
+ "default_type": preset.get("default_type", "2d"),
+ "properties": properties,
+ }
diff --git a/addons/godot_ai/handlers/camera_presets.gd.uid b/addons/godot_ai/handlers/camera_presets.gd.uid
new file mode 100644
index 0000000..9f9f839
--- /dev/null
+++ b/addons/godot_ai/handlers/camera_presets.gd.uid
@@ -0,0 +1 @@
+uid://bl3rfy72o3wy5
diff --git a/addons/godot_ai/handlers/camera_values.gd b/addons/godot_ai/handlers/camera_values.gd
new file mode 100644
index 0000000..2ee4865
--- /dev/null
+++ b/addons/godot_ai/handlers/camera_values.gd
@@ -0,0 +1,132 @@
+@tool
+extends RefCounted
+
+## Value coercion helpers for camera authoring.
+##
+## Handles:
+## - enum-by-name (keep_aspect="keep_height" -> Camera3D.KEEP_HEIGHT)
+## - {x, y} dict -> Vector2 (zoom, offset, drag_*_offset)
+## - serialization back to JSON-friendly shapes
+
+
+const _ENUM_TABLES := {
+ "projection": {
+ "perspective": Camera3D.PROJECTION_PERSPECTIVE,
+ "orthogonal": Camera3D.PROJECTION_ORTHOGONAL,
+ "frustum": Camera3D.PROJECTION_FRUSTUM,
+ },
+ "keep_aspect": {
+ "keep_width": Camera3D.KEEP_WIDTH,
+ "keep_height": Camera3D.KEEP_HEIGHT,
+ },
+ "anchor_mode": {
+ "fixed_top_left": Camera2D.ANCHOR_MODE_FIXED_TOP_LEFT,
+ "drag_center": Camera2D.ANCHOR_MODE_DRAG_CENTER,
+ },
+ "doppler_tracking": {
+ "disabled": Camera3D.DOPPLER_TRACKING_DISABLED,
+ "idle_step": Camera3D.DOPPLER_TRACKING_IDLE_STEP,
+ "physics_step": Camera3D.DOPPLER_TRACKING_PHYSICS_STEP,
+ },
+ "process_callback": {
+ "physics": Camera2D.CAMERA2D_PROCESS_PHYSICS,
+ "idle": Camera2D.CAMERA2D_PROCESS_IDLE,
+ },
+}
+
+
+## Return the enum int for (property, string_name), or null if not a known enum string.
+static func resolve_enum(property: String, value: Variant) -> Variant:
+ if not (value is String):
+ return null
+ if not _ENUM_TABLES.has(property):
+ return null
+ var table: Dictionary = _ENUM_TABLES[property]
+ var key: String = String(value).to_lower()
+ if table.has(key):
+ return table[key]
+ return null
+
+
+## Valid enum names for a property, for error messages.
+static func enum_keys(property: String) -> Array:
+ if not _ENUM_TABLES.has(property):
+ return []
+ return (_ENUM_TABLES[property] as Dictionary).keys()
+
+
+static func parse_vector2(value: Variant) -> Variant:
+ ## Camera-specific sugar kept from the pre-#714 copy: a bare number is
+ ## a uniform zoom, splatted to both axes. Everything else goes through
+ ## the canonical strict parser.
+ if value is int or value is float:
+ return Vector2(float(value), float(value))
+ return McpJsonValues.parse_vector2(value)
+
+
+static func parse_vector3(value: Variant) -> Variant:
+ return McpJsonValues.parse_vector3(value)
+
+
+## Coerce a JSON-shaped value for a camera property against the declared type.
+## Returns {ok: true, value: ...} or {ok: false, error: "..."}.
+static func coerce(property: String, value: Variant, target_type: int) -> Dictionary:
+ # Enum-by-name: must match before generic TYPE_INT coercion.
+ if _ENUM_TABLES.has(property):
+ if value is String:
+ var enum_val = resolve_enum(property, value)
+ if enum_val == null:
+ return {
+ "ok": false,
+ "error": "Invalid %s value: '%s'. Valid: %s" % [
+ property, value, ", ".join(enum_keys(property))
+ ],
+ }
+ return {"ok": true, "value": int(enum_val)}
+ if value is int or value is float:
+ return {"ok": true, "value": int(value)}
+
+ match target_type:
+ TYPE_VECTOR2:
+ var v2 = parse_vector2(value)
+ if v2 == null:
+ return {"ok": false, "error": "Invalid vector2 for %s: %s" % [property, value]}
+ return {"ok": true, "value": v2}
+ TYPE_VECTOR3:
+ var v3 = parse_vector3(value)
+ if v3 == null:
+ return {"ok": false, "error": "Invalid vector3 for %s: %s" % [property, value]}
+ return {"ok": true, "value": v3}
+ TYPE_BOOL:
+ if value is bool:
+ return {"ok": true, "value": value}
+ if value is int or value is float:
+ return {"ok": true, "value": bool(value)}
+ return {"ok": false, "error": "Expected bool for %s" % property}
+ TYPE_INT:
+ if value is int:
+ return {"ok": true, "value": value}
+ if value is float:
+ return {"ok": true, "value": int(value)}
+ return {"ok": false, "error": "Expected int for %s" % property}
+ TYPE_FLOAT:
+ ## Canonical scalar parser (#964): numeric strings coerce too.
+ var parsed: Variant = McpJsonValues.parse_float(value)
+ if parsed != null:
+ return {"ok": true, "value": parsed}
+ return {"ok": false, "error": "Expected number for %s" % property}
+ TYPE_STRING:
+ return {"ok": true, "value": String(value)}
+
+ return {"ok": true, "value": value}
+
+
+## Serialize a Variant into a JSON-friendly shape for responses.
+static func serialize(value: Variant) -> Variant:
+ if value == null:
+ return null
+ if value is Vector2:
+ return {"x": value.x, "y": value.y}
+ if value is Vector3:
+ return {"x": value.x, "y": value.y, "z": value.z}
+ return value
diff --git a/addons/godot_ai/handlers/camera_values.gd.uid b/addons/godot_ai/handlers/camera_values.gd.uid
new file mode 100644
index 0000000..45e3c09
--- /dev/null
+++ b/addons/godot_ai/handlers/camera_values.gd.uid
@@ -0,0 +1 @@
+uid://bgjnubgnv6ses
diff --git a/addons/godot_ai/handlers/client_handler.gd b/addons/godot_ai/handlers/client_handler.gd
new file mode 100644
index 0000000..f6df09c
--- /dev/null
+++ b/addons/godot_ai/handlers/client_handler.gd
@@ -0,0 +1,58 @@
+@tool
+extends "res://addons/godot_ai/handlers/command_handler.gd"
+
+const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
+
+## Handles MCP client configuration commands.
+
+var _client_jobs
+
+
+func _init(client_jobs = null) -> void:
+ _client_jobs = client_jobs
+
+
+func configure_client(params: Dictionary) -> Dictionary:
+ return _request_client_action(params, "configure")
+
+
+func remove_client(params: Dictionary) -> Dictionary:
+ return _request_client_action(params, "remove")
+
+
+func _request_client_action(params: Dictionary, action: String) -> Dictionary:
+ var client_id: String = params.get("client", "")
+ if not McpClientConfigurator.has_client(client_id):
+ var valid := ", ".join(McpClientConfigurator.client_ids())
+ return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Unknown client: %s. Use one of: %s" % [client_id, valid])
+ var request_id: String = params.get("_request_id", "")
+ if _client_jobs == null or request_id.is_empty():
+ return ErrorCodes.make(
+ ErrorCodes.INTERNAL_ERROR,
+ "Client mutations must run as top-level deferred commands.",
+ )
+ var started: Dictionary = _client_jobs.request_mcp_action(request_id, client_id, action)
+ if not bool(started.get("ok", false)):
+ return ErrorCodes.make(
+ ErrorCodes.INTERNAL_ERROR,
+ str(started.get("error", "Client worker is unavailable.")),
+ )
+ return {
+ "_deferred": true,
+ "_deferred_timeout_ms": int(started.get("deferred_timeout_ms", 0)),
+ }
+
+
+func check_client_status(params: Dictionary) -> Dictionary:
+ var request_id: String = params.get("_request_id", "")
+ if _client_jobs == null or request_id.is_empty():
+ return ErrorCodes.make(
+ ErrorCodes.INTERNAL_ERROR,
+ "Client status requires a deferred request context.",
+ )
+ if not _client_jobs.request_mcp_status(request_id):
+ return ErrorCodes.make(
+ ErrorCodes.INTERNAL_ERROR,
+ "Client status worker is unavailable.",
+ )
+ return McpDispatcher.DEFERRED_RESPONSE
diff --git a/addons/godot_ai/handlers/client_handler.gd.uid b/addons/godot_ai/handlers/client_handler.gd.uid
new file mode 100644
index 0000000..25687f5
--- /dev/null
+++ b/addons/godot_ai/handlers/client_handler.gd.uid
@@ -0,0 +1 @@
+uid://bmo4foc5fq75c
diff --git a/addons/godot_ai/handlers/command_handler.gd b/addons/godot_ai/handlers/command_handler.gd
new file mode 100644
index 0000000..f7096d4
--- /dev/null
+++ b/addons/godot_ai/handlers/command_handler.gd
@@ -0,0 +1,13 @@
+@tool
+extends RefCounted
+
+## Built-in handlers execute synchronously, or register every detached
+## coroutine with ScriptWork. Client/vision OS workers belong to the root's
+## worker owners and are joined separately before any scripts are replaced.
+## New async entry points MUST retain their ledger entry until they return,
+## including early returns; a request timeout/disconnect must not clear it.
+const ScriptWork := preload("res://addons/godot_ai/utils/script_work.gd")
+
+
+func quiesce_for_script_swap() -> Dictionary:
+ return ScriptWork.quiescence()
diff --git a/addons/godot_ai/handlers/command_handler.gd.uid b/addons/godot_ai/handlers/command_handler.gd.uid
new file mode 100644
index 0000000..0e99878
--- /dev/null
+++ b/addons/godot_ai/handlers/command_handler.gd.uid
@@ -0,0 +1 @@
+uid://xx6r8yaedry5
diff --git a/addons/godot_ai/handlers/control_draw_recipe_handler.gd b/addons/godot_ai/handlers/control_draw_recipe_handler.gd
new file mode 100644
index 0000000..2076d67
--- /dev/null
+++ b/addons/godot_ai/handlers/control_draw_recipe_handler.gd
@@ -0,0 +1,318 @@
+@tool
+extends "res://addons/godot_ai/handlers/command_handler.gd"
+
+const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
+
+## Handles the control_draw_recipe MCP command. Attaches a shared DrawRecipe
+## script to a Control and stores the caller's ordered draw ops in node
+## metadata under "_ops". The DrawRecipe script dispatches each op to a
+## CanvasItem draw_* call in _draw(). One Ctrl+Z reverts script + meta as a
+## single undo step.
+
+const DRAW_RECIPE_SCRIPT := preload("res://addons/godot_ai/runtime/draw_recipe.gd")
+const UiHandler := preload("res://addons/godot_ai/handlers/ui_handler.gd")
+
+var _undo_redo: EditorUndoRedoManager
+
+
+func _init(undo_redo: EditorUndoRedoManager) -> void:
+ _undo_redo = undo_redo
+
+
+func control_draw_recipe(params: Dictionary) -> Dictionary:
+ var path: String = params.get("path", "")
+ var ops_raw: Variant = params.get("ops", null)
+ var clear_existing: bool = bool(params.get("clear_existing", true))
+
+ if path.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: path")
+ if typeof(ops_raw) != TYPE_ARRAY:
+ return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "ops must be an Array")
+
+ var _resolved := McpNodeValidator.resolve_or_error(path, "path")
+ if _resolved.has("error"):
+ return _resolved
+ var node: Node = _resolved.node
+ var scene_root: Node = _resolved.scene_root
+ if not node is Control:
+ return ErrorCodes.make(
+ ErrorCodes.WRONG_TYPE,
+ "control_draw_recipe requires a Control node, got %s" % node.get_class()
+ )
+
+ var coerced := _coerce_ops(ops_raw)
+ if coerced.has("error"):
+ return coerced
+ var coerced_ops: Array = coerced.ops
+
+ var old_script: Variant = node.get_script()
+ if old_script != null and old_script != DRAW_RECIPE_SCRIPT:
+ if not clear_existing:
+ return ErrorCodes.make(
+ ErrorCodes.INVALID_PARAMS,
+ (
+ "Node %s already has a script. Pass clear_existing=true to replace."
+ % path
+ )
+ )
+
+ var had_meta := node.has_meta("_ops")
+ var old_ops: Variant = node.get_meta("_ops") if had_meta else null
+
+ _undo_redo.create_action("MCP: Draw recipe on %s" % node.name)
+ _undo_redo.add_do_method(node, "set_script", DRAW_RECIPE_SCRIPT)
+ _undo_redo.add_do_method(node, "set_meta", "_ops", coerced_ops)
+ _undo_redo.add_do_method(node, "queue_redraw")
+ _undo_redo.add_undo_method(node, "set_script", old_script)
+ if had_meta:
+ _undo_redo.add_undo_method(node, "set_meta", "_ops", old_ops)
+ else:
+ _undo_redo.add_undo_method(node, "remove_meta", "_ops")
+ _undo_redo.add_undo_method(node, "queue_redraw")
+ _undo_redo.commit_action()
+
+ return {
+ "data":
+ {
+ "path": McpScenePath.from_node(node, scene_root),
+ "ops_count": coerced_ops.size(),
+ "script_attached": old_script == null,
+ "script_replaced": old_script != null and old_script != DRAW_RECIPE_SCRIPT,
+ "undoable": true,
+ }
+ }
+
+
+
+## Validate and coerce every op dict. Returns {"ops": Array} or an error dict.
+func _coerce_ops(ops: Array) -> Dictionary:
+ var result: Array = []
+ for i in ops.size():
+ var op: Variant = ops[i]
+ if typeof(op) != TYPE_DICTIONARY:
+ return ErrorCodes.make(
+ ErrorCodes.WRONG_TYPE, "ops[%d] must be a dictionary" % i
+ )
+ var coerced := _coerce_single_op(op, i)
+ if coerced.has("error"):
+ return coerced
+ result.append(coerced.op)
+ return {"ops": result}
+
+
+func _coerce_single_op(op: Dictionary, idx: int) -> Dictionary:
+ var draw_type: String = op.get("draw", "")
+ if draw_type.is_empty():
+ return ErrorCodes.make(
+ ErrorCodes.MISSING_REQUIRED_PARAM, "ops[%d]: missing 'draw' field" % idx
+ )
+ match draw_type:
+ "line":
+ return _coerce_line(op, idx)
+ "rect":
+ return _coerce_rect(op, idx)
+ "arc":
+ return _coerce_arc(op, idx)
+ "circle":
+ return _coerce_circle(op, idx)
+ "polyline":
+ return _coerce_polyline_or_polygon(op, idx, "polyline")
+ "polygon":
+ return _coerce_polyline_or_polygon(op, idx, "polygon")
+ "string":
+ return _coerce_string(op, idx)
+ return ErrorCodes.make(
+ ErrorCodes.VALUE_OUT_OF_RANGE,
+ "ops[%d]: unknown draw type '%s'" % [idx, draw_type]
+ )
+
+
+func _require_fields(op: Dictionary, idx: int, kind: String, fields: Array) -> Dictionary:
+ for f in fields:
+ if not op.has(f):
+ return ErrorCodes.make(
+ ErrorCodes.INVALID_PARAMS,
+ "ops[%d] (%s): missing '%s'" % [idx, kind, f]
+ )
+ return {}
+
+
+func _coerce_typed(value: Variant, prop_type: int, idx: int, kind: String, field: String) -> Dictionary:
+ var r := UiHandler._coerce_for_type(value, prop_type)
+ if r.ok:
+ return {"ok": true, "value": r.value}
+ return ErrorCodes.make(
+ ErrorCodes.VALUE_OUT_OF_RANGE, "ops[%d] (%s): invalid '%s'" % [idx, kind, field]
+ )
+
+
+func _coerce_line(op: Dictionary, idx: int) -> Dictionary:
+ var missing := _require_fields(op, idx, "line", ["from", "to", "color"])
+ if missing.has("error"):
+ return missing
+ var frm := _coerce_typed(op.from, TYPE_VECTOR2, idx, "line", "from")
+ if frm.has("error"):
+ return frm
+ var to_ := _coerce_typed(op.to, TYPE_VECTOR2, idx, "line", "to")
+ if to_.has("error"):
+ return to_
+ var c := _coerce_typed(op.color, TYPE_COLOR, idx, "line", "color")
+ if c.has("error"):
+ return c
+ var out := {"draw": "line", "from": frm.value, "to": to_.value, "color": c.value}
+ if op.has("width"):
+ out["width"] = float(op.width)
+ if op.has("antialiased"):
+ out["antialiased"] = bool(op.antialiased)
+ return {"op": out}
+
+
+func _coerce_rect(op: Dictionary, idx: int) -> Dictionary:
+ var missing := _require_fields(op, idx, "rect", ["rect", "color"])
+ if missing.has("error"):
+ return missing
+ var r := _coerce_typed(op.rect, TYPE_RECT2, idx, "rect", "rect")
+ if r.has("error"):
+ return r
+ var c := _coerce_typed(op.color, TYPE_COLOR, idx, "rect", "color")
+ if c.has("error"):
+ return c
+ var out := {"draw": "rect", "rect": r.value, "color": c.value}
+ if op.has("filled"):
+ out["filled"] = bool(op.filled)
+ if op.has("width"):
+ out["width"] = float(op.width)
+ return {"op": out}
+
+
+func _coerce_arc(op: Dictionary, idx: int) -> Dictionary:
+ var missing := _require_fields(
+ op, idx, "arc", ["center", "radius", "start_angle", "end_angle", "color"]
+ )
+ if missing.has("error"):
+ return missing
+ var center := _coerce_typed(op.center, TYPE_VECTOR2, idx, "arc", "center")
+ if center.has("error"):
+ return center
+ var c := _coerce_typed(op.color, TYPE_COLOR, idx, "arc", "color")
+ if c.has("error"):
+ return c
+ var out := {
+ "draw": "arc",
+ "center": center.value,
+ "radius": float(op.radius),
+ "start_angle": float(op.start_angle),
+ "end_angle": float(op.end_angle),
+ "color": c.value,
+ }
+ if op.has("point_count"):
+ out["point_count"] = int(op.point_count)
+ if op.has("width"):
+ out["width"] = float(op.width)
+ if op.has("antialiased"):
+ out["antialiased"] = bool(op.antialiased)
+ return {"op": out}
+
+
+func _coerce_circle(op: Dictionary, idx: int) -> Dictionary:
+ var missing := _require_fields(op, idx, "circle", ["center", "radius", "color"])
+ if missing.has("error"):
+ return missing
+ var center := _coerce_typed(op.center, TYPE_VECTOR2, idx, "circle", "center")
+ if center.has("error"):
+ return center
+ var c := _coerce_typed(op.color, TYPE_COLOR, idx, "circle", "color")
+ if c.has("error"):
+ return c
+ return {
+ "op":
+ {
+ "draw": "circle",
+ "center": center.value,
+ "radius": float(op.radius),
+ "color": c.value,
+ }
+ }
+
+
+func _coerce_polyline_or_polygon(op: Dictionary, idx: int, kind: String) -> Dictionary:
+ if not op.has("points"):
+ return ErrorCodes.make(
+ ErrorCodes.MISSING_REQUIRED_PARAM, "ops[%d] (%s): missing 'points'" % [idx, kind]
+ )
+ if typeof(op.points) != TYPE_ARRAY:
+ return ErrorCodes.make(
+ ErrorCodes.WRONG_TYPE,
+ "ops[%d] (%s): 'points' must be an Array" % [idx, kind]
+ )
+ var points := PackedVector2Array()
+ for j in op.points.size():
+ var p := UiHandler._coerce_for_type(op.points[j], TYPE_VECTOR2)
+ if not p.ok:
+ return ErrorCodes.make(
+ ErrorCodes.INVALID_PARAMS,
+ "ops[%d] (%s): points[%d] invalid" % [idx, kind, j]
+ )
+ points.append(p.value)
+
+ var out := {"draw": kind, "points": points}
+
+ if op.has("colors"):
+ if typeof(op.colors) != TYPE_ARRAY:
+ return ErrorCodes.make(
+ ErrorCodes.INVALID_PARAMS,
+ "ops[%d] (%s): 'colors' must be an Array" % [idx, kind]
+ )
+ var colors := PackedColorArray()
+ for k in op.colors.size():
+ var ck := UiHandler._coerce_for_type(op.colors[k], TYPE_COLOR)
+ if not ck.ok:
+ return ErrorCodes.make(
+ ErrorCodes.INVALID_PARAMS,
+ "ops[%d] (%s): colors[%d] invalid" % [idx, kind, k]
+ )
+ colors.append(ck.value)
+ out["colors"] = colors
+ elif op.has("color"):
+ var c := UiHandler._coerce_for_type(op.color, TYPE_COLOR)
+ if not c.ok:
+ return ErrorCodes.make(
+ ErrorCodes.VALUE_OUT_OF_RANGE, "ops[%d] (%s): invalid 'color'" % [idx, kind]
+ )
+ out["color"] = c.value
+ else:
+ return ErrorCodes.make(
+ ErrorCodes.MISSING_REQUIRED_PARAM,
+ "ops[%d] (%s): missing 'color' or 'colors'" % [idx, kind]
+ )
+
+ if op.has("width"):
+ out["width"] = float(op.width)
+ if op.has("antialiased"):
+ out["antialiased"] = bool(op.antialiased)
+ return {"op": out}
+
+
+func _coerce_string(op: Dictionary, idx: int) -> Dictionary:
+ var missing := _require_fields(op, idx, "string", ["position", "text", "color"])
+ if missing.has("error"):
+ return missing
+ var pos := _coerce_typed(op.position, TYPE_VECTOR2, idx, "string", "position")
+ if pos.has("error"):
+ return pos
+ var c := _coerce_typed(op.color, TYPE_COLOR, idx, "string", "color")
+ if c.has("error"):
+ return c
+ var out := {
+ "draw": "string",
+ "position": pos.value,
+ "text": str(op.text),
+ "color": c.value,
+ }
+ if op.has("font_size"):
+ out["font_size"] = int(op.font_size)
+ if op.has("align"):
+ out["align"] = int(op.align)
+ if op.has("max_width"):
+ out["max_width"] = float(op.max_width)
+ return {"op": out}
diff --git a/addons/godot_ai/handlers/control_draw_recipe_handler.gd.uid b/addons/godot_ai/handlers/control_draw_recipe_handler.gd.uid
new file mode 100644
index 0000000..da0aaf9
--- /dev/null
+++ b/addons/godot_ai/handlers/control_draw_recipe_handler.gd.uid
@@ -0,0 +1 @@
+uid://buat1mt0fjlqb
diff --git a/addons/godot_ai/handlers/csg_handler.gd b/addons/godot_ai/handlers/csg_handler.gd
new file mode 100644
index 0000000..b85143d
--- /dev/null
+++ b/addons/godot_ai/handlers/csg_handler.gd
@@ -0,0 +1,118 @@
+@tool
+extends "res://addons/godot_ai/handlers/command_handler.gd"
+
+## CSG authoring — create CSG shapes (box, sphere, cylinder, torus, prism)
+## and set their boolean operation (union / intersection / subtraction) so
+## agents can carve geometry (holes, caves, tunnels) directly in the editor.
+##
+## All ops target nodes in the currently edited scene by scene-relative
+## path. All write ops are undoable via EditorUndoRedoManager. Sibling CSG
+## shapes under the same parent combine automatically; use a CSGCombiner3D
+## parent when you need explicit grouping.
+
+const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
+
+const SHAPES := {
+ "box": "CSGBox3D",
+ "sphere": "CSGSphere3D",
+ "cylinder": "CSGCylinder3D",
+ "torus": "CSGTorus3D",
+ "polygon": "CSGPolygon3D",
+}
+
+const OPERATIONS := {
+ "union": CSGShape3D.OPERATION_UNION,
+ "intersection": CSGShape3D.OPERATION_INTERSECTION,
+ "subtraction": CSGShape3D.OPERATION_SUBTRACTION,
+}
+
+var _undo_redo: EditorUndoRedoManager
+
+
+func _init(undo_redo: EditorUndoRedoManager) -> void:
+ _undo_redo = undo_redo
+
+
+## Create a CSG shape under a Node3D parent.
+## params: {parent_path, name="", shape="box", operation="union"}
+## Returns: {path, name, shape, operation, undoable}
+func create(params: Dictionary) -> Dictionary:
+ var parent_path: String = params.get("parent_path", "")
+ var shape: String = params.get("shape", "box")
+ var operation: String = params.get("operation", "union")
+ var scene_file: String = params.get("scene_file", "")
+
+ var shape_class: String = SHAPES.get(shape, "")
+ if shape_class.is_empty():
+ return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
+ "Unknown shape: %s. Valid shapes: %s" % [shape, ", ".join(SHAPES.keys())])
+ if not OPERATIONS.has(operation):
+ return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
+ "Unknown operation: %s. Valid operations: %s" % [operation, ", ".join(OPERATIONS.keys())])
+
+ var scene_check := McpScenePath.require_edited_scene(scene_file)
+ if scene_check.has("error"):
+ return scene_check
+ var scene_root: Node = scene_check.node
+
+ var parent: Node = scene_root
+ if not parent_path.is_empty():
+ parent = McpScenePath.resolve(parent_path, scene_root)
+ if parent == null:
+ return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND,
+ McpScenePath.format_parent_error(parent_path, scene_root))
+ if not parent is Node3D:
+ return ErrorCodes.make(ErrorCodes.WRONG_TYPE,
+ "CSG parent must be a Node3D (got %s)" % parent.get_class())
+
+ var node: CSGShape3D = ClassDB.instantiate(shape_class)
+ if node == null:
+ return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate %s" % shape_class)
+ var node_name: String = params.get("name", "")
+ if node_name.is_empty():
+ node_name = shape_class
+ node.name = node_name
+ node.operation = OPERATIONS[operation]
+
+ _undo_redo.create_action("MCP: Create %s" % node.name)
+ _undo_redo.add_do_method(parent, "add_child", node, true)
+ _undo_redo.add_do_method(node, "set_owner", scene_root)
+ _undo_redo.add_do_reference(node)
+ _undo_redo.add_undo_method(parent, "remove_child", node)
+ _undo_redo.commit_action()
+
+ return {"data": {
+ "path": McpScenePath.from_node(node, scene_root),
+ "name": node.name,
+ "shape": shape,
+ "operation": operation,
+ "undoable": true,
+ }}
+
+
+## Set the boolean operation of a CSG shape.
+## params: {path, operation}
+## Returns: {operation, undoable}
+func set_operation(params: Dictionary) -> Dictionary:
+ var operation: String = params.get("operation", "")
+ if not OPERATIONS.has(operation):
+ return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
+ "Unknown operation: %s. Valid operations: %s" % [operation, ", ".join(OPERATIONS.keys())])
+ var resolved := McpNodeValidator.resolve_or_error(
+ params.get("path", ""), "path", params.get("scene_file", ""))
+ if resolved.has("error"):
+ return resolved
+ var node: Node = resolved.node
+ if not node is CSGShape3D:
+ return ErrorCodes.make(ErrorCodes.WRONG_TYPE,
+ "Node is not a CSGShape3D: %s" % params.get("path", ""))
+ var shape: CSGShape3D = node
+ var prev: int = shape.operation
+ var next: int = OPERATIONS[operation]
+ ## Target the node in both callbacks so the action lands in the scene
+ ## undo history (first-target routing); `set` exists on every Object.
+ _undo_redo.create_action("MCP: CSG set_operation")
+ _undo_redo.add_do_method(shape, "set", "operation", next)
+ _undo_redo.add_undo_method(shape, "set", "operation", prev)
+ _undo_redo.commit_action()
+ return {"data": {"operation": operation, "undoable": true}}
diff --git a/addons/godot_ai/handlers/csg_handler.gd.uid b/addons/godot_ai/handlers/csg_handler.gd.uid
new file mode 100644
index 0000000..0f7b976
--- /dev/null
+++ b/addons/godot_ai/handlers/csg_handler.gd.uid
@@ -0,0 +1 @@
+uid://c4mb0tptu52x2
diff --git a/addons/godot_ai/handlers/curve_handler.gd b/addons/godot_ai/handlers/curve_handler.gd
new file mode 100644
index 0000000..fb7c593
--- /dev/null
+++ b/addons/godot_ai/handlers/curve_handler.gd
@@ -0,0 +1,243 @@
+@tool
+extends "res://addons/godot_ai/handlers/command_handler.gd"
+
+const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
+
+## Replaces all points on a Curve / Curve2D / Curve3D resource. The point
+## list shape depends on resource type (see `set_points` for the schemas).
+##
+## Dedicated tool rather than a property set because Curve2D/Curve3D.add_point
+## is a method call, not a property — resource_create's `properties` dict can't
+## reach it.
+
+const NodeHandler := preload("res://addons/godot_ai/handlers/node_handler.gd")
+
+var _undo_redo: EditorUndoRedoManager
+var _connection: McpConnection
+
+
+func _init(undo_redo: EditorUndoRedoManager, connection: McpConnection = null) -> void:
+ _undo_redo = undo_redo
+ _connection = connection
+
+
+func set_points(params: Dictionary) -> Dictionary:
+ var node_path: String = params.get("path", "")
+ var property: String = params.get("property", "")
+ var resource_path: String = params.get("resource_path", "")
+ var new_points: Array = params.get("points", [])
+
+ var home_err := McpResourceIO.validate_home(params)
+ if home_err != null:
+ return home_err
+ var has_file_target := not resource_path.is_empty()
+ if not (new_points is Array):
+ return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "points must be an array")
+
+ var curve: Resource
+ var node: Node = null
+ var curve_created := false
+ if has_file_target:
+ var rpath_err = McpPathValidator.loadable_error(resource_path, "resource_path")
+ if rpath_err != null:
+ return rpath_err
+ if not ResourceLoader.exists(resource_path):
+ return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Resource not found: %s" % resource_path)
+ # ResourceLoader.load() returns Godot's cached Resource. Duplicate
+ # before mutating so: (a) open scenes holding a reference to this
+ # .tres don't silently see the new points outside any undo action,
+ # and (b) if ResourceSaver.save() fails we haven't corrupted the
+ # in-memory cache (cache/disk divergence). Also guard against
+ # ResourceLoader.exists() succeeding but load() returning null
+ # (corrupt .tres, unregistered class) — otherwise curve.get_class()
+ # on the response line below would crash the plugin.
+ var loaded_curve: Resource = ResourceLoader.load(resource_path)
+ if loaded_curve == null:
+ return ErrorCodes.make(
+ ErrorCodes.INTERNAL_ERROR,
+ "Failed to load curve from %s (file exists but load returned null — may be corrupt)" % resource_path
+ )
+ curve = loaded_curve.duplicate()
+ else:
+ var _scene_check := McpNodeValidator.require_scene_or_error()
+ if _scene_check.has("error"):
+ return _scene_check
+ var scene_root: Node = _scene_check.scene_root
+ node = McpScenePath.resolve(node_path, scene_root)
+ if node == null:
+ return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, McpScenePath.format_node_error(node_path, scene_root))
+ if not (property in node):
+ return ErrorCodes.make(
+ ErrorCodes.PROPERTY_NOT_ON_CLASS,
+ McpPropertyErrors.build_message(node, property)
+ )
+ curve = node.get(property)
+ # Auto-create a fresh Curve subclass if the slot is empty. Infer the
+ # concrete class from the property's hint_string (e.g. Path3D.curve's
+ # hint is "Curve3D"). Creation is bundled into the same undo action
+ # as the point-set below, so Ctrl-Z rolls back both.
+ if curve == null:
+ var inferred := _infer_curve_class(node, property)
+ if inferred.is_empty():
+ return ErrorCodes.make(
+ ErrorCodes.INVALID_PARAMS,
+ "Curve slot on %s.%s is null and the Curve class can't be inferred from the property hint — create one first with resource_create (type=Curve3D/Curve2D/Curve)" % [node.get_class(), property]
+ )
+ curve = ClassDB.instantiate(inferred)
+ if curve == null:
+ return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate %s" % inferred)
+ curve_created = true
+
+ if not (curve is Curve or curve is Curve2D or curve is Curve3D):
+ return ErrorCodes.make(
+ ErrorCodes.WRONG_TYPE,
+ "Resource is %s — must be Curve, Curve2D, or Curve3D" % curve.get_class()
+ )
+
+ var coerced := _coerce_points(curve, new_points)
+ if coerced.has("error"):
+ return coerced.error
+
+ var new_snapshot: Array = coerced.snapshot
+
+ if has_file_target:
+ _apply_snapshot_to_curve(curve, new_snapshot)
+ # curve_set_points EDITS an existing .tres, so override the default
+ # "delete to revert" message via extra_fields.
+ return McpResourceIO.save_to_disk(curve, resource_path, true, "Curve", {
+ "curve_class": curve.get_class(),
+ "point_count": new_snapshot.size(),
+ "reason": "File save is persistent; edit the .tres file manually to revert",
+ }, _connection)
+
+ # Inline (node-attached) path: swap the curve property so the action lands
+ # cleanly in scene history, mirroring the resource-swap pattern used by
+ # material_handler::assign_material. When curve_created is true the
+ # "old" value is null — undo clears the slot back to empty.
+ var new_curve: Resource = curve if curve_created else curve.duplicate()
+ _apply_snapshot_to_curve(new_curve, new_snapshot)
+ var old_curve: Resource = null if curve_created else curve
+
+ _undo_redo.create_action("MCP: Set %d points on %s.%s" % [new_snapshot.size(), node.name, property])
+ _undo_redo.add_do_property(node, property, new_curve)
+ _undo_redo.add_undo_property(node, property, old_curve)
+ _undo_redo.add_do_reference(new_curve)
+ _undo_redo.commit_action()
+
+ return {
+ "data": {
+ "path": node_path,
+ "property": property,
+ "curve_class": new_curve.get_class(),
+ "point_count": new_snapshot.size(),
+ "curve_created": curve_created,
+ "undoable": true,
+ }
+ }
+
+
+## Infer the concrete Curve class to instantiate for a null property slot.
+## Reads the property's hint_string (set by Godot on resource-typed exports)
+## to get the exact accepted class name (e.g. "Curve3D" for Path3D.curve).
+## Returns empty string if no viable curve class can be determined.
+static func _infer_curve_class(node: Node, property: String) -> String:
+ for prop in node.get_property_list():
+ if prop.name != property:
+ continue
+ var hint_string: String = prop.get("hint_string", "")
+ if hint_string.is_empty():
+ return ""
+ if not ClassDB.class_exists(hint_string):
+ return ""
+ if hint_string == "Curve" or hint_string == "Curve2D" or hint_string == "Curve3D":
+ return hint_string
+ # Some custom properties may list a parent class; require an exact
+ # match against our three supported types to avoid surprises.
+ return ""
+ return ""
+
+
+## Convert input `points` into a normalized snapshot of typed values for
+## the given curve type. Returns {snapshot: Array} on success or
+## {error: ...} on failure.
+static func _coerce_points(curve: Resource, points: Array) -> Dictionary:
+ var snapshot: Array = []
+ if curve is Curve:
+ for i in range(points.size()):
+ var p = points[i]
+ if not (p is Dictionary) or not p.has("offset") or not p.has("value"):
+ return {"error": ErrorCodes.make(
+ ErrorCodes.INVALID_PARAMS,
+ "Curve points[%d] must be {offset, value, [left_tangent, right_tangent]}" % i
+ )}
+ snapshot.append({
+ "offset": float(p["offset"]),
+ "value": float(p["value"]),
+ "left_tangent": float(p.get("left_tangent", 0.0)),
+ "right_tangent": float(p.get("right_tangent", 0.0)),
+ })
+ elif curve is Curve2D:
+ var zero2 := {"x": 0, "y": 0}
+ for i in range(points.size()):
+ var p2 = points[i]
+ if not (p2 is Dictionary) or not p2.has("position"):
+ return {"error": ErrorCodes.make(
+ ErrorCodes.INVALID_PARAMS,
+ "Curve2D points[%d] must have 'position' (and optional 'in', 'out')" % i
+ )}
+ var axes2 := {
+ "position": p2["position"],
+ "in": p2.get("in", zero2),
+ "out": p2.get("out", zero2),
+ }
+ var coerced2 := {}
+ for field in ["position", "in", "out"]:
+ var v = NodeHandler._coerce_value(axes2[field], TYPE_VECTOR2)
+ var err := NodeHandler._check_coerced(v, TYPE_VECTOR2, "Curve2D points[%d].%s" % [i, field])
+ if err != null:
+ return {"error": err}
+ coerced2[field] = v
+ snapshot.append(coerced2)
+ else: # Curve3D
+ var zero3 := {"x": 0, "y": 0, "z": 0}
+ for i in range(points.size()):
+ var p3 = points[i]
+ if not (p3 is Dictionary) or not p3.has("position"):
+ return {"error": ErrorCodes.make(
+ ErrorCodes.INVALID_PARAMS,
+ "Curve3D points[%d] must have 'position' (and optional 'in', 'out', 'tilt')" % i
+ )}
+ var axes3 := {
+ "position": p3["position"],
+ "in": p3.get("in", zero3),
+ "out": p3.get("out", zero3),
+ }
+ var coerced3 := {}
+ for field in ["position", "in", "out"]:
+ var v = NodeHandler._coerce_value(axes3[field], TYPE_VECTOR3)
+ var err := NodeHandler._check_coerced(v, TYPE_VECTOR3, "Curve3D points[%d].%s" % [i, field])
+ if err != null:
+ return {"error": err}
+ coerced3[field] = v
+ coerced3["tilt"] = float(p3.get("tilt", 0.0))
+ snapshot.append(coerced3)
+ return {"snapshot": snapshot}
+
+
+func _apply_snapshot_to_curve(curve: Resource, snapshot: Array) -> void:
+ curve.clear_points()
+ if curve is Curve:
+ for p: Dictionary in snapshot:
+ curve.add_point(
+ Vector2(p.offset, p.value),
+ p.left_tangent,
+ p.right_tangent
+ )
+ elif curve is Curve2D:
+ for p: Dictionary in snapshot:
+ curve.add_point(p.position, p["in"], p.out)
+ elif curve is Curve3D:
+ for i in range(snapshot.size()):
+ var p: Dictionary = snapshot[i]
+ curve.add_point(p.position, p["in"], p.out)
+ curve.set_point_tilt(i, p.tilt)
diff --git a/addons/godot_ai/handlers/curve_handler.gd.uid b/addons/godot_ai/handlers/curve_handler.gd.uid
new file mode 100644
index 0000000..8eb5b25
--- /dev/null
+++ b/addons/godot_ai/handlers/curve_handler.gd.uid
@@ -0,0 +1 @@
+uid://dboqr06a1fvqx
diff --git a/addons/godot_ai/handlers/editor_handler.gd b/addons/godot_ai/handlers/editor_handler.gd
new file mode 100644
index 0000000..ec889c1
--- /dev/null
+++ b/addons/godot_ai/handlers/editor_handler.gd
@@ -0,0 +1,1069 @@
+@tool
+extends "res://addons/godot_ai/handlers/command_handler.gd"
+
+const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
+const Telemetry := preload("res://addons/godot_ai/telemetry.gd")
+const PluginReload := preload("res://addons/godot_ai/utils/plugin_reload.gd")
+const VisionRoutingScript := preload("res://addons/godot_ai/vision_routing.gd")
+
+## Handles editor state, selection, log, screenshot, and performance commands.
+
+var _log_buffer: McpLogBuffer
+var _connection: McpConnection
+var _debugger_plugin: McpDebuggerPlugin
+var _game_log_buffer: McpGameLogBuffer
+var _editor_log_buffer: McpEditorLogBuffer
+var _debugger_errors_root: Node
+var _surfaced_error_tracker
+var _vision_routing: VisionRoutingScript = null
+
+
+func _init(log_buffer: McpLogBuffer, connection: McpConnection = null, debugger_plugin: McpDebuggerPlugin = null, game_log_buffer: McpGameLogBuffer = null, editor_log_buffer: McpEditorLogBuffer = null, debugger_errors_root: Node = null, surfaced_error_tracker = null, vision_routing: VisionRoutingScript = null) -> void:
+ _log_buffer = log_buffer
+ _connection = connection
+ _debugger_plugin = debugger_plugin
+ _game_log_buffer = game_log_buffer
+ _editor_log_buffer = editor_log_buffer
+ _debugger_errors_root = debugger_errors_root
+ _surfaced_error_tracker = surfaced_error_tracker
+ _vision_routing = vision_routing
+ if _surfaced_error_tracker == null:
+ _surfaced_error_tracker = McpSurfacedErrorTracker.new(_editor_log_buffer, _game_log_buffer, _debugger_errors_root)
+
+
+func get_editor_state(_params: Dictionary) -> Dictionary:
+ var scene_root := EditorInterface.get_edited_scene_root()
+ var game_status := _current_game_status()
+ var data := {
+ "godot_version": Engine.get_version_info().get("string", "unknown"),
+ "project_name": ProjectSettings.get_setting("application/config/name", ""),
+ "current_scene": scene_root.scene_file_path if scene_root else "",
+ "is_playing": EditorInterface.is_playing_scene(),
+ "readiness": McpConnection.get_readiness(),
+ ## True once the game subprocess autoload has beaconed mcp:hello;
+ ## false between Play→Stop cycles. Lets capture-source=game callers
+ ## poll for a real ready signal instead of guessing with sleep().
+ "game_capture_ready": _debugger_plugin != null and _debugger_plugin.is_game_capture_ready(),
+ "game_status": game_status,
+ "helper_live": bool(game_status.get("helper_live", false)),
+ "session_active": bool(game_status.get("session_active", false)),
+ }
+ return {"data": data}
+
+
+func get_selection(_params: Dictionary) -> Dictionary:
+ var scene_root := EditorInterface.get_edited_scene_root()
+ var selected := EditorInterface.get_selection().get_selected_nodes()
+ var paths: Array[String] = []
+ for node in selected:
+ paths.append(McpScenePath.from_node(node, scene_root))
+ return {"data": {"selected_paths": paths, "count": paths.size()}}
+
+
+const VALID_LOG_SOURCES := ["plugin", "game", "editor", "all"]
+
+## Deferred budget for the `input_sequence` game op. Unlike the one-shot game
+## ops (covered by game_command's 15s entry), it drives the game forward one
+## frame per step, so the reply legitimately takes seconds. The game side caps
+## the sequence length (GameHelper.MAX_SEQUENCE_FRAMES) well inside this; the
+## budget is the backstop for a frozen game loop, mirroring take_screenshot.
+const INPUT_SEQUENCE_TIMEOUT_SEC := 30.0
+
+
+func get_logs(params: Dictionary) -> Dictionary:
+ ## Coerce defensively — MCP clients can send JSON numbers as floats or
+ ## stray `null` values that would otherwise fail the typed locals
+ ## before we ever reach the INVALID_PARAMS return below.
+ var count: int = maxi(0, int(params.get("count", 50)))
+ var offset: int = maxi(0, int(params.get("offset", 0)))
+ var source: String = str(params.get("source", "plugin"))
+ var include_details: bool = bool(params.get("include_details", false))
+ var has_since_cursor := params.has("since_cursor") and params.get("since_cursor") != null
+ var since_cursor: int = maxi(0, int(params.get("since_cursor", 0)))
+ var since_run_id := "" if params.get("since_run_id", null) == null else str(params.get("since_run_id", ""))
+ if not source in VALID_LOG_SOURCES:
+ return ErrorCodes.make(
+ ErrorCodes.VALUE_OUT_OF_RANGE,
+ "Invalid source '%s' — use 'plugin', 'game', 'editor', or 'all'" % source,
+ )
+
+ match source:
+ "plugin":
+ return _get_plugin_logs(count, offset)
+ "game":
+ return _get_game_logs(count, offset, include_details, since_run_id)
+ "editor":
+ return _get_editor_logs(count, offset, include_details, has_since_cursor, since_cursor)
+ "all":
+ return _get_all_logs(count, offset, include_details)
+ return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Unreachable")
+
+
+func _current_game_status() -> Dictionary:
+ if _debugger_plugin == null:
+ return McpDebuggerPlugin.with_liveness_flags({
+ "status": "stopped",
+ "active": false,
+ "ready": false,
+ "helper_expected": true,
+ })
+ return _debugger_plugin.get_game_status()
+
+
+func _get_plugin_logs(count: int, offset: int) -> Dictionary:
+ var all_lines := _log_buffer.get_recent(_log_buffer.total_count())
+ var page: Array[Dictionary] = []
+ var stop := mini(all_lines.size(), offset + count)
+ for i in range(mini(offset, all_lines.size()), stop):
+ page.append({"source": "plugin", "level": "info", "text": all_lines[i]})
+ return {
+ "data": {
+ "source": "plugin",
+ "lines": page,
+ "total_count": all_lines.size(),
+ "returned_count": page.size(),
+ "offset": offset,
+ }
+ }
+
+
+func _get_game_logs(count: int, offset: int, include_details: bool, since_run_id: String = "") -> Dictionary:
+ var game_status := _current_game_status()
+ var helper_live := bool(game_status.get("helper_live", false))
+ var session_active := bool(game_status.get("session_active", false))
+ if _game_log_buffer == null:
+ return {
+ "data": {
+ "source": "game",
+ "lines": [],
+ "total_count": 0,
+ "returned_count": 0,
+ "offset": offset,
+ "run_id": "",
+ "current_run_id": "",
+ "is_running": session_active,
+ "helper_live": helper_live,
+ "session_active": session_active,
+ "game_status": game_status,
+ "dropped_count": 0,
+ "stale_run_id": false,
+ }
+ }
+ var current_run_id := _game_log_buffer.run_id()
+ var target_run_id := since_run_id if not since_run_id.is_empty() else current_run_id
+ var stale_run_id := not since_run_id.is_empty() and since_run_id != current_run_id
+ var run_page := _game_log_buffer.get_run_page(target_run_id, offset, count)
+ var page := _entries_for_response(run_page.get("entries", []), include_details)
+ var data := {
+ "source": "game",
+ "lines": page,
+ "total_count": int(run_page.get("total_count", 0)),
+ "returned_count": page.size(),
+ "offset": offset,
+ "run_id": target_run_id,
+ "current_run_id": current_run_id,
+ "is_running": session_active,
+ "helper_live": helper_live,
+ "session_active": session_active,
+ "game_status": game_status,
+ "dropped_count": _game_log_buffer.dropped_count(),
+ "stale_run_id": stale_run_id,
+ }
+ _merge_editor_errors_hint(data, game_status)
+ return {"data": data}
+
+
+## #641: boot-time parse errors happen while autoload scripts compile — before
+## the game helper's logger attaches via OS.add_logger — so they can NEVER
+## appear in the game buffer. They surface only through the editor scope
+## (Errors-tab rows + editor logger). Cross-reference them here so an
+## empty/clean game log is not mistaken for a clean launch.
+func _merge_editor_errors_hint(data: Dictionary, game_status: Dictionary) -> void:
+ if _debugger_plugin == null:
+ return
+ ## A since_run_id read of a prior run must not carry the CURRENT run's
+ ## editor errors — the hint interprets the run being read.
+ if bool(data.get("stale_run_id", false)):
+ return
+ ## run_token == 0 means no tracked run ever started this session; the
+ ## run-start cursor would be 0 and every retained editor error would be
+ ## misattributed to "this run".
+ if int(game_status.get("run_token", 0)) <= 0:
+ return
+ ## One-shot read — force the scan so rows that landed after the last
+ ## gated scan (and before the deferred timers fire) make the FIRST
+ ## logs_read(source='game') response, not just a later one.
+ var errors_info: Dictionary = _debugger_plugin.recent_editor_errors_since(
+ int(game_status.get("editor_log_cursor", 0)), true)
+ if str(errors_info.get("scope", "none")) != "run":
+ return
+ var errors: Array = errors_info.get("errors", [])
+ if errors.is_empty():
+ return
+ data["editor_errors_count"] = errors.size()
+ data["editor_errors_hint"] = (
+ "%d editor-side error%s from this run (first: %s) missing from the game log — boot-time parse/load errors occur before the game helper's logger attaches. Read logs_read(source='editor', include_details=true)."
+ % [errors.size(), "s" if errors.size() != 1 else "", _format_editor_error_summary(errors[0])]
+ )
+
+
+func _format_editor_error_summary(entry: Dictionary) -> String:
+ return McpSurfacedErrorTracker.format_editor_error_summary(entry)
+
+
+func _get_editor_logs(count: int, offset: int, include_details: bool, has_since_cursor: bool = false, since_cursor: int = 0) -> Dictionary:
+ ## Editor-process script errors (parse errors, @tool runtime errors,
+ ## EditorPlugin errors, push_error/push_warning). Captured by
+ ## editor_logger.gd via OS.add_logger on every v4-supported engine. During
+ ## partial initialization the buffer can still be null. Godot also sends GDScript reload
+ ## warnings/errors straight to the Debugger dock's Errors tab; those do
+ ## not flow through OS.add_logger, so merge the visible tree rows here.
+ if has_since_cursor:
+ return _get_editor_logs_since(count, since_cursor, include_details)
+ var all_entries := _collect_editor_log_entries()
+ var page := _entries_for_response(_slice_entries(all_entries, offset, count), include_details)
+ var appended_total := _editor_log_buffer.appended_total() if _editor_log_buffer != null else 0
+ return {
+ "data": {
+ "source": "editor",
+ "lines": page,
+ "total_count": all_entries.size(),
+ "returned_count": page.size(),
+ "offset": offset,
+ "dropped_count": _editor_log_buffer.dropped_count() if _editor_log_buffer != null else 0,
+ "next_cursor": appended_total,
+ "appended_total": appended_total,
+ }
+ }
+
+
+func _get_editor_logs_since(count: int, since_cursor: int, include_details: bool) -> Dictionary:
+ ## Cursor reads are defined over the monotonic editor logger ring only.
+ ## Visible Debugger Errors-tab rows are live UI state, not ring entries,
+ ## so regular offset reads still merge them while since_cursor polling
+ ## reports only Logger-backed entries.
+ var captured := {
+ "cursor": since_cursor,
+ "oldest_cursor": 0,
+ "next_cursor": 0,
+ "appended_total": 0,
+ "truncated": false,
+ "has_more": false,
+ "entries": [],
+ }
+ var dropped := 0
+ if _editor_log_buffer != null:
+ captured = _editor_log_buffer.get_since(since_cursor, count)
+ dropped = _editor_log_buffer.dropped_count()
+ var page := _entries_for_response(captured.get("entries", []), include_details)
+ return {
+ "data": {
+ "source": "editor",
+ "lines": page,
+ "total_count": int(captured.get("appended_total", 0)),
+ "returned_count": page.size(),
+ "offset": 0,
+ "dropped_count": dropped,
+ "cursor": int(captured.get("cursor", since_cursor)),
+ "oldest_cursor": int(captured.get("oldest_cursor", 0)),
+ "next_cursor": int(captured.get("next_cursor", 0)),
+ "appended_total": int(captured.get("appended_total", 0)),
+ "truncated": bool(captured.get("truncated", false)),
+ "has_more": bool(captured.get("has_more", false)),
+ }
+ }
+
+
+func _get_all_logs(count: int, offset: int, include_details: bool) -> Dictionary:
+ ## Plugin lines have no timestamp, so we can't merge chronologically.
+ ## Concatenate plugin → editor → game and apply the offset/count window
+ ## over the combined list. The per-line `source` field tells callers
+ ## where each entry came from. Editor goes between plugin and game so
+ ## script errors stay grouped near the plugin recv/send traffic that
+ ## triggered them, with game runtime logs at the end.
+ var combined: Array[Dictionary] = []
+ for line in _log_buffer.get_recent(_log_buffer.total_count()):
+ combined.append({"source": "plugin", "level": "info", "text": line})
+ for entry in _collect_editor_log_entries():
+ combined.append(entry)
+ var run_id := ""
+ var current_run_id := ""
+ var dropped := 0
+ if _game_log_buffer != null:
+ run_id = _game_log_buffer.run_id()
+ current_run_id = run_id
+ dropped = _game_log_buffer.dropped_count()
+ var run_page := _game_log_buffer.get_run_page(run_id, 0, McpGameLogBuffer.MAX_LINES)
+ for entry in run_page.get("entries", []):
+ combined.append(entry)
+ var stop := mini(combined.size(), offset + count)
+ var page: Array[Dictionary] = []
+ for i in range(mini(offset, combined.size()), stop):
+ page.append(combined[i])
+ page = _entries_for_response(page, include_details)
+ if _editor_log_buffer != null:
+ dropped += _editor_log_buffer.dropped_count()
+ var game_status := _current_game_status()
+ return {
+ "data": {
+ "source": "all",
+ "lines": page,
+ "total_count": combined.size(),
+ "returned_count": page.size(),
+ "offset": offset,
+ "run_id": run_id,
+ "current_run_id": current_run_id,
+ "is_running": bool(game_status.get("session_active", false)),
+ "helper_live": bool(game_status.get("helper_live", false)),
+ "session_active": bool(game_status.get("session_active", false)),
+ "game_status": game_status,
+ "dropped_count": dropped,
+ }
+ }
+
+
+func _entries_for_response(entries: Array[Dictionary], include_details: bool) -> Array[Dictionary]:
+ ## Compact responses only drop the top-level "details" key, so a shallow
+ ## copy is enough; the deep copy is reserved for the opt-in details path
+ ## where nested dicts leave the buffer.
+ var out: Array[Dictionary] = []
+ for entry in entries:
+ if include_details:
+ out.append(entry.duplicate(true))
+ else:
+ var copy: Dictionary = entry.duplicate(false)
+ copy.erase("details")
+ out.append(copy)
+ return out
+
+
+func _collect_editor_log_entries() -> Array[Dictionary]:
+ return _surfaced_error_tracker.collect_editor_log_entries()
+
+
+static func _slice_entries(entries: Array[Dictionary], offset: int, count: int) -> Array[Dictionary]:
+ var page: Array[Dictionary] = []
+ var stop := mini(entries.size(), offset + count)
+ for i in range(mini(offset, entries.size()), stop):
+ page.append(entries[i])
+ return page
+
+
+## Map of human-readable monitor names to Performance.Monitor enum values.
+const MONITORS := {
+ "time/fps": Performance.TIME_FPS,
+ "time/process": Performance.TIME_PROCESS,
+ "time/physics_process": Performance.TIME_PHYSICS_PROCESS,
+ "time/navigation_process": Performance.TIME_NAVIGATION_PROCESS,
+ "memory/static": Performance.MEMORY_STATIC,
+ "memory/static_max": Performance.MEMORY_STATIC_MAX,
+ "memory/message_buffer_max": Performance.MEMORY_MESSAGE_BUFFER_MAX,
+ "object/count": Performance.OBJECT_COUNT,
+ "object/resource_count": Performance.OBJECT_RESOURCE_COUNT,
+ "object/node_count": Performance.OBJECT_NODE_COUNT,
+ "object/orphan_node_count": Performance.OBJECT_ORPHAN_NODE_COUNT,
+ "render/total_objects_in_frame": Performance.RENDER_TOTAL_OBJECTS_IN_FRAME,
+ "render/total_primitives_in_frame": Performance.RENDER_TOTAL_PRIMITIVES_IN_FRAME,
+ "render/total_draw_calls_in_frame": Performance.RENDER_TOTAL_DRAW_CALLS_IN_FRAME,
+ "render/video_mem_used": Performance.RENDER_VIDEO_MEM_USED,
+ "physics_2d/active_objects": Performance.PHYSICS_2D_ACTIVE_OBJECTS,
+ "physics_2d/collision_pairs": Performance.PHYSICS_2D_COLLISION_PAIRS,
+ "physics_2d/island_count": Performance.PHYSICS_2D_ISLAND_COUNT,
+ "physics_3d/active_objects": Performance.PHYSICS_3D_ACTIVE_OBJECTS,
+ "physics_3d/collision_pairs": Performance.PHYSICS_3D_COLLISION_PAIRS,
+ "physics_3d/island_count": Performance.PHYSICS_3D_ISLAND_COUNT,
+ "navigation/active_maps": Performance.NAVIGATION_ACTIVE_MAPS,
+ "navigation/region_count": Performance.NAVIGATION_REGION_COUNT,
+ "navigation/agent_count": Performance.NAVIGATION_AGENT_COUNT,
+ "navigation/link_count": Performance.NAVIGATION_LINK_COUNT,
+ "navigation/polygon_count": Performance.NAVIGATION_POLYGON_COUNT,
+ "navigation/edge_count": Performance.NAVIGATION_EDGE_COUNT,
+ "navigation/edge_merge_count": Performance.NAVIGATION_EDGE_MERGE_COUNT,
+ "navigation/edge_connection_count": Performance.NAVIGATION_EDGE_CONNECTION_COUNT,
+ "navigation/edge_free_count": Performance.NAVIGATION_EDGE_FREE_COUNT,
+}
+
+
+## Compute coverage angles from the target's AABB geometry.
+## Returns an establishing perspective shot (faces the longest ground axis)
+## and an orthographic top-down for spatial layout. The AI iterates from
+## there with explicit elevation/azimuth/fov for closeups and detail shots.
+func _compute_coverage_angles(aabb: AABB) -> Array[Dictionary]:
+ var size := aabb.size
+ var ground_x := maxf(size.x, 0.01)
+ var ground_z := maxf(size.z, 0.01)
+
+ ## Face the longest ground axis — establishing shot shows maximum extent
+ var estab_azimuth: float
+ if ground_x >= ground_z:
+ estab_azimuth = 0.0 # face along Z, showing X width
+ else:
+ estab_azimuth = 90.0 # face along X, showing Z width
+
+ ## FOV: wider for spread-out subjects, narrower for compact ones
+ var ground_ratio := maxf(ground_x, ground_z) / minf(ground_x, ground_z)
+ var estab_fov := clampf(40.0 + ground_ratio * 5.0, 45.0, 65.0)
+
+ return [
+ {"label": "establishing", "elevation": 25.0, "azimuth": estab_azimuth + 20.0,
+ "fov": estab_fov, "ortho": false, "padding": 1.8},
+ {"label": "top", "elevation": 90.0, "azimuth": 0.0,
+ "fov": 0.0, "ortho": true},
+ ]
+
+
+func take_screenshot(params: Dictionary) -> Dictionary:
+ ## Vision Routing hook: when enabled, the capture is described by the
+ ## configured vision provider on a worker thread and the text description
+ ## is returned instead of the raw image (see vision_routing.gd). Off, no
+ ## key, or non-image results keep the original behavior. The single source
+ ## of truth for the `match source:` dispatch lives in _take_screenshot_impl
+ ## (pinned by tests/unit/test_docs_screenshot_sources.py).
+ if _vision_routing != null and _vision_routing.is_routing_enabled():
+ return _vision_routing.route_editor_screenshot(params, Callable(self, "_take_screenshot_impl"), _connection)
+ return _take_screenshot_impl(params)
+
+
+func _take_screenshot_impl(params: Dictionary) -> Dictionary:
+ var source: String = params.get("source", "viewport")
+ var max_resolution: int = params.get("max_resolution", 0)
+ var view_target: String = params.get("view_target", "")
+ var coverage: bool = params.get("coverage", false)
+ var custom_elevation = params.get("elevation", null)
+ var custom_azimuth = params.get("azimuth", null)
+ var custom_fov = params.get("fov", null)
+
+ var viewport: Viewport
+ match source:
+ "viewport":
+ viewport = EditorInterface.get_editor_viewport_3d()
+ if viewport == null:
+ return ErrorCodes.make_not_ready(
+ ErrorCodes.SUB_EDITOR_VIEWPORT_UNAVAILABLE,
+ "No 3D viewport available", false)
+ ## The 3D viewport's texture is empty when the edited scene
+ ## has no Node3D content (2D-only scene, or no scene open),
+ ## and the empty-image guard further down used to surface
+ ## that as INTERNAL_ERROR — leaving callers with no signal
+ ## that the failure was caller-side. Reject up front with a
+ ## structured hint so the LLM can pick a sensible next step
+ ## (open a 3D scene, switch to source="cinematic", etc.).
+ var precheck := viewport_screenshot_precheck(EditorInterface.get_edited_scene_root())
+ if precheck.has("error"):
+ return precheck
+ "game":
+ if not EditorInterface.is_playing_scene():
+ ## Same editor state as game_eval/game_command's gate below —
+ ## same EDITOR_NOT_READY shape, not INVALID_PARAMS (the params
+ ## were fine; the editor just isn't in the required state).
+ return ErrorCodes.make_not_ready(
+ ErrorCodes.SUB_EDITOR_GAME_NOT_RUNNING,
+ "Game is not running — start the project first", false,
+ "Use source='viewport' for the editor viewport, or start the game with project_run and retry.")
+ ## The game is always a separate OS process (embedded mode just
+ ## reparents its window into the editor). Reach the framebuffer
+ ## via the debugger channel: the `_mcp_game_helper` autoload
+ ## inside the game process replies with a PNG, and
+ ## McpDebuggerPlugin pushes the response back through our
+ ## WebSocket with the same request_id via McpConnection.send_deferred_response.
+ if _debugger_plugin == null or _connection == null:
+ return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Debugger bridge unavailable — plugin may not be fully initialised")
+ var request_id: String = params.get("_request_id", "")
+ if request_id.is_empty():
+ return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Missing request_id — cannot correlate deferred response")
+ _debugger_plugin.request_game_screenshot(request_id, max_resolution, _connection)
+ return McpDispatcher.DEFERRED_RESPONSE
+ "cinematic":
+ return _take_cinematic_screenshot(max_resolution)
+ "viewport_2d":
+ viewport = EditorInterface.get_editor_viewport_2d()
+ if viewport == null:
+ return ErrorCodes.make_not_ready(
+ ErrorCodes.SUB_EDITOR_VIEWPORT_UNAVAILABLE,
+ "No 2D viewport available", false)
+ var scene_root_2d := EditorInterface.get_edited_scene_root()
+ if scene_root_2d == null:
+ return ErrorCodes.make_not_ready(
+ ErrorCodes.SUB_EDITOR_NO_SCENE,
+ "No scene open — open a scene first", false,
+ "Call scene_open with a scene path (e.g. \"res://main.tscn\") first.")
+ if not view_target.is_empty() or coverage or custom_elevation != null or custom_azimuth != null or custom_fov != null:
+ return ErrorCodes.make(
+ ErrorCodes.INVALID_PARAMS,
+ "view_target, coverage, elevation, azimuth, and fov are not supported with source='viewport_2d'"
+ )
+ ## Capture the 2D editor viewport directly; no view_target/coverage for 2D.
+ RenderingServer.force_draw(false)
+ var image_2d: Image = viewport.get_texture().get_image()
+ if image_2d == null or image_2d.is_empty():
+ return _empty_image_error(
+ "viewport_2d",
+ "Captured an empty image from the 2D viewport. The 2D viewport produced no output — typically headless mode or the 2D viewport has not drawn a frame yet."
+ )
+ return _finalize_image(image_2d, "viewport_2d", max_resolution)
+ _:
+ return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Invalid source '%s' — use 'viewport', 'viewport_2d', 'cinematic', or 'game'" % source)
+
+ ## Handle view_target: temporarily reposition the editor's own camera to
+ ## frame one or more target nodes, force a render, capture, then restore.
+ if not view_target.is_empty() and source == "viewport":
+ var _scene_check := McpNodeValidator.require_scene_or_error()
+ if _scene_check.has("error"):
+ return _scene_check
+ var scene_root: Node = _scene_check.scene_root
+
+ ## Parse comma-separated paths, deduplicate
+ var raw_paths := view_target.split(",")
+ var seen := {}
+ var unique_paths: Array[String] = []
+ for rp in raw_paths:
+ var p := rp.strip_edges()
+ if not p.is_empty() and not seen.has(p):
+ seen[p] = true
+ unique_paths.append(p)
+
+ ## Resolve each path, collect valid Node3D targets
+ var targets: Array[Node3D] = []
+ var not_found: Array[String] = []
+ for p in unique_paths:
+ var node := McpScenePath.resolve(p, scene_root)
+ if node == null:
+ not_found.append(p)
+ elif not node is Node3D:
+ not_found.append(p)
+ else:
+ targets.append(node as Node3D)
+
+ if targets.is_empty():
+ return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, "No valid Node3D targets found: %s" % ", ".join(not_found))
+
+ var cam := viewport.get_camera_3d()
+ if cam == null:
+ return ErrorCodes.make_not_ready(
+ ErrorCodes.SUB_EDITOR_VIEWPORT_UNAVAILABLE,
+ "No camera in 3D viewport", false)
+
+ ## Merge AABBs from all targets
+ var combined_aabb := _get_visual_aabb(targets[0])
+ for i in range(1, targets.size()):
+ combined_aabb = combined_aabb.merge(_get_visual_aabb(targets[i]))
+
+ var cam_rid := cam.get_camera_rid()
+ var saved_xform := cam.global_transform
+ var saved_fov := cam.fov
+ var saved_near := cam.near
+ var saved_far := cam.far
+
+ ## --- Coverage path: multi-angle sweep ---
+ if coverage:
+ var images: Array[Dictionary] = []
+ for preset in _compute_coverage_angles(combined_aabb):
+ if preset.get("ortho", false):
+ ## Orthographic top-down view
+ var ortho_size := combined_aabb.size.length() * 1.8
+ var cam_height := maxf(combined_aabb.size.length() * 3.0, 10.0)
+ var center := combined_aabb.get_center()
+ var xform := Transform3D(Basis.IDENTITY, center + Vector3.UP * cam_height)
+ xform = xform.looking_at(center, Vector3.FORWARD)
+ RenderingServer.camera_set_orthogonal(cam_rid, ortho_size, saved_near, maxf(saved_far, cam_height * 2.0))
+ RenderingServer.camera_set_transform(cam_rid, xform)
+ else:
+ ## Perspective view — padding per preset (wide for establishing, tight for detail)
+ var pad: float = preset.get("padding", 2.5)
+ var xform := _frame_transform_for_aabb(combined_aabb, preset.fov, preset.elevation, preset.azimuth, pad)
+ RenderingServer.camera_set_perspective(cam_rid, preset.fov, saved_near, saved_far)
+ RenderingServer.camera_set_transform(cam_rid, xform)
+ RenderingServer.force_draw(false)
+ var img: Image = viewport.get_texture().get_image()
+ if img != null and not img.is_empty():
+ var entry := _finalize_image(img, "viewport", max_resolution)
+ entry.data["label"] = preset.label
+ entry.data["elevation"] = preset.elevation
+ entry.data["azimuth"] = preset.azimuth
+ entry.data["fov"] = preset.fov
+ entry.data["ortho"] = preset.get("ortho", false)
+ images.append(entry.data)
+
+ ## Restore camera state (back to perspective + original transform)
+ RenderingServer.camera_set_perspective(cam_rid, saved_fov, saved_near, saved_far)
+ RenderingServer.camera_set_transform(cam_rid, saved_xform)
+
+ ## Consistent with single-shot path: error if no frames rendered
+ ## (e.g. headless mode where force_draw produces no output).
+ if images.is_empty():
+ return _empty_image_error(
+ "viewport",
+ "Coverage sweep rendered no images. The 3D viewport produced no output across any of the preset angles — typically because the editor is in headless mode (force_draw has no rendered output) or the 3D viewport has not drawn a frame yet."
+ )
+
+ var aabb_center := combined_aabb.get_center()
+ var aabb_size := combined_aabb.size
+ var result_data := {
+ "source": "viewport",
+ "view_target": view_target,
+ "view_target_count": targets.size(),
+ "coverage": true,
+ "images": images,
+ "aabb_center": [aabb_center.x, aabb_center.y, aabb_center.z],
+ "aabb_size": [aabb_size.x, aabb_size.y, aabb_size.z],
+ "aabb_longest_ground_axis": "x" if aabb_size.x >= aabb_size.z else "z",
+ }
+ if not not_found.is_empty():
+ result_data["view_target_not_found"] = not_found
+ return {"data": result_data}
+
+ ## --- Custom angle / FOV path ---
+ var use_elev: float = 25.0 if custom_elevation == null else float(custom_elevation)
+ var use_azim: float = 30.0 if custom_azimuth == null else float(custom_azimuth)
+ var use_fov: float = saved_fov if custom_fov == null else float(custom_fov)
+
+ var cam_xform := _frame_transform_for_aabb(combined_aabb, use_fov, use_elev, use_azim)
+
+ if custom_fov != null:
+ RenderingServer.camera_set_perspective(cam_rid, use_fov, saved_near, saved_far)
+ RenderingServer.camera_set_transform(cam_rid, cam_xform)
+ RenderingServer.force_draw(false)
+
+ var image: Image = viewport.get_texture().get_image()
+
+ ## Restore camera state
+ if custom_fov != null:
+ RenderingServer.camera_set_perspective(cam_rid, saved_fov, saved_near, saved_far)
+ RenderingServer.camera_set_transform(cam_rid, saved_xform)
+
+ if image == null or image.is_empty():
+ return _empty_image_error(
+ "viewport",
+ "Framed viewport rendered an empty image after repositioning the camera onto the view_target. The 3D viewport produced no output — typically headless mode or the 3D viewport has not drawn a frame yet."
+ )
+
+ var result := _finalize_image(image, "viewport", max_resolution)
+ result.data["view_target"] = view_target
+ result.data["view_target_count"] = targets.size()
+ var aabb_c := combined_aabb.get_center()
+ var aabb_s := combined_aabb.size
+ result.data["aabb_center"] = [aabb_c.x, aabb_c.y, aabb_c.z]
+ result.data["aabb_size"] = [aabb_s.x, aabb_s.y, aabb_s.z]
+ result.data["aabb_longest_ground_axis"] = "x" if aabb_s.x >= aabb_s.z else "z"
+ if custom_elevation != null or custom_azimuth != null:
+ result.data["elevation"] = use_elev
+ result.data["azimuth"] = use_azim
+ if custom_fov != null:
+ result.data["fov"] = use_fov
+ if not not_found.is_empty():
+ result.data["view_target_not_found"] = not_found
+ return result
+
+ var image: Image = viewport.get_texture().get_image()
+
+ if image == null or image.is_empty():
+ return _empty_image_error(
+ source,
+ "Captured an empty image from %s. The 3D viewport produced no output — typically headless mode or the 3D viewport has not drawn a frame yet." % source
+ )
+
+ return _finalize_image(image, source, max_resolution)
+
+
+## Render the edited scene through its active Camera3D without running the
+## game. Mirrors Godot's "Cinematic Preview" display mode but via a
+## throwaway SubViewport, so the output has no editor gizmos, selection
+## outlines, or grid lines.
+func _take_cinematic_screenshot(max_resolution: int) -> Dictionary:
+ var _scene_check := McpNodeValidator.require_scene_or_error()
+ if _scene_check.has("error"):
+ return _scene_check
+ var scene_root: Node = _scene_check.scene_root
+
+ var scene_camera := _find_current_camera_3d(scene_root)
+ if scene_camera == null:
+ return ErrorCodes.make(
+ ErrorCodes.NODE_NOT_FOUND,
+ "No current Camera3D in scene — mark a Camera3D as `current` or add one to the scene",
+ )
+
+ ## Default to a 16:9 HD capture; size is overridden by _finalize_image's
+ ## `max_resolution` downscale step when requested.
+ var render_size := Vector2i(1920, 1080)
+ var edit_vp := EditorInterface.get_editor_viewport_3d()
+ if edit_vp != null:
+ var vs := edit_vp.get_visible_rect().size
+ if vs.x >= 1.0 and vs.y >= 1.0:
+ render_size = Vector2i(int(vs.x), int(vs.y))
+
+ var sub_vp := SubViewport.new()
+ sub_vp.size = render_size
+ sub_vp.own_world_3d = false
+ sub_vp.transparent_bg = false
+ sub_vp.render_target_update_mode = SubViewport.UPDATE_ONCE
+
+ var cam := Camera3D.new()
+ cam.fov = scene_camera.fov
+ cam.near = scene_camera.near
+ cam.far = scene_camera.far
+ cam.projection = scene_camera.projection
+ cam.size = scene_camera.size
+ cam.keep_aspect = scene_camera.keep_aspect
+ cam.cull_mask = scene_camera.cull_mask
+ cam.environment = scene_camera.environment
+ cam.attributes = scene_camera.attributes
+ cam.current = true
+
+ sub_vp.add_child(cam)
+ scene_root.add_child(sub_vp)
+ ## global_transform is resolved against the ancestor Node3D chain, so it
+ ## must be set after parenting — otherwise the camera ends up at origin.
+ cam.global_transform = scene_camera.global_transform
+ ## NOTIFICATION_TRANSFORM_CHANGED is delivered deferred (next frame's
+ ## flush_transform_notifications), but force_draw renders immediately —
+ ## without this flush the RenderingServer still has the identity
+ ## transform pushed at ENTER_WORLD and the capture shows only sky
+ ## instead of the camera's actual view (issue #650).
+ cam.force_update_transform()
+
+ RenderingServer.force_draw(false)
+ var image: Image = sub_vp.get_texture().get_image()
+
+ scene_root.remove_child(sub_vp)
+ sub_vp.queue_free()
+
+ if image == null or image.is_empty():
+ return _empty_image_error(
+ "cinematic",
+ "Cinematic render produced an empty image. The SubViewport returned no texture — typically headless mode (force_draw has no rendered output) or the scene's Camera3D is positioned so nothing visible is in frame."
+ )
+
+ var result := _finalize_image(image, "cinematic", max_resolution)
+ result.data["camera_path"] = McpScenePath.from_node(scene_camera, scene_root)
+ return result
+
+
+## Reject a `source="viewport"` screenshot before we ever pull the
+## texture if the edited scene has no Node3D content. The 3D viewport
+## returns an empty (or stale) image in that case; surfacing it as
+## INTERNAL_ERROR ("Failed to capture image from viewport") gave LLM
+## callers no signal that the right move is to switch source or open a
+## 3D scene. 152 hits / 63 uuids in 24h across plugin versions 2.5.0 ->
+## 2.5.6 traced back to this. Returns `{}` on success.
+##
+## Caller passes `EditorInterface.get_edited_scene_root()`; the static
+## form lets tests exercise the branches with a synthetic scene root
+## without driving the editor.
+static func viewport_screenshot_precheck(scene_root: Node) -> Dictionary:
+ if scene_root == null:
+ var no_scene_err := _make_viewport_not_3d_error(
+ "",
+ "The editor 3D viewport is empty because no scene is open. Open a scene with `scene_open` first."
+ )
+ ## The honest state here is "no scene", not "scene lacks 3D content"
+ ## — relabel the sub-code so telemetry doesn't conflate the two.
+ ## `editor_state` stays "viewport_not_3d" for pre-#651 consumers.
+ no_scene_err["error"]["data"]["sub_code"] = ErrorCodes.SUB_EDITOR_NO_SCENE
+ return no_scene_err
+ ## A scene with any Node3D content — root or descendant — has
+ ## something the 3D viewport can render. Walking the tree (rather
+ ## than only checking the root type) avoids a false reject on the
+ ## common `Node` / `Node2D` root + Node3D descendant pattern.
+ if _scene_has_node3d_content(scene_root):
+ return {}
+ var root_type := scene_root.get_class()
+ var hint: String
+ var is_2d_scene := scene_root is CanvasItem
+ if is_2d_scene:
+ hint = (
+ "The 3D viewport is empty because the current scene is 2D (%s root) with no Node3D descendants. "
+ + "Options: (a) open a 3D scene, "
+ + "(b) use source=\"cinematic\" if a Camera3D exists in the scene, "
+ + "(c) use source=\"viewport_2d\" to capture the 2D editor viewport directly, "
+ + "(d) call scene_get_hierarchy first to inspect what's available."
+ ) % root_type
+ else:
+ hint = (
+ "The 3D viewport is empty because the current scene (%s root) has no Node3D content anywhere in the tree. "
+ + "Options: (a) open or add a Node3D, "
+ + "(b) use source=\"cinematic\" if a Camera3D exists in the scene, "
+ + "(c) call scene_get_hierarchy first to inspect what's available."
+ ) % root_type
+ var err := _make_viewport_not_3d_error(root_type, hint)
+ if is_2d_scene:
+ err["error"]["data"]["suggestion"] = "use source='viewport_2d' for 2D scenes"
+ return err
+
+
+## True if scene_root is itself a Node3D or owns any Node3D descendant.
+## DFS short-circuits on the first hit so empty 2D scenes stay cheap.
+static func _scene_has_node3d_content(scene_root: Node) -> bool:
+ if scene_root is Node3D:
+ return true
+ var stack: Array[Node] = [scene_root]
+ while not stack.is_empty():
+ var node: Node = stack.pop_back()
+ for child in node.get_children():
+ if child is Node3D:
+ return true
+ stack.append(child)
+ return false
+
+
+static func _make_viewport_not_3d_error(scene_root_type: String, hint: String) -> Dictionary:
+ ## `hint` becomes `error.message`; not duplicated into `data` because
+ ## `GodotCommandError`'s string form already appends every `data` key
+ ## as a suffix on the agent-visible error.
+ var err := ErrorCodes.make_not_ready(
+ ErrorCodes.SUB_EDITOR_VIEWPORT_NOT_3D, hint, false)
+ err["error"]["data"]["editor_state"] = "viewport_not_3d"
+ err["error"]["data"]["scene_root_type"] = scene_root_type
+ return err
+
+
+## Reached only when the precheck passed but the texture still came
+## back empty — headless rendering, a freshly opened editor whose 3D
+## viewport hasn't drawn a frame, or a SubViewport that lost its target.
+static func _empty_image_error(source: String, hint: String) -> Dictionary:
+ ## retryable=false: an empty capture is usually headless mode, where a
+ ## retry loops forever — the not-yet-drawn-frame case is transient but
+ ## indistinguishable from here, so don't invite a retry loop.
+ var err := ErrorCodes.make_not_ready(
+ ErrorCodes.SUB_EDITOR_VIEWPORT_EMPTY, hint, false)
+ err["error"]["data"]["editor_state"] = "viewport_empty"
+ err["error"]["data"]["source"] = source
+ return err
+
+
+## Return the Camera3D that would be active if the scene were running.
+## Preference: a descendant with `current=true`, else the first Camera3D
+## found in a depth-first walk.
+func _find_current_camera_3d(root: Node) -> Camera3D:
+ var first: Camera3D = null
+ var stack: Array[Node] = [root]
+ while not stack.is_empty():
+ var node: Node = stack.pop_back()
+ if node is Camera3D:
+ if node.current:
+ return node
+ if first == null:
+ first = node
+ for child in node.get_children():
+ stack.append(child)
+ return first
+
+
+func _finalize_image(image: Image, source: String, max_resolution: int) -> Dictionary:
+ ## Shared with the game-process copy in runtime/game_helper.gd (#716).
+ var encoded := McpScreenshotEncode.downscale_and_encode(image, max_resolution)
+ return {
+ "data": {
+ "source": source,
+ "width": encoded.width,
+ "height": encoded.height,
+ "original_width": encoded.original_width,
+ "original_height": encoded.original_height,
+ "format": "png",
+ "image_base64": encoded.base64,
+ }
+ }
+
+
+## Recursively compute the visual bounding box of a Node3D and its children.
+func _get_visual_aabb(node: Node3D) -> AABB:
+ var aabb := AABB()
+ var found := false
+ if node is VisualInstance3D:
+ aabb = node.global_transform * node.get_aabb()
+ found = true
+ for child in node.get_children():
+ if child is Node3D:
+ var child_aabb := _get_visual_aabb(child)
+ if child_aabb.size != Vector3.ZERO:
+ if found:
+ aabb = aabb.merge(child_aabb)
+ else:
+ aabb = child_aabb
+ found = true
+ if not found:
+ aabb = AABB(node.global_position - Vector3(0.5, 0.5, 0.5), Vector3(1, 1, 1))
+ return aabb
+
+
+## Calculate a camera Transform3D that frames the given AABB nicely.
+## elevation_deg: camera elevation (0 = level, 90 = directly above). Default 25.
+## azimuth_deg: camera azimuth (0 = front, 90 = right side). Default 30.
+## padding: distance multiplier for breathing room (1.2 = tight, 2.5 = context). Default 1.8.
+func _frame_transform_for_aabb(aabb: AABB, fov_degrees: float = 75.0, elevation_deg: float = 25.0, azimuth_deg: float = 30.0, padding: float = 1.8) -> Transform3D:
+ var center := aabb.get_center()
+ var radius := aabb.size.length() * 0.5
+ var fov_rad := deg_to_rad(fov_degrees)
+ var distance := radius / tan(fov_rad * 0.5) * padding
+ ## Floor with an absolute offset so unit-scale AABBs don't place the camera
+ ## inside or against the target. `radius * 2.0` alone scales to zero as the
+ ## AABB shrinks; the +1.0 guarantees a minimum of ~1 world-unit of standoff.
+ distance = maxf(distance, radius * 2.0 + 1.0)
+ var elev := deg_to_rad(elevation_deg)
+ var azim := deg_to_rad(azimuth_deg)
+ var cam_pos := center + Vector3(
+ distance * cos(elev) * sin(azim),
+ distance * sin(elev),
+ distance * cos(elev) * cos(azim),
+ )
+ var xform := Transform3D(Basis.IDENTITY, cam_pos)
+ ## At ~90° elevation the view direction is parallel to Vector3.UP — use
+ ## FORWARD as the up hint so looking_at doesn't degenerate.
+ var up := Vector3.FORWARD if elevation_deg > 85.0 else Vector3.UP
+ return xform.looking_at(center, up)
+
+
+func get_performance_monitors(params: Dictionary) -> Dictionary:
+ var filter: Array = params.get("monitors", [])
+ var result := {}
+
+ if filter.is_empty():
+ for key in MONITORS:
+ result[key] = Performance.get_monitor(MONITORS[key])
+ else:
+ for key in filter:
+ if MONITORS.has(key):
+ result[key] = Performance.get_monitor(MONITORS[key])
+
+ return {
+ "data": {
+ "monitors": result,
+ "monitor_count": result.size(),
+ }
+ }
+
+
+func clear_logs(params: Dictionary) -> Dictionary:
+ var count := _log_buffer.total_count()
+ _log_buffer.clear()
+ var data := {"cleared_count": count}
+ ## The Debugger Errors panel is user-visible editor UI, not an MCP-owned
+ ## buffer — wiping it stays behind an explicit opt-in.
+ if bool(params.get("clear_debugger_errors", false)):
+ data["debugger_errors_cleared"] = _clear_debugger_error_trees()
+ return {"data": data}
+
+
+func _clear_debugger_error_trees() -> int:
+ return _surfaced_error_tracker.clear_debugger_error_trees()
+
+
+func reload_plugin(_params: Dictionary) -> Dictionary:
+ var work := PluginReload.reserve_reload()
+ if work == 0:
+ return ErrorCodes.make(ErrorCodes.EDITOR_NOT_READY, "A plugin reload is already pending.")
+ _log_buffer.log("reload_plugin requested, reloading next frame")
+ ## Persist a pending plugin_reload telemetry event *before* the
+ ## disable kills the live WebSocket. The re-enabled plugin's
+ ## _enter_tree flushes via `_telemetry.flush_pending_plugin_reload()`.
+ Telemetry.record_pending_plugin_reload("mcp_tool")
+ _do_reload_plugin.call_deferred(work)
+ return {"data": {"status": "reloading", "message": "Plugin reload initiated"}}
+
+
+## Force a filesystem rescan before toggling the plugin, so Godot's
+## class-name registry picks up any .gd files added since the last scan
+## (e.g. via git pull or an agent-driven sync). Without this, re-enable can
+## fail with "Could not find type X" when new class_name scripts are on disk
+## but not yet registered, leaving the plugin disabled with no recovery path
+## short of killing the editor. See issue #83.
+# The deferred entry captures no handler. The helper owns the bounded native
+# signal handoff: is_scanning() can become false before resource reloads and
+# filesystem_changed run on the main thread. No suspended GDScript frame may
+# span a scan which can recompile that very frame's script.
+static func _do_reload_plugin(work: int = 0) -> void:
+ PluginReload.reload_after_scan(work)
+
+
+func quit_editor(_params: Dictionary) -> Dictionary:
+ _log_buffer.log("quit_editor requested, quitting next frame")
+ ## Defer the quit so the response is sent back before the editor exits.
+ EditorInterface.get_base_control().get_tree().call_deferred("quit")
+ return {"data": {"status": "quitting", "message": "Editor quit initiated"}}
+
+
+func game_eval(params: Dictionary) -> Dictionary:
+ var code: String = params.get("code", "")
+ if code.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "code is required")
+
+ if _debugger_plugin == null or _connection == null:
+ return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR,
+ "Debugger bridge unavailable — plugin may not be fully initialised")
+
+ if not EditorInterface.is_playing_scene():
+ return ErrorCodes.make_not_ready(
+ ErrorCodes.SUB_EDITOR_GAME_NOT_RUNNING,
+ "Game is not running — start the project first", false,
+ "Start the game with project_run (or wait for the user to run it), then retry.")
+
+ var request_id: String = params.get("_request_id", "")
+ if request_id.is_empty():
+ return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR,
+ "Missing request_id — cannot correlate deferred response")
+
+ _debugger_plugin.request_game_eval(code, request_id, _connection)
+ return McpDispatcher.DEFERRED_RESPONSE
+
+
+func game_debug_control(params: Dictionary) -> Dictionary:
+ var action := str(params.get("action", ""))
+ if action not in ["suspend", "resume", "next_frame", "debug_status"]:
+ return ErrorCodes.make(
+ ErrorCodes.VALUE_OUT_OF_RANGE,
+ "Invalid game debug action '%s' — use suspend, resume, next_frame, or debug_status" % action,
+ )
+ if _debugger_plugin == null or _connection == null:
+ return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR,
+ "Debugger bridge unavailable — plugin may not be fully initialised")
+ if not EditorInterface.is_playing_scene():
+ return ErrorCodes.make_not_ready(
+ ErrorCodes.SUB_EDITOR_GAME_NOT_RUNNING,
+ "Game is not running — start the project first", false,
+ "Start the game with project_run (or wait for the user to run it), then retry.")
+ var request_id := str(params.get("_request_id", ""))
+ if request_id.is_empty():
+ return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR,
+ "Missing internal _request_id — cannot correlate deferred response")
+ _debugger_plugin.request_game_debug_control(action, request_id, _connection)
+ return McpDispatcher.DEFERRED_RESPONSE
+
+
+func game_command(params: Dictionary) -> Dictionary:
+ var op: String = str(params.get("op", ""))
+ if op.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "op is required")
+
+ if _debugger_plugin == null or _connection == null:
+ return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR,
+ "Debugger bridge unavailable — plugin may not be fully initialised")
+
+ if not EditorInterface.is_playing_scene():
+ return ErrorCodes.make_not_ready(
+ ErrorCodes.SUB_EDITOR_GAME_NOT_RUNNING,
+ "Game is not running — start the project first", false,
+ "Start the game with project_run (or wait for the user to run it), then retry.")
+
+ var request_id: String = params.get("_request_id", "")
+ if request_id.is_empty():
+ return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR,
+ "Missing request_id — cannot correlate deferred response")
+
+ var command_params: Dictionary = params.get("params", {})
+
+ ## input_sequence steps the game forward frame-by-frame in one call, so it
+ ## needs a far larger budget than the one-shot game ops that share the
+ ## `game_command` deferred entry (15s). Widen both timers only for it: the
+ ## debugger-side pending timer (below) and the dispatcher-side deferred
+ ## budget (via the sentinel's `_deferred_timeout_ms`). Every other op keeps
+ ## request_game_command's tight default.
+ if op == "input_sequence":
+ _debugger_plugin.request_game_command(
+ op, command_params, request_id, _connection, INPUT_SEQUENCE_TIMEOUT_SEC
+ )
+ return {
+ "_deferred": true,
+ "_deferred_timeout_ms": int(INPUT_SEQUENCE_TIMEOUT_SEC * 1000.0),
+ }
+
+ _debugger_plugin.request_game_command(op, command_params, request_id, _connection)
+ return McpDispatcher.DEFERRED_RESPONSE
diff --git a/addons/godot_ai/handlers/editor_handler.gd.uid b/addons/godot_ai/handlers/editor_handler.gd.uid
new file mode 100644
index 0000000..16d785f
--- /dev/null
+++ b/addons/godot_ai/handlers/editor_handler.gd.uid
@@ -0,0 +1 @@
+uid://dcro7yc8bor6v
diff --git a/addons/godot_ai/handlers/environment_handler.gd b/addons/godot_ai/handlers/environment_handler.gd
new file mode 100644
index 0000000..3722c3e
--- /dev/null
+++ b/addons/godot_ai/handlers/environment_handler.gd
@@ -0,0 +1,181 @@
+@tool
+extends "res://addons/godot_ai/handlers/command_handler.gd"
+
+const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
+
+## Creates an Environment (+ optional Sky + ProceduralSkyMaterial) chain and
+## either assigns it to a WorldEnvironment node or saves it to a .tres file.
+## Bundles sub-resource creation + assignment in a single undo action.
+
+const ResourceHandler := preload("res://addons/godot_ai/handlers/resource_handler.gd")
+
+var _undo_redo: EditorUndoRedoManager
+var _connection: McpConnection
+
+
+func _init(undo_redo: EditorUndoRedoManager, connection: McpConnection = null) -> void:
+ _undo_redo = undo_redo
+ _connection = connection
+
+
+const _PRESETS := {
+ "default": {"sky": true, "fog": false},
+ "clear": {"sky": true, "fog": false},
+ "sunset": {"sky": true, "fog": false},
+ "night": {"sky": true, "fog": false},
+ "fog": {"sky": true, "fog": true},
+}
+
+
+func create_environment(params: Dictionary) -> Dictionary:
+ var node_path: String = params.get("path", "")
+ var resource_path: String = params.get("resource_path", "")
+ var overwrite: bool = params.get("overwrite", false)
+ var preset: String = params.get("preset", "default")
+ var properties: Dictionary = params.get("properties", {})
+ var sky_param = params.get("sky", null) # nullable — falls back to preset default
+
+ # environment_create targets the whole WorldEnvironment node (no separate
+ # `property` param) — pass require_property=false.
+ var home_err := McpResourceIO.validate_home(params, false)
+ if home_err != null:
+ return home_err
+
+ if not _PRESETS.has(preset):
+ return ErrorCodes.make(
+ ErrorCodes.VALUE_OUT_OF_RANGE,
+ "Invalid preset '%s'. Valid: %s" % [preset, ", ".join(_PRESETS.keys())]
+ )
+
+ var preset_config: Dictionary = _PRESETS[preset]
+ var want_sky: bool = preset_config.sky
+ var sky_properties: Dictionary = {}
+ if sky_param != null:
+ if sky_param is bool:
+ want_sky = sky_param
+ elif sky_param is Dictionary:
+ var sky_config: Dictionary = (sky_param as Dictionary).duplicate()
+ var material_type: String = String(sky_config.get("sky_material", "procedural")).to_lower()
+ if material_type != "procedural":
+ return ErrorCodes.make(
+ ErrorCodes.INVALID_PARAMS,
+ "sky.sky_material must be 'procedural' when sky is a dictionary"
+ )
+ sky_config.erase("sky_material")
+ sky_properties = sky_config
+ want_sky = true
+ else:
+ return ErrorCodes.make(
+ ErrorCodes.WRONG_TYPE,
+ "sky must be a bool, null, or dictionary of ProceduralSkyMaterial properties"
+ )
+
+ var env := Environment.new()
+ var sky: Sky = null
+ var sky_material: ProceduralSkyMaterial = null
+ if want_sky:
+ sky_material = ProceduralSkyMaterial.new()
+ sky = Sky.new()
+ sky.sky_material = sky_material
+ env.background_mode = Environment.BG_SKY
+ env.sky = sky
+ else:
+ env.background_mode = Environment.BG_CLEAR_COLOR
+
+ _apply_preset(env, sky_material, preset)
+ if not sky_properties.is_empty():
+ var sky_apply_err := ResourceHandler._apply_resource_properties(sky_material, sky_properties)
+ if sky_apply_err != null:
+ return sky_apply_err
+ if preset_config.fog:
+ env.volumetric_fog_enabled = true
+ env.volumetric_fog_density = 0.03
+
+ if not properties.is_empty():
+ var apply_err := ResourceHandler._apply_resource_properties(env, properties)
+ if apply_err != null:
+ return apply_err
+
+ if not resource_path.is_empty():
+ return _save_environment(env, sky, sky_material, resource_path, overwrite, preset)
+ return _assign_environment(env, sky, sky_material, node_path, preset)
+
+
+static func _apply_preset(env: Environment, sky_material: ProceduralSkyMaterial, preset: String) -> void:
+ match preset:
+ "default", "clear":
+ if sky_material != null:
+ sky_material.sky_top_color = Color(0.38, 0.45, 0.55)
+ sky_material.sky_horizon_color = Color(0.65, 0.67, 0.7)
+ sky_material.ground_horizon_color = Color(0.65, 0.67, 0.7)
+ sky_material.ground_bottom_color = Color(0.2, 0.17, 0.13)
+ sky_material.sun_angle_max = 30.0
+ env.ambient_light_source = Environment.AMBIENT_SOURCE_SKY
+ env.ambient_light_energy = 1.0
+ "sunset":
+ if sky_material != null:
+ sky_material.sky_top_color = Color(0.25, 0.3, 0.55)
+ sky_material.sky_horizon_color = Color(1.0, 0.55, 0.3)
+ sky_material.ground_horizon_color = Color(0.85, 0.4, 0.25)
+ sky_material.ground_bottom_color = Color(0.2, 0.12, 0.1)
+ env.ambient_light_source = Environment.AMBIENT_SOURCE_SKY
+ env.ambient_light_color = Color(1.0, 0.75, 0.55)
+ env.ambient_light_energy = 0.8
+ "night":
+ if sky_material != null:
+ sky_material.sky_top_color = Color(0.02, 0.02, 0.07)
+ sky_material.sky_horizon_color = Color(0.05, 0.07, 0.15)
+ sky_material.ground_horizon_color = Color(0.04, 0.05, 0.1)
+ sky_material.ground_bottom_color = Color(0.0, 0.0, 0.02)
+ env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
+ env.ambient_light_color = Color(0.2, 0.22, 0.35)
+ env.ambient_light_energy = 0.4
+ "fog":
+ if sky_material != null:
+ sky_material.sky_top_color = Color(0.65, 0.65, 0.7)
+ sky_material.sky_horizon_color = Color(0.8, 0.8, 0.82)
+ sky_material.ground_horizon_color = Color(0.7, 0.7, 0.72)
+ sky_material.ground_bottom_color = Color(0.3, 0.3, 0.32)
+ env.ambient_light_source = Environment.AMBIENT_SOURCE_SKY
+ env.ambient_light_energy = 0.7
+
+
+func _assign_environment(env: Environment, sky: Sky, sky_material: ProceduralSkyMaterial, node_path: String, preset: String) -> Dictionary:
+ var _resolved := McpNodeValidator.resolve_or_error(node_path, "node_path")
+ if _resolved.has("error"):
+ return _resolved
+ var node: Node = _resolved.node
+ var _scene_root: Node = _resolved.scene_root
+ if not (node is WorldEnvironment):
+ return ErrorCodes.make(
+ ErrorCodes.WRONG_TYPE,
+ "Node at %s is %s — must be WorldEnvironment" % [node_path, node.get_class()]
+ )
+
+ var old_env = (node as WorldEnvironment).environment
+
+ _undo_redo.create_action("MCP: Create Environment (%s) for %s" % [preset, node.name])
+ _undo_redo.add_do_property(node, "environment", env)
+ _undo_redo.add_undo_property(node, "environment", old_env)
+ _undo_redo.add_do_reference(env)
+ if sky != null:
+ _undo_redo.add_do_reference(sky)
+ if sky_material != null:
+ _undo_redo.add_do_reference(sky_material)
+ _undo_redo.commit_action()
+
+ return {
+ "data": {
+ "path": node_path,
+ "preset": preset,
+ "sky_created": sky != null,
+ "sky_material_class": sky_material.get_class() if sky_material != null else "",
+ "undoable": true,
+ }
+ }
+
+
+func _save_environment(env: Environment, _sky: Sky, _sky_material: ProceduralSkyMaterial, resource_path: String, overwrite: bool, preset: String) -> Dictionary:
+ return McpResourceIO.save_to_disk(env, resource_path, overwrite, "Environment", {
+ "preset": preset,
+ }, _connection)
diff --git a/addons/godot_ai/handlers/environment_handler.gd.uid b/addons/godot_ai/handlers/environment_handler.gd.uid
new file mode 100644
index 0000000..f495f8f
--- /dev/null
+++ b/addons/godot_ai/handlers/environment_handler.gd.uid
@@ -0,0 +1 @@
+uid://b1k7jldwjp5jt
diff --git a/addons/godot_ai/handlers/filesystem_handler.gd b/addons/godot_ai/handlers/filesystem_handler.gd
new file mode 100644
index 0000000..16c46f5
--- /dev/null
+++ b/addons/godot_ai/handlers/filesystem_handler.gd
@@ -0,0 +1,320 @@
+@tool
+extends "res://addons/godot_ai/handlers/command_handler.gd"
+
+const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
+const ScriptHandler := preload("res://addons/godot_ai/handlers/script_handler.gd")
+
+## Handles file read/write operations and reimport within the Godot project.
+
+## Bounds for the deferred scan wait. `write_file`/`reimport` register single
+## files with `update_file()` (cheap, no global-class rebuild); `scan_filesystem`
+## is the heavier, explicit "rebuild the class registry" path agents call after
+## adding `class_name` scripts headlessly (no window focus to trigger it).
+## Kept under the dispatcher's "scan_filesystem" deferred timeout (30s) so we
+## always send a real reply before a DEFERRED_TIMEOUT is synthesised.
+const _SCAN_START_GRACE_MSEC := 750
+const _SCAN_SETTLE_MAX_MSEC := 28000
+
+## Sidecar the editor writes next to every imported resource. `reimport` reads
+## it to tell imported assets from files that merely have a filesystem entry
+## (see `_is_imported_resource`).
+const IMPORT_SIDECAR_SUFFIX := ".import"
+
+## Shared single-flight latch for scan_filesystem. `is_scanning()` alone can't
+## enforce single-flight: `EditorFileSystem.scan()` doesn't flip `is_scanning()`
+## for a frame or two (hence _SCAN_START_GRACE_MSEC), so a second request landing
+## in that window would observe `false` and stack another scan() — the exact
+## stacked-worker SIGABRT this op exists to avoid (dsarno/godot#6). The latch is
+## set before the first scan() and cleared when its settle coroutine finishes;
+## concurrent requests coalesce onto the running scan instead of starting one.
+## `static` so it's shared across handler instances; it resets on plugin reload
+## (script re-parse), which self-heals any latch orphaned by a mid-await teardown.
+static var _scan_in_flight := false
+
+var _connection: McpConnection
+
+
+func _init(connection: McpConnection = null) -> void:
+ _connection = connection
+
+
+func read_file(params: Dictionary) -> Dictionary:
+ var path: String = params.get("path", "")
+
+ var path_err = McpPathValidator.path_error(path, "path")
+ if path_err != null:
+ return path_err
+
+ if not FileAccess.file_exists(path):
+ return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "File not found: %s" % path)
+
+ var file := FileAccess.open(path, FileAccess.READ)
+ if file == null:
+ return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to open file: %s" % path)
+
+ var content := file.get_as_text()
+ file.close()
+
+ return {
+ "data": {
+ "path": path,
+ "content": content,
+ "size": content.length(),
+ "line_count": content.count("\n") + (1 if not content.is_empty() else 0),
+ }
+ }
+
+
+func write_file(params: Dictionary) -> Dictionary:
+ var path: String = params.get("path", "")
+ var content: String = params.get("content", "")
+
+ var path_err = McpPathValidator.path_error(path, "path", true)
+ if path_err != null:
+ return path_err
+
+ var existed_before := FileAccess.file_exists(path)
+
+ # Shared write path (#714): parent mkdir + write/flush + explicit error
+ # check live on McpResourceIO so this can't drift from create_script.
+ var write_failure: Variant = McpResourceIO.write_text_to_disk(path, content)
+ if write_failure != null:
+ return write_failure
+
+ # Single-file register, not a full scan() — a scan() per write stacks
+ # filesystem WorkerThreadPool tasks under concurrent writes and can SIGABRT
+ # in the global-class update (see dsarno/godot#6 and create_script in
+ # script_handler.gd). update_file() is what reimport()/material/theme use.
+ var efs := EditorInterface.get_resource_filesystem()
+ if efs != null:
+ efs.update_file(path)
+
+ var data := {
+ "path": path,
+ "size": content.length(),
+ "undoable": false,
+ "reason": "File system operations cannot be undone via editor undo",
+ }
+ var is_gdscript := path.ends_with(".gd")
+ ## A .gd written through the filesystem tool used to skip the parse
+ ## diagnostics create_script attaches (#714) — the agent's broken
+ ## script reported plain success and the parse error surfaced only in
+ ## later editor logs. Same shared check, same response fields. A bare
+ ## ScriptHandler works here: the diagnostics path touches no instance
+ ## state (it stays an instance method only for test stubbing).
+ if is_gdscript:
+ ScriptHandler.new(null)._attach_gdscript_diagnostics(data, path, content)
+ data["committed"] = true
+ data["import_settled"] = existed_before
+ data["import_settle"] = "already_known" if existed_before else "not_waited"
+ McpResourceIO.attach_cleanup_hint(data, existed_before, [path])
+
+ ## Fresh `.gd` writes take create_script's import-settle deferral (#714,
+ ## #261): reply only once ResourceLoader can see the new resource (or the
+ ## bounded window elapses), so write_file -> script_attach back-to-back
+ ## can't 404 on the not-yet-imported script. This CHANGES write_file's
+ ## response timing for that case — the reply lands up to
+ ## McpResourceIO.IMPORT_SETTLE_MAX_MSEC later instead of immediately.
+ ## Scoped to .gd: ResourceLoader never learns plain text files, so an
+ ## unconditional wait would burn the full window on every fresh .txt.
+ ## Overwrites, batch_execute (no request_id) and unit-test contexts (no
+ ## connection) keep the synchronous reply.
+ var request_id: String = params.get("_request_id", "")
+ if is_gdscript and not existed_before and _connection != null and not request_id.is_empty():
+ McpResourceIO.finish_text_write_deferred(_connection, request_id, path, data)
+ return McpDispatcher.DEFERRED_RESPONSE
+
+ return {"data": data}
+
+
+func reimport(params: Dictionary) -> Dictionary:
+ var paths: Array = params.get("paths", [])
+
+ if paths.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: paths (non-empty array)")
+
+ var efs := EditorInterface.get_resource_filesystem()
+ if efs == null:
+ return ErrorCodes.make_not_ready(
+ ErrorCodes.SUB_EDITOR_UNAVAILABLE,
+ "EditorFileSystem not available", false)
+
+ var reimported: Array[String] = []
+ var skipped_non_imported: Array[String] = []
+ var not_found: Array[String] = []
+
+ for path_variant in paths:
+ var path: String = str(path_variant)
+ var path_err := McpPathValidator.validate_resource_path(path)
+ if not path_err.is_empty():
+ not_found.append("%s (%s)" % [path, path_err])
+ continue
+ if not FileAccess.file_exists(path):
+ not_found.append("%s (file does not exist)" % path)
+ continue
+ efs.update_file(path)
+ if _is_imported_resource(path):
+ reimported.append(path)
+ else:
+ skipped_non_imported.append(path)
+
+ var data := {
+ "reimported": reimported,
+ "skipped_non_imported": skipped_non_imported,
+ "not_found": not_found,
+ "reimported_count": reimported.size(),
+ "skipped_non_imported_count": skipped_non_imported.size(),
+ "not_found_count": not_found.size(),
+ "undoable": false,
+ "reason": "Reimport is a file system operation",
+ }
+ ## Only when it applies: a hint on every call would cost tokens on the
+ ## all-assets path this op is actually for.
+ if not skipped_non_imported.is_empty():
+ data["skipped_non_imported_hint"] = (
+ "%d path(s) are not imported resources. Their editor filesystem entry was "
+ + "refreshed, but no import ran — a success here is not evidence that a "
+ + "script parsed or that diagnostics were produced. Use script_patch/"
+ + "script_create for GDScript diagnostics, or filesystem_manage(op=\"scan\") "
+ + "for an asset the editor has not imported yet."
+ ) % skipped_non_imported.size()
+ return {"data": data}
+
+
+## #778: `update_file()` registers a path with the resource pipeline; it only
+## runs an *import* for files that have one. Scripts, scenes and hand-written
+## `.tres` are not imported resources, so listing them under `reimported` reads
+## as proof that a parse or import ran when nothing did.
+##
+## The `.import` sidecar is the editor's own record that a path goes through
+## the import pipeline, so it decides the split. An extension allow-list was
+## rejected: importers come and go with plugins, so the list would drift out of
+## agreement with the editor it claims to describe.
+##
+## Known edge: an asset the editor has never imported (just written, no scan
+## yet) has no sidecar and reports as non-imported. That is accurate at the
+## moment of the call — `update_file()` did not import it either — and the
+## hint names `scan` as the way through.
+##
+## Behaviour is unchanged for every path: `update_file()` still runs on all of
+## them, because refreshing an externally-edited `.tscn`/`.tres` is a real use
+## of this op. This splits the report, not the work.
+static func _is_imported_resource(path: String) -> bool:
+ if path.ends_with(IMPORT_SIDECAR_SUFFIX):
+ return false ## The sidecar itself is not an imported resource.
+ return FileAccess.file_exists(path + IMPORT_SIDECAR_SUFFIX)
+
+
+## Force a full EditorFileSystem scan and wait for it to settle. This is the
+## headless equivalent of the editor regaining window focus: `update_file()`
+## (used by write_file/reimport/script_create) registers a single file with the
+## resource pipeline but does NOT rebuild the global `class_name` table, so a
+## freshly-created `class_name MyThing extends Resource` stays invisible to
+## `ClassDB`/`ProjectSettings.get_global_class_list()` until a scan runs. Agents
+## driving the editor without focus call this once after a batch of script
+## creates to make new types instantiable/referenceable. See issue #83.
+func scan_filesystem(params: Dictionary) -> Dictionary:
+ var efs := EditorInterface.get_resource_filesystem()
+ if efs == null:
+ return ErrorCodes.make_not_ready(
+ ErrorCodes.SUB_EDITOR_UNAVAILABLE,
+ "EditorFileSystem not available", false)
+
+ var request_id: String = params.get("_request_id", "")
+ # Async path: a scan can't be awaited on the calling frame without freezing
+ # the editor, so hand control back to the dispatcher (DEFERRED_RESPONSE) and
+ # push the real reply from a static coroutine once the scan settles — by
+ # which point new class_names are registered.
+ if _connection != null and not request_id.is_empty():
+ _finish_scan_deferred(_connection, request_id, efs)
+ return McpDispatcher.DEFERRED_RESPONSE
+
+ # Synchronous fallback: batch_execute (no request_id) and unit-test contexts
+ # (no connection) can't await, so kick a single-flight scan and return
+ # immediately without the settle confirmation. Respect the latch so we don't
+ # stack onto a deferred scan; don't set it (there's no coroutine here to
+ # clear it — the brief is_scanning() window covers the rest).
+ var already := _scan_in_flight or efs.is_scanning()
+ if not already:
+ efs.scan()
+ return {
+ "data": {
+ "scan_completed": false,
+ "scan_settle": "not_waited",
+ "was_already_scanning": already,
+ "global_class_count": ProjectSettings.get_global_class_list().size(),
+ # Present in both paths for a consistent response shape; the sync
+ # path doesn't await, so it can't measure a delta.
+ "global_classes_registered_delta": 0,
+ "undoable": false,
+ "reason": "Filesystem scan is an editor operation",
+ }
+ }
+
+
+## `static` is load-bearing for the same reason as ScriptHandler's deferred
+## finish: the coroutine must outlive the handler RefCounted, which can be freed
+## mid-await (e.g. an editor_reload_plugin fired during the scan). Parameterise
+## everything; reference no instance state.
+static func _finish_scan_deferred(
+ connection: McpConnection,
+ request_id: String,
+ efs: EditorFileSystem,
+) -> void:
+ var work := ScriptWork.begin("scan_filesystem")
+ await _settle_scan(connection, request_id, efs)
+ ScriptWork.finish(work)
+
+
+static func _settle_scan(
+ connection: McpConnection, request_id: String, efs: EditorFileSystem,
+) -> void:
+ if not is_instance_valid(connection):
+ return
+ var tree := connection.get_tree()
+ if tree == null:
+ return
+ var classes_before := ProjectSettings.get_global_class_list().size()
+ # Single-flight via the shared `_scan_in_flight` latch (NOT is_scanning(),
+ # which lags scan() by a frame or two — see the latch declaration). Only the
+ # request that sets the latch calls scan(); concurrent requests coalesce and
+ # just await the running scan. This is what actually prevents the stacked
+ # scan() SIGABRT (dsarno/godot#6), even within the start-grace window.
+ var was_already_scanning := _scan_in_flight or efs.is_scanning()
+ var we_started := not was_already_scanning
+ if we_started:
+ _scan_in_flight = true
+ efs.scan()
+ # Hand back a frame so _dispatch() registers this request as deferred before
+ # the coroutine can push a reply (mirrors McpResourceIO.finish_text_write_deferred).
+ await tree.process_frame
+ var deadline_ms := Time.get_ticks_msec() + _SCAN_SETTLE_MAX_MSEC
+ var start_grace_ms := Time.get_ticks_msec() + _SCAN_START_GRACE_MSEC
+ var saw_scanning := efs.is_scanning()
+ while Time.get_ticks_msec() < deadline_ms:
+ if efs.is_scanning():
+ saw_scanning = true
+ elif saw_scanning or Time.get_ticks_msec() > start_grace_ms:
+ # Either the scan ran and finished, or it never flipped is_scanning()
+ # within the grace window (a no-op scan because nothing changed).
+ break
+ await tree.process_frame
+ # Clear the latch in all paths (no try/finally in GDScript): do it before the
+ # is_instance_valid early-return so a freed connection can't orphan it.
+ if we_started:
+ _scan_in_flight = false
+ if not is_instance_valid(connection):
+ return
+ var completed := not efs.is_scanning()
+ var classes_after := ProjectSettings.get_global_class_list().size()
+ connection.send_deferred_response(request_id, {
+ "data": {
+ "scan_completed": completed,
+ "scan_settle": "settled" if completed else "timeout",
+ "was_already_scanning": was_already_scanning,
+ "global_class_count": classes_after,
+ "global_classes_registered_delta": classes_after - classes_before,
+ "undoable": false,
+ "reason": "Filesystem scan is an editor operation",
+ }
+ })
diff --git a/addons/godot_ai/handlers/filesystem_handler.gd.uid b/addons/godot_ai/handlers/filesystem_handler.gd.uid
new file mode 100644
index 0000000..94135a4
--- /dev/null
+++ b/addons/godot_ai/handlers/filesystem_handler.gd.uid
@@ -0,0 +1 @@
+uid://c7ovtpdiumtju
diff --git a/addons/godot_ai/handlers/gridmap_handler.gd b/addons/godot_ai/handlers/gridmap_handler.gd
new file mode 100644
index 0000000..8d8544e
--- /dev/null
+++ b/addons/godot_ai/handlers/gridmap_handler.gd
@@ -0,0 +1,192 @@
+@tool
+extends "res://addons/godot_ai/handlers/command_handler.gd"
+
+## GridMap authoring — set, fill, clear, and read 3D cells plus mesh-library
+## items directly in the editor scene with full undo/redo support.
+##
+## All ops target GridMap nodes in the currently edited scene by
+## scene-relative path (e.g. "/Main/Terrain"). All write ops are undoable
+## via EditorUndoRedoManager.
+
+const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
+const MAX_FILL_CELLS := 4096
+
+var _undo_redo: EditorUndoRedoManager
+
+
+func _init(undo_redo: EditorUndoRedoManager) -> void:
+ _undo_redo = undo_redo
+
+
+## Set a single cell item. item = -1 erases the cell.
+## params: {path, item, map_x, map_y, map_z, orientation=0}
+## Returns: {map_x, map_y, map_z, item, orientation, undoable}
+func set_item(params: Dictionary) -> Dictionary:
+ var gm := _resolve_gridmap(params)
+ if gm.has("error"): return gm
+ var node: GridMap = gm.node
+ var pos := Vector3i(int(params.get("map_x", 0)), int(params.get("map_y", 0)), int(params.get("map_z", 0)))
+ var item := int(params.get("item", 0))
+ var orientation := int(params.get("orientation", 0))
+ if orientation < 0 or orientation > 24:
+ return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
+ "orientation must be in 0..24 (GridMap baked rotations), got %d" % orientation)
+ var prev := _capture_cell_state(node, pos)
+ _undo_redo.create_action("MCP: GridMap set_item")
+ _undo_redo.add_do_method(node, "set_cell_item", pos, item, orientation)
+ _undo_redo.add_undo_method(self, "_restore_cell_state", node, pos, prev)
+ _undo_redo.commit_action()
+ return {"data": {"map_x": pos.x, "map_y": pos.y, "map_z": pos.z,
+ "item": item, "orientation": orientation, "undoable": true}}
+
+
+## Fill a box region with one item in a single undo action.
+## params: {path, item, rect_x, rect_y, rect_z, rect_w, rect_h, rect_d, orientation=0}
+## Returns: {cells_filled, rect: {x, y, z, w, h, d}}
+func fill(params: Dictionary) -> Dictionary:
+ var gm := _resolve_gridmap(params)
+ if gm.has("error"): return gm
+ var node: GridMap = gm.node
+ var item := int(params.get("item", 0))
+ var orientation := int(params.get("orientation", 0))
+ if orientation < 0 or orientation > 24:
+ return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
+ "orientation must be in 0..24 (GridMap baked rotations), got %d" % orientation)
+ var rx := int(params.get("rect_x", 0)); var ry := int(params.get("rect_y", 0)); var rz := int(params.get("rect_z", 0))
+ var rw := int(params.get("rect_w", 1)); var rh := int(params.get("rect_h", 1)); var rd := int(params.get("rect_d", 1))
+ if rw <= 0 or rh <= 0 or rd <= 0:
+ return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
+ "rect_w, rect_h and rect_d must be > 0 (got %d x %d x %d)" % [rw, rh, rd])
+ var cell_count := rw * rh * rd
+ if cell_count > MAX_FILL_CELLS:
+ return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
+ "Region too large: %d cells exceeds max %d" % [cell_count, MAX_FILL_CELLS])
+ var snapshot: Array[Dictionary] = []
+ for x in range(rx, rx + rw):
+ for y in range(ry, ry + rh):
+ for z in range(rz, rz + rd):
+ var pos := Vector3i(x, y, z)
+ snapshot.append({"pos": pos, "state": _capture_cell_state(node, pos)})
+ _undo_redo.create_action("MCP: GridMap fill %dx%dx%d" % [rw, rh, rd])
+ ## First callback targets the node so the action lands in the scene undo
+ ## history (first-target routing); _apply_fill batches the rest of the
+ ## region into a single history entry instead of one per cell.
+ _undo_redo.add_do_method(node, "set_cell_item", snapshot[0].pos, item, orientation)
+ _undo_redo.add_do_method(self, "_apply_fill", node, snapshot, item, orientation)
+ _undo_redo.add_undo_method(self, "_restore_rect_snapshot", node, snapshot)
+ _undo_redo.commit_action()
+ return {"data": {"cells_filled": snapshot.size(),
+ "rect": {"x": rx, "y": ry, "z": rz, "w": rw, "h": rh, "d": rd}, "undoable": true}}
+
+
+## Remove all cells from the GridMap.
+## params: {path}
+## Returns: {cleared: true}
+func clear_layer(params: Dictionary) -> Dictionary:
+ var gm := _resolve_gridmap(params)
+ if gm.has("error"): return gm
+ var node: GridMap = gm.node
+ var used := node.get_used_cells()
+ if used.size() > MAX_FILL_CELLS:
+ return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
+ "GridMap has %d cells, exceeds max %d for undoable clear"
+ % [used.size(), MAX_FILL_CELLS])
+ var snapshot := _capture_used_cells_snapshot(node)
+ _undo_redo.create_action("MCP: GridMap clear")
+ _undo_redo.add_do_method(node, "clear")
+ _undo_redo.add_undo_method(self, "_restore_cells_snapshot", node, snapshot)
+ _undo_redo.commit_action()
+ return {"data": {"cleared": true, "undoable": true}}
+
+
+## Return all used cell coordinates.
+## params: {path}
+## Returns: {cells: [{x, y, z}, ...], count: int}
+func get_used_cells(params: Dictionary) -> Dictionary:
+ var gm := _resolve_gridmap(params)
+ if gm.has("error"): return gm
+ var node: GridMap = gm.node
+ var result: Array = []
+ for c in node.get_used_cells():
+ result.append({"x": c.x, "y": c.y, "z": c.z})
+ return {"data": {"cells": result, "count": result.size()}}
+
+
+## List the items available in the GridMap's MeshLibrary, so agents can
+## discover item ids and names before placing cells (the 3D analogue of
+## tileset atlas inspection).
+## params: {path}
+## Returns: {library: res:// path or "", items: [{item, name, mesh}...], count}
+func list_library_items(params: Dictionary) -> Dictionary:
+ var gm := _resolve_gridmap(params)
+ if gm.has("error"): return gm
+ var node: GridMap = gm.node
+ var library: MeshLibrary = node.mesh_library
+ if library == null:
+ return {"data": {"library": "", "items": [], "count": 0}}
+ var items: Array = []
+ var ids := library.get_item_list()
+ ids.sort()
+ for item in ids:
+ var mesh: Mesh = library.get_item_mesh(item)
+ var mesh_path := mesh.resource_path if mesh != null else ""
+ items.append({
+ "item": item,
+ "name": library.get_item_name(item),
+ "mesh": mesh_path,
+ })
+ return {"data": {"library": library.resource_path, "items": items, "count": items.size()}}
+
+
+## Resolve a GridMap node from params["path"] in the currently edited
+## scene. Returns {"node": GridMap} on success, or an error dict.
+func _resolve_gridmap(params: Dictionary) -> Dictionary:
+ var path: String = params.get("path", "")
+ var scene_file: String = params.get("scene_file", "")
+ var resolved := McpNodeValidator.resolve_or_error(path, "path", scene_file)
+ if resolved.has("error"):
+ return resolved
+ var node: Node = resolved.node
+ if not node is GridMap:
+ return ErrorCodes.make(ErrorCodes.WRONG_TYPE,
+ "Node is not a GridMap: %s" % path)
+ return {"node": node}
+
+
+func _capture_cell_state(node: GridMap, pos: Vector3i) -> Dictionary:
+ var item := node.get_cell_item(pos)
+ if item == -1:
+ return {"has_item": false}
+ return {"has_item": true, "item": item, "orientation": node.get_cell_item_orientation(pos)}
+
+
+func _capture_used_cells_snapshot(node: GridMap) -> Array[Dictionary]:
+ var snapshot: Array[Dictionary] = []
+ for pos in node.get_used_cells():
+ snapshot.append({"pos": pos, "state": _capture_cell_state(node, pos)})
+ return snapshot
+
+
+func _restore_cells_snapshot(node: GridMap, snapshot: Array[Dictionary]) -> void:
+ node.clear()
+ for entry in snapshot:
+ _restore_cell_state(node, entry.pos, entry.state)
+
+
+## Batched do-method for fill: one undo-history entry applies the whole
+## region instead of one entry per cell.
+func _apply_fill(node: GridMap, snapshot: Array[Dictionary], item: int, orientation: int) -> void:
+ for entry in snapshot:
+ node.set_cell_item(entry.pos, item, orientation)
+
+
+func _restore_rect_snapshot(node: GridMap, snapshot: Array[Dictionary]) -> void:
+ for entry in snapshot:
+ _restore_cell_state(node, entry.pos, entry.state)
+
+
+func _restore_cell_state(node: GridMap, pos: Vector3i, state: Dictionary) -> void:
+ if not state.get("has_item", false):
+ node.set_cell_item(pos, -1)
+ return
+ node.set_cell_item(pos, int(state.get("item", 0)), int(state.get("orientation", 0)))
diff --git a/addons/godot_ai/handlers/gridmap_handler.gd.uid b/addons/godot_ai/handlers/gridmap_handler.gd.uid
new file mode 100644
index 0000000..35bb1d0
--- /dev/null
+++ b/addons/godot_ai/handlers/gridmap_handler.gd.uid
@@ -0,0 +1 @@
+uid://cdv1yrjeyo5es
diff --git a/addons/godot_ai/handlers/input_handler.gd b/addons/godot_ai/handlers/input_handler.gd
new file mode 100644
index 0000000..d423ee7
--- /dev/null
+++ b/addons/godot_ai/handlers/input_handler.gd
@@ -0,0 +1,462 @@
+@tool
+extends "res://addons/godot_ai/handlers/command_handler.gd"
+
+const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
+
+## Handles input action listing, creation, removal, and event binding.
+## Actions are persisted via ProjectSettings so they survive editor restarts.
+
+
+func list_actions(params: Dictionary) -> Dictionary:
+ var include_builtin: bool = params.get("include_builtin", false)
+ ## Authoritative source for user-authored actions is the ``[input]``
+ ## section of ``project.godot``. ``ProjectSettings.has_setting`` is not
+ ## reliable here because Godot registers ``ui_*`` defaults via
+ ## ``GLOBAL_DEF_BASIC``, which makes ``has_setting`` return true for
+ ## them. Reading the file via ``ConfigFile`` distinguishes the user's
+ ## entries from engine-registered defaults regardless of namespace.
+ ## See #213.
+ var user_authored := _read_user_authored_actions()
+ var actions: Array[Dictionary] = []
+ var seen := {}
+ for action_name in InputMap.get_actions():
+ var name_str := str(action_name)
+ var is_user_action := user_authored.has(name_str)
+ if not include_builtin and not is_user_action:
+ continue
+ seen[name_str] = true
+ var events: Array[Dictionary] = []
+ for event in InputMap.action_get_events(action_name):
+ events.append(_serialize_event(event))
+ actions.append({
+ "name": name_str,
+ "events": events,
+ "event_count": events.size(),
+ "is_builtin": not is_user_action,
+ "loaded_in_input_map": true,
+ })
+ for action_name in user_authored.keys():
+ var name_str := str(action_name)
+ if seen.has(name_str):
+ continue
+ var setting: Dictionary = user_authored.get(name_str, {})
+ var events: Array[Dictionary] = []
+ for event in setting.get("events", []):
+ if event is InputEvent:
+ events.append(_serialize_event(event))
+ else:
+ events.append({"type": type_string(typeof(event)), "string": str(event)})
+ actions.append({
+ "name": name_str,
+ "events": events,
+ "event_count": events.size(),
+ "is_builtin": false,
+ "loaded_in_input_map": false,
+ })
+ return {"data": {"actions": actions, "count": actions.size()}}
+
+
+func _read_user_authored_actions() -> Dictionary:
+ var cfg := ConfigFile.new()
+ if cfg.load("res://project.godot") != OK:
+ return {}
+ if not cfg.has_section("input"):
+ return {}
+ var result: Dictionary = {}
+ for key in cfg.get_section_keys("input"):
+ var value = cfg.get_value("input", key, {})
+ result[key] = value if value is Dictionary else {}
+ return result
+
+
+func add_action(params: Dictionary) -> Dictionary:
+ var action: String = params.get("action", "")
+ var deadzone: float = params.get("deadzone", 0.5)
+
+ if action.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: action")
+
+ var deadzone_error := _validate_deadzone(deadzone)
+ if deadzone_error.has("error"):
+ return deadzone_error
+
+ if InputMap.has_action(action):
+ return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "Action '%s' already exists" % action)
+
+ InputMap.add_action(action, deadzone)
+
+ var key := "input/%s" % action
+ ProjectSettings.set_setting(key, {
+ "deadzone": deadzone,
+ "events": [],
+ })
+ var err := ProjectSettings.save()
+ if err != OK:
+ InputMap.erase_action(action)
+ ProjectSettings.clear(key)
+ return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR,
+ "Failed to save project settings while adding action '%s': %s (error %d)" % [action, error_string(err), err])
+
+ return {
+ "data": {
+ "action": action,
+ "deadzone": deadzone,
+ "undoable": false,
+ "reason": "Input actions are saved to project.godot",
+ }
+ }
+
+
+func ensure_action(params: Dictionary) -> Dictionary:
+ var action: String = params.get("action", "")
+ var deadzone: float = params.get("deadzone", 0.5)
+
+ if action.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: action")
+
+ var deadzone_error := _validate_deadzone(deadzone)
+ if deadzone_error.has("error"):
+ return deadzone_error
+
+ var result := _ensure_action_state(action, deadzone)
+ if result.has("error"):
+ return result
+ return {"data": result}
+
+
+func remove_action(params: Dictionary) -> Dictionary:
+ var action: String = params.get("action", "")
+ if action.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: action")
+
+ var key := "input/%s" % action
+ var was_loaded := InputMap.has_action(action)
+ var old_setting = ProjectSettings.get_setting(key) if ProjectSettings.has_setting(key) else null
+
+ ## An action can live in the editor process's InputMap, in project.godot,
+ ## or both. Actions persisted by a previous editor session exist only on
+ ## disk (`loaded_in_input_map: false` in list_actions) — those must still
+ ## be removable. #632
+ if not was_loaded and old_setting == null:
+ return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Action '%s' not found" % action)
+
+ if was_loaded:
+ InputMap.erase_action(action)
+
+ if old_setting != null:
+ ProjectSettings.clear(key)
+ var err := ProjectSettings.save()
+ if err != OK:
+ if was_loaded:
+ var dz: float = old_setting.get("deadzone", 0.5) if old_setting is Dictionary else 0.5
+ InputMap.add_action(action, dz)
+ if old_setting is Dictionary:
+ for ev in old_setting.get("events", []):
+ if ev is InputEvent:
+ InputMap.action_add_event(action, ev)
+ ProjectSettings.set_setting(key, old_setting)
+ return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR,
+ "Failed to save project settings while removing action '%s': %s (error %d)" % [action, error_string(err), err])
+
+ return {
+ "data": {
+ "action": action,
+ "removed": true,
+ "was_loaded": was_loaded,
+ "undoable": false,
+ "reason": "Input actions are saved to project.godot",
+ }
+ }
+
+
+func bind_event(params: Dictionary) -> Dictionary:
+ var action: String = params.get("action", "")
+ var event_type: String = params.get("event_type", "")
+
+ if action.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: action")
+ if event_type.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: event_type")
+
+ if not InputMap.has_action(action):
+ return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
+ "Action '%s' not found. Call input_map_manage(op='add_action', params={action: '%s'}) first." % [action, action])
+
+ var event_or_error = _create_event(event_type, params)
+ if event_or_error is Dictionary:
+ return event_or_error
+ var event: InputEvent = event_or_error
+
+ InputMap.action_add_event(action, event)
+
+ var err := _save_action_events(action)
+ if err != OK:
+ InputMap.action_erase_event(action, event)
+ return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR,
+ "Failed to save project settings while binding event to action '%s': %s (error %d)" % [action, error_string(err), err])
+
+ return {
+ "data": {
+ "action": action,
+ "event": _serialize_event(event),
+ "undoable": false,
+ "reason": "Input bindings are saved to project.godot",
+ }
+ }
+
+
+func ensure_binding(params: Dictionary) -> Dictionary:
+ var action: String = params.get("action", "")
+ var event_type: String = params.get("event_type", "")
+ var deadzone: float = params.get("deadzone", 0.5)
+
+ if action.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: action")
+ if event_type.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: event_type")
+
+ var deadzone_error := _validate_deadzone(deadzone)
+ if deadzone_error.has("error"):
+ return deadzone_error
+
+ var event_or_error = _create_event(event_type, params)
+ if event_or_error is Dictionary:
+ return event_or_error
+ var event: InputEvent = event_or_error
+
+ var ensured := _ensure_action_state(action, deadzone)
+ if ensured.has("error"):
+ return ensured
+
+ for existing in InputMap.action_get_events(action):
+ if _events_match(existing, event):
+ return {
+ "data": {
+ "action": action,
+ "event": _serialize_event(existing),
+ "already_bound": true,
+ "action_created": ensured.get("created", false),
+ "undoable": false,
+ "reason": "Input binding already exists",
+ }
+ }
+
+ InputMap.action_add_event(action, event)
+ var err := _save_action_events(action)
+ if err != OK:
+ InputMap.action_erase_event(action, event)
+ return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR,
+ "Failed to save project settings while binding event to action '%s': %s (error %d)" % [action, error_string(err), err])
+
+ return {
+ "data": {
+ "action": action,
+ "event": _serialize_event(event),
+ "already_bound": false,
+ "action_created": ensured.get("created", false),
+ "undoable": false,
+ "reason": "Input bindings are saved to project.godot",
+ }
+ }
+
+
+func _validate_deadzone(deadzone: float) -> Dictionary:
+ if deadzone < 0.0 or deadzone > 1.0:
+ return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
+ "deadzone must be in [0.0, 1.0] (got %s). Typical values are 0.2-0.5; default is 0.5." % deadzone)
+ return {}
+
+
+func _ensure_action_state(action: String, deadzone: float) -> Dictionary:
+ var key := "input/%s" % action
+ var user_authored := _read_user_authored_actions()
+ var existed_in_input_map := InputMap.has_action(action)
+ var existed_in_project := user_authored.has(action) or ProjectSettings.has_setting(key)
+ var old_setting = user_authored.get(action, null) if user_authored.has(action) else null
+ if old_setting == null and ProjectSettings.has_setting(key):
+ old_setting = ProjectSettings.get_setting(key)
+
+ if not existed_in_input_map:
+ var dz := deadzone
+ if old_setting is Dictionary:
+ dz = float(old_setting.get("deadzone", deadzone))
+ InputMap.add_action(action, dz)
+ if old_setting is Dictionary:
+ for ev in old_setting.get("events", []):
+ if ev is InputEvent:
+ InputMap.action_add_event(action, ev)
+
+ if not existed_in_project:
+ var err := _save_action_events(action)
+ if err != OK:
+ if not existed_in_input_map:
+ InputMap.erase_action(action)
+ return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR,
+ "Failed to save project settings while ensuring action '%s': %s (error %d)" % [action, error_string(err), err])
+
+ var stored_deadzone := deadzone
+ if ProjectSettings.has_setting(key):
+ var stored = ProjectSettings.get_setting(key)
+ if stored is Dictionary:
+ stored_deadzone = float(stored.get("deadzone", deadzone))
+ return {
+ "action": action,
+ "deadzone": stored_deadzone,
+ "created": not existed_in_input_map and not existed_in_project,
+ "already_exists": existed_in_input_map or existed_in_project,
+ "loaded_in_input_map": true,
+ "persisted": true,
+ "undoable": false,
+ "reason": "Input actions are saved to project.godot",
+ }
+
+
+## Returns an InputEvent on success, or a Dictionary error on failure.
+## Caller must check ``result is Dictionary`` before treating it as an event.
+func _create_event(event_type: String, params: Dictionary):
+ match event_type:
+ "key":
+ var ev := InputEventKey.new()
+ var keycode_str: String = params.get("keycode", "")
+ if keycode_str.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM,
+ "event_type='key' requires keycode (e.g. 'Space', 'A', 'Enter', 'Escape', 'F1').")
+ ev.keycode = OS.find_keycode_from_string(keycode_str)
+ if ev.keycode == KEY_NONE:
+ return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
+ "Invalid keycode '%s'. Use Godot keycode names like 'A', 'Space', 'Enter', 'Escape', 'F1', 'Left', 'Right'." % keycode_str)
+ ev.ctrl_pressed = params.get("ctrl", false)
+ ev.alt_pressed = params.get("alt", false)
+ ev.shift_pressed = params.get("shift", false)
+ ev.meta_pressed = params.get("meta", false)
+ ev.device = -1
+ return ev
+ "mouse_button":
+ if not params.has("button"):
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM,
+ "event_type='mouse_button' requires button (1=left, 2=right, 3=middle, 4=wheel up, 5=wheel down).")
+ var button: int = int(params.get("button", 0))
+ if button <= 0:
+ return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
+ "mouse_button button must be > 0 (got %d). Use 1=left, 2=right, 3=middle, 4=wheel up, 5=wheel down." % button)
+ var ev := InputEventMouseButton.new()
+ ev.button_index = button
+ ev.device = -1
+ return ev
+ "joy_button":
+ if not params.has("button"):
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM,
+ "event_type='joy_button' requires button (JoyButton index, e.g. 0=A/Cross, 1=B/Circle).")
+ var ev := InputEventJoypadButton.new()
+ ev.button_index = int(params.get("button", 0))
+ return ev
+ "joy_axis":
+ var axis_param = params.get("axis", null)
+ if axis_param == null:
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM,
+ "event_type='joy_axis' requires axis (JoyAxis index, e.g. 0=left stick X, 1=left stick Y).")
+ var axis: int
+ match typeof(axis_param):
+ TYPE_INT:
+ axis = axis_param
+ TYPE_FLOAT:
+ if axis_param != floor(axis_param):
+ return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
+ "joy_axis axis must be an integer JoyAxis index (got %s)." % str(axis_param))
+ axis = int(axis_param)
+ TYPE_STRING:
+ var axis_text := str(axis_param)
+ if not axis_text.is_valid_int():
+ return ErrorCodes.make(ErrorCodes.WRONG_TYPE,
+ "joy_axis axis must be an integer JoyAxis index (got '%s')." % axis_text)
+ axis = int(axis_text)
+ _:
+ return ErrorCodes.make(ErrorCodes.WRONG_TYPE,
+ "joy_axis axis must be an integer JoyAxis index (got %s)." % type_string(typeof(axis_param)))
+ var ev := InputEventJoypadMotion.new()
+ ev.axis = axis
+ ev.axis_value = float(params.get("axis_value", 1.0))
+ return ev
+ return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
+ "Unsupported event_type: '%s'. Use 'key', 'mouse_button', 'joy_button', or 'joy_axis'." % event_type)
+
+
+func _serialize_event(event: InputEvent) -> Dictionary:
+ if event is InputEventKey:
+ return {
+ "type": "key",
+ "keycode": OS.get_keycode_string(event.keycode),
+ "physical_keycode": OS.get_keycode_string(event.physical_keycode),
+ "ctrl": event.ctrl_pressed,
+ "alt": event.alt_pressed,
+ "shift": event.shift_pressed,
+ "meta": event.meta_pressed,
+ }
+ if event is InputEventMouseButton:
+ return {
+ "type": "mouse_button",
+ "button": event.button_index,
+ }
+ if event is InputEventJoypadButton:
+ return {
+ "type": "joy_button",
+ "button": event.button_index,
+ }
+ if event is InputEventJoypadMotion:
+ return {
+ "type": "joy_axis",
+ "axis": event.axis,
+ "axis_value": event.axis_value,
+ }
+ return {"type": event.get_class(), "string": str(event)}
+
+
+func _events_match(a: InputEvent, b: InputEvent) -> bool:
+ if a is InputEventKey and b is InputEventKey:
+ return _key_events_match(a as InputEventKey, b as InputEventKey)
+ return _serialize_event(a) == _serialize_event(b)
+
+
+func _key_events_match(a: InputEventKey, b: InputEventKey) -> bool:
+ if a.ctrl_pressed != b.ctrl_pressed:
+ return false
+ if a.alt_pressed != b.alt_pressed:
+ return false
+ if a.shift_pressed != b.shift_pressed:
+ return false
+ if a.meta_pressed != b.meta_pressed:
+ return false
+ var a_codes := [a.keycode, a.physical_keycode]
+ var b_codes := [b.keycode, b.physical_keycode]
+ for a_code in a_codes:
+ if int(a_code) == KEY_NONE:
+ continue
+ for b_code in b_codes:
+ if int(b_code) != KEY_NONE and int(a_code) == int(b_code):
+ return true
+ return false
+
+
+func _save_action_events(action: String) -> int:
+ var events: Array = []
+ for event in InputMap.action_get_events(action):
+ events.append(event)
+ var key := "input/%s" % action
+ var had_setting := ProjectSettings.has_setting(key)
+ var old_setting = ProjectSettings.get_setting(key) if had_setting else null
+ var deadzone: float = 0.5
+ if old_setting is Dictionary:
+ deadzone = old_setting.get("deadzone", 0.5)
+ elif InputMap.has_action(action):
+ deadzone = InputMap.action_get_deadzone(action)
+ ProjectSettings.set_setting(key, {
+ "deadzone": deadzone,
+ "events": events,
+ })
+ var err := ProjectSettings.save()
+ if err != OK:
+ if had_setting:
+ ProjectSettings.set_setting(key, old_setting)
+ else:
+ ProjectSettings.clear(key)
+ return err
diff --git a/addons/godot_ai/handlers/input_handler.gd.uid b/addons/godot_ai/handlers/input_handler.gd.uid
new file mode 100644
index 0000000..7d509f4
--- /dev/null
+++ b/addons/godot_ai/handlers/input_handler.gd.uid
@@ -0,0 +1 @@
+uid://buk68rbwssqwp
diff --git a/addons/godot_ai/handlers/material_handler.gd b/addons/godot_ai/handlers/material_handler.gd
new file mode 100644
index 0000000..bd617ef
--- /dev/null
+++ b/addons/godot_ai/handlers/material_handler.gd
@@ -0,0 +1,809 @@
+@tool
+extends "res://addons/godot_ai/handlers/command_handler.gd"
+
+const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
+
+## Handles Material authoring: creating .tres files, setting BaseMaterial3D
+## properties / shader uniforms, assigning to nodes, high-level presets.
+##
+## File-resource lifecycle mirrors ThemeHandler (create/load/mutate/save).
+## Undo pattern mirrors AnimationHandler (single create_action bundles
+## every dependency spawn).
+
+const MaterialValues := preload("res://addons/godot_ai/handlers/material_values.gd")
+const MaterialPresets := preload("res://addons/godot_ai/handlers/material_presets.gd")
+
+const _TYPE_TO_CLASS := {
+ "standard": "StandardMaterial3D",
+ "orm": "ORMMaterial3D",
+ "canvas_item": "CanvasItemMaterial",
+ "shader": "ShaderMaterial",
+}
+
+const _SUPPORTED_SUFFIXES := [".tres", ".material", ".res"]
+
+
+var _undo_redo: EditorUndoRedoManager
+var _connection: McpConnection
+
+
+func _init(undo_redo: EditorUndoRedoManager, connection: McpConnection = null) -> void:
+ _undo_redo = undo_redo
+ _connection = connection
+
+
+# ============================================================================
+# material_create
+# ============================================================================
+
+func create_material(params: Dictionary) -> Dictionary:
+ var path: String = params.get("path", "")
+ var type_str: String = params.get("type", "standard")
+ var shader_path: String = params.get("shader_path", "")
+ var overwrite: bool = params.get("overwrite", false)
+
+ var err := _validate_material_path(path, "path", true)
+ if err != null:
+ return err
+
+ if not _TYPE_TO_CLASS.has(type_str):
+ return ErrorCodes.make(
+ ErrorCodes.VALUE_OUT_OF_RANGE,
+ "Invalid material type '%s'. Valid: %s" % [type_str, ", ".join(_TYPE_TO_CLASS.keys())]
+ )
+
+ var existed_before := FileAccess.file_exists(path)
+ if existed_before and not overwrite:
+ return ErrorCodes.make(
+ ErrorCodes.INVALID_PARAMS,
+ "Material already exists at %s (pass overwrite=true to replace)" % path
+ )
+
+ var mat := _instantiate_material(type_str)
+ if mat == null:
+ return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate material")
+
+ if type_str == "shader":
+ if shader_path.is_empty():
+ return ErrorCodes.make(
+ ErrorCodes.INVALID_PARAMS,
+ "ShaderMaterial requires shader_path (res:// / uid:// / user:// path to a .gdshader)"
+ )
+ var shader_path_err = McpPathValidator.loadable_error(shader_path, "shader_path")
+ if shader_path_err != null:
+ return shader_path_err
+ if not ResourceLoader.exists(shader_path):
+ return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Shader not found: %s" % shader_path)
+ var shader_res := ResourceLoader.load(shader_path)
+ if not (shader_res is Shader):
+ return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Resource at %s is not a Shader" % shader_path)
+ (mat as ShaderMaterial).shader = shader_res
+
+ var dir_path := path.get_base_dir()
+ var mkdir_err := DirAccess.make_dir_recursive_absolute(dir_path)
+ if mkdir_err != OK and mkdir_err != ERR_ALREADY_EXISTS:
+ return ErrorCodes.make(
+ ErrorCodes.INTERNAL_ERROR,
+ "Failed to create directory: %s (error %d)" % [dir_path, mkdir_err]
+ )
+
+ var save_err := McpResourceIO.guarded_save(mat, path, _connection)
+ if save_err != OK:
+ return ErrorCodes.make(
+ ErrorCodes.INTERNAL_ERROR,
+ "Failed to save material to %s (error %d)" % [path, save_err]
+ )
+
+ var efs := EditorInterface.get_resource_filesystem()
+ if efs != null:
+ efs.update_file(path)
+
+ return {
+ "data": {
+ "path": path,
+ "type": type_str,
+ "class": mat.get_class(),
+ "shader_path": shader_path,
+ "overwritten": existed_before,
+ "undoable": false,
+ "reason": "File creation is persistent; delete the file manually to revert",
+ }
+ }
+
+
+# ============================================================================
+# material_set_param
+# ============================================================================
+
+func set_param(params: Dictionary) -> Dictionary:
+ var load_result := _load_material_from_path(params.get("path", ""), true)
+ if load_result.has("error"):
+ return load_result
+ var mat: Material = load_result.material
+ var mat_path: String = load_result.path
+
+ var property: String = params.get("param", "")
+ if property.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: param")
+
+ if not ("value" in params):
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: value")
+
+ var raw_value = params.get("value")
+
+ # Probe the property. We allow any property present in get_property_list,
+ # plus `shader` on ShaderMaterial.
+ var prop_type: int = TYPE_NIL
+ var property_exists := false
+ for prop in mat.get_property_list():
+ if prop.name == property:
+ property_exists = true
+ prop_type = prop.get("type", TYPE_NIL)
+ break
+ if not property_exists:
+ return ErrorCodes.make(
+ ErrorCodes.PROPERTY_NOT_ON_CLASS,
+ McpPropertyErrors.build_message(mat, property)
+ )
+
+ var coerced := MaterialValues.coerce_material_value(property, raw_value, prop_type)
+ if not coerced.ok:
+ return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, String(coerced.error))
+ var new_value = coerced.value
+
+ var old_value = mat.get(property)
+
+ _undo_redo.create_action("MCP: Set material %s.%s" % [mat_path.get_file(), property])
+ _undo_redo.add_do_method(self, "_apply_param", mat_path, property, new_value, false)
+ _undo_redo.add_undo_method(self, "_apply_param", mat_path, property, old_value, false)
+ _undo_redo.commit_action()
+
+ return {
+ "data": {
+ "path": mat_path,
+ "property": property,
+ "value": MaterialValues.serialize_value(new_value),
+ "previous_value": MaterialValues.serialize_value(old_value),
+ "undoable": true,
+ }
+ }
+
+
+# ============================================================================
+# material_set_shader_param
+# ============================================================================
+
+func set_shader_param(params: Dictionary) -> Dictionary:
+ var load_result := _load_material_from_path(params.get("path", ""), true)
+ if load_result.has("error"):
+ return load_result
+ var mat: Material = load_result.material
+ var mat_path: String = load_result.path
+
+ if not (mat is ShaderMaterial):
+ return ErrorCodes.make(
+ ErrorCodes.WRONG_TYPE,
+ "Material at %s is %s, not ShaderMaterial" % [mat_path, mat.get_class()]
+ )
+ var shader_mat := mat as ShaderMaterial
+ if shader_mat.shader == null:
+ return ErrorCodes.make(
+ ErrorCodes.WRONG_TYPE,
+ "ShaderMaterial at %s has no shader assigned" % mat_path
+ )
+
+ var param_name: String = params.get("param", "")
+ if param_name.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: param")
+
+ if not ("value" in params):
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: value")
+
+ # Verify the uniform exists in the shader.
+ var uniform_type := _shader_uniform_type(shader_mat.shader, param_name)
+ if uniform_type == TYPE_NIL:
+ return ErrorCodes.make(
+ ErrorCodes.PROPERTY_NOT_ON_CLASS,
+ "Shader uniform '%s' not declared on shader at %s" % [param_name, shader_mat.shader.resource_path]
+ )
+
+ var raw_value = params.get("value")
+ var coerced := MaterialValues.coerce_material_value(param_name, raw_value, uniform_type)
+ if not coerced.ok:
+ return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, String(coerced.error))
+ var new_value = coerced.value
+
+ var old_value = shader_mat.get_shader_parameter(param_name)
+
+ _undo_redo.create_action("MCP: Set shader param %s.%s" % [mat_path.get_file(), param_name])
+ _undo_redo.add_do_method(self, "_apply_shader_param", mat_path, param_name, new_value)
+ _undo_redo.add_undo_method(self, "_apply_shader_param", mat_path, param_name, old_value)
+ _undo_redo.commit_action()
+
+ return {
+ "data": {
+ "path": mat_path,
+ "param": param_name,
+ "value": MaterialValues.serialize_value(new_value),
+ "previous_value": MaterialValues.serialize_value(old_value),
+ "undoable": true,
+ }
+ }
+
+
+# ============================================================================
+# material_get
+# ============================================================================
+
+func get_material(params: Dictionary) -> Dictionary:
+ var load_result := _load_material_from_path(params.get("path", ""))
+ if load_result.has("error"):
+ return load_result
+ var mat: Material = load_result.material
+ var mat_path: String = load_result.path
+
+ var properties: Array[Dictionary] = []
+ for prop in mat.get_property_list():
+ var usage: int = prop.get("usage", 0)
+ if not (usage & PROPERTY_USAGE_EDITOR):
+ continue
+ var name: String = prop.name
+ if name.begins_with("shader_parameter/"):
+ continue # handled below
+ var value = mat.get(name)
+ if value == null and prop.type != TYPE_NIL:
+ continue
+ properties.append({
+ "name": name,
+ "type": type_string(prop.type),
+ "value": MaterialValues.serialize_value(value),
+ })
+
+ var shader_params: Array[Dictionary] = []
+ if mat is ShaderMaterial:
+ var shader_mat := mat as ShaderMaterial
+ if shader_mat.shader != null:
+ for u in shader_mat.shader.get_shader_uniform_list():
+ var u_name: String = u.get("name", "")
+ if u_name.is_empty():
+ continue
+ shader_params.append({
+ "name": u_name,
+ "type": type_string(u.get("type", TYPE_NIL)),
+ "value": MaterialValues.serialize_value(shader_mat.get_shader_parameter(u_name)),
+ })
+
+ var reverse_type_map := _reverse_type_map()
+
+ var shader_path_str := ""
+ if mat is ShaderMaterial:
+ var sm := mat as ShaderMaterial
+ if sm.shader != null:
+ shader_path_str = sm.shader.resource_path
+
+ return {
+ "data": {
+ "path": mat_path,
+ "class": mat.get_class(),
+ "type": reverse_type_map.get(mat.get_class(), ""),
+ "properties": properties,
+ "property_count": properties.size(),
+ "shader_parameters": shader_params,
+ "shader_path": shader_path_str,
+ }
+ }
+
+
+# ============================================================================
+# material_list
+# ============================================================================
+
+func list_materials(params: Dictionary) -> Dictionary:
+ var root: String = params.get("root", "res://")
+ var type_filter: String = params.get("type", "")
+
+ var root_err = McpPathValidator.path_error(root, "root")
+ if root_err != null:
+ return root_err
+
+ var efs := EditorInterface.get_resource_filesystem()
+ if efs == null:
+ return ErrorCodes.make_not_ready(
+ ErrorCodes.SUB_EDITOR_UNAVAILABLE,
+ "EditorFileSystem not available", false)
+
+ var results: Array[Dictionary] = []
+ var start_dir := efs.get_filesystem_path(root)
+ if start_dir == null:
+ start_dir = efs.get_filesystem()
+ _scan_materials(start_dir, type_filter, root, results)
+
+ return {"data": {"materials": results, "count": results.size()}}
+
+
+func _scan_materials(dir: EditorFileSystemDirectory, type_filter: String, root: String, out: Array[Dictionary]) -> void:
+ if dir == null:
+ return
+ for i in dir.get_file_count():
+ var file_path := dir.get_file_path(i)
+ if not file_path.begins_with(root):
+ continue
+ var file_type := dir.get_file_type(i)
+ var is_material := file_type == "Material" or ClassDB.is_parent_class(file_type, "Material")
+ if not is_material:
+ # Some material variants serialize as specific classes.
+ if not (file_type in _TYPE_TO_CLASS.values()):
+ continue
+
+ if not type_filter.is_empty():
+ if file_type != type_filter and not ClassDB.is_parent_class(file_type, type_filter):
+ continue
+
+ out.append({"path": file_path, "class": file_type})
+
+ for i in dir.get_subdir_count():
+ _scan_materials(dir.get_subdir(i), type_filter, root, out)
+
+
+# ============================================================================
+# material_assign
+# ============================================================================
+
+func assign_material(params: Dictionary) -> Dictionary:
+ var node_path: String = params.get("node_path", "")
+ if node_path.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: node_path")
+
+ var _resolved := McpNodeValidator.resolve_or_error(node_path, "node_path")
+ if _resolved.has("error"):
+ return _resolved
+ var node: Node = _resolved.node
+ var _scene_root: Node = _resolved.scene_root
+
+ var slot: String = params.get("slot", "override")
+ var resource_path: String = params.get("resource_path", "")
+ var create_if_missing: bool = params.get("create_if_missing", false)
+ var type_str: String = params.get("type", "standard")
+
+ var slot_result := _resolve_slot_property(node, slot)
+ if slot_result.has("error"):
+ return slot_result
+ var property: String = slot_result.property
+
+ # Load or create the material.
+ var mat: Material = null
+ var material_created := false
+ if not resource_path.is_empty():
+ var rpath_err = McpPathValidator.loadable_error(resource_path, "resource_path")
+ if rpath_err != null:
+ return rpath_err
+ if not ResourceLoader.exists(resource_path):
+ if create_if_missing:
+ # We'd need to create a new file here — refuse; callers should
+ # use material_create first or omit resource_path to get an
+ # inline material.
+ return ErrorCodes.make(
+ ErrorCodes.RESOURCE_NOT_FOUND,
+ "Resource not found: %s. Create it first with material_create or omit resource_path for an inline material." % resource_path
+ )
+ return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Resource not found: %s" % resource_path)
+ var loaded := ResourceLoader.load(resource_path)
+ if not (loaded is Material):
+ var loaded_class := "null"
+ if loaded != null:
+ loaded_class = loaded.get_class()
+ return ErrorCodes.make(
+ ErrorCodes.WRONG_TYPE,
+ "Resource at %s is not a Material (got %s)" % [resource_path, loaded_class]
+ )
+ mat = loaded
+ else:
+ if not create_if_missing:
+ return ErrorCodes.make(
+ ErrorCodes.INVALID_PARAMS,
+ "Missing resource_path (pass create_if_missing=true to create a new inline material)"
+ )
+ if not _TYPE_TO_CLASS.has(type_str):
+ return ErrorCodes.make(
+ ErrorCodes.VALUE_OUT_OF_RANGE,
+ "Invalid material type '%s'" % type_str
+ )
+ mat = _instantiate_material(type_str)
+ material_created = true
+
+ var old_value = node.get(property)
+
+ _undo_redo.create_action("MCP: Assign material to %s.%s" % [node.name, property])
+ _undo_redo.add_do_property(node, property, mat)
+ _undo_redo.add_undo_property(node, property, old_value)
+ if material_created:
+ _undo_redo.add_do_reference(mat)
+ _undo_redo.commit_action()
+
+ return {
+ "data": {
+ "node_path": node_path,
+ "property": property,
+ "slot": slot,
+ "resource_path": resource_path,
+ "material_class": mat.get_class(),
+ "material_created": material_created,
+ "undoable": true,
+ }
+ }
+
+
+# ============================================================================
+# material_apply_to_node
+# ============================================================================
+
+func apply_to_node(params: Dictionary) -> Dictionary:
+ var node_path: String = params.get("node_path", "")
+ if node_path.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: node_path")
+
+ var type_str: String = params.get("type", "standard")
+ if not _TYPE_TO_CLASS.has(type_str):
+ return ErrorCodes.make(
+ ErrorCodes.VALUE_OUT_OF_RANGE,
+ "Invalid material type '%s'. Valid: %s" % [type_str, ", ".join(_TYPE_TO_CLASS.keys())]
+ )
+
+ var _resolved := McpNodeValidator.resolve_or_error(node_path, "node_path")
+ if _resolved.has("error"):
+ return _resolved
+ var node: Node = _resolved.node
+ var _scene_root: Node = _resolved.scene_root
+
+ var slot: String = params.get("slot", "override")
+ var slot_result := _resolve_slot_property(node, slot)
+ if slot_result.has("error"):
+ return slot_result
+ var property: String = slot_result.property
+
+ var mat := _instantiate_material(type_str)
+
+ var props_to_set: Dictionary = params.get("params", {})
+ var applied: Array[String] = []
+ for prop_name in props_to_set:
+ var apply_err := _apply_one_param_on_instance(mat, String(prop_name), props_to_set[prop_name])
+ if apply_err != null:
+ return apply_err
+ applied.append(String(prop_name))
+
+ var save_to: String = params.get("save_to", "")
+ var saved := false
+ var overwritten := false
+ if not save_to.is_empty():
+ var save_err_validation := _validate_material_path(save_to, "save_to", true)
+ if save_err_validation != null:
+ return save_err_validation
+ # Same clobber guard as create_material/apply_preset: agents reuse
+ # names like res://materials/metal.tres, and a silent save here
+ # destroys a hand-authored file that undo can't restore (undo only
+ # reverts the node's slot assignment, not file contents). See #685.
+ var existed_before := FileAccess.file_exists(save_to)
+ if existed_before and not params.get("overwrite", false):
+ return ErrorCodes.make(
+ ErrorCodes.INVALID_PARAMS,
+ "Material already exists at %s (pass overwrite=true to replace)" % save_to
+ )
+ overwritten = existed_before
+ var dir_path := save_to.get_base_dir()
+ var mkdir_err := DirAccess.make_dir_recursive_absolute(dir_path)
+ if mkdir_err != OK and mkdir_err != ERR_ALREADY_EXISTS:
+ return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to create directory: %s" % dir_path)
+ var save_err := McpResourceIO.guarded_save(mat, save_to, _connection)
+ if save_err != OK:
+ return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to save material to %s (error %d)" % [save_to, save_err])
+ var efs := EditorInterface.get_resource_filesystem()
+ if efs != null:
+ efs.update_file(save_to)
+ # Prefer the on-disk reference (keeps the scene ref small), but fall
+ # back to the in-memory material if the reload fails — otherwise a null
+ # would clear the slot and crash mat.get_class() below.
+ var reloaded := ResourceLoader.load(save_to)
+ if reloaded != null:
+ mat = reloaded
+ saved = true
+
+ var old_value = node.get(property)
+
+ _undo_redo.create_action("MCP: Apply %s material to %s" % [type_str, node.name])
+ _undo_redo.add_do_property(node, property, mat)
+ _undo_redo.add_undo_property(node, property, old_value)
+ _undo_redo.add_do_reference(mat)
+ _undo_redo.commit_action()
+
+ return {
+ "data": {
+ "node_path": node_path,
+ "property": property,
+ "slot": slot,
+ "type": type_str,
+ "class": mat.get_class(),
+ "applied_params": applied,
+ "material_created": true,
+ "saved_to": save_to if saved else "",
+ "overwritten": overwritten,
+ "undoable": true,
+ }
+ }
+
+
+# ============================================================================
+# material_apply_preset
+# ============================================================================
+
+func apply_preset(params: Dictionary) -> Dictionary:
+ var preset_name: String = params.get("preset", "")
+ if preset_name.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: preset")
+
+ var overrides: Dictionary = params.get("overrides", {})
+ var blueprint = MaterialPresets.build(preset_name, overrides)
+ if blueprint == null:
+ return ErrorCodes.make(
+ ErrorCodes.VALUE_OUT_OF_RANGE,
+ "Unknown preset '%s'. Valid: %s" % [preset_name, ", ".join(MaterialPresets.list())]
+ )
+
+ var type_str: String = blueprint.get("type", "standard")
+ var preset_params: Dictionary = blueprint.get("params", {})
+
+ var path: String = params.get("path", "")
+ var node_path: String = params.get("node_path", "")
+
+ if path.is_empty() and node_path.is_empty():
+ return ErrorCodes.make(
+ ErrorCodes.MISSING_REQUIRED_PARAM,
+ "Pass at least one of: path (save to disk), node_path (assign to node)"
+ )
+
+ # If both path and node_path, save to disk, then assign the saved resource.
+ # If only path, save to disk.
+ # If only node_path, inline material via apply_to_node.
+
+ if not node_path.is_empty() and path.is_empty():
+ # Inline
+ var inline_result := apply_to_node({
+ "node_path": node_path,
+ "type": type_str,
+ "params": preset_params,
+ "slot": params.get("slot", "override"),
+ })
+ if inline_result.has("data"):
+ inline_result.data["preset"] = preset_name
+ inline_result.data["assigned"] = true
+ inline_result.data["path"] = ""
+ inline_result.data["saved_to_disk"] = false
+ inline_result.data["reason"] = "Inline material assigned to node"
+ return inline_result
+
+ # Save-to-disk path. Validate the path BEFORE the exists/overwrite
+ # check, matching create_material's order — an invalid path should
+ # always be reported as invalid, not as an overwrite conflict.
+ var path_err := _validate_material_path(path, "path", true)
+ if path_err != null:
+ return path_err
+
+ var existed_before := FileAccess.file_exists(path)
+ if existed_before and not params.get("overwrite", false):
+ return ErrorCodes.make(
+ ErrorCodes.INVALID_PARAMS,
+ "Material already exists at %s (pass overwrite=true to replace)" % path
+ )
+
+ var mat := _instantiate_material(type_str)
+ for prop_name in preset_params:
+ var apply_err := _apply_one_param_on_instance(mat, String(prop_name), preset_params[prop_name])
+ if apply_err != null:
+ return apply_err
+
+ var dir_path := path.get_base_dir()
+ var mkdir_err := DirAccess.make_dir_recursive_absolute(dir_path)
+ if mkdir_err != OK and mkdir_err != ERR_ALREADY_EXISTS:
+ return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to create directory: %s" % dir_path)
+
+ var save_err := McpResourceIO.guarded_save(mat, path, _connection)
+ if save_err != OK:
+ return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to save material: %s" % path)
+
+ var efs := EditorInterface.get_resource_filesystem()
+ if efs != null:
+ efs.update_file(path)
+
+ var assigned := false
+ if not node_path.is_empty():
+ var _resolved := McpNodeValidator.resolve_or_error(node_path, "node_path")
+ if _resolved.has("error"):
+ return _resolved
+ var node: Node = _resolved.node
+ var _scene_root: Node = _resolved.scene_root
+ var slot_result := _resolve_slot_property(node, params.get("slot", "override"))
+ if slot_result.has("error"):
+ return slot_result
+ var property: String = slot_result.property
+ var saved_mat := ResourceLoader.load(path)
+ var old_value = node.get(property)
+ _undo_redo.create_action("MCP: Apply preset %s to %s" % [preset_name, node.name])
+ _undo_redo.add_do_property(node, property, saved_mat)
+ _undo_redo.add_undo_property(node, property, old_value)
+ _undo_redo.commit_action()
+ assigned = true
+
+ return {
+ "data": {
+ "preset": preset_name,
+ "type": type_str,
+ "path": path,
+ "node_path": node_path,
+ "material_created": true,
+ "assigned": assigned,
+ "saved_to_disk": true,
+ "undoable": assigned, # assign is undoable; save is not
+ "reason": "" if assigned else "File save is not undoable",
+ }
+ }
+
+
+# ============================================================================
+# Undo-callable: applies a param on the loaded resource and saves.
+# ============================================================================
+
+func _apply_param(mat_path: String, property: String, value: Variant, _is_shader: bool) -> void:
+ var mat: Material = ResourceLoader.load(mat_path)
+ if mat == null:
+ push_warning("MCP: Failed to load material for undo/redo: %s" % mat_path)
+ return
+ mat.set(property, value)
+ McpResourceIO.guarded_save(mat, mat_path, _connection)
+
+
+func _apply_shader_param(mat_path: String, param_name: String, value: Variant) -> void:
+ var mat: Material = ResourceLoader.load(mat_path)
+ if mat == null or not (mat is ShaderMaterial):
+ push_warning("MCP: Failed to load shader material for undo/redo: %s" % mat_path)
+ return
+ (mat as ShaderMaterial).set_shader_parameter(param_name, value)
+ McpResourceIO.guarded_save(mat, mat_path, _connection)
+
+
+# ============================================================================
+# Helpers
+# ============================================================================
+
+static func _instantiate_material(type_str: String) -> Material:
+ match type_str:
+ "standard":
+ return StandardMaterial3D.new()
+ "orm":
+ return ORMMaterial3D.new()
+ "canvas_item":
+ return CanvasItemMaterial.new()
+ "shader":
+ return ShaderMaterial.new()
+ return null
+
+
+static func _reverse_type_map() -> Dictionary:
+ var out := {}
+ for k in _TYPE_TO_CLASS:
+ out[_TYPE_TO_CLASS[k]] = k
+ return out
+
+
+static func _validate_material_path(path: String, param_name: String, for_write: bool = false) -> Variant:
+ if path.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: %s" % param_name)
+ var path_err := McpPathValidator.validate_resource_path(path, for_write)
+ if not path_err.is_empty():
+ return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "%s: %s" % [param_name, path_err])
+ var has_suffix := false
+ for s in _SUPPORTED_SUFFIXES:
+ if path.ends_with(s):
+ has_suffix = true
+ break
+ if not has_suffix:
+ return ErrorCodes.make(
+ ErrorCodes.VALUE_OUT_OF_RANGE,
+ "%s must end with one of %s (got %s)" % [param_name, ", ".join(_SUPPORTED_SUFFIXES), path]
+ )
+ return null
+
+
+func _load_material_from_path(path: String, for_write: bool = false) -> Dictionary:
+ var err := _validate_material_path(path, "path", for_write)
+ if err != null:
+ return err
+ if not ResourceLoader.exists(path):
+ return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Material not found: %s" % path)
+ var res := ResourceLoader.load(path)
+ if res == null or not (res is Material):
+ return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Resource at %s is not a Material" % path)
+ return {"material": res, "path": path}
+
+
+## Map a slot name to a Godot property name on the given node.
+## Returns {property: "..."} or an error dict.
+func _resolve_slot_property(node: Node, slot: String) -> Dictionary:
+ if slot == "override":
+ if node is MeshInstance3D or node is CSGShape3D:
+ return {"property": "material_override"}
+ if node is CanvasItem:
+ return {"property": "material"}
+ if node is GPUParticles3D or node is GPUParticles2D or node is CPUParticles3D or node is CPUParticles2D:
+ return {"property": "material_override"} if node is GeometryInstance3D else {"property": "material"}
+ return ErrorCodes.make(
+ ErrorCodes.PROPERTY_NOT_ON_CLASS,
+ "Slot 'override' not supported on %s" % node.get_class()
+ )
+ if slot == "canvas":
+ if node is CanvasItem:
+ return {"property": "material"}
+ return ErrorCodes.make(
+ ErrorCodes.WRONG_TYPE,
+ "Slot 'canvas' requires a CanvasItem (got %s)" % node.get_class()
+ )
+ if slot == "process":
+ if node is GPUParticles3D or node is GPUParticles2D:
+ return {"property": "process_material"}
+ return ErrorCodes.make(
+ ErrorCodes.WRONG_TYPE,
+ "Slot 'process' requires a GPUParticles2D/3D (got %s)" % node.get_class()
+ )
+ if slot.begins_with("surface_"):
+ if not (node is MeshInstance3D):
+ return ErrorCodes.make(
+ ErrorCodes.WRONG_TYPE,
+ "Slot '%s' requires a MeshInstance3D (got %s)" % [slot, node.get_class()]
+ )
+ var idx_str := slot.substr(len("surface_"))
+ if not idx_str.is_valid_int():
+ return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Invalid surface slot: %s" % slot)
+ var idx := int(idx_str)
+ var mi := node as MeshInstance3D
+ var surf_count := mi.mesh.get_surface_count() if mi.mesh != null else 0
+ if idx < 0 or idx >= surf_count:
+ return ErrorCodes.make(
+ ErrorCodes.INVALID_PARAMS,
+ "Surface index %d out of range (mesh has %d surfaces)" % [idx, surf_count]
+ )
+ return {"property": "surface_material_override/%d" % idx}
+ return ErrorCodes.make(
+ ErrorCodes.VALUE_OUT_OF_RANGE,
+ "Unknown slot '%s'. Valid: override, canvas, process, surface_N" % slot
+ )
+
+
+## Apply one property to an in-memory material instance; returns null on
+## success or an error dict on failure.
+func _apply_one_param_on_instance(mat: Material, property: String, raw_value: Variant) -> Variant:
+ var prop_type: int = TYPE_NIL
+ var property_exists := false
+ for prop in mat.get_property_list():
+ if prop.name == property:
+ property_exists = true
+ prop_type = prop.get("type", TYPE_NIL)
+ break
+ if not property_exists:
+ return ErrorCodes.make(
+ ErrorCodes.PROPERTY_NOT_ON_CLASS,
+ McpPropertyErrors.build_message(mat, property)
+ )
+ var coerced := MaterialValues.coerce_material_value(property, raw_value, prop_type)
+ if not coerced.ok:
+ return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, String(coerced.error))
+ mat.set(property, coerced.value)
+ return null
+
+
+## Inspect a shader to get the Variant type of a uniform. Returns TYPE_NIL if
+## the uniform is not declared.
+static func _shader_uniform_type(shader: Shader, name: String) -> int:
+ if shader == null:
+ return TYPE_NIL
+ for u in shader.get_shader_uniform_list():
+ if u.get("name", "") == name:
+ return int(u.get("type", TYPE_NIL))
+ return TYPE_NIL
diff --git a/addons/godot_ai/handlers/material_handler.gd.uid b/addons/godot_ai/handlers/material_handler.gd.uid
new file mode 100644
index 0000000..fdf789a
--- /dev/null
+++ b/addons/godot_ai/handlers/material_handler.gd.uid
@@ -0,0 +1 @@
+uid://blh4norn3rjga
diff --git a/addons/godot_ai/handlers/material_presets.gd b/addons/godot_ai/handlers/material_presets.gd
new file mode 100644
index 0000000..db48036
--- /dev/null
+++ b/addons/godot_ai/handlers/material_presets.gd
@@ -0,0 +1,92 @@
+@tool
+extends RefCounted
+
+## Curated material preset blueprints.
+##
+## Each preset returns {type, params}. Handler applies them through the
+## normal material build path so they get undo + validation for free.
+
+
+const _PRESETS := {
+ "metal": {
+ "type": "orm",
+ "params": {
+ "metallic": 1.0,
+ "roughness": 0.25,
+ "albedo_color": {"r": 0.85, "g": 0.85, "b": 0.88, "a": 1.0},
+ },
+ },
+ "glass": {
+ "type": "standard",
+ "params": {
+ "transparency": "alpha",
+ "albedo_color": {"r": 0.9, "g": 0.95, "b": 1.0, "a": 0.3},
+ "metallic": 0.0,
+ "metallic_specular": 0.5,
+ "roughness": 0.05,
+ "refraction_enabled": true,
+ "refraction_scale": 0.05,
+ },
+ },
+ "emissive": {
+ "type": "standard",
+ "params": {
+ "emission_enabled": true,
+ "emission_energy_multiplier": 3.0,
+ "emission": {"r": 1.0, "g": 1.0, "b": 1.0, "a": 1.0},
+ "albedo_color": {"r": 1.0, "g": 1.0, "b": 1.0, "a": 1.0},
+ },
+ },
+ "unlit": {
+ "type": "standard",
+ "params": {
+ "shading_mode": "unshaded",
+ "albedo_color": {"r": 1.0, "g": 1.0, "b": 1.0, "a": 1.0},
+ },
+ },
+ "matte": {
+ "type": "standard",
+ "params": {
+ "roughness": 1.0,
+ "metallic": 0.0,
+ "albedo_color": {"r": 0.7, "g": 0.7, "b": 0.7, "a": 1.0},
+ },
+ },
+ "ceramic": {
+ "type": "standard",
+ "params": {
+ "roughness": 0.4,
+ "metallic": 0.0,
+ "clearcoat_enabled": true,
+ "clearcoat": 0.7,
+ "clearcoat_roughness": 0.15,
+ "albedo_color": {"r": 0.95, "g": 0.95, "b": 0.95, "a": 1.0},
+ },
+ },
+}
+
+
+static func list() -> Array:
+ return _PRESETS.keys()
+
+
+static func has(preset_name: String) -> bool:
+ return _PRESETS.has(preset_name)
+
+
+## Returns a deep-copied {type, params} blueprint for the named preset, or
+## null if the preset is unknown. Overrides are merged into params.
+static func build(preset_name: String, overrides: Dictionary) -> Variant:
+ if not _PRESETS.has(preset_name):
+ return null
+ var entry: Dictionary = _PRESETS[preset_name].duplicate(true)
+ var params: Dictionary = entry.get("params", {})
+ # Allow overrides to change type, too.
+ if overrides.has("type"):
+ entry["type"] = overrides["type"]
+ for key in overrides:
+ if key == "type":
+ continue
+ params[key] = overrides[key]
+ entry["params"] = params
+ return entry
diff --git a/addons/godot_ai/handlers/material_presets.gd.uid b/addons/godot_ai/handlers/material_presets.gd.uid
new file mode 100644
index 0000000..2f46051
--- /dev/null
+++ b/addons/godot_ai/handlers/material_presets.gd.uid
@@ -0,0 +1 @@
+uid://bnuwye1r8ow7g
diff --git a/addons/godot_ai/handlers/material_values.gd b/addons/godot_ai/handlers/material_values.gd
new file mode 100644
index 0000000..466c8bb
--- /dev/null
+++ b/addons/godot_ai/handlers/material_values.gd
@@ -0,0 +1,194 @@
+@tool
+extends RefCounted
+
+## Value coercion helpers for material authoring.
+##
+## Extends node_handler._coerce_value with material-specific cases:
+## - enum-by-name (transparency="alpha" → TRANSPARENCY_ALPHA)
+## - texture path → Texture2D
+## - {r,g,b,a} dict → Color (also handled by node coerce, but we want it inline)
+
+
+const _ENUM_TABLES := {
+ "transparency": {
+ "disabled": BaseMaterial3D.TRANSPARENCY_DISABLED,
+ "alpha": BaseMaterial3D.TRANSPARENCY_ALPHA,
+ "alpha_scissor": BaseMaterial3D.TRANSPARENCY_ALPHA_SCISSOR,
+ "alpha_hash": BaseMaterial3D.TRANSPARENCY_ALPHA_HASH,
+ "alpha_depth_pre_pass": BaseMaterial3D.TRANSPARENCY_ALPHA_DEPTH_PRE_PASS,
+ },
+ "shading_mode": {
+ "unshaded": BaseMaterial3D.SHADING_MODE_UNSHADED,
+ "per_pixel": BaseMaterial3D.SHADING_MODE_PER_PIXEL,
+ "per_vertex": BaseMaterial3D.SHADING_MODE_PER_VERTEX,
+ },
+ "blend_mode": {
+ "mix": BaseMaterial3D.BLEND_MODE_MIX,
+ "add": BaseMaterial3D.BLEND_MODE_ADD,
+ "sub": BaseMaterial3D.BLEND_MODE_SUB,
+ "mul": BaseMaterial3D.BLEND_MODE_MUL,
+ },
+ "cull_mode": {
+ "back": BaseMaterial3D.CULL_BACK,
+ "front": BaseMaterial3D.CULL_FRONT,
+ "disabled": BaseMaterial3D.CULL_DISABLED,
+ },
+ "depth_draw_mode": {
+ "opaque_only": BaseMaterial3D.DEPTH_DRAW_OPAQUE_ONLY,
+ "always": BaseMaterial3D.DEPTH_DRAW_ALWAYS,
+ "disabled": BaseMaterial3D.DEPTH_DRAW_DISABLED,
+ },
+ "diffuse_mode": {
+ "burley": BaseMaterial3D.DIFFUSE_BURLEY,
+ "lambert": BaseMaterial3D.DIFFUSE_LAMBERT,
+ "lambert_wrap": BaseMaterial3D.DIFFUSE_LAMBERT_WRAP,
+ "toon": BaseMaterial3D.DIFFUSE_TOON,
+ },
+ "specular_mode": {
+ "schlick_ggx": BaseMaterial3D.SPECULAR_SCHLICK_GGX,
+ "toon": BaseMaterial3D.SPECULAR_TOON,
+ "disabled": BaseMaterial3D.SPECULAR_DISABLED,
+ },
+ "billboard_mode": {
+ "disabled": BaseMaterial3D.BILLBOARD_DISABLED,
+ "enabled": BaseMaterial3D.BILLBOARD_ENABLED,
+ "fixed_y": BaseMaterial3D.BILLBOARD_FIXED_Y,
+ "particles": BaseMaterial3D.BILLBOARD_PARTICLES,
+ },
+ "texture_filter": {
+ "nearest": BaseMaterial3D.TEXTURE_FILTER_NEAREST,
+ "linear": BaseMaterial3D.TEXTURE_FILTER_LINEAR,
+ "nearest_mipmap": BaseMaterial3D.TEXTURE_FILTER_NEAREST_WITH_MIPMAPS,
+ "linear_mipmap": BaseMaterial3D.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS,
+ },
+}
+
+
+## Return the enum int for (property, string_name), or null if not a known enum string.
+static func resolve_enum(property: String, value: Variant) -> Variant:
+ if not (value is String):
+ return null
+ if not _ENUM_TABLES.has(property):
+ return null
+ var table: Dictionary = _ENUM_TABLES[property]
+ var key: String = String(value).to_lower()
+ if table.has(key):
+ return table[key]
+ return null
+
+
+## Parse a color from Color, "#rrggbb(aa)", named string, {r,g,b[,a]} dict,
+## or [r,g,b(,a)] array. Delegates to the canonical parser (#714); returns
+## null if the input cannot be parsed.
+static func parse_color(value: Variant) -> Variant:
+ return McpJsonValues.parse_color(value)
+
+
+static func parse_vector3(value: Variant) -> Variant:
+ return McpJsonValues.parse_vector3(value)
+
+
+static func parse_vector2(value: Variant) -> Variant:
+ return McpJsonValues.parse_vector2(value)
+
+
+## Load a Texture2D from a res:// / uid:// / user:// path (validate_loadable_path).
+## Returns null on failure (including a path that fails confinement / traversal).
+static func load_texture(path: String) -> Texture2D:
+ if not McpPathValidator.validate_loadable_path(path).is_empty():
+ return null
+ if not ResourceLoader.exists(path):
+ return null
+ var res := ResourceLoader.load(path)
+ if res is Texture2D:
+ return res
+ return null
+
+
+## Coerce a JSON-shaped value for a material property.
+## Returns a dict {ok: true, value: ...} on success, or {ok: false, error: "..."} on failure.
+## For properties the coercer doesn't have special logic for, falls back to target_type.
+static func coerce_material_value(property: String, value: Variant, target_type: int) -> Dictionary:
+ # Enum-by-name: must match before generic TYPE_INT coercion.
+ if _ENUM_TABLES.has(property):
+ if value is String:
+ var enum_val = resolve_enum(property, value)
+ if enum_val == null:
+ return {
+ "ok": false,
+ "error": "Invalid %s value: '%s'. Valid: %s" % [
+ property, value, ", ".join(_ENUM_TABLES[property].keys())
+ ],
+ }
+ return {"ok": true, "value": int(enum_val)}
+ if value is int or value is float:
+ return {"ok": true, "value": int(value)}
+
+ match target_type:
+ TYPE_COLOR:
+ var c = parse_color(value)
+ if c == null:
+ return {"ok": false, "error": "Invalid color for %s: %s" % [property, value]}
+ return {"ok": true, "value": c}
+ TYPE_VECTOR3:
+ var v3 = parse_vector3(value)
+ if v3 == null:
+ return {"ok": false, "error": "Invalid vector3 for %s: %s" % [property, value]}
+ return {"ok": true, "value": v3}
+ TYPE_VECTOR2:
+ var v2 = parse_vector2(value)
+ if v2 == null:
+ return {"ok": false, "error": "Invalid vector2 for %s: %s" % [property, value]}
+ return {"ok": true, "value": v2}
+ TYPE_BOOL:
+ if value is bool:
+ return {"ok": true, "value": value}
+ if value is int or value is float:
+ return {"ok": true, "value": bool(value)}
+ return {"ok": false, "error": "Expected bool for %s" % property}
+ TYPE_INT:
+ if value is int:
+ return {"ok": true, "value": value}
+ if value is float:
+ return {"ok": true, "value": int(value)}
+ return {"ok": false, "error": "Expected int for %s" % property}
+ TYPE_FLOAT:
+ ## Canonical scalar parser (#964): numeric strings coerce too.
+ var parsed: Variant = McpJsonValues.parse_float(value)
+ if parsed != null:
+ return {"ok": true, "value": parsed}
+ return {"ok": false, "error": "Expected number for %s" % property}
+ TYPE_OBJECT:
+ if value == null:
+ return {"ok": true, "value": null}
+ if value is Object:
+ return {"ok": true, "value": value}
+ if value is String:
+ var tex := load_texture(value)
+ if tex == null:
+ return {"ok": false, "error": "Resource not found or wrong type: %s" % value}
+ return {"ok": true, "value": tex}
+ return {"ok": false, "error": "Expected resource path (string) for %s" % property}
+ TYPE_STRING:
+ return {"ok": true, "value": String(value)}
+
+ # Unknown target type — pass through.
+ return {"ok": true, "value": value}
+
+
+## Serialize a Variant into JSON-friendly shape for responses.
+static func serialize_value(value: Variant) -> Variant:
+ if value == null:
+ return null
+ if value is Color:
+ return {"r": value.r, "g": value.g, "b": value.b, "a": value.a}
+ if value is Vector3:
+ return {"x": value.x, "y": value.y, "z": value.z}
+ if value is Vector2:
+ return {"x": value.x, "y": value.y}
+ if value is Resource:
+ var path := (value as Resource).resource_path
+ if path.is_empty():
+ return {"type": value.get_class(), "path": ""}
+ return {"type": value.get_class(), "path": path}
+ return value
diff --git a/addons/godot_ai/handlers/material_values.gd.uid b/addons/godot_ai/handlers/material_values.gd.uid
new file mode 100644
index 0000000..7f8bbe9
--- /dev/null
+++ b/addons/godot_ai/handlers/material_values.gd.uid
@@ -0,0 +1 @@
+uid://daqgjkflia8nk
diff --git a/addons/godot_ai/handlers/node_handler.gd b/addons/godot_ai/handlers/node_handler.gd
new file mode 100644
index 0000000..9a969e6
--- /dev/null
+++ b/addons/godot_ai/handlers/node_handler.gd
@@ -0,0 +1,1418 @@
+@tool
+extends "res://addons/godot_ai/handlers/command_handler.gd"
+
+const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
+const VariantSerializer := preload("res://addons/godot_ai/utils/variant_serializer.gd")
+
+## Handles node creation and manipulation with undo/redo support.
+
+const ResourceHandler := preload("res://addons/godot_ai/handlers/resource_handler.gd")
+
+var _undo_redo: EditorUndoRedoManager
+
+
+func _init(undo_redo: EditorUndoRedoManager) -> void:
+ _undo_redo = undo_redo
+
+
+func create_node(params: Dictionary) -> Dictionary:
+ var node_type: String = params.get("type", "")
+ var node_name: String = params.get("name", "")
+ var parent_path: String = params.get("parent_path", "")
+ var scene_path: String = params.get("scene_path", "")
+
+ var scene_check := McpScenePath.require_edited_scene(params.get("scene_file", ""))
+ if scene_check.has("error"):
+ return scene_check
+ var scene_root: Node = scene_check.node
+
+ var parent: Node = scene_root
+ if not parent_path.is_empty():
+ parent = McpScenePath.resolve(parent_path, scene_root)
+ if parent == null:
+ return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, McpScenePath.format_parent_error(parent_path, scene_root))
+
+ var new_node: Node
+
+ if not scene_path.is_empty():
+ # Scene instancing path — load and instantiate a PackedScene.
+ # GEN_EDIT_STATE_INSTANCE makes the editor treat the result as a real
+ # scene instance (foldout icon, the .tscn stores a reference instead of
+ # an exploded subtree). Descendants remain owned by their sub-scene;
+ # setting their owner to our scene_root would break the instance link.
+ var scene_path_err = McpPathValidator.loadable_error(scene_path, "scene_path")
+ if scene_path_err != null:
+ return scene_path_err
+ if not ResourceLoader.exists(scene_path):
+ return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Scene not found: %s" % scene_path)
+ var packed_scene = ResourceLoader.load(scene_path)
+ if packed_scene == null or not packed_scene is PackedScene:
+ return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Resource at %s is not a PackedScene" % scene_path)
+ new_node = packed_scene.instantiate(PackedScene.GEN_EDIT_STATE_INSTANCE)
+ if new_node == null:
+ return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate scene: %s" % scene_path)
+ else:
+ # ClassDB path — create by type.
+ if node_type.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: type (or provide scene_path)")
+ if not ClassDB.class_exists(node_type):
+ return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Unknown node type: %s" % node_type)
+ if not ClassDB.is_parent_class(node_type, "Node"):
+ return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "%s is not a Node type" % node_type)
+ new_node = ClassDB.instantiate(node_type)
+ if new_node == null:
+ return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate %s" % node_type)
+
+ if not node_name.is_empty():
+ new_node.name = node_name
+
+ _undo_redo.create_action("MCP: Create %s" % new_node.name)
+ _undo_redo.add_do_method(parent, "add_child", new_node, true)
+ _undo_redo.add_do_method(new_node, "set_owner", scene_root)
+ _undo_redo.add_do_reference(new_node)
+ _undo_redo.add_undo_method(parent, "remove_child", new_node)
+ _undo_redo.commit_action()
+
+ var response := {
+ "name": new_node.name,
+ "type": new_node.get_class(),
+ "path": McpScenePath.from_node(new_node, scene_root),
+ "parent_path": McpScenePath.from_node(parent, scene_root),
+ "undoable": true,
+ }
+ if not scene_path.is_empty():
+ response["scene_path"] = scene_path
+ return {"data": response}
+
+
+func delete_node(params: Dictionary) -> Dictionary:
+ var resolved := _resolve_node(params)
+ if resolved.has("error"):
+ return resolved
+ var node: Node = resolved.node
+ var node_path: String = resolved.path
+ var scene_root: Node = resolved.scene_root
+
+ var root_err := _reject_if_scene_root(node, scene_root, "delete")
+ if root_err != null:
+ return root_err
+
+ var parent := node.get_parent()
+ var idx := node.get_index()
+
+ _undo_redo.create_action("MCP: Delete %s" % node.name)
+ _undo_redo.add_do_method(parent, "remove_child", node)
+ _undo_redo.add_undo_method(parent, "add_child", node, true)
+ _undo_redo.add_undo_method(parent, "move_child", node, idx)
+ _undo_redo.add_undo_method(node, "set_owner", scene_root)
+ _undo_redo.add_undo_reference(node)
+ _undo_redo.commit_action()
+
+ return {
+ "data": {
+ "path": node_path,
+ "undoable": true,
+ }
+ }
+
+
+func reparent_node(params: Dictionary) -> Dictionary:
+ var resolved := _resolve_node(params)
+ if resolved.has("error"):
+ return resolved
+ var node: Node = resolved.node
+ var node_path: String = resolved.path
+ var scene_root: Node = resolved.scene_root
+
+ var new_parent_path: String = params.get("new_parent", "")
+ if new_parent_path.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: new_parent")
+
+ var new_parent := McpScenePath.resolve(new_parent_path, scene_root)
+ if new_parent == null:
+ return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, McpScenePath.format_parent_error(new_parent_path, scene_root))
+
+ var root_err := _reject_if_scene_root(node, scene_root, "reparent")
+ if root_err != null:
+ return root_err
+
+ # Prevent reparenting a node to itself or to one of its own descendants.
+ # Godot's `A.is_ancestor_of(B)` returns true iff B is a descendant of A, so
+ # the direction here matters: we want `node.is_ancestor_of(new_parent)` to
+ # catch "new_parent is below node in the tree" and thus would create a
+ # cycle. The previous direction (`new_parent.is_ancestor_of(node)`) asked
+ # the opposite question — whether we were trying to move a node to one of
+ # its own ancestors — which is a perfectly valid operation. See issue #121.
+ if node == new_parent or node.is_ancestor_of(new_parent):
+ return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "Cannot reparent a node to itself or its descendant")
+
+ var old_parent := node.get_parent()
+ var old_idx := node.get_index()
+ ## Ported from upstream PR #927 at
+ ## 1a95bcca51d81d29de925c2f636814eaa037c1c2 (issue #904). Snapshot
+ ## descendants before commit: remove_child clears owner on any child whose
+ ## owner sits outside the pruned subtree, so both do and undo must restore
+ ## those owners as part of the recorded action.
+ var descendants := _collect_descendants(node)
+
+ _undo_redo.create_action("MCP: Reparent %s" % node.name)
+ _undo_redo.add_do_method(old_parent, "remove_child", node)
+ _undo_redo.add_do_method(new_parent, "add_child", node, true)
+ _undo_redo.add_do_method(node, "set_owner", scene_root)
+ for child in descendants:
+ _undo_redo.add_do_method(child, "set_owner", scene_root)
+ _undo_redo.add_do_reference(node)
+ _undo_redo.add_undo_method(new_parent, "remove_child", node)
+ _undo_redo.add_undo_method(old_parent, "add_child", node, true)
+ _undo_redo.add_undo_method(old_parent, "move_child", node, old_idx)
+ _undo_redo.add_undo_method(node, "set_owner", scene_root)
+ for child in descendants:
+ ## Keep a null owner as null. Substituting scene_root would make an
+ ## intentionally unowned descendant scene-owned on undo (#904).
+ var prior_owner: Node = child.owner
+ _undo_redo.add_undo_method(child, "set_owner", prior_owner)
+ _undo_redo.add_undo_reference(node)
+ _undo_redo.commit_action()
+
+ return {
+ "data": {
+ "path": McpScenePath.from_node(node, scene_root),
+ "old_parent": McpScenePath.from_node(old_parent, scene_root),
+ "new_parent": McpScenePath.from_node(new_parent, scene_root),
+ "undoable": true,
+ }
+ }
+
+
+func set_property(params: Dictionary) -> Dictionary:
+ var resolved := _resolve_node(params)
+ if resolved.has("error"):
+ return resolved
+ var node: Node = resolved.node
+ var node_path: String = resolved.path
+ var scene_root: Node = resolved.scene_root
+
+ var property: String = params.get("property", "")
+ if property.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: property")
+
+ if not "value" in params:
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: value")
+
+ var value = params.get("value")
+
+ var found := false
+ var prop_type: int = TYPE_NIL
+ for prop in node.get_property_list():
+ if prop.name == property:
+ found = true
+ prop_type = prop.get("type", TYPE_NIL)
+ break
+ if not found:
+ return ErrorCodes.make(ErrorCodes.PROPERTY_NOT_ON_CLASS, McpPropertyErrors.build_message(node, property))
+
+ var old_value = node.get(property)
+ # Prefer declared property type; fall back to runtime type for dynamic props
+ # (scripted @export vars can report TYPE_NIL in the property list).
+ var target_type: int = prop_type if prop_type != TYPE_NIL else typeof(old_value)
+
+ var instantiated_resource := false
+
+ # Some MCP clients (Cline) stringify the documented {"__class__": "BoxMesh", ...}
+ # value before sending. Promote that string back to a Dictionary here so the
+ # `__class__` branch below handles it, instead of the next branch treating
+ # the JSON blob as a res:// path and emitting "Resource not found: {...}".
+ # See #206.
+ if target_type == TYPE_OBJECT and value is String and value.begins_with("{"):
+ var json := JSON.new()
+ if json.parse(value) == OK and json.data is Dictionary and (json.data as Dictionary).has("__class__"):
+ value = json.data
+
+ var nil_resource_string: bool = target_type == TYPE_NIL and (value == "" or (value is String and value.begins_with("res://")))
+ var resource_string_value: bool = value is String and (target_type == TYPE_OBJECT or nil_resource_string)
+ if resource_string_value:
+ if value == "":
+ value = null
+ else:
+ var value_path_err = McpPathValidator.loadable_error(value, "value")
+ if value_path_err != null:
+ return value_path_err
+ if not ResourceLoader.exists(value):
+ return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Resource not found: %s" % value)
+ var loaded := ResourceLoader.load(value)
+ if loaded == null:
+ return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Resource not found: %s" % value)
+ value = loaded
+ elif target_type == TYPE_OBJECT and value is Dictionary and value.has("__class__"):
+ # Shortcut: {"__class__": "BoxMesh", "size": {...}} instantiates a
+ # fresh Resource subclass and applies the remaining keys as
+ # properties. Mirrors resource_create's inline-assign path but
+ # avoids a separate tool call for the common case.
+ var type_str: String = value.get("__class__", "")
+ var made := ResourceHandler._instantiate_resource(type_str)
+ if made is Dictionary:
+ return made
+ var res: Resource = made
+ var remaining: Dictionary = (value as Dictionary).duplicate()
+ remaining.erase("__class__")
+ if not remaining.is_empty():
+ var apply_err := ResourceHandler._apply_resource_properties(res, remaining)
+ if apply_err != null:
+ return apply_err
+ value = res
+ instantiated_resource = true
+ elif target_type == TYPE_ARRAY and old_value is Array and (old_value as Array).is_typed():
+ ## Typed Array[T] slot (#612): the generic TYPE_ARRAY passthrough
+ ## hands an untyped Array to Godot's typed setter, which rejects it
+ ## wholesale and leaves the slot at its default — with success still
+ ## reported. Route through the element-aware coercer instead; errors
+ ## name the offending element index.
+ var typed_out: Variant = _coerce_typed_array(value, old_value)
+ if typed_out is Dictionary:
+ return typed_out
+ value = typed_out
+ elif (
+ target_type == TYPE_DICTIONARY
+ and old_value is Dictionary
+ and (old_value as Dictionary).is_typed()
+ ):
+ ## Typed Dictionary[K, V] slot (#612 stage 3) — same silent-drop
+ ## family as typed arrays. A successful result is always a TYPED
+ ## Dictionary (a cleared duplicate of the slot), while the error
+ ## envelope is an untyped {"error": ...} — that's the discriminator
+ ## (a legit payload could contain an "error" key; typedness can't lie).
+ var typed_dict_out: Dictionary = _coerce_typed_dictionary(value, old_value)
+ if not typed_dict_out.is_typed():
+ return typed_dict_out
+ value = typed_dict_out
+ else:
+ value = _coerce_value(value, target_type)
+ ## Refuse any value that didn't land as the target compound Variant
+ ## — wrong-shape dict (#123) or non-dict input like list / JSON string
+ ## that used to silently default-construct Vector3.ZERO (#191).
+ var coerce_err := _check_coerced(value, target_type)
+ if coerce_err != null:
+ return coerce_err
+
+ _undo_redo.create_action("MCP: Set %s.%s" % [node.name, property])
+ _undo_redo.add_do_property(node, property, value)
+ _undo_redo.add_undo_property(node, property, old_value)
+ if instantiated_resource:
+ _undo_redo.add_do_reference(value)
+ _undo_redo.commit_action()
+
+ return {
+ "data": {
+ "path": node_path,
+ "property": property,
+ "value": _serialize_value(node.get(property)),
+ "old_value": _serialize_value(old_value),
+ "undoable": true,
+ }
+ }
+
+
+func rename_node(params: Dictionary) -> Dictionary:
+ var resolved := _resolve_node(params)
+ if resolved.has("error"):
+ return resolved
+ var node: Node = resolved.node
+ var node_path: String = resolved.path
+ var scene_root: Node = resolved.scene_root
+
+ var new_name: String = params.get("new_name", "")
+ if new_name.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: new_name")
+
+ ## The scene root's name is baked into the .tscn serialization and is
+ ## referenced by every NodePath that starts with `/` (AnimationPlayer
+ ## tracks, RemoteTransform3D targets, exported NodePath @vars, etc.).
+ ## Renaming it silently breaks those references. The MCP tool's docstring
+ ## has always promised "Cannot rename the scene root" — enforce it. #122
+ if node == scene_root:
+ return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "Cannot rename the scene root")
+
+ if new_name.validate_node_name() != new_name:
+ return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Invalid characters in name: %s" % new_name)
+
+ var old_name := String(node.name)
+ if old_name == new_name:
+ return {
+ "data": {
+ "path": node_path,
+ "name": new_name,
+ "old_name": old_name,
+ "unchanged": true,
+ "undoable": false,
+ "reason": "Name unchanged",
+ }
+ }
+
+ var parent := node.get_parent()
+ for sibling in parent.get_children():
+ if sibling != node and String(sibling.name) == new_name:
+ return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "A sibling already has the name '%s'" % new_name)
+
+ _undo_redo.create_action("MCP: Rename %s to %s" % [old_name, new_name])
+ _undo_redo.add_do_property(node, "name", new_name)
+ _undo_redo.add_undo_property(node, "name", old_name)
+ _undo_redo.commit_action()
+
+ return {
+ "data": {
+ "path": McpScenePath.from_node(node, scene_root),
+ "old_path": node_path,
+ "name": String(node.name),
+ "old_name": old_name,
+ "undoable": true,
+ }
+ }
+
+
+func duplicate_node(params: Dictionary) -> Dictionary:
+ var resolved := _resolve_node(params)
+ if resolved.has("error"):
+ return resolved
+ var node: Node = resolved.node
+ var node_path: String = resolved.path
+ var scene_root: Node = resolved.scene_root
+
+ var root_err := _reject_if_scene_root(node, scene_root, "duplicate")
+ if root_err != null:
+ return root_err
+
+ var parent := node.get_parent()
+ var dup: Node = node.duplicate()
+ if dup == null:
+ return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to duplicate node")
+
+ # Apply optional name
+ var new_name: String = params.get("name", "")
+ if not new_name.is_empty():
+ dup.name = new_name
+
+ ## Ported from upstream PR #927 (issue #904). Record descendant owners
+ ## inside the action so redo restores them. Undo is just remove_child of
+ ## the copy; descendants live on `dup` via add_do_reference and do not need
+ ## their own undo set_owner.
+ var descendants := _collect_descendants(dup)
+
+ _undo_redo.create_action("MCP: Duplicate %s" % node.name)
+ _undo_redo.add_do_method(parent, "add_child", dup, true)
+ _undo_redo.add_do_method(dup, "set_owner", scene_root)
+ for child in descendants:
+ _undo_redo.add_do_method(child, "set_owner", scene_root)
+ _undo_redo.add_do_reference(dup)
+ _undo_redo.add_undo_method(parent, "remove_child", dup)
+ _undo_redo.commit_action()
+
+ return {
+ "data": {
+ "path": McpScenePath.from_node(dup, scene_root),
+ "original_path": node_path,
+ "name": dup.name,
+ "type": dup.get_class(),
+ "undoable": true,
+ }
+ }
+
+
+func move_node(params: Dictionary) -> Dictionary:
+ var resolved := _resolve_node(params)
+ if resolved.has("error"):
+ return resolved
+ var node: Node = resolved.node
+ var node_path: String = resolved.path
+ var scene_root: Node = resolved.scene_root
+
+ var root_err := _reject_if_scene_root(node, scene_root, "reorder")
+ if root_err != null:
+ return root_err
+
+ if not "index" in params:
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: index")
+
+ var new_index: int = params.get("index", 0)
+ var parent := node.get_parent()
+ var old_index := node.get_index()
+ var sibling_count := parent.get_child_count()
+
+ if new_index < 0 or new_index >= sibling_count:
+ return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Index %d out of range (0..%d)" % [new_index, sibling_count - 1])
+
+ _undo_redo.create_action("MCP: Move %s to index %d" % [node.name, new_index])
+ _undo_redo.add_do_method(parent, "move_child", node, new_index)
+ _undo_redo.add_undo_method(parent, "move_child", node, old_index)
+ _undo_redo.commit_action()
+
+ return {
+ "data": {
+ "path": node_path,
+ "old_index": old_index,
+ "new_index": new_index,
+ "undoable": true,
+ }
+ }
+
+
+func add_to_group(params: Dictionary) -> Dictionary:
+ var resolved := _resolve_node(params)
+ if resolved.has("error"):
+ return resolved
+ var node: Node = resolved.node
+ var node_path: String = resolved.path
+
+ var group_value: Variant = params.get("group", "")
+ var type_err := McpParamValidators.require_string("group", group_value)
+ if type_err != null:
+ return type_err
+ var group := String(group_value)
+ if group.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: group")
+
+ if node.is_in_group(group):
+ return {"data": {"path": node_path, "group": group, "already_member": true, "undoable": false, "reason": "No change made"}}
+
+ _undo_redo.create_action("MCP: Add %s to group %s" % [node.name, group])
+ _undo_redo.add_do_method(node, "add_to_group", group, true)
+ _undo_redo.add_undo_method(node, "remove_from_group", group)
+ _undo_redo.commit_action()
+
+ return {
+ "data": {
+ "path": node_path,
+ "group": group,
+ "undoable": true,
+ }
+ }
+
+
+func remove_from_group(params: Dictionary) -> Dictionary:
+ var resolved := _resolve_node(params)
+ if resolved.has("error"):
+ return resolved
+ var node: Node = resolved.node
+ var node_path: String = resolved.path
+
+ var group_value: Variant = params.get("group", "")
+ var type_err := McpParamValidators.require_string("group", group_value)
+ if type_err != null:
+ return type_err
+ var group := String(group_value)
+ if group.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: group")
+
+ if not node.is_in_group(group):
+ return {"data": {"path": node_path, "group": group, "not_member": true, "undoable": false, "reason": "Node not in group"}}
+
+ _undo_redo.create_action("MCP: Remove %s from group %s" % [node.name, group])
+ _undo_redo.add_do_method(node, "remove_from_group", group)
+ _undo_redo.add_undo_method(node, "add_to_group", group, true)
+ _undo_redo.commit_action()
+
+ return {
+ "data": {
+ "path": node_path,
+ "group": group,
+ "undoable": true,
+ }
+ }
+
+
+func set_selection(params: Dictionary) -> Dictionary:
+ var paths: Array = params.get("paths", [])
+ var _scene_check := McpNodeValidator.require_scene_or_error()
+ if _scene_check.has("error"):
+ return _scene_check
+ var scene_root: Node = _scene_check.scene_root
+
+ var selection := EditorInterface.get_selection()
+ selection.clear()
+
+ var selected: Array[String] = []
+ var not_found: Array[String] = []
+ for path_variant in paths:
+ var path: String = str(path_variant)
+ var node := McpScenePath.resolve(path, scene_root)
+ if node:
+ selection.add_node(node)
+ selected.append(path)
+ else:
+ not_found.append(path)
+
+ return {
+ "data": {
+ "selected": selected,
+ "not_found": not_found,
+ "count": selected.size(),
+ "undoable": false,
+ "reason": "Selection changes are not tracked in undo history",
+ }
+ }
+
+
+## All descendants of `node` (not including `node` itself), depth-first.
+## Used to record per-child set_owner inside an undo action without targeting
+## the handler as an UndoRedo receiver (upstream PR #927 / issue #904).
+static func _collect_descendants(node: Node) -> Array[Node]:
+ var out: Array[Node] = []
+ for child in node.get_children():
+ out.append(child)
+ out.append_array(_collect_descendants(child))
+ return out
+
+
+## Canonical dict-key sets for dict→Variant coercion. Alpha on `COLOR_KEYS`
+## is optional — the coercer defaults it to 1.0 when absent.
+const VECTOR2_KEYS: Array[String] = ["x", "y"]
+const VECTOR3_KEYS: Array[String] = ["x", "y", "z"]
+const VECTOR4_KEYS: Array[String] = ["x", "y", "z", "w"]
+const COLOR_KEYS: Array[String] = ["r", "g", "b"]
+
+
+## End-to-end coerce check for compound JSON-shaped targets
+## (Vector2/Vector3/Color). Returns a full `make(...)`-shaped error dict
+## if `value` didn't land as the target Variant after `_coerce_value`,
+## else null. Wrong-shape dicts get the `_check_dict_coerce_failed`
+## message (expected-vs-got keys); non-dict inputs (Array, String,
+## primitive) name the received type and a JSON shape hint. No-op for
+## non-compound targets — Godot's setter handles those.
+##
+## Used by set_property, resource_handler, and validation handlers
+## (curve, texture). Issue #191 — passing a list, JSON string, or
+## anything else to a Vector3 property used to silently store
+## Vector3.ZERO; this gates that path.
+static func _check_coerced(value: Variant, target_type: int, prefix: String = "") -> Variant:
+ var ok := false
+ match target_type:
+ TYPE_VECTOR2:
+ ok = value is Vector2
+ TYPE_VECTOR3:
+ ok = value is Vector3
+ TYPE_COLOR:
+ ok = value is Color
+ TYPE_PACKED_VECTOR2_ARRAY:
+ ok = value is PackedVector2Array
+ TYPE_PACKED_VECTOR3_ARRAY:
+ ok = value is PackedVector3Array
+ TYPE_PACKED_VECTOR4_ARRAY:
+ ok = value is PackedVector4Array
+ TYPE_PACKED_COLOR_ARRAY:
+ ok = value is PackedColorArray
+ TYPE_PACKED_INT32_ARRAY:
+ ok = value is PackedInt32Array
+ TYPE_PACKED_INT64_ARRAY:
+ ok = value is PackedInt64Array
+ TYPE_PACKED_FLOAT32_ARRAY:
+ ok = value is PackedFloat32Array
+ TYPE_PACKED_FLOAT64_ARRAY:
+ ok = value is PackedFloat64Array
+ TYPE_PACKED_STRING_ARRAY:
+ ok = value is PackedStringArray
+ TYPE_VECTOR2I: ok = value is Vector2i
+ TYPE_VECTOR3I: ok = value is Vector3i
+ TYPE_VECTOR4: ok = value is Vector4
+ TYPE_VECTOR4I: ok = value is Vector4i
+ TYPE_QUATERNION: ok = value is Quaternion
+ TYPE_RECT2: ok = value is Rect2
+ TYPE_RECT2I: ok = value is Rect2i
+ TYPE_AABB: ok = value is AABB
+ TYPE_PLANE: ok = value is Plane
+ TYPE_BASIS: ok = value is Basis
+ TYPE_TRANSFORM2D: ok = value is Transform2D
+ TYPE_TRANSFORM3D: ok = value is Transform3D
+ TYPE_PROJECTION: ok = value is Projection
+ _:
+ # null / untyped-TYPE_NIL / already-correct-type are handled by
+ # Godot's setter; anything else would silently no-op, so error.
+ if value == null or target_type == TYPE_NIL or typeof(value) == target_type:
+ return null
+ var unsupported := ErrorCodes.make(
+ ErrorCodes.WRONG_TYPE,
+ "Cannot write %s to a %s property; godot-ai has no coercion for that type" % [
+ type_string(typeof(value)), type_string(target_type),
+ ],
+ )
+ return ErrorCodes.prefix_message(unsupported, prefix)
+ if ok:
+ return null
+ var dict_err := _check_dict_coerce_failed(value, target_type)
+ if dict_err != null:
+ return ErrorCodes.prefix_message(dict_err, prefix)
+ ## Wording stays neutral on shape — `_shape_hint` already produces a
+ ## dict-shaped string for Vector2/3/Color and a list-shaped one for
+ ## the Packed*Array slots. The old "expected a dict like [...]" phrasing
+ ## read self-contradictory for packed targets (PR #424 review).
+ var err := ErrorCodes.make(
+ ErrorCodes.WRONG_TYPE,
+ "Cannot coerce %s to %s; expected %s" % [
+ type_string(typeof(value)), type_string(target_type), _shape_hint(target_type),
+ ],
+ )
+ return ErrorCodes.prefix_message(err, prefix)
+
+
+## Build a "{\"x\":1,...}" hint string from the canonical key constants
+## so adding a key (e.g. Vector4) only touches VECTORN_KEYS. Packed*Array
+## targets short-circuit to a literal list-shaped hint.
+static func _shape_hint(target_type: int) -> String:
+ match target_type:
+ TYPE_PACKED_VECTOR2_ARRAY:
+ return "[{\"x\":0,\"y\":0}, ...]"
+ TYPE_PACKED_VECTOR3_ARRAY:
+ return "[{\"x\":0,\"y\":0,\"z\":0}, ...]"
+ TYPE_PACKED_VECTOR4_ARRAY:
+ return "[{\"x\":0,\"y\":0,\"z\":0,\"w\":0}, ...]"
+ TYPE_PACKED_COLOR_ARRAY:
+ return "[{\"r\":0,\"g\":0,\"b\":0,\"a\":1}, ...]"
+ TYPE_PACKED_INT32_ARRAY, TYPE_PACKED_INT64_ARRAY:
+ return "[int, ...]"
+ TYPE_PACKED_FLOAT32_ARRAY, TYPE_PACKED_FLOAT64_ARRAY:
+ return "[float, ...]"
+ TYPE_PACKED_STRING_ARRAY:
+ return "[\"...\", ...]"
+ TYPE_VECTOR2I:
+ return "{\"x\":0,\"y\":0}"
+ TYPE_VECTOR3I:
+ return "{\"x\":0,\"y\":0,\"z\":0}"
+ TYPE_VECTOR4, TYPE_VECTOR4I, TYPE_QUATERNION:
+ return "{\"x\":0,\"y\":0,\"z\":0,\"w\":0}"
+ TYPE_RECT2, TYPE_RECT2I, TYPE_AABB:
+ return "{\"position\":{...},\"size\":{...}}"
+ TYPE_PLANE:
+ return "{\"normal\":{...},\"d\":0}"
+ TYPE_BASIS:
+ return "{\"x\":{...},\"y\":{...},\"z\":{...}}"
+ TYPE_TRANSFORM2D:
+ return "{\"x\":{...},\"y\":{...},\"origin\":{...}}"
+ TYPE_TRANSFORM3D:
+ return "{\"basis\":{...},\"origin\":{...}}"
+ TYPE_PROJECTION:
+ return "{\"x\":{...},\"y\":{...},\"z\":{...},\"w\":{...}}"
+ var keys: Array[String] = []
+ match target_type:
+ TYPE_VECTOR2: keys = VECTOR2_KEYS
+ TYPE_VECTOR3: keys = VECTOR3_KEYS
+ TYPE_COLOR: keys = COLOR_KEYS
+ var pairs: Array[String] = []
+ for k in keys:
+ pairs.append("\"%s\":0" % k)
+ return "{" + ",".join(pairs) + "}"
+
+
+## Detect a failed dict→typed-Variant coercion. Returns an INVALID_PARAMS
+## error dict if `value` is still a Dictionary after a coercion attempt
+## targeting a Vector2/Vector3/Color slot, else null. Message names the
+## expected keys and the keys actually received so agents self-correct
+## on the next retry.
+static func _check_dict_coerce_failed(value: Variant, target_type: int) -> Variant:
+ if not (value is Dictionary):
+ return null
+ var expected: Array[String] = []
+ var type_name := ""
+ match target_type:
+ TYPE_VECTOR2:
+ expected = VECTOR2_KEYS
+ type_name = "Vector2"
+ TYPE_VECTOR3:
+ expected = VECTOR3_KEYS
+ type_name = "Vector3"
+ TYPE_COLOR:
+ expected = COLOR_KEYS
+ type_name = "Color"
+ _:
+ return null
+ var got_keys: Array = (value as Dictionary).keys()
+ return ErrorCodes.make(
+ ErrorCodes.WRONG_TYPE,
+ "Cannot coerce dict to %s: expected keys %s; got %s" % [type_name, str(expected), str(got_keys)]
+ )
+
+
+## Coerce JSON-shaped values into Godot Variants when the target property
+## type is known. Returns the coerced value on success, or the input
+## unchanged on failure — callers detect the type mismatch via an
+## `is ` check (curve_handler, texture_handler) or via the
+## `_check_dict_coerce_failed` helper (set_property, resource_handler).
+##
+## Dictionary→Vector2/Vector3/Color cases REQUIRE all canonical keys;
+## wrong-shape dicts flow through unchanged. See issue #123 — previous
+## `dict.get(key, 0)` defaults silently zero-filled missing axes.
+static func _coerce_value(value: Variant, target_type: int) -> Variant:
+ match target_type:
+ ## Vector2/Vector3/Color route through the canonical strict parser
+ ## (#714): same dict/array/string shapes as every other handler, and
+ ## non-numeric components fall through (returning the original
+ ## value) so _check_coerced flags them instead of crashing a typed
+ ## constructor or silently writing black/zeros.
+ TYPE_VECTOR2:
+ var v2 = McpJsonValues.parse_vector2(value)
+ if v2 != null:
+ return v2
+ TYPE_VECTOR3:
+ var v3 = McpJsonValues.parse_vector3(value)
+ if v3 != null:
+ return v3
+ TYPE_COLOR:
+ var col = McpJsonValues.parse_color(value)
+ if col != null:
+ return col
+ TYPE_BOOL:
+ if value is float or value is int:
+ return bool(value)
+ TYPE_INT:
+ if value is float:
+ return int(value)
+ TYPE_FLOAT:
+ if value is int:
+ return float(value)
+ if value is String:
+ ## #964: some MCP clients stringify float arguments ("4.0").
+ ## Accept strictly-numeric strings; unparseable ones flow
+ ## through unchanged so _check_coerced raises the typed
+ ## WRONG_TYPE error instead of a silent zero/null write.
+ var parsed: Variant = McpJsonValues.parse_float(value)
+ if parsed != null:
+ return parsed
+ TYPE_STRING_NAME:
+ if value is String:
+ return StringName(value)
+ TYPE_NODE_PATH:
+ if value is String:
+ return NodePath(value)
+ if value == null:
+ return NodePath()
+ TYPE_OBJECT:
+ # Resource loading is handled in set_property so we can return a
+ # typed error; here we only pass through cleared values.
+ if value == null:
+ return null
+ TYPE_ARRAY:
+ if value is Array:
+ return value
+ TYPE_DICTIONARY:
+ if value is Dictionary:
+ return value
+ TYPE_PACKED_VECTOR2_ARRAY:
+ if value is Array:
+ var out := PackedVector2Array()
+ for item in value:
+ if item is Vector2:
+ out.append(item)
+ elif item is Dictionary and item.has_all(VECTOR2_KEYS):
+ out.append(Vector2(item["x"], item["y"]))
+ else:
+ return value # leave for _check_coerced to flag
+ return out
+ TYPE_PACKED_VECTOR3_ARRAY:
+ if value is Array:
+ var out := PackedVector3Array()
+ for item in value:
+ if item is Vector3:
+ out.append(item)
+ elif item is Dictionary and item.has_all(VECTOR3_KEYS):
+ out.append(Vector3(item["x"], item["y"], item["z"]))
+ else:
+ return value
+ return out
+ TYPE_PACKED_VECTOR4_ARRAY:
+ if value is Array:
+ var out := PackedVector4Array()
+ for item in value:
+ if item is Vector4:
+ out.append(item)
+ elif item is Dictionary and item.has_all(VECTOR4_KEYS):
+ out.append(Vector4(item["x"], item["y"], item["z"], item["w"]))
+ else:
+ return value
+ return out
+ TYPE_PACKED_COLOR_ARRAY:
+ if value is Array:
+ var out := PackedColorArray()
+ for item in value:
+ if item is Color:
+ out.append(item)
+ elif item is Dictionary and item.has_all(COLOR_KEYS):
+ out.append(Color(item["r"], item["g"], item["b"], item.get("a", 1.0)))
+ elif item is String:
+ out.append(Color(item))
+ else:
+ return value
+ return out
+ TYPE_PACKED_INT32_ARRAY, TYPE_PACKED_INT64_ARRAY:
+ if value is Array:
+ var out: Variant = PackedInt32Array() if target_type == TYPE_PACKED_INT32_ARRAY else PackedInt64Array()
+ for item in value:
+ if item is int or item is float:
+ out.append(int(item))
+ else:
+ return value
+ return out
+ TYPE_PACKED_FLOAT32_ARRAY, TYPE_PACKED_FLOAT64_ARRAY:
+ if value is Array:
+ var out: Variant = PackedFloat32Array() if target_type == TYPE_PACKED_FLOAT32_ARRAY else PackedFloat64Array()
+ for item in value:
+ if item is float or item is int:
+ out.append(float(item))
+ else:
+ return value
+ return out
+ TYPE_PACKED_STRING_ARRAY:
+ if value is Array:
+ var out := PackedStringArray()
+ for item in value:
+ if item is String:
+ out.append(item)
+ else:
+ return value
+ return out
+ TYPE_VECTOR2I:
+ if value is Dictionary and value.has_all(VECTOR2_KEYS):
+ return Vector2i(int(value["x"]), int(value["y"]))
+ TYPE_VECTOR3I:
+ if value is Dictionary and value.has_all(VECTOR3_KEYS):
+ return Vector3i(int(value["x"]), int(value["y"]), int(value["z"]))
+ TYPE_VECTOR4:
+ if value is Dictionary and value.has_all(VECTOR4_KEYS):
+ return Vector4(value["x"], value["y"], value["z"], value["w"])
+ TYPE_VECTOR4I:
+ if value is Dictionary and value.has_all(VECTOR4_KEYS):
+ return Vector4i(int(value["x"]), int(value["y"]), int(value["z"]), int(value["w"]))
+ TYPE_QUATERNION:
+ if value is Dictionary and value.has_all(VECTOR4_KEYS):
+ return Quaternion(value["x"], value["y"], value["z"], value["w"])
+ TYPE_RECT2:
+ if value is Dictionary and value.has("position") and value.has("size"):
+ var p: Variant = _coerce_value(value["position"], TYPE_VECTOR2)
+ var s: Variant = _coerce_value(value["size"], TYPE_VECTOR2)
+ if p is Vector2 and s is Vector2:
+ return Rect2(p, s)
+ TYPE_RECT2I:
+ if value is Dictionary and value.has("position") and value.has("size"):
+ var p: Variant = _coerce_value(value["position"], TYPE_VECTOR2I)
+ var s: Variant = _coerce_value(value["size"], TYPE_VECTOR2I)
+ if p is Vector2i and s is Vector2i:
+ return Rect2i(p, s)
+ TYPE_AABB:
+ if value is Dictionary and value.has("position") and value.has("size"):
+ var p: Variant = _coerce_value(value["position"], TYPE_VECTOR3)
+ var s: Variant = _coerce_value(value["size"], TYPE_VECTOR3)
+ if p is Vector3 and s is Vector3:
+ return AABB(p, s)
+ TYPE_PLANE:
+ if value is Dictionary and value.has("normal") and value.has("d"):
+ var n: Variant = _coerce_value(value["normal"], TYPE_VECTOR3)
+ if n is Vector3:
+ return Plane(n, float(value["d"]))
+ TYPE_BASIS:
+ if value is Dictionary and value.has_all(["x", "y", "z"]):
+ var bx: Variant = _coerce_value(value["x"], TYPE_VECTOR3)
+ var by: Variant = _coerce_value(value["y"], TYPE_VECTOR3)
+ var bz: Variant = _coerce_value(value["z"], TYPE_VECTOR3)
+ if bx is Vector3 and by is Vector3 and bz is Vector3:
+ return Basis(bx, by, bz)
+ TYPE_TRANSFORM2D:
+ if value is Dictionary and value.has_all(["x", "y", "origin"]):
+ var tx: Variant = _coerce_value(value["x"], TYPE_VECTOR2)
+ var ty: Variant = _coerce_value(value["y"], TYPE_VECTOR2)
+ var to_: Variant = _coerce_value(value["origin"], TYPE_VECTOR2)
+ if tx is Vector2 and ty is Vector2 and to_ is Vector2:
+ return Transform2D(tx, ty, to_)
+ TYPE_TRANSFORM3D:
+ if value is Dictionary and value.has("basis") and value.has("origin"):
+ var b: Variant = _coerce_value(value["basis"], TYPE_BASIS)
+ var o: Variant = _coerce_value(value["origin"], TYPE_VECTOR3)
+ if b is Basis and o is Vector3:
+ return Transform3D(b, o)
+ TYPE_PROJECTION:
+ if value is Dictionary and value.has_all(VECTOR4_KEYS):
+ var px: Variant = _coerce_value(value["x"], TYPE_VECTOR4)
+ var py: Variant = _coerce_value(value["y"], TYPE_VECTOR4)
+ var pz: Variant = _coerce_value(value["z"], TYPE_VECTOR4)
+ var pw: Variant = _coerce_value(value["w"], TYPE_VECTOR4)
+ if px is Vector4 and py is Vector4 and pz is Vector4 and pw is Vector4:
+ return Projection(px, py, pz, pw)
+ # PackedByteArray intentionally unhandled — needs design decision
+ # (base64 string vs. raw int list); JSON has no native byte type.
+ return value
+
+
+## Fill a typed `Array[T]` slot from a JSON list (#612 stage 1: value-element
+## types). `slot_value` is the property's current typed Array — Godot's getter
+## returns the (possibly empty) typed container, which carries the element
+## type, so no PROPERTY_HINT_TYPE_STRING parsing is needed. Elements coerce
+## one at a time through the existing `_coerce_value` / `_check_coerced`
+## pair, then bulk-move via `Array.assign()` with a post-assign size check,
+## so a wrong element can never silently drop the write: it errors naming
+## the element index. Returns the filled typed Array on success, or a
+## `make(...)`-shaped error Dictionary (callers discriminate on
+## `result is Dictionary` — a successful result is always an Array).
+##
+## Object elements (Array[Texture2D], Array[MyResource], ...) coerce per
+## element through `_coerce_object_element` (#612 stage 2), mirroring the
+## single-slot TYPE_OBJECT paths: res:// strings load, {"__class__": ...}
+## instantiates, and each landed element is conformance-checked against the
+## slot's element class/script so a wrong-class Resource errors naming the
+## index instead of being rejected wholesale by `assign`.
+static func _coerce_typed_array(value: Variant, slot_value: Array, prefix: String = "") -> Variant:
+ var elem_label := _typed_array_element_label(slot_value)
+ if not (value is Array):
+ var err := ErrorCodes.make(
+ ErrorCodes.WRONG_TYPE,
+ "Cannot write %s to a typed Array[%s] property; expected a list" % [
+ type_string(typeof(value)), elem_label,
+ ],
+ )
+ return ErrorCodes.prefix_message(err, prefix)
+ var elem_type := slot_value.get_typed_builtin()
+ var staging: Array = []
+ var in_list: Array = value
+ for i in in_list.size():
+ var elem_prefix := ("element %d" % i) if prefix.is_empty() else "%s element %d" % [prefix, i]
+ var coerced: Variant
+ if elem_type == TYPE_OBJECT:
+ coerced = _coerce_object_element(in_list[i], elem_prefix)
+ if coerced is Dictionary:
+ ## Object elements are never legit Dictionaries (a dict input
+ ## is either {"__class__"} — consumed above — or an error), so
+ ## a Dictionary return is unambiguously the error envelope.
+ return coerced
+ if coerced != null and not _object_element_conforms(coerced, slot_value):
+ var conform_err := ErrorCodes.make(
+ ErrorCodes.WRONG_TYPE,
+ "element is %s, which is not a %s" % [
+ (coerced as Object).get_class(), elem_label,
+ ],
+ )
+ return ErrorCodes.prefix_message(conform_err, elem_prefix)
+ else:
+ coerced = _coerce_value(in_list[i], elem_type)
+ var elem_err := _check_coerced(coerced, elem_type, elem_prefix)
+ if elem_err != null:
+ return elem_err
+ if coerced == null:
+ ## Prefix with `elem_prefix` (which already folds in `prefix`),
+ ## not `prefix` again — the latter double-stamped the property
+ ## context (PR #682 review). Object arrays allow null entries
+ ## (Godot typed object arrays store null); value-type arrays
+ ## don't.
+ var null_err := ErrorCodes.make(
+ ErrorCodes.WRONG_TYPE,
+ "cannot store null in Array[%s]" % elem_label,
+ )
+ return ErrorCodes.prefix_message(null_err, elem_prefix)
+ staging.append(coerced)
+ var out := slot_value.duplicate()
+ out.clear()
+ out.assign(staging)
+ if out.size() != staging.size():
+ ## Backstop for element shapes `_check_coerced` waves through but the
+ ## typed container still rejects — `assign` loud-rejects and leaves a
+ ## short array, which without this check would be a partial write.
+ var assign_err := ErrorCodes.make(
+ ErrorCodes.WRONG_TYPE,
+ "Array[%s] element conversion failed during assign (%d of %d elements landed)" % [
+ elem_label, out.size(), staging.size(),
+ ],
+ )
+ return ErrorCodes.prefix_message(assign_err, prefix)
+ return out
+
+
+## "int" / "Vector3" / "Texture2D" / "MyItemData" — element-type name of a
+## typed Array slot, for error messages.
+static func _typed_array_element_label(slot_value: Array) -> String:
+ if slot_value.get_typed_builtin() == TYPE_OBJECT:
+ return _object_type_label(slot_value.get_typed_class_name(), slot_value.get_typed_script())
+ return type_string(slot_value.get_typed_builtin())
+
+
+## Coerce one element of an object-typed Array (#612 stage 2). Mirrors the
+## single-slot TYPE_OBJECT paths in set_property: a res:// path string loads
+## the Resource; {"__class__": "X", ...} (including the #206 stringified
+## form) instantiates via ResourceHandler and applies the remaining keys;
+## "" / null store a null entry (typed object arrays allow them). Returns
+## the Object (or null), or a make(...)-shaped error Dictionary with
+## `elem_prefix` already folded in.
+static func _coerce_object_element(elem: Variant, elem_prefix: String) -> Variant:
+ if elem == null:
+ return null
+ if elem is Object:
+ return elem
+ if elem is String and (elem as String).begins_with("{"):
+ var json := JSON.new()
+ if json.parse(elem) == OK and json.data is Dictionary and (json.data as Dictionary).has("__class__"):
+ elem = json.data
+ if elem is String:
+ if String(elem).is_empty():
+ return null
+ var path_err = McpPathValidator.loadable_error(elem, "value")
+ if path_err != null:
+ return ErrorCodes.prefix_message(path_err, elem_prefix)
+ if not ResourceLoader.exists(elem):
+ return ErrorCodes.prefix_message(
+ ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Resource not found: %s" % elem),
+ elem_prefix,
+ )
+ var loaded := ResourceLoader.load(elem)
+ if loaded == null:
+ return ErrorCodes.prefix_message(
+ ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Resource not found: %s" % elem),
+ elem_prefix,
+ )
+ return loaded
+ if elem is Dictionary and (elem as Dictionary).has("__class__"):
+ var type_str: String = (elem as Dictionary).get("__class__", "")
+ var made := ResourceHandler._instantiate_resource(type_str)
+ if made is Dictionary:
+ return ErrorCodes.prefix_message(made, elem_prefix)
+ var res: Resource = made
+ var remaining: Dictionary = (elem as Dictionary).duplicate()
+ remaining.erase("__class__")
+ if not remaining.is_empty():
+ var apply_err: Variant = ResourceHandler._apply_resource_properties(res, remaining)
+ if apply_err != null:
+ return ErrorCodes.prefix_message(apply_err, elem_prefix)
+ return res
+ return ErrorCodes.prefix_message(
+ ErrorCodes.make(
+ ErrorCodes.WRONG_TYPE,
+ 'cannot convert %s to an Object element; pass a res:// path or {"__class__": ...}'
+ % type_string(typeof(elem)),
+ ),
+ elem_prefix,
+ )
+
+
+## True when `elem` satisfies the object-typed slot's element constraint —
+## script type when the slot is Array[MyScriptClass], native class
+## otherwise. Checked per element so a wrong-class Resource errors naming
+## the index instead of being rejected wholesale by `Array.assign()`.
+static func _object_element_conforms(elem: Object, slot_value: Array) -> bool:
+ return _object_conforms(elem, slot_value.get_typed_class_name(), slot_value.get_typed_script())
+
+
+## Shared class/script conformance predicate for typed Array elements and
+## typed Dictionary values (#612 stages 2–3).
+static func _object_conforms(elem: Object, cls_name: StringName, script: Variant) -> bool:
+ if script is Script:
+ return is_instance_of(elem, script)
+ var cls := String(cls_name)
+ return cls.is_empty() or elem.is_class(cls)
+
+
+## Fill a typed `Dictionary[K, V]` slot from a JSON object (#612 stage 3).
+## `slot_value` is the property's current typed Dictionary — the getter
+## returns the (possibly empty) typed container carrying both constraint
+## sides. Keys coerce via `_coerce_typed_dict_key` (JSON object keys are
+## always Strings, so int/float/StringName key slots parse the string and
+## fail closed on anything inexact); values mirror the typed-array element
+## rules — object values through `_coerce_object_element` + conformance,
+## everything else through `_coerce_value`/`_check_coerced`. Never partial:
+## any bad key or value errors naming the key and nothing is written.
+##
+## Returns the filled TYPED Dictionary on success or an UNTYPED
+## `make(...)`-shaped error Dictionary — callers discriminate on
+## `is_typed()`, since a success result is always a duplicate of the typed
+## slot and error envelopes are plain dicts.
+static func _coerce_typed_dictionary(
+ value: Variant, slot_value: Dictionary, prefix: String = ""
+) -> Dictionary:
+ var label := _typed_dictionary_label(slot_value)
+ if not (value is Dictionary):
+ var err := ErrorCodes.make(
+ ErrorCodes.WRONG_TYPE,
+ "Cannot write %s to a typed %s property; expected an object" % [
+ type_string(typeof(value)), label,
+ ],
+ )
+ return ErrorCodes.prefix_message(err, prefix)
+ var key_type := slot_value.get_typed_key_builtin() if slot_value.is_typed_key() else TYPE_NIL
+ var value_type := (
+ slot_value.get_typed_value_builtin() if slot_value.is_typed_value() else TYPE_NIL
+ )
+ var out := slot_value.duplicate()
+ out.clear()
+ var in_dict: Dictionary = value
+ for raw_key in in_dict.keys():
+ var key_prefix := (
+ 'key "%s"' % str(raw_key) if prefix.is_empty()
+ else '%s key "%s"' % [prefix, str(raw_key)]
+ )
+ var key: Variant = _coerce_typed_dict_key(raw_key, key_type, label, key_prefix)
+ if key is Dictionary:
+ ## Scalar-only key coercion never returns a legit Dictionary key,
+ ## so a Dictionary here is unambiguously the error envelope.
+ return key
+ var raw_value: Variant = in_dict[raw_key]
+ var coerced: Variant
+ if value_type == TYPE_NIL:
+ ## Untyped value side (e.g. Dictionary[String, Variant]).
+ coerced = raw_value
+ elif value_type == TYPE_OBJECT:
+ coerced = _coerce_object_element(raw_value, key_prefix)
+ if coerced is Dictionary:
+ return coerced
+ if coerced != null and not _object_conforms(
+ coerced,
+ slot_value.get_typed_value_class_name(),
+ slot_value.get_typed_value_script(),
+ ):
+ var conform_err := ErrorCodes.make(
+ ErrorCodes.WRONG_TYPE,
+ "value is %s, which is not a %s" % [
+ (coerced as Object).get_class(),
+ _object_type_label(
+ slot_value.get_typed_value_class_name(),
+ slot_value.get_typed_value_script(),
+ ),
+ ],
+ )
+ return ErrorCodes.prefix_message(conform_err, key_prefix)
+ else:
+ coerced = _coerce_value(raw_value, value_type)
+ var value_err := _check_coerced(coerced, value_type, key_prefix)
+ if value_err != null:
+ return value_err
+ if coerced == null:
+ var null_err := ErrorCodes.make(
+ ErrorCodes.WRONG_TYPE,
+ "cannot store null as a %s value in %s" % [type_string(value_type), label],
+ )
+ return ErrorCodes.prefix_message(null_err, key_prefix)
+ out[key] = coerced
+ if out.size() != in_dict.size():
+ ## Two input keys collapsing onto one coerced key ("1" and "01" both
+ ## parse to int 1) would silently lose an entry — refuse instead.
+ var collide_err := ErrorCodes.make(
+ ErrorCodes.WRONG_TYPE,
+ "%s keys collide after coercion (%d of %d entries landed)" % [
+ label, out.size(), in_dict.size(),
+ ],
+ )
+ return ErrorCodes.prefix_message(collide_err, prefix)
+ return out
+
+
+## Coerce one JSON-object key onto a typed Dictionary's key slot. JSON keys
+## are always Strings, so int/float/StringName key types accept exactly the
+## strings that parse cleanly; everything else fails closed naming the key.
+## Object/compound key types are unreachable from JSON and refuse loudly.
+static func _coerce_typed_dict_key(
+ raw_key: Variant, key_type: int, label: String, key_prefix: String
+) -> Variant:
+ if key_type == TYPE_NIL or typeof(raw_key) == key_type:
+ return raw_key
+ if raw_key is String:
+ var key_str := raw_key as String
+ match key_type:
+ TYPE_STRING_NAME:
+ return StringName(key_str)
+ TYPE_INT:
+ if key_str.is_valid_int():
+ return int(key_str)
+ TYPE_FLOAT:
+ if key_str.is_valid_float():
+ return float(key_str)
+ ## String key that didn't parse: JSON object keys are ALWAYS strings,
+ ## so blaming the String-ness would imply the caller could somehow
+ ## send a non-string key — name the expected key type instead.
+ var parse_err := ErrorCodes.make(
+ ErrorCodes.WRONG_TYPE,
+ "key does not parse as %s (the %s key type)" % [type_string(key_type), label],
+ )
+ return ErrorCodes.prefix_message(parse_err, key_prefix)
+ if raw_key is float and key_type == TYPE_INT and is_equal_approx(raw_key, roundf(raw_key)):
+ ## Whole JSON numbers arrive as floats through some non-JSON callers.
+ return int(raw_key)
+ var err := ErrorCodes.make(
+ ErrorCodes.WRONG_TYPE,
+ "cannot use %s as a %s key" % [type_string(typeof(raw_key)), label],
+ )
+ return ErrorCodes.prefix_message(err, key_prefix)
+
+
+## "Dictionary[String, int]" / "Dictionary[int, Texture2D]" — for error
+## messages. Untyped sides read as Variant.
+static func _typed_dictionary_label(slot_value: Dictionary) -> String:
+ var key_label := "Variant"
+ if slot_value.is_typed_key():
+ key_label = (
+ _object_type_label(
+ slot_value.get_typed_key_class_name(), slot_value.get_typed_key_script()
+ )
+ if slot_value.get_typed_key_builtin() == TYPE_OBJECT
+ else type_string(slot_value.get_typed_key_builtin())
+ )
+ var value_label := "Variant"
+ if slot_value.is_typed_value():
+ value_label = (
+ _object_type_label(
+ slot_value.get_typed_value_class_name(), slot_value.get_typed_value_script()
+ )
+ if slot_value.get_typed_value_builtin() == TYPE_OBJECT
+ else type_string(slot_value.get_typed_value_builtin())
+ )
+ return "Dictionary[%s, %s]" % [key_label, value_label]
+
+
+## Class/script display name for an object-typed constraint side.
+static func _object_type_label(cls_name: StringName, script: Variant) -> String:
+ var cls := String(cls_name)
+ if script is Script and not String((script as Script).get_global_name()).is_empty():
+ cls = String((script as Script).get_global_name())
+ return cls if not cls.is_empty() else "Object"
+
+
+func get_node_properties(params: Dictionary) -> Dictionary:
+ var resolved := _resolve_node(params)
+ if resolved.has("error"):
+ return resolved
+ var node: Node = resolved.node
+ var node_path: String = resolved.path
+ var scene_root: Node = resolved.scene_root
+
+ # Optional token-reducing filter: `fields` restricts the response to a
+ # named subset. Defaults off (empty), so existing callers see the full
+ # dump unchanged. The MCP tool types this as a list, but batch_execute and
+ # raw callers bypass that, so validate the shape here before iterating.
+ var fields_param: Variant = params.get("fields", [])
+ if fields_param == null:
+ fields_param = []
+ if not (fields_param is Array):
+ return ErrorCodes.make(
+ ErrorCodes.INVALID_PARAMS,
+ "'fields' must be an array of property names, got %s (%s)" % [
+ type_string(typeof(fields_param)), str(fields_param),
+ ],
+ )
+ var field_filter := {}
+ for f in fields_param:
+ ## Property names are strings on the wire; anything else is a
+ ## malformed filter (e.g. [123] or [["fov"]]) — reject rather than
+ ## silently stringify into a filter that matches nothing (#123/#126:
+ ## strict within the accepted shape). StringName is allowed for
+ ## editor-side callers.
+ if not (f is String or f is StringName):
+ return ErrorCodes.make(
+ ErrorCodes.INVALID_PARAMS,
+ "'fields' elements must be property-name strings, got %s in %s" % [
+ type_string(typeof(f)), str(fields_param),
+ ],
+ )
+ field_filter[str(f)] = true
+ var use_field_filter := not field_filter.is_empty()
+
+ var properties: Array[Dictionary] = []
+ var editor_property_count := 0
+ var matched_fields := {}
+ for prop in node.get_property_list():
+ var usage: int = prop.get("usage", 0)
+ if not (usage & PROPERTY_USAGE_EDITOR):
+ continue
+ editor_property_count += 1
+ if use_field_filter:
+ if not field_filter.has(prop.name):
+ continue
+ matched_fields[prop.name] = true
+ # Null reads are values, not omissions: `script` on an unscripted node
+ # and unset Resource slots (mesh, material, …) read back null and must
+ # appear as "value": null with their declared type, so callers can tell
+ # "Object-typed, currently unset" from "doesn't exist" (#771).
+ properties.append({
+ "name": prop.name,
+ "type": type_string(prop.type),
+ "value": _serialize_value(node.get(prop.name)),
+ })
+ # Requested names that matched no editor-visible property — distinguishes
+ # "you asked for something that doesn't exist" from "exists and is null".
+ var unknown_fields: Array[String] = []
+ for f in field_filter:
+ if not matched_fields.has(f):
+ unknown_fields.append(f)
+ return {
+ "data": {
+ "path": node_path,
+ "node_type": node.get_class(),
+ "properties": properties,
+ "count": properties.size(),
+ # Total editor-visible properties before field filtering, so a
+ # caller that passed `fields` knows how many were withheld.
+ # Invariant: an unfiltered call returns every editor-visible
+ # property, so count == total_count; only the `fields` filter
+ # can make count < total_count.
+ "total_count": editor_property_count,
+ "unknown_fields": unknown_fields,
+ }
+ }
+
+
+func get_children(params: Dictionary) -> Dictionary:
+ var resolved := _resolve_node(params)
+ if resolved.has("error"):
+ return resolved
+ var node: Node = resolved.node
+ var node_path: String = resolved.path
+ var scene_root: Node = resolved.scene_root
+
+ var children: Array[Dictionary] = []
+ for child in node.get_children():
+ children.append({
+ "name": child.name,
+ "type": child.get_class(),
+ "path": McpScenePath.from_node(child, scene_root),
+ "children_count": child.get_child_count(),
+ })
+ return {
+ "data": {
+ "parent_path": node_path,
+ "children": children,
+ "count": children.size(),
+ }
+ }
+
+
+func get_groups(params: Dictionary) -> Dictionary:
+ var resolved := _resolve_node(params)
+ if resolved.has("error"):
+ return resolved
+ var node: Node = resolved.node
+ var node_path: String = resolved.path
+
+ var groups: Array[String] = []
+ for group in node.get_groups():
+ # Skip internal groups (start with underscore)
+ if not str(group).begins_with("_"):
+ groups.append(str(group))
+ return {
+ "data": {
+ "path": node_path,
+ "groups": groups,
+ "count": groups.size(),
+ }
+ }
+
+
+## Validate path param, resolve to node. Returns dict with node/path/scene_root
+## on success, or an error dict (has "error" key) on failure. Thin wrapper
+## around the shared `McpNodeValidator.resolve_or_error` helper (audit-v2 #20).
+func _resolve_node(params: Dictionary) -> Dictionary:
+ return McpNodeValidator.resolve_or_error(
+ params.get("path", ""), "path", params.get("scene_file", ""),
+ )
+
+
+## Reject operations targeting the scene root. Returns an INVALID_PARAMS error
+## dict with "Cannot the scene root", or null if `node` is not the root.
+static func _reject_if_scene_root(node: Node, scene_root: Node, op: String) -> Variant:
+ if node == scene_root:
+ return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "Cannot %s the scene root" % op)
+ return null
+
+
+## Convert a Godot Variant to a JSON-safe value. Compound geometry types
+## (AABB, Rect2, Transforms, …) and packed arrays serialize as structured
+## dicts/arrays so agents can inspect fields instead of parsing Godot's
+## debug repr — see issue #214.
+static func _serialize_value(value: Variant) -> Variant:
+ return VariantSerializer.serialize(value)
diff --git a/addons/godot_ai/handlers/node_handler.gd.uid b/addons/godot_ai/handlers/node_handler.gd.uid
new file mode 100644
index 0000000..86149b4
--- /dev/null
+++ b/addons/godot_ai/handlers/node_handler.gd.uid
@@ -0,0 +1 @@
+uid://qhhd5mm5awym
diff --git a/addons/godot_ai/handlers/particle_handler.gd b/addons/godot_ai/handlers/particle_handler.gd
new file mode 100644
index 0000000..d994bad
--- /dev/null
+++ b/addons/godot_ai/handlers/particle_handler.gd
@@ -0,0 +1,860 @@
+@tool
+extends "res://addons/godot_ai/handlers/command_handler.gd"
+
+const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
+
+## Handles particle emitter authoring (GPU + CPU, 2D + 3D).
+##
+## All write operations bundle node creation and sub-resource spawns
+## (ParticleProcessMaterial, default QuadMesh) in a single create_action
+## so Ctrl-Z rolls back the whole effect atomically.
+
+const ParticleValues := preload("res://addons/godot_ai/handlers/particle_values.gd")
+const ParticlePresets := preload("res://addons/godot_ai/handlers/particle_presets.gd")
+
+const _VALID_TYPES := {
+ "gpu_3d": "GPUParticles3D",
+ "gpu_2d": "GPUParticles2D",
+ "cpu_3d": "CPUParticles3D",
+ "cpu_2d": "CPUParticles2D",
+}
+
+const _MAIN_KEYS := [
+ "amount",
+ "lifetime",
+ "one_shot",
+ "explosiveness",
+ "preprocess",
+ "speed_scale",
+ "randomness",
+ "fixed_fps",
+ "emitting",
+ "local_coords",
+ "interp_to_end",
+]
+
+
+var _undo_redo: EditorUndoRedoManager
+
+
+func _init(undo_redo: EditorUndoRedoManager) -> void:
+ _undo_redo = undo_redo
+
+
+# ============================================================================
+# particle_create
+# ============================================================================
+
+func create_particle(params: Dictionary) -> Dictionary:
+ var parent_path: String = params.get("parent_path", "")
+ var node_name: String = params.get("name", "Particles")
+ var type_str: String = params.get("type", "gpu_3d")
+
+ if not _VALID_TYPES.has(type_str):
+ return ErrorCodes.make(
+ ErrorCodes.VALUE_OUT_OF_RANGE,
+ "Invalid particle type '%s'. Valid: %s" % [type_str, ", ".join(_VALID_TYPES.keys())]
+ )
+
+ var _scene_check := McpNodeValidator.require_scene_or_error()
+ if _scene_check.has("error"):
+ return _scene_check
+ var scene_root: Node = _scene_check.scene_root
+
+ var parent: Node = scene_root
+ if not parent_path.is_empty():
+ parent = McpScenePath.resolve(parent_path, scene_root)
+ if parent == null:
+ return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, McpScenePath.format_parent_error(parent_path, scene_root))
+
+ var node := _instantiate_particle(type_str)
+ if node == null:
+ return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate particle node")
+ if not node_name.is_empty():
+ node.name = node_name
+
+ var process_mat: ParticleProcessMaterial = null
+ var process_material_created := false
+ var draw_mesh: Mesh = null
+ var draw_material: StandardMaterial3D = null
+ var draw_pass_mesh_created := false
+ var draw_material_created := false
+
+ if type_str == "gpu_3d" or type_str == "gpu_2d":
+ process_mat = ParticleProcessMaterial.new()
+ process_material_created = true
+ if type_str == "gpu_3d":
+ draw_mesh = QuadMesh.new()
+ (draw_mesh as QuadMesh).size = Vector2(0.25, 0.25)
+ # Without a material, the mesh renders flat white — ignoring
+ # ParticleProcessMaterial.color_ramp entirely. Give it the standard
+ # billboard + vertex-color-as-albedo setup so color_ramp works.
+ draw_material = ParticleValues.build_draw_material({}).material
+ (draw_mesh as QuadMesh).material = draw_material
+ draw_pass_mesh_created = true
+ draw_material_created = true
+
+ _undo_redo.create_action("MCP: Create %s '%s'" % [_VALID_TYPES[type_str], node.name])
+ _undo_redo.add_do_method(parent, "add_child", node, true)
+ _undo_redo.add_do_method(node, "set_owner", scene_root)
+ if process_mat != null:
+ _undo_redo.add_do_property(node, "process_material", process_mat)
+ _undo_redo.add_do_reference(process_mat)
+ if draw_mesh != null:
+ _undo_redo.add_do_property(node, "draw_pass_1", draw_mesh)
+ _undo_redo.add_do_reference(draw_mesh)
+ if draw_material != null:
+ _undo_redo.add_do_reference(draw_material)
+ _undo_redo.add_do_reference(node)
+ _undo_redo.add_undo_method(parent, "remove_child", node)
+ _undo_redo.commit_action()
+
+ return {
+ "data": {
+ "path": McpScenePath.from_node(node, scene_root),
+ "parent_path": McpScenePath.from_node(parent, scene_root),
+ "name": String(node.name),
+ "type": type_str,
+ "class": _VALID_TYPES[type_str],
+ "process_material_created": process_material_created,
+ "draw_pass_mesh_created": draw_pass_mesh_created,
+ "draw_material_created": draw_material_created,
+ "undoable": true,
+ }
+ }
+
+
+# ============================================================================
+# particle_set_main
+# ============================================================================
+
+func set_main(params: Dictionary) -> Dictionary:
+ var resolved := _resolve_particle(params)
+ if resolved.has("error"):
+ return resolved
+ var node: Node = resolved.node
+ var node_path: String = resolved.path
+
+ var properties: Dictionary = params.get("properties", {})
+ if properties.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "properties dict is empty")
+
+ var coerced: Dictionary = {}
+ var old_values: Dictionary = {}
+ for property in properties:
+ var prop_name: String = String(property)
+ if not (prop_name in _MAIN_KEYS):
+ return ErrorCodes.make(
+ ErrorCodes.INVALID_PARAMS,
+ "Unknown main property '%s'. Valid: %s" % [prop_name, ", ".join(_MAIN_KEYS)]
+ )
+ var prop_type := _node_property_type(node, prop_name)
+ if prop_type == TYPE_NIL:
+ return ErrorCodes.make(
+ ErrorCodes.PROPERTY_NOT_ON_CLASS,
+ "Property '%s' not present on %s" % [prop_name, node.get_class()]
+ )
+ var coerce_result := ParticleValues.coerce(prop_name, properties[prop_name], prop_type)
+ if not coerce_result.ok:
+ return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, String(coerce_result.error))
+ coerced[prop_name] = coerce_result.value
+ old_values[prop_name] = node.get(prop_name)
+
+ _undo_redo.create_action("MCP: Set particle main on %s" % node.name)
+ for prop_name in coerced:
+ _undo_redo.add_do_property(node, prop_name, coerced[prop_name])
+ _undo_redo.add_undo_property(node, prop_name, old_values[prop_name])
+ _undo_redo.commit_action()
+
+ var applied: Array[String] = []
+ var serialized_values: Dictionary = {}
+ for prop_name in coerced:
+ applied.append(prop_name)
+ serialized_values[prop_name] = ParticleValues.serialize(coerced[prop_name])
+
+ return {
+ "data": {
+ "path": node_path,
+ "applied": applied,
+ "values": serialized_values,
+ "undoable": true,
+ }
+ }
+
+
+# ============================================================================
+# particle_set_process
+# ============================================================================
+
+func set_process(params: Dictionary) -> Dictionary:
+ var resolved := _resolve_particle(params)
+ if resolved.has("error"):
+ return resolved
+ var node: Node = resolved.node
+ var node_path: String = resolved.path
+
+ var properties: Dictionary = params.get("properties", {})
+ if properties.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "properties dict is empty")
+
+ # GPU: work through process_material; CPU: properties live on node directly.
+ if node is GPUParticles3D or node is GPUParticles2D:
+ return _set_process_gpu(node, node_path, properties)
+ return _set_process_cpu(node, node_path, properties)
+
+
+func _set_process_gpu(node: Node, node_path: String, properties: Dictionary) -> Dictionary:
+ var existing_mat: ParticleProcessMaterial = node.process_material as ParticleProcessMaterial
+ var process_material_created := false
+ var mat: ParticleProcessMaterial = existing_mat
+ if mat == null:
+ mat = ParticleProcessMaterial.new()
+ process_material_created = true
+
+ var coerced: Dictionary = {}
+ for property in properties:
+ var prop_name: String = String(property)
+ var prop_type := _object_property_type(mat, prop_name)
+ if prop_type == TYPE_NIL:
+ return ErrorCodes.make(
+ ErrorCodes.PROPERTY_NOT_ON_CLASS,
+ "Property '%s' not present on ParticleProcessMaterial" % prop_name
+ )
+ var coerce_result := ParticleValues.coerce(prop_name, properties[prop_name], prop_type)
+ if not coerce_result.ok:
+ return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, String(coerce_result.error))
+ coerced[prop_name] = coerce_result.value
+
+ _undo_redo.create_action("MCP: Set particle process on %s" % node.name)
+ if process_material_created:
+ _undo_redo.add_do_property(node, "process_material", mat)
+ _undo_redo.add_undo_property(node, "process_material", null)
+ _undo_redo.add_do_reference(mat)
+ # Apply new values directly on the (newly created) material. No old values to restore.
+ for prop_name in coerced:
+ mat.set(prop_name, coerced[prop_name])
+ else:
+ # Use the reusable apply/restore pattern for existing material.
+ var old_values: Dictionary = {}
+ for prop_name in coerced:
+ old_values[prop_name] = mat.get(prop_name)
+ for prop_name in coerced:
+ _undo_redo.add_do_property(mat, prop_name, coerced[prop_name])
+ _undo_redo.add_undo_property(mat, prop_name, old_values[prop_name])
+ _undo_redo.commit_action()
+
+ var applied: Array[String] = []
+ var serialized: Dictionary = {}
+ for prop_name in coerced:
+ applied.append(prop_name)
+ serialized[prop_name] = ParticleValues.serialize(mat.get(prop_name))
+
+ return {
+ "data": {
+ "path": node_path,
+ "applied": applied,
+ "values": serialized,
+ "process_material_created": process_material_created,
+ "undoable": true,
+ }
+ }
+
+
+func _set_process_cpu(node: Node, node_path: String, properties: Dictionary) -> Dictionary:
+ # CPU particles expose the same property vocabulary directly on the node,
+ # so property names pass through unchanged.
+ var coerced: Dictionary = {}
+ var old_values: Dictionary = {}
+
+ for property in properties:
+ var prop_name: String = String(property)
+ var prop_type := _node_property_type(node, prop_name)
+ if prop_type == TYPE_NIL:
+ return ErrorCodes.make(
+ ErrorCodes.PROPERTY_NOT_ON_CLASS,
+ "Property '%s' not present on %s" % [prop_name, node.get_class()]
+ )
+ var coerce_result := ParticleValues.coerce(prop_name, properties[property], prop_type)
+ if not coerce_result.ok:
+ return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, String(coerce_result.error))
+ coerced[prop_name] = coerce_result.value
+ old_values[prop_name] = node.get(prop_name)
+
+ _undo_redo.create_action("MCP: Set particle process on %s" % node.name)
+ for prop_name in coerced:
+ _undo_redo.add_do_property(node, prop_name, coerced[prop_name])
+ _undo_redo.add_undo_property(node, prop_name, old_values[prop_name])
+ _undo_redo.commit_action()
+
+ var applied: Array[String] = []
+ var serialized: Dictionary = {}
+ for prop_name in coerced:
+ applied.append(prop_name)
+ serialized[prop_name] = ParticleValues.serialize(coerced[prop_name])
+
+ return {
+ "data": {
+ "path": node_path,
+ "applied": applied,
+ "values": serialized,
+ "process_material_created": false,
+ "undoable": true,
+ }
+ }
+
+
+# ============================================================================
+# particle_set_draw_pass
+# ============================================================================
+
+func set_draw_pass(params: Dictionary) -> Dictionary:
+ var resolved := _resolve_particle(params)
+ if resolved.has("error"):
+ return resolved
+ var node: Node = resolved.node
+ var node_path: String = resolved.path
+
+ var pass_idx: int = int(params.get("pass", 1))
+ var mesh_path: String = params.get("mesh", "")
+ var texture_path: String = params.get("texture", "")
+ var material_path: String = params.get("material", "")
+
+ if node is GPUParticles3D:
+ return _set_draw_pass_gpu_3d(node, node_path, pass_idx, mesh_path, material_path)
+ if node is CPUParticles3D:
+ return _set_draw_pass_cpu_3d(node, node_path, mesh_path, material_path)
+ if node is GPUParticles2D or node is CPUParticles2D:
+ return _set_draw_pass_2d(node, node_path, texture_path)
+ return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Node %s is not a particle node" % node.get_class())
+
+
+func _set_draw_pass_gpu_3d(node: GPUParticles3D, node_path: String, pass_idx: int, mesh_path: String, material_path: String) -> Dictionary:
+ if pass_idx < 1 or pass_idx > 4:
+ return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "pass must be 1..4 (got %d)" % pass_idx)
+
+ var mesh: Mesh = null
+ var mesh_created := false
+ var property_name := "draw_pass_%d" % pass_idx
+ # draw_pass_N is only a live property when draw_passes >= N. Probe via
+ # get_property_list so we don't read a ghost value.
+ var existing_mesh: Mesh = null
+ if int(node.draw_passes) >= pass_idx:
+ existing_mesh = node.get(property_name) as Mesh
+ if not mesh_path.is_empty():
+ var mesh_path_err = McpPathValidator.loadable_error(mesh_path, "mesh_path")
+ if mesh_path_err != null:
+ return mesh_path_err
+ if not ResourceLoader.exists(mesh_path):
+ return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Mesh not found: %s" % mesh_path)
+ var loaded := ResourceLoader.load(mesh_path)
+ if not (loaded is Mesh):
+ return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Resource at %s is not a Mesh" % mesh_path)
+ mesh = loaded
+ else:
+ if existing_mesh == null:
+ mesh = QuadMesh.new()
+ (mesh as QuadMesh).size = Vector2(0.25, 0.25)
+ mesh_created = true
+ else:
+ mesh = existing_mesh
+
+ var material: Material = null
+ if not material_path.is_empty():
+ var material_path_err = McpPathValidator.loadable_error(material_path, "material_path")
+ if material_path_err != null:
+ return material_path_err
+ if not ResourceLoader.exists(material_path):
+ return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Material not found: %s" % material_path)
+ var loaded_mat := ResourceLoader.load(material_path)
+ if not (loaded_mat is Material):
+ return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Resource at %s is not a Material" % material_path)
+ material = loaded_mat
+
+ var old_draw_passes: int = int(node.draw_passes)
+ var new_draw_passes: int = max(old_draw_passes, pass_idx)
+ var old_value = existing_mesh # Null if draw_passes < pass_idx
+ var old_material: Material = null
+ if material != null:
+ old_material = node.material_override
+
+ _undo_redo.create_action("MCP: Set %s.draw_pass_%d" % [node.name, pass_idx])
+ # Grow draw_passes first so draw_pass_N property exists before we set it.
+ if new_draw_passes != old_draw_passes:
+ _undo_redo.add_do_property(node, "draw_passes", new_draw_passes)
+ _undo_redo.add_undo_property(node, "draw_passes", old_draw_passes)
+ if not mesh_path.is_empty() or mesh_created:
+ _undo_redo.add_do_property(node, property_name, mesh)
+ _undo_redo.add_undo_property(node, property_name, old_value)
+ if mesh_created:
+ _undo_redo.add_do_reference(mesh)
+ if material != null:
+ _undo_redo.add_do_property(node, "material_override", material)
+ _undo_redo.add_undo_property(node, "material_override", old_material)
+ _undo_redo.commit_action()
+
+ return {
+ "data": {
+ "path": node_path,
+ "pass": pass_idx,
+ "mesh_path": mesh_path,
+ "mesh_class": mesh.get_class() if mesh else "",
+ "material_path": material_path,
+ "draw_pass_mesh_created": mesh_created,
+ "draw_passes_grown": new_draw_passes != old_draw_passes,
+ "undoable": true,
+ }
+ }
+
+
+func _set_draw_pass_cpu_3d(node: CPUParticles3D, node_path: String, mesh_path: String, material_path: String) -> Dictionary:
+ if mesh_path.is_empty() and material_path.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "CPUParticles3D requires mesh or material param")
+
+ var mesh: Mesh = node.mesh
+ var old_mesh: Mesh = mesh
+ if not mesh_path.is_empty():
+ var mesh_path_err = McpPathValidator.loadable_error(mesh_path, "mesh_path")
+ if mesh_path_err != null:
+ return mesh_path_err
+ if not ResourceLoader.exists(mesh_path):
+ return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Mesh not found: %s" % mesh_path)
+ var loaded := ResourceLoader.load(mesh_path)
+ if not (loaded is Mesh):
+ return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Resource at %s is not a Mesh" % mesh_path)
+ mesh = loaded
+
+ var material: Material = null
+ var old_material: Material = node.material_override
+ if not material_path.is_empty():
+ var material_path_err = McpPathValidator.loadable_error(material_path, "material_path")
+ if material_path_err != null:
+ return material_path_err
+ if not ResourceLoader.exists(material_path):
+ return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Material not found: %s" % material_path)
+ var loaded_mat := ResourceLoader.load(material_path)
+ if not (loaded_mat is Material):
+ return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Resource at %s is not a Material" % material_path)
+ material = loaded_mat
+
+ _undo_redo.create_action("MCP: Set CPU particle draw on %s" % node.name)
+ if not mesh_path.is_empty():
+ _undo_redo.add_do_property(node, "mesh", mesh)
+ _undo_redo.add_undo_property(node, "mesh", old_mesh)
+ if material != null:
+ _undo_redo.add_do_property(node, "material_override", material)
+ _undo_redo.add_undo_property(node, "material_override", old_material)
+ _undo_redo.commit_action()
+
+ return {
+ "data": {
+ "path": node_path,
+ "mesh_path": mesh_path,
+ "material_path": material_path,
+ "draw_pass_mesh_created": false,
+ "undoable": true,
+ }
+ }
+
+
+func _set_draw_pass_2d(node: Node, node_path: String, texture_path: String) -> Dictionary:
+ if texture_path.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "2D particles require texture param")
+ var texture_path_err = McpPathValidator.loadable_error(texture_path, "texture_path")
+ if texture_path_err != null:
+ return texture_path_err
+ if not ResourceLoader.exists(texture_path):
+ return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Texture not found: %s" % texture_path)
+ var tex := ResourceLoader.load(texture_path)
+ if not (tex is Texture2D):
+ return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Resource at %s is not a Texture2D" % texture_path)
+
+ var old_texture: Texture2D = node.get("texture")
+
+ _undo_redo.create_action("MCP: Set 2D particle texture on %s" % node.name)
+ _undo_redo.add_do_property(node, "texture", tex)
+ _undo_redo.add_undo_property(node, "texture", old_texture)
+ _undo_redo.commit_action()
+
+ return {
+ "data": {
+ "path": node_path,
+ "texture_path": texture_path,
+ "undoable": true,
+ }
+ }
+
+
+# ============================================================================
+# particle_restart
+# ============================================================================
+
+func restart_particle(params: Dictionary) -> Dictionary:
+ var resolved := _resolve_particle(params)
+ if resolved.has("error"):
+ return resolved
+ var node: Node = resolved.node
+ var node_path: String = resolved.path
+ if node.has_method("restart"):
+ node.restart()
+ return {
+ "data": {
+ "path": node_path,
+ "undoable": false,
+ "reason": "Restart is a runtime operation, not tracked in undo history",
+ }
+ }
+
+
+# ============================================================================
+# particle_get
+# ============================================================================
+
+func get_particle(params: Dictionary) -> Dictionary:
+ var resolved := _resolve_particle(params)
+ if resolved.has("error"):
+ return resolved
+ var node: Node = resolved.node
+ var node_path: String = resolved.path
+
+ var type_str := ""
+ for key in _VALID_TYPES:
+ if node.get_class() == _VALID_TYPES[key]:
+ type_str = key
+ break
+
+ var main_values: Dictionary = {}
+ var node_prop_names := _property_names(node)
+ for key in _MAIN_KEYS:
+ if node_prop_names.has(key):
+ main_values[key] = ParticleValues.serialize(node.get(key))
+
+ var process_data: Dictionary = {}
+ if node is GPUParticles3D or node is GPUParticles2D:
+ var mat: ParticleProcessMaterial = node.process_material as ParticleProcessMaterial
+ if mat != null:
+ var process_props: Dictionary = {}
+ for prop in mat.get_property_list():
+ var usage: int = prop.get("usage", 0)
+ if not (usage & PROPERTY_USAGE_EDITOR):
+ continue
+ var v = mat.get(prop.name)
+ if v == null:
+ continue
+ process_props[prop.name] = ParticleValues.serialize(v)
+ process_data = {
+ "class": "ParticleProcessMaterial",
+ "properties": process_props,
+ }
+
+ var draw_passes: Array[Dictionary] = []
+ if node is GPUParticles3D:
+ var active_draw_pass_count: int = min(int(node.draw_passes), 4)
+ for i in range(1, active_draw_pass_count + 1):
+ var prop_name := "draw_pass_%d" % i
+ var m: Mesh = node.get(prop_name) as Mesh
+ draw_passes.append({
+ "pass": i,
+ "mesh_class": m.get_class() if m != null else "",
+ })
+
+ var texture_path := ""
+ if node is GPUParticles2D or node is CPUParticles2D:
+ var t: Texture2D = node.get("texture")
+ if t != null:
+ texture_path = t.resource_path
+
+ return {
+ "data": {
+ "path": node_path,
+ "type": type_str,
+ "class": node.get_class(),
+ "main": main_values,
+ "process": process_data,
+ "draw_passes": draw_passes,
+ "texture_path": texture_path,
+ }
+ }
+
+
+# ============================================================================
+# particle_apply_preset
+# ============================================================================
+
+func apply_preset(params: Dictionary) -> Dictionary:
+ var preset_name: String = params.get("preset", "")
+ if preset_name.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: preset")
+
+ var overrides: Dictionary = params.get("overrides", {})
+ var blueprint = ParticlePresets.build(preset_name, overrides)
+ if blueprint == null:
+ return ErrorCodes.make(
+ ErrorCodes.VALUE_OUT_OF_RANGE,
+ "Unknown preset '%s'. Valid: %s" % [preset_name, ", ".join(ParticlePresets.list())]
+ )
+
+ var parent_path: String = params.get("parent_path", "")
+ var node_name: String = params.get("name", "")
+ var type_str: String = params.get("type", "gpu_3d")
+ if node_name.is_empty():
+ node_name = preset_name.capitalize()
+ if not _VALID_TYPES.has(type_str):
+ return ErrorCodes.make(
+ ErrorCodes.VALUE_OUT_OF_RANGE,
+ "Invalid particle type '%s'. Valid: %s" % [type_str, ", ".join(_VALID_TYPES.keys())]
+ )
+
+ var _scene_check := McpNodeValidator.require_scene_or_error()
+ if _scene_check.has("error"):
+ return _scene_check
+ var scene_root: Node = _scene_check.scene_root
+
+ var parent: Node = scene_root
+ if not parent_path.is_empty():
+ parent = McpScenePath.resolve(parent_path, scene_root)
+ if parent == null:
+ return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, McpScenePath.format_parent_error(parent_path, scene_root))
+
+ var node := _instantiate_particle(type_str)
+ node.name = node_name
+
+ var is_gpu := type_str == "gpu_3d" or type_str == "gpu_2d"
+ var is_3d := type_str == "gpu_3d" or type_str == "cpu_3d"
+
+ var process_mat: ParticleProcessMaterial = null
+ var process_material_created := false
+ if is_gpu:
+ process_mat = ParticleProcessMaterial.new()
+ process_material_created = true
+
+ # User-supplied override keys per group. Preset-blueprint keys may skip
+ # silently on types they don't apply to (presets are cross-type by
+ # design); user-requested overrides must apply or error (#770).
+ var user_keys: Dictionary = blueprint.get("user_keys", {})
+ var user_main: Dictionary = user_keys.get("main", {})
+ var user_process: Dictionary = user_keys.get("process", {})
+ var user_draw: Dictionary = user_keys.get("draw", {})
+
+ var draw_mesh: Mesh = null
+ var draw_material: StandardMaterial3D = null
+ var draw_pass_mesh_created := false
+ var draw_material_created := false
+ var applied_draw: Array[String] = []
+ var draw_config: Dictionary = blueprint.get("draw", {})
+ if type_str == "gpu_3d":
+ var draw_result := ParticleValues.build_draw_material(draw_config)
+ if not draw_result.ok:
+ node.free()
+ var msg := String(draw_result.error)
+ if draw_result.get("unknown_key", false):
+ msg = _draw_key_unsupported_message(String(draw_result.key), type_str)
+ return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, msg)
+ draw_mesh = QuadMesh.new()
+ (draw_mesh as QuadMesh).size = Vector2(0.25, 0.25)
+ draw_material = draw_result.material
+ (draw_mesh as QuadMesh).material = draw_material
+ draw_pass_mesh_created = true
+ draw_material_created = true
+ for applied_key in draw_result.applied:
+ applied_draw.append(String(applied_key))
+ elif type_str == "gpu_2d":
+ # GPUParticles2D has no draw-pass material; the one draw override it
+ # supports is draw.texture → the node's texture property.
+ for key in user_draw:
+ if String(key) != "texture":
+ node.free()
+ return ErrorCodes.make(
+ ErrorCodes.INVALID_PARAMS,
+ _draw_key_unsupported_message(String(key), type_str)
+ )
+ if user_draw.has("texture"):
+ var tex_result := _load_draw_texture(draw_config.get("texture"))
+ if tex_result.has("error"):
+ node.free()
+ return tex_result
+ node.set("texture", tex_result.texture)
+ applied_draw.append("texture")
+ else:
+ # cpu_3d / cpu_2d: no draw support — reject user draw overrides
+ # instead of dropping them.
+ for key in user_draw:
+ node.free()
+ return ErrorCodes.make(
+ ErrorCodes.INVALID_PARAMS,
+ _draw_key_unsupported_message(String(key), type_str)
+ )
+
+ # Pre-apply preset values to in-memory targets (no undo needed; nodes not in tree yet).
+ var main_values: Dictionary = blueprint.get("main", {})
+ var process_values: Dictionary = blueprint.get("process", {})
+ var applied_main: Array[String] = []
+ var applied_process: Array[String] = []
+
+ for prop in main_values:
+ var prop_name := String(prop)
+ var prop_type := _object_property_type(node, prop_name)
+ if prop_type == TYPE_NIL:
+ if user_main.has(prop_name):
+ var node_class := node.get_class()
+ node.free()
+ return ErrorCodes.make(
+ ErrorCodes.INVALID_PARAMS,
+ "Override main.%s does not apply to type '%s' (no such property on %s)" % [
+ prop_name, type_str, node_class
+ ]
+ )
+ continue # Blueprint key: not all main keys apply to all types.
+ var coerce_result := ParticleValues.coerce(prop_name, main_values[prop_name], prop_type)
+ if not coerce_result.ok:
+ node.free()
+ return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, String(coerce_result.error))
+ node.set(prop_name, coerce_result.value)
+ applied_main.append(prop_name)
+
+ # Apply process: GPU targets the ParticleProcessMaterial; CPU targets the node.
+ var process_target: Object = process_mat if is_gpu else node
+ for prop in process_values:
+ var prop_name := String(prop)
+ var prop_type := _object_property_type(process_target, prop_name)
+ if prop_type == TYPE_NIL:
+ if user_process.has(prop_name):
+ var target_class := process_target.get_class()
+ node.free()
+ return ErrorCodes.make(
+ ErrorCodes.INVALID_PARAMS,
+ "Override process.%s does not apply to type '%s' (no such property on %s)" % [
+ prop_name, type_str, target_class
+ ]
+ )
+ continue # Blueprint key: preset property doesn't apply to this variant.
+ var coerce_result := ParticleValues.coerce(prop_name, process_values[prop_name], prop_type)
+ if not coerce_result.ok:
+ node.free()
+ return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, String(coerce_result.error))
+ process_target.set(prop_name, coerce_result.value)
+ applied_process.append(prop_name)
+
+ _undo_redo.create_action("MCP: Apply preset %s" % preset_name)
+ _undo_redo.add_do_method(parent, "add_child", node, true)
+ _undo_redo.add_do_method(node, "set_owner", scene_root)
+ _undo_redo.add_do_reference(node)
+ if process_mat != null:
+ _undo_redo.add_do_property(node, "process_material", process_mat)
+ _undo_redo.add_do_reference(process_mat)
+ if draw_mesh != null:
+ _undo_redo.add_do_property(node, "draw_pass_1", draw_mesh)
+ _undo_redo.add_do_reference(draw_mesh)
+ if draw_material != null:
+ _undo_redo.add_do_reference(draw_material)
+ _undo_redo.add_undo_method(parent, "remove_child", node)
+ _undo_redo.commit_action()
+
+ return {
+ "data": {
+ "path": McpScenePath.from_node(node, scene_root),
+ "parent_path": McpScenePath.from_node(parent, scene_root),
+ "name": node_name,
+ "preset": preset_name,
+ "type": type_str,
+ "class": _VALID_TYPES[type_str],
+ "applied_main": applied_main,
+ "applied_process": applied_process,
+ "applied_draw": applied_draw,
+ "process_material_created": process_material_created,
+ "draw_pass_mesh_created": draw_pass_mesh_created,
+ "draw_material_created": draw_material_created,
+ "is_3d": is_3d,
+ "undoable": true,
+ }
+ }
+
+
+# ============================================================================
+# Helpers
+# ============================================================================
+
+## Actionable rejection for a user draw override that can't apply to the
+## selected particle type: name the key and where it IS supported.
+static func _draw_key_unsupported_message(key: String, type_str: String) -> String:
+ if key == "texture":
+ return "draw.texture is only supported for gpu_2d (got type '%s')" % type_str
+ var probe := StandardMaterial3D.new()
+ if _object_property_type(probe, key) != TYPE_NIL:
+ return "draw.%s is only supported for gpu_3d (got type '%s')" % [key, type_str]
+ return (
+ "Unknown draw key '%s' (draw overrides configure the gpu_3d "
+ + "draw-pass StandardMaterial3D; gpu_2d supports only draw.texture)"
+ ) % key
+
+
+## Load a Texture2D for the gpu_2d draw.texture override. Returns
+## {texture: Texture2D} or an error dict (same validation as set_draw_pass).
+static func _load_draw_texture(value: Variant) -> Dictionary:
+ if not (value is String) or String(value).is_empty():
+ return ErrorCodes.make(
+ ErrorCodes.INVALID_PARAMS,
+ "draw.texture must be a non-empty res:// path string (got %s)" % type_string(typeof(value))
+ )
+ var texture_path := String(value)
+ var texture_path_err = McpPathValidator.loadable_error(texture_path, "draw.texture")
+ if texture_path_err != null:
+ return texture_path_err
+ if not ResourceLoader.exists(texture_path):
+ return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Texture not found: %s" % texture_path)
+ var tex := ResourceLoader.load(texture_path)
+ if not (tex is Texture2D):
+ return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Resource at %s is not a Texture2D" % texture_path)
+ return {"texture": tex}
+
+
+static func _instantiate_particle(type_str: String) -> Node:
+ match type_str:
+ "gpu_3d":
+ return GPUParticles3D.new()
+ "gpu_2d":
+ return GPUParticles2D.new()
+ "cpu_3d":
+ return CPUParticles3D.new()
+ "cpu_2d":
+ return CPUParticles2D.new()
+ return null
+
+
+func _resolve_particle(params: Dictionary) -> Dictionary:
+ var resolved := McpNodeValidator.resolve_or_error(
+ params.get("node_path", ""), "node_path",
+ )
+ if resolved.has("error"):
+ return resolved
+ var node: Node = resolved.node
+ var node_path: String = resolved.path
+ var is_particle := node is GPUParticles3D or node is GPUParticles2D \
+ or node is CPUParticles3D or node is CPUParticles2D
+ if not is_particle:
+ return ErrorCodes.make(
+ ErrorCodes.WRONG_TYPE,
+ "Node %s is not a particle node (got %s)" % [node_path, node.get_class()]
+ )
+ return {"node": node, "path": node_path}
+
+
+static func _node_property_type(node: Object, name: String) -> int:
+ return _object_property_type(node, name)
+
+
+static func _object_property_type(obj: Object, name: String) -> int:
+ if obj == null:
+ return TYPE_NIL
+ for prop in obj.get_property_list():
+ if prop.name == name:
+ return int(prop.get("type", TYPE_NIL))
+ return TYPE_NIL
+
+
+static func _property_names(obj: Object) -> Dictionary:
+ var out: Dictionary = {}
+ if obj == null:
+ return out
+ for prop in obj.get_property_list():
+ out[prop.name] = true
+ return out
diff --git a/addons/godot_ai/handlers/particle_handler.gd.uid b/addons/godot_ai/handlers/particle_handler.gd.uid
new file mode 100644
index 0000000..ead3722
--- /dev/null
+++ b/addons/godot_ai/handlers/particle_handler.gd.uid
@@ -0,0 +1 @@
+uid://byfc0pnyb5qww
diff --git a/addons/godot_ai/handlers/particle_presets.gd b/addons/godot_ai/handlers/particle_presets.gd
new file mode 100644
index 0000000..3bbf1f4
--- /dev/null
+++ b/addons/godot_ai/handlers/particle_presets.gd
@@ -0,0 +1,293 @@
+@tool
+extends RefCounted
+
+## Curated particle effect blueprints.
+##
+## Each preset returns {main, process, draw}. The handler applies them
+## through the normal write path (one undo action wraps all spawns).
+
+
+## Each preset has {main, process, draw}. `draw` configures the StandardMaterial3D
+## attached to the auto-created QuadMesh in draw_pass_1 (GPU 3D only); if
+## omitted, the handler falls back to a sensible billboard-particles default.
+## `blend_mode: "add"` is what makes fire/magic/explosion glow — without
+## additive blending, additively-layered particles just stack to gray.
+const _PRESETS := {
+ "fire": {
+ "main": {
+ "amount": 80,
+ "lifetime": 1.2,
+ "one_shot": false,
+ "explosiveness": 0.0,
+ "preprocess": 0.5,
+ "local_coords": false,
+ },
+ "process": {
+ "emission_shape": "sphere",
+ "emission_sphere_radius": 0.3,
+ "direction": {"x": 0.0, "y": 1.0, "z": 0.0},
+ "spread": 15.0,
+ "initial_velocity_min": 2.0,
+ "initial_velocity_max": 4.0,
+ "gravity": {"x": 0.0, "y": 1.0, "z": 0.0}, # buoyancy
+ "scale_min": 0.4,
+ "scale_max": 0.8,
+ "color_ramp": {
+ "stops": [
+ {"time": 0.0, "color": [1.0, 1.0, 0.9, 1.0]},
+ {"time": 0.3, "color": [1.0, 0.6, 0.1, 1.0]},
+ {"time": 0.7, "color": [0.8, 0.1, 0.05, 0.7]},
+ {"time": 1.0, "color": [0.2, 0.05, 0.05, 0.0]},
+ ]
+ },
+ },
+ "draw": {"blend_mode": "add"},
+ },
+ "smoke": {
+ "main": {
+ "amount": 40,
+ "lifetime": 3.0,
+ "one_shot": false,
+ "explosiveness": 0.0,
+ "local_coords": false,
+ },
+ "process": {
+ "emission_shape": "sphere",
+ "emission_sphere_radius": 0.4,
+ "direction": {"x": 0.0, "y": 1.0, "z": 0.0},
+ "spread": 20.0,
+ "initial_velocity_min": 0.5,
+ "initial_velocity_max": 1.5,
+ "gravity": {"x": 0.0, "y": 0.2, "z": 0.0},
+ "scale_min": 0.6,
+ "scale_max": 1.4,
+ "color_ramp": {
+ "stops": [
+ {"time": 0.0, "color": [0.3, 0.3, 0.3, 0.0]},
+ {"time": 0.25, "color": [0.35, 0.35, 0.35, 0.7]},
+ {"time": 0.75, "color": [0.2, 0.2, 0.2, 0.5]},
+ {"time": 1.0, "color": [0.1, 0.1, 0.1, 0.0]},
+ ]
+ },
+ },
+ # Smoke uses regular alpha blending so it darkens the background.
+ "draw": {"blend_mode": "mix"},
+ },
+ "spark_burst": {
+ "main": {
+ "amount": 60,
+ "lifetime": 0.8,
+ "one_shot": true,
+ "explosiveness": 1.0,
+ "local_coords": false,
+ },
+ "process": {
+ "emission_shape": "point",
+ "direction": {"x": 0.0, "y": 1.0, "z": 0.0},
+ "spread": 180.0,
+ "initial_velocity_min": 5.0,
+ "initial_velocity_max": 12.0,
+ "gravity": {"x": 0.0, "y": -9.8, "z": 0.0},
+ "scale_min": 0.05,
+ "scale_max": 0.12,
+ "color": {"r": 1.0, "g": 0.9, "b": 0.2, "a": 1.0},
+ },
+ "draw": {
+ "blend_mode": "add",
+ "emission_enabled": true,
+ "emission": {"r": 1.0, "g": 0.8, "b": 0.2, "a": 1.0},
+ "emission_energy_multiplier": 2.0,
+ },
+ },
+ "magic_swirl": {
+ "main": {
+ "amount": 120,
+ "lifetime": 2.0,
+ "one_shot": false,
+ "explosiveness": 0.0,
+ "local_coords": false,
+ },
+ "process": {
+ "emission_shape": "ring",
+ "emission_ring_radius": 0.8,
+ "emission_ring_inner_radius": 0.6,
+ "emission_ring_height": 0.0,
+ "direction": {"x": 0.0, "y": 1.0, "z": 0.0},
+ "spread": 30.0,
+ "initial_velocity_min": 1.0,
+ "initial_velocity_max": 2.0,
+ "gravity": {"x": 0.0, "y": 0.0, "z": 0.0},
+ "angular_velocity_min": 90.0,
+ "angular_velocity_max": 180.0,
+ "scale_min": 0.1,
+ "scale_max": 0.2,
+ "color_ramp": {
+ "stops": [
+ {"time": 0.0, "color": [0.4, 0.9, 1.0, 0.0]},
+ {"time": 0.3, "color": [0.5, 0.7, 1.0, 1.0]},
+ {"time": 0.7, "color": [1.0, 0.4, 0.9, 1.0]},
+ {"time": 1.0, "color": [0.8, 0.2, 0.7, 0.0]},
+ ]
+ },
+ },
+ "draw": {"blend_mode": "add"},
+ },
+ "rain": {
+ "main": {
+ "amount": 500,
+ "lifetime": 1.5,
+ "one_shot": false,
+ "explosiveness": 0.0,
+ "local_coords": false,
+ },
+ "process": {
+ "emission_shape": "box",
+ "emission_box_extents": {"x": 10.0, "y": 0.1, "z": 10.0},
+ "direction": {"x": 0.0, "y": -1.0, "z": 0.0},
+ "spread": 2.0,
+ "initial_velocity_min": 15.0,
+ "initial_velocity_max": 18.0,
+ "gravity": {"x": 0.0, "y": -2.0, "z": 0.0},
+ "scale_min": 0.02,
+ "scale_max": 0.04,
+ "color": {"r": 0.7, "g": 0.85, "b": 1.0, "a": 0.5},
+ },
+ # Rain drops render as streaks; fixed_y aligns them vertically.
+ "draw": {"billboard_mode": "fixed_y", "blend_mode": "mix"},
+ },
+ "explosion": {
+ "main": {
+ "amount": 200,
+ "lifetime": 1.5,
+ "one_shot": true,
+ "explosiveness": 1.0,
+ "local_coords": false,
+ },
+ "process": {
+ "emission_shape": "sphere",
+ "emission_sphere_radius": 0.1,
+ "direction": {"x": 0.0, "y": 1.0, "z": 0.0},
+ "spread": 180.0,
+ "initial_velocity_min": 6.0,
+ "initial_velocity_max": 10.0,
+ "gravity": {"x": 0.0, "y": -4.0, "z": 0.0},
+ "scale_min": 0.3,
+ "scale_max": 0.7,
+ "color_ramp": {
+ "stops": [
+ {"time": 0.0, "color": [1.0, 0.95, 0.5, 1.0]},
+ {"time": 0.2, "color": [1.0, 0.4, 0.1, 1.0]},
+ {"time": 0.7, "color": [0.3, 0.15, 0.1, 0.7]},
+ {"time": 1.0, "color": [0.1, 0.1, 0.1, 0.0]},
+ ]
+ },
+ },
+ "draw": {
+ "blend_mode": "add",
+ "emission_enabled": true,
+ "emission": {"r": 1.0, "g": 0.5, "b": 0.1, "a": 1.0},
+ "emission_energy_multiplier": 1.5,
+ },
+ },
+ "lightning": {
+ # Short, bright, electric-blue spark burst. One-shot — call
+ # particle_restart to re-trigger. Pairs well with a scene-wide flash.
+ "main": {
+ "amount": 40,
+ "lifetime": 0.35,
+ "one_shot": true,
+ "explosiveness": 1.0,
+ "local_coords": false,
+ },
+ "process": {
+ "emission_shape": "box",
+ "emission_box_extents": {"x": 0.1, "y": 1.5, "z": 0.1},
+ "direction": {"x": 0.0, "y": -1.0, "z": 0.0},
+ "spread": 8.0,
+ "initial_velocity_min": 18.0,
+ "initial_velocity_max": 28.0,
+ "gravity": {"x": 0.0, "y": 0.0, "z": 0.0},
+ "scale_min": 0.08,
+ "scale_max": 0.18,
+ "color_ramp": {
+ "stops": [
+ {"time": 0.0, "color": [1.0, 1.0, 1.0, 1.0]},
+ {"time": 0.2, "color": [0.6, 0.85, 1.0, 1.0]},
+ {"time": 0.6, "color": [0.3, 0.5, 1.0, 0.9]},
+ {"time": 1.0, "color": [0.1, 0.2, 0.7, 0.0]},
+ ]
+ },
+ },
+ "draw": {
+ "blend_mode": "add",
+ "emission_enabled": true,
+ "emission": {"r": 0.5, "g": 0.8, "b": 1.0, "a": 1.0},
+ "emission_energy_multiplier": 4.0,
+ },
+ },
+}
+
+
+static func list() -> Array:
+ return _PRESETS.keys()
+
+
+static func has(preset_name: String) -> bool:
+ return _PRESETS.has(preset_name)
+
+
+## Return deep-copied {main, process, draw} blueprint with overrides merged in,
+## plus "user_keys" ({main/process/draw: {key: true}}) recording which keys the
+## caller supplied. The handler needs that distinction: preset-blueprint keys
+## may skip silently on types they don't apply to (presets are cross-type by
+## design), but user-requested overrides must apply or error (#770).
+## Overrides may include top-level "main" / "process" / "draw" dicts, or bare
+## keys routed to main (_MAIN_KEYS) or process — draw keys must be nested.
+static func build(preset_name: String, overrides: Dictionary) -> Variant:
+ if not _PRESETS.has(preset_name):
+ return null
+ var entry: Dictionary = _PRESETS[preset_name].duplicate(true)
+ var main: Dictionary = entry.get("main", {})
+ var process: Dictionary = entry.get("process", {})
+ var draw: Dictionary = entry.get("draw", {})
+ var user_keys := {"main": {}, "process": {}, "draw": {}}
+ for key in overrides:
+ var val = overrides[key]
+ if key == "main" and val is Dictionary:
+ for k in val:
+ main[k] = val[k]
+ user_keys.main[String(k)] = true
+ elif key == "process" and val is Dictionary:
+ for k in val:
+ process[k] = val[k]
+ user_keys.process[String(k)] = true
+ elif key == "draw" and val is Dictionary:
+ for k in val:
+ draw[k] = val[k]
+ user_keys.draw[String(k)] = true
+ elif _MAIN_KEYS.has(key):
+ main[key] = val
+ user_keys.main[String(key)] = true
+ else:
+ process[key] = val
+ user_keys.process[String(key)] = true
+ entry["main"] = main
+ entry["process"] = process
+ entry["draw"] = draw
+ entry["user_keys"] = user_keys
+ return entry
+
+
+const _MAIN_KEYS := {
+ "amount": true,
+ "lifetime": true,
+ "one_shot": true,
+ "explosiveness": true,
+ "preprocess": true,
+ "speed_scale": true,
+ "randomness": true,
+ "fixed_fps": true,
+ "emitting": true,
+ "local_coords": true,
+ "interp_to_end": true,
+}
diff --git a/addons/godot_ai/handlers/particle_presets.gd.uid b/addons/godot_ai/handlers/particle_presets.gd.uid
new file mode 100644
index 0000000..7c1aac9
--- /dev/null
+++ b/addons/godot_ai/handlers/particle_presets.gd.uid
@@ -0,0 +1 @@
+uid://bss2ccpmsxo4p
diff --git a/addons/godot_ai/handlers/particle_values.gd b/addons/godot_ai/handlers/particle_values.gd
new file mode 100644
index 0000000..66782f6
--- /dev/null
+++ b/addons/godot_ai/handlers/particle_values.gd
@@ -0,0 +1,246 @@
+@tool
+extends RefCounted
+
+## Value coercion + gradient/curve builders for particle properties.
+
+const MaterialValues := preload("res://addons/godot_ai/handlers/material_values.gd")
+
+const _EMISSION_SHAPES := {
+ "point": ParticleProcessMaterial.EMISSION_SHAPE_POINT,
+ "sphere": ParticleProcessMaterial.EMISSION_SHAPE_SPHERE,
+ "sphere_surface": ParticleProcessMaterial.EMISSION_SHAPE_SPHERE_SURFACE,
+ "box": ParticleProcessMaterial.EMISSION_SHAPE_BOX,
+ "points": ParticleProcessMaterial.EMISSION_SHAPE_POINTS,
+ "directed_points": ParticleProcessMaterial.EMISSION_SHAPE_DIRECTED_POINTS,
+ "ring": ParticleProcessMaterial.EMISSION_SHAPE_RING,
+}
+
+
+## Resolve a shape name to the int enum, or return null.
+static func resolve_emission_shape(value: Variant) -> Variant:
+ if value is int:
+ return value
+ if value is float:
+ return int(value)
+ if value is String:
+ var key := String(value).to_lower()
+ if _EMISSION_SHAPES.has(key):
+ return _EMISSION_SHAPES[key]
+ return null
+
+
+static func emission_shape_names() -> Array:
+ return _EMISSION_SHAPES.keys()
+
+
+## Build a Gradient from {stops: [{time, color}]} dict.
+static func build_gradient(value: Variant) -> Variant:
+ if value is Gradient:
+ return value
+ if value is GradientTexture1D:
+ return (value as GradientTexture1D).gradient
+ if not (value is Dictionary):
+ return null
+ var d: Dictionary = value
+ if not d.has("stops"):
+ return null
+ var stops_array = d.get("stops")
+ if not (stops_array is Array):
+ return null
+ var offsets := PackedFloat32Array()
+ var colors := PackedColorArray()
+ for stop in stops_array:
+ if not (stop is Dictionary):
+ return null
+ offsets.append(float(stop.get("time", 0.0)))
+ var c = MaterialValues.parse_color(stop.get("color"))
+ if c == null:
+ return null
+ colors.append(c)
+ var grad := Gradient.new()
+ grad.offsets = offsets
+ grad.colors = colors
+ return grad
+
+
+## Build a GradientTexture1D wrapping a Gradient (what ParticleProcessMaterial.color_ramp wants).
+static func build_gradient_texture(value: Variant) -> Variant:
+ if value is GradientTexture1D:
+ return value
+ var grad = build_gradient(value)
+ if grad == null:
+ return null
+ var tex := GradientTexture1D.new()
+ tex.gradient = grad
+ return tex
+
+
+## Build a Curve from [{time, value}] or {points: [...]} (float-over-time).
+static func build_curve(value: Variant) -> Variant:
+ if value is Curve:
+ return value
+ if value is CurveTexture:
+ return (value as CurveTexture).curve
+ var points_array: Variant = null
+ if value is Array:
+ points_array = value
+ elif value is Dictionary and value.has("points"):
+ points_array = value["points"]
+ if not (points_array is Array):
+ return null
+ var curve := Curve.new()
+ for pt in points_array:
+ if not (pt is Dictionary):
+ return null
+ var t := float(pt.get("time", 0.0))
+ var v := float(pt.get("value", 0.0))
+ curve.add_point(Vector2(t, v))
+ return curve
+
+
+static func build_curve_texture(value: Variant) -> Variant:
+ if value is CurveTexture:
+ return value
+ var curve = build_curve(value)
+ if curve == null:
+ return null
+ var tex := CurveTexture.new()
+ tex.curve = curve
+ return tex
+
+
+## Coerce a particle property value to the appropriate type.
+## Handles: Vector3/gravity/direction, Color, float, int, bool, enum strings.
+## For color_ramp returns a GradientTexture1D; for *_curve returns CurveTexture.
+static func coerce(property: String, value: Variant, target_type: int) -> Dictionary:
+ # Special-cased properties.
+ if property == "emission_shape":
+ var shape = resolve_emission_shape(value)
+ if shape == null:
+ return {
+ "ok": false,
+ "error": "Invalid emission_shape '%s'. Valid: %s" % [
+ value, ", ".join(emission_shape_names())
+ ],
+ }
+ return {"ok": true, "value": int(shape)}
+
+ if property == "color_ramp" or property == "color_initial_ramp":
+ var tex = build_gradient_texture(value)
+ if tex == null:
+ return {"ok": false, "error": "Invalid gradient for %s (expected {stops: [{time, color}]})" % property}
+ return {"ok": true, "value": tex}
+
+ if property == "color" and value is Dictionary and not (value as Dictionary).has("stops"):
+ # color is a single Color, not a ramp.
+ var c = MaterialValues.parse_color(value)
+ if c == null:
+ return {"ok": false, "error": "Invalid color"}
+ return {"ok": true, "value": c}
+
+ if property.ends_with("_curve"):
+ var tex = build_curve_texture(value)
+ if tex == null:
+ return {"ok": false, "error": "Invalid curve for %s (expected [{time, value}])" % property}
+ return {"ok": true, "value": tex}
+
+ # Fall through to the material coercer (handles Color/Vec3/Vec2/float/int/bool/enum).
+ return MaterialValues.coerce_material_value(property, value, target_type)
+
+
+## Build a StandardMaterial3D suitable for GPUParticles3D draw-pass rendering.
+##
+## Godot's default Mesh has no material, which means ParticleProcessMaterial's
+## color_ramp (which drives the COLOR varying) gets ignored and particles
+## render as flat white squares that don't face the camera. A correct default
+## must have vertex_color_use_as_albedo=true, billboard=particles, unshaded,
+## and alpha transparency so the gradient actually modulates the pixels.
+##
+## Config is an optional dict that overrides individual properties. Supported
+## keys match BaseMaterial3D properties (plus enum-by-name via MaterialValues):
+## blend_mode: "mix" | "add" | "sub" | "mul"
+## transparency: "disabled" | "alpha" | "alpha_scissor" | "alpha_hash" | "alpha_depth_pre_pass"
+## shading_mode: "unshaded" | "per_pixel" | "per_vertex"
+## billboard_mode: "disabled" | "enabled" | "fixed_y" | "particles"
+## vertex_color_use_as_albedo: bool
+## emission_enabled: bool
+## emission: Color
+## emission_energy_multiplier: float
+## albedo_color: Color
+## albedo_texture: res:// path
+## (anything else accepted by BaseMaterial3D.set())
+##
+## Returns {ok: true, material: StandardMaterial3D, applied: Array[String]},
+## or {ok: false, key, error, unknown_key} when a config key is not a
+## StandardMaterial3D property or its value fails coercion — draw config must
+## not disappear silently (#770).
+static func build_draw_material(config: Dictionary) -> Dictionary:
+ var mat := StandardMaterial3D.new()
+ # Sensible defaults for particle draw-pass rendering.
+ mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
+ mat.vertex_color_use_as_albedo = true
+ mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
+ mat.billboard_mode = BaseMaterial3D.BILLBOARD_PARTICLES
+ mat.billboard_keep_scale = true
+ # Configure from dict overrides.
+ var applied: Array[String] = []
+ for key in config:
+ var prop_name := String(key)
+ var prop_type := _object_property_type(mat, prop_name)
+ if prop_type == TYPE_NIL:
+ return {
+ "ok": false,
+ "key": prop_name,
+ "unknown_key": true,
+ "error": "Unknown draw key '%s' (not a StandardMaterial3D property)" % prop_name,
+ }
+ var coerce_result := MaterialValues.coerce_material_value(
+ prop_name, config[prop_name], prop_type
+ )
+ if not coerce_result.ok:
+ return {
+ "ok": false,
+ "key": prop_name,
+ "unknown_key": false,
+ "error": "draw.%s: %s" % [prop_name, coerce_result.error],
+ }
+ mat.set(prop_name, coerce_result.value)
+ applied.append(prop_name)
+ return {"ok": true, "material": mat, "applied": applied}
+
+
+static func _object_property_type(obj: Object, name: String) -> int:
+ if obj == null:
+ return TYPE_NIL
+ for prop in obj.get_property_list():
+ if prop.name == name:
+ return int(prop.get("type", TYPE_NIL))
+ return TYPE_NIL
+
+
+## Serialize for response.
+static func serialize(value: Variant) -> Variant:
+ if value == null:
+ return null
+ if value is GradientTexture1D:
+ var grad := (value as GradientTexture1D).gradient
+ if grad == null:
+ return {"type": "GradientTexture1D", "stops": []}
+ var stops: Array = []
+ for i in grad.offsets.size():
+ var c: Color = grad.colors[i]
+ stops.append({
+ "time": grad.offsets[i],
+ "color": {"r": c.r, "g": c.g, "b": c.b, "a": c.a},
+ })
+ return {"type": "GradientTexture1D", "stops": stops}
+ if value is CurveTexture:
+ var curve := (value as CurveTexture).curve
+ if curve == null:
+ return {"type": "CurveTexture", "points": []}
+ var points: Array = []
+ for i in curve.get_point_count():
+ var p := curve.get_point_position(i)
+ points.append({"time": p.x, "value": p.y})
+ return {"type": "CurveTexture", "points": points}
+ return MaterialValues.serialize_value(value)
diff --git a/addons/godot_ai/handlers/particle_values.gd.uid b/addons/godot_ai/handlers/particle_values.gd.uid
new file mode 100644
index 0000000..71f6adc
--- /dev/null
+++ b/addons/godot_ai/handlers/particle_values.gd.uid
@@ -0,0 +1 @@
+uid://bnnnjq06dmclc
diff --git a/addons/godot_ai/handlers/physics_shape_handler.gd b/addons/godot_ai/handlers/physics_shape_handler.gd
new file mode 100644
index 0000000..b958abb
--- /dev/null
+++ b/addons/godot_ai/handlers/physics_shape_handler.gd
@@ -0,0 +1,772 @@
+@tool
+extends "res://addons/godot_ai/handlers/command_handler.gd"
+
+const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
+
+## Sizes a CollisionShape2D/CollisionShape3D to match a visual sibling's
+## bounds. Auto-creates the concrete Shape subclass when the slot is empty
+## or the requested type differs — bundling creation and sizing in a single
+## undo action.
+##
+## Shape type defaults: Box for 3D, Rectangle for 2D.
+
+var _undo_redo: EditorUndoRedoManager
+var _connection
+
+
+func _init(undo_redo: EditorUndoRedoManager, connection = null) -> void:
+ _undo_redo = undo_redo
+ _connection = connection
+
+
+const _SHAPE_3D_CLASSES := {
+ "box": "BoxShape3D",
+ "sphere": "SphereShape3D",
+ "capsule": "CapsuleShape3D",
+ "cylinder": "CylinderShape3D",
+}
+
+const _SHAPE_2D_CLASSES := {
+ "rectangle": "RectangleShape2D",
+ "circle": "CircleShape2D",
+ "capsule": "CapsuleShape2D",
+}
+
+const _GENERATED_BODY_CLASSES := {
+ "static": "StaticBody3D",
+ "area": "Area3D",
+}
+const _GENERATE_DIRECT_MAX_PATHS := 16
+const _GENERATE_MAX_PATHS := 1024
+## One deferred request may spend this long across editor frames. The Python
+## handler's timeout is this plus its transport margin (a source-shape test
+## keeps the two together).
+const _GENERATE_DEFERRED_TIMEOUT_MS := 30000
+## Wall-clock budget one editor frame gives the job, the same order as the
+## dispatcher's own tick budget: a 1024-path request neither stalls the editor
+## nor waits out hundreds of near-empty frames.
+const _GENERATE_FRAME_BUDGET_USEC := 4000
+const _GENERATE_COLLIDER_SUFFIX := "Collider"
+const _GENERATE_SCALE_EPSILON := 0.0001
+
+
+## Accept either the short form ("box") or the matching Godot class name
+## ("BoxShape3D") — every other tool in the server takes class names, and
+## resource_get_info(type="Shape3D") surfaces concrete_subclasses by class.
+## Returns "" when neither form matches.
+static func _normalize_shape_type(type_map: Dictionary, requested: String) -> String:
+ if type_map.has(requested):
+ return requested
+ for short_form in type_map:
+ if type_map[short_form] == requested:
+ return short_form
+ return ""
+
+
+## Generates sibling physics bodies and collision shapes for MeshInstance3D
+## nodes. Every path and option is validated before the first mutation, and a
+## deferred request re-validates each mesh again when it is applied, so a
+## scene edited during the window can only fail the whole request, never
+## leave a partially generated batch.
+func generate(params: Dictionary) -> Dictionary:
+ var validated := _validate_generate_request(params)
+ if validated.has("error"):
+ return validated
+
+ var request_id: String = params.get("_request_id", "")
+ if _connection != null and not request_id.is_empty():
+ var job := _generate_job(validated, _undo_redo, _connection, request_id)
+ _drive_generate_job(job, _connection)
+ return {
+ "_deferred": true,
+ "_deferred_timeout_ms": _GENERATE_DEFERRED_TIMEOUT_MS,
+ }
+
+ ## `dispatch_direct()` strips the request id, so batch_execute and unit-test
+ ## callers cannot yield across frames. Keep that compatibility path bounded;
+ ## larger requests must use the normal MCP command and its deferred reply.
+ var path_count: int = validated.paths.size()
+ if path_count > _GENERATE_DIRECT_MAX_PATHS:
+ return ErrorCodes.make(
+ ErrorCodes.VALUE_OUT_OF_RANGE,
+ (
+ "physics_shape_generate received %d paths; inside batch_execute at most %d are "
+ + "supported — call resource_manage(op='physics_shape_generate') directly for larger batches"
+ ) % [path_count, _GENERATE_DIRECT_MAX_PATHS],
+ )
+ var job := _generate_job(validated, _undo_redo, null, "")
+ while not _generate_step(job, -1):
+ pass
+ return job.result
+
+
+## Validate the request shape and options first (no scene needed), then the
+## edited scene. Nothing here touches a node.
+static func _validate_generate_request(params: Dictionary) -> Dictionary:
+ var requested_shape := String(params.get("shape_type", "box"))
+ var shape_type := _normalize_shape_type(_SHAPE_3D_CLASSES, requested_shape)
+ if shape_type.is_empty():
+ return ErrorCodes.make(
+ ErrorCodes.VALUE_OUT_OF_RANGE,
+ "Invalid shape_type '%s'. Valid: %s" % [requested_shape, ", ".join(_SHAPE_3D_CLASSES.keys())]
+ )
+ var body_type := String(params.get("body_type", "static"))
+ if not _GENERATED_BODY_CLASSES.has(body_type):
+ return ErrorCodes.make(
+ ErrorCodes.VALUE_OUT_OF_RANGE,
+ "Invalid body_type '%s'. Valid: %s" % [body_type, ", ".join(_GENERATED_BODY_CLASSES.keys())]
+ )
+
+ var raw_paths: Variant = params.get("paths", [])
+ if not raw_paths is Array:
+ return ErrorCodes.make(
+ ErrorCodes.WRONG_TYPE,
+ "paths must be an array of MeshInstance3D scene paths, got %s" % type_string(typeof(raw_paths)),
+ )
+ if raw_paths.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: paths")
+ if raw_paths.size() > _GENERATE_MAX_PATHS:
+ return ErrorCodes.make(
+ ErrorCodes.VALUE_OUT_OF_RANGE,
+ "paths supports at most %d entries per request, got %d" % [_GENERATE_MAX_PATHS, raw_paths.size()],
+ )
+ var paths: Array[String] = []
+ var seen := {}
+ for index in raw_paths.size():
+ var raw_path: Variant = raw_paths[index]
+ if not raw_path is String:
+ return ErrorCodes.make(
+ ErrorCodes.WRONG_TYPE,
+ "paths[%d] must be a MeshInstance3D scene path string, got %s"
+ % [index, type_string(typeof(raw_path))],
+ )
+ if seen.has(raw_path):
+ return ErrorCodes.make(
+ ErrorCodes.VALUE_OUT_OF_RANGE,
+ "paths lists %s twice (entries %d and %d); each mesh gets one collider"
+ % [raw_path, int(seen[raw_path]), index],
+ )
+ seen[raw_path] = index
+ paths.append(raw_path)
+
+ var scene_check := McpScenePath.require_edited_scene(params.get("scene_file", ""))
+ if scene_check.has("error"):
+ return scene_check
+ return {
+ "data": true,
+ "scene_root": scene_check.node,
+ "scene_file": params.get("scene_file", ""),
+ "shape_type": shape_type,
+ "body_type": body_type,
+ "paths": paths,
+ }
+
+
+## Resolve one mesh and capture everything the apply step must find unchanged.
+static func _plan_generate_mesh(
+ mesh_path: String, scene_file: String, scene_root: Node, shape_type: String
+) -> Dictionary:
+ var resolved := McpNodeValidator.resolve_or_error(mesh_path, "paths", scene_file)
+ if resolved.has("error"):
+ return resolved
+ var node: Node = resolved.node
+ if node == scene_root:
+ return ErrorCodes.make(
+ ErrorCodes.VALUE_OUT_OF_RANGE,
+ "%s is the scene root — a sibling body needs a parent inside the scene" % mesh_path
+ )
+ if not node is MeshInstance3D:
+ return ErrorCodes.make(
+ ErrorCodes.WRONG_TYPE,
+ "Node at %s is %s — must be MeshInstance3D" % [mesh_path, node.get_class()]
+ )
+ var mesh := node as MeshInstance3D
+ var parent := mesh.get_parent()
+ if parent == null:
+ return ErrorCodes.make(
+ ErrorCodes.INVALID_PARAMS,
+ "MeshInstance3D at %s has no parent — cannot create a sibling body" % mesh_path
+ )
+ if mesh.mesh == null:
+ return ErrorCodes.make(
+ ErrorCodes.RESOURCE_NOT_FOUND,
+ "MeshInstance3D at %s has no mesh resource — there are no bounds to fit" % mesh_path
+ )
+ var collider_name := mesh.name + _GENERATE_COLLIDER_SUFFIX
+ var existing := parent.get_node_or_null(NodePath(collider_name))
+ if existing != null:
+ return ErrorCodes.make(
+ ErrorCodes.VALUE_OUT_OF_RANGE,
+ "MeshInstance3D at %s already has a collider sibling at %s — remove or rename it first"
+ % [mesh_path, McpScenePath.from_node(existing, scene_root)]
+ )
+ ## A sibling in parent space inherits the parent chain's scale exactly like
+ ## the mesh does, so its box matches the visual; but Godot cannot scale a
+ ## sphere, capsule or cylinder non-uniformly (the warning it prints is a
+ ## deformed collider). A top-level mesh ignores its parent's transform.
+ if not mesh.top_level and shape_type != "box" and parent is Node3D:
+ var parent_scale: Vector3 = (parent as Node3D).global_transform.basis.get_scale()
+ var spread := absf(parent_scale.x - parent_scale.y)
+ spread = maxf(spread, absf(parent_scale.y - parent_scale.z))
+ if spread > _GENERATE_SCALE_EPSILON * maxf(1.0, parent_scale.length()):
+ return ErrorCodes.make(
+ ErrorCodes.VALUE_OUT_OF_RANGE,
+ (
+ "MeshInstance3D at %s sits under a parent chain scaled %s; a %s collider "
+ + "cannot be scaled non-uniformly — use shape_type 'box' or reparent the mesh"
+ ) % [mesh_path, parent_scale, shape_type]
+ )
+ var source_transform := mesh.global_transform if mesh.top_level else mesh.transform
+ var body_transform := Transform3D(
+ Basis(source_transform.basis.get_rotation_quaternion()),
+ source_transform.origin,
+ )
+ var mesh_to_body := body_transform.affine_inverse() * source_transform
+ var bounds: AABB = mesh_to_body * mesh.get_aabb()
+ if bounds.size.x <= 0.0 or bounds.size.y <= 0.0 or bounds.size.z <= 0.0:
+ return ErrorCodes.make(
+ ErrorCodes.VALUE_OUT_OF_RANGE,
+ "MeshInstance3D at %s has empty bounds %s — nothing to fit" % [mesh_path, bounds]
+ )
+ return {"plan": {
+ "mesh": mesh,
+ "mesh_path": mesh_path,
+ "parent": parent,
+ "collider_name": collider_name,
+ "top_level": mesh.top_level,
+ "source_transform": source_transform,
+ "body_transform": body_transform,
+ "bounds": bounds,
+ }}
+
+
+## Why a plan captured earlier no longer describes the scene, or "" when it
+## still does. Checked again right before each body is added, so a deferred
+## request can never apply plan-time state to a mesh that moved, was
+## reparented, lost its mesh, gained a collider, or went away meanwhile.
+static func _plan_stale_reason(plan: Dictionary) -> String:
+ var mesh: MeshInstance3D = plan.mesh
+ ## A node taken out of the tree and queued to free is still a valid
+ ## instance for the rest of the frame; not being in the tree is what
+ ## "removed" means here.
+ if not is_instance_valid(mesh) or not mesh.is_inside_tree():
+ return "was removed"
+ var parent: Node = plan.parent
+ if not is_instance_valid(parent) or mesh.get_parent() != parent:
+ return "was reparented"
+ if mesh.top_level != bool(plan.top_level):
+ return "changed its top_level setting"
+ var source_transform := mesh.global_transform if mesh.top_level else mesh.transform
+ if not source_transform.is_equal_approx(plan.source_transform):
+ return "moved"
+ if mesh.mesh == null:
+ return "lost its mesh resource"
+ if parent.get_node_or_null(NodePath(str(plan.collider_name))) != null:
+ return "gained a collider sibling"
+ return ""
+
+
+## Build one detached body/collision pair from a prevalidated mesh plan.
+static func _create_generated_entry(
+ plan: Dictionary, shape_type: String, body_type: String
+) -> Dictionary:
+ var mesh: MeshInstance3D = plan.mesh
+ var body: CollisionObject3D = ClassDB.instantiate(_GENERATED_BODY_CLASSES[body_type])
+ body.name = str(plan.collider_name)
+ body.top_level = bool(plan.top_level)
+ body.transform = plan.body_transform
+ var collision := CollisionShape3D.new()
+ collision.name = "CollisionShape3D"
+ var bounds: AABB = plan.bounds
+ collision.position = bounds.get_center()
+ var shape: Shape3D = ClassDB.instantiate(_SHAPE_3D_CLASSES[shape_type])
+ _apply_shape_size(shape, shape_type, {"aabb": bounds}, true)
+ collision.shape = shape
+ body.add_child(collision)
+ return {
+ "mesh": mesh,
+ "parent": plan.parent,
+ "body": body,
+ "collision": collision,
+ }
+
+
+## Record all generated nodes as one undo action, optionally executing it.
+static func _commit_generated_action(
+ created_nodes: Array[Dictionary],
+ scene_root: Node,
+ undo_redo: EditorUndoRedoManager,
+ execute: bool,
+) -> void:
+ undo_redo.create_action("MCP: Generate physics shapes for %d mesh(es)" % created_nodes.size())
+ for entry in created_nodes:
+ var parent: Node = entry.parent
+ var body: CollisionObject3D = entry.body
+ var collision: CollisionShape3D = entry.collision
+ undo_redo.add_do_method(parent, "add_child", body, true)
+ undo_redo.add_do_method(body, "set_owner", scene_root)
+ undo_redo.add_do_method(collision, "set_owner", scene_root)
+ undo_redo.add_do_reference(body)
+ undo_redo.add_undo_method(parent, "remove_child", body)
+ undo_redo.commit_action(execute)
+
+
+## Build the public response after every body is present in the edited scene.
+static func _generated_response(
+ created_nodes: Array[Dictionary], scene_root: Node, shape_type: String, body_type: String
+) -> Dictionary:
+ var created: Array[Dictionary] = []
+ for entry in created_nodes:
+ created.append({
+ "mesh_path": McpScenePath.from_node(entry.mesh, scene_root),
+ "body_path": McpScenePath.from_node(entry.body, scene_root),
+ "shape_path": McpScenePath.from_node(entry.collision, scene_root),
+ "shape_type": shape_type,
+ "body_type": body_type,
+ })
+ return {"data": {"created": created, "undoable": true}}
+
+
+## The whole request as a value the frame loop (or a test) advances with
+## `_generate_step`. Phase "plan" resolves and measures every path; phase
+## "apply" re-validates each plan and adds its body immediately; the last
+## step records one undo action and fills `result`.
+static func _generate_job(
+ validated: Dictionary, undo_redo: EditorUndoRedoManager, connection, request_id: String
+) -> Dictionary:
+ return {
+ "validated": validated,
+ "undo_redo": undo_redo,
+ "connection": connection,
+ "request_id": request_id,
+ "phase": "plan",
+ "index": 0,
+ "plans": [],
+ "created": [],
+ "result": {},
+ "started_ms": Time.get_ticks_msec(),
+ }
+
+
+## Advance the job for at most `budget_usec` of wall-clock time (0 does one
+## item, a negative budget runs to completion). Returns true once `job.result`
+## holds the reply, or once the request was abandoned (empty result).
+static func _generate_step(job: Dictionary, budget_usec: int) -> bool:
+ if str(job.phase) == "done":
+ return true
+ var validated: Dictionary = job.validated
+ var connection = job.connection
+ if connection != null:
+ if not _deferred_request_pending(connection, str(job.request_id)):
+ ## The dispatcher gave up on this request (timeout, client gone):
+ ## nothing may be left behind and nothing can be answered.
+ _generate_rollback(job)
+ job.phase = "done"
+ return true
+ if Time.get_ticks_msec() - int(job.started_ms) > _GENERATE_DEFERRED_TIMEOUT_MS:
+ return _generate_fail(job, ErrorCodes.make(
+ ErrorCodes.DEFERRED_TIMEOUT,
+ "physics_shape_generate exceeded its %d ms budget" % _GENERATE_DEFERRED_TIMEOUT_MS,
+ ))
+ var scene_root: Node = validated.scene_root
+ if not is_instance_valid(scene_root) or EditorInterface.get_edited_scene_root() != scene_root:
+ return _generate_fail(job, ErrorCodes.make(
+ ErrorCodes.EDITED_SCENE_MISMATCH,
+ "The edited scene changed while physics shapes were generated",
+ ))
+ ## The budget is checked before each item after the first, so every step
+ ## makes progress and a phase that just finished its last item moves on.
+ var frame_start := Time.get_ticks_usec()
+ var work := 0
+ var paths: Array = validated.paths
+ while str(job.phase) == "plan":
+ if int(job.index) >= paths.size():
+ job.phase = "apply"
+ job.index = 0
+ break
+ if work > 0 and budget_usec >= 0 and Time.get_ticks_usec() - frame_start >= budget_usec:
+ return false
+ var planned := _plan_generate_mesh(
+ str(paths[int(job.index)]), str(validated.scene_file), scene_root, str(validated.shape_type)
+ )
+ if planned.has("error"):
+ return _generate_fail(job, planned)
+ job.plans.append(planned.plan)
+ job.index = int(job.index) + 1
+ work += 1
+ var plans: Array = job.plans
+ while str(job.phase) == "apply" and int(job.index) < plans.size():
+ if work > 0 and budget_usec >= 0 and Time.get_ticks_usec() - frame_start >= budget_usec:
+ return false
+ var plan: Dictionary = plans[int(job.index)]
+ var stale := _plan_stale_reason(plan)
+ if not stale.is_empty():
+ var code: String = ErrorCodes.NODE_NOT_FOUND if stale == "was removed" else ErrorCodes.EDITED_SCENE_MISMATCH
+ return _generate_fail(job, ErrorCodes.make(
+ code,
+ "MeshInstance3D at %s %s while physics shapes were generated; nothing was changed"
+ % [str(plan.mesh_path), stale],
+ ))
+ var entry := _create_generated_entry(plan, str(validated.shape_type), str(validated.body_type))
+ var parent: Node = plan.parent
+ parent.add_child(entry.body, true)
+ entry.body.set_owner(scene_root)
+ entry.collision.set_owner(scene_root)
+ job.created.append(entry)
+ job.index = int(job.index) + 1
+ work += 1
+ ## The nodes are already in the scene, so commit with execute=false: one
+ ## atomic undo/redo action without replaying every mutation now.
+ var created: Array[Dictionary] = []
+ created.assign(job.created)
+ _commit_generated_action(created, scene_root, job.undo_redo, false)
+ job.result = _generated_response(
+ created, scene_root, str(validated.shape_type), str(validated.body_type)
+ )
+ job.phase = "done"
+ return true
+
+
+static func _generate_fail(job: Dictionary, error: Dictionary) -> bool:
+ _generate_rollback(job)
+ job.result = error
+ job.phase = "done"
+ return true
+
+
+## Remove and free every body this job added; none is in an undo action yet.
+static func _generate_rollback(job: Dictionary) -> void:
+ for entry in job.created:
+ var body: Node = entry.body
+ if not is_instance_valid(body):
+ continue
+ var parent := body.get_parent()
+ if parent != null:
+ parent.remove_child(body)
+ body.free()
+ job.created.clear()
+
+
+## Check that the connection and deferred dispatcher entry are both live.
+static func _deferred_request_pending(connection, request_id: String) -> bool:
+ if not is_instance_valid(connection):
+ return false
+ var dispatcher = connection.dispatcher
+ return dispatcher == null or dispatcher.has_pending_deferred_response(request_id)
+
+
+## Drive a deferred job one editor frame at a time and reply when it ends.
+## `send_deferred_response` itself drops a reply whose request is gone.
+static func _drive_generate_job(job: Dictionary, connection) -> void:
+ if not is_instance_valid(connection):
+ return
+ var tree: SceneTree = connection.get_tree()
+ if tree == null:
+ return
+ ## The first yield lets the dispatcher register the deferred request before
+ ## any validation error or successful result can be sent.
+ await tree.process_frame
+ while is_instance_valid(connection) and not _generate_step(job, _GENERATE_FRAME_BUDGET_USEC):
+ await tree.process_frame
+ if is_instance_valid(connection) and not job.result.is_empty():
+ connection.send_deferred_response(str(job.request_id), job.result)
+
+
+func autofit(params: Dictionary) -> Dictionary:
+ var node_path: String = params.get("path", "")
+ if node_path.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: path")
+
+ var _resolved := McpNodeValidator.resolve_or_error(node_path, "node_path")
+ if _resolved.has("error"):
+ return _resolved
+ var node: Node = _resolved.node
+ var scene_root: Node = _resolved.scene_root
+
+ var is_3d := node is CollisionShape3D
+ var is_2d := node is CollisionShape2D
+ if not (is_3d or is_2d):
+ return ErrorCodes.make(
+ ErrorCodes.WRONG_TYPE,
+ "Node at %s is %s — must be CollisionShape3D or CollisionShape2D" % [node_path, node.get_class()]
+ )
+
+ var source_path: String = params.get("source_path", "")
+ var source: Node = null
+ if source_path.is_empty():
+ var search := _find_bounds_visual(node, is_3d, scene_root)
+ if search.has("error"):
+ return search.error
+ source = search.source
+ else:
+ source = McpScenePath.resolve(source_path, scene_root)
+ if source == null:
+ return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND,
+ "source_path: %s" % McpScenePath.format_node_error(source_path, scene_root))
+
+ var requested_shape: String = params.get("shape_type", "box" if is_3d else "rectangle")
+ var type_map := _SHAPE_3D_CLASSES if is_3d else _SHAPE_2D_CLASSES
+ var shape_type := _normalize_shape_type(type_map, requested_shape)
+ if shape_type.is_empty():
+ var valid_pairs: Array[String] = []
+ for short_form in type_map:
+ valid_pairs.append("%s (%s)" % [short_form, type_map[short_form]])
+ return ErrorCodes.make(
+ ErrorCodes.VALUE_OUT_OF_RANGE,
+ "Invalid shape_type '%s' for %s. Valid: %s" % [requested_shape, node.get_class(), ", ".join(valid_pairs)]
+ )
+ var shape_class: String = type_map[shape_type]
+
+ # Measure the visual.
+ var bounds := _measure_bounds(source, is_3d)
+ if bounds.has("error"):
+ return bounds.error
+
+ # Reuse the existing shape if it already matches the requested class;
+ # otherwise create a fresh one of the right type in the same undo action.
+ var existing_shape: Shape3D = null
+ var existing_shape_2d: Shape2D = null
+ if is_3d:
+ existing_shape = node.shape
+ else:
+ existing_shape_2d = node.shape
+
+ var needs_new_shape := false
+ if is_3d:
+ needs_new_shape = existing_shape == null or existing_shape.get_class() != shape_class
+ else:
+ needs_new_shape = existing_shape_2d == null or existing_shape_2d.get_class() != shape_class
+
+ var target_shape: Resource
+ if needs_new_shape:
+ var instance := ClassDB.instantiate(shape_class)
+ if instance == null:
+ return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate %s" % shape_class)
+ target_shape = instance
+ else:
+ target_shape = existing_shape if is_3d else existing_shape_2d
+
+ # Compute and apply size.
+ var size_info := _apply_shape_size(target_shape, shape_type, bounds, is_3d)
+ var old_shape = existing_shape if is_3d else existing_shape_2d
+
+ _undo_redo.create_action("MCP: Autofit %s on %s" % [shape_class, node.name])
+ if needs_new_shape:
+ _undo_redo.add_do_property(node, "shape", target_shape)
+ _undo_redo.add_undo_property(node, "shape", old_shape)
+ _undo_redo.add_do_reference(target_shape)
+ else:
+ # Existing shape stays, but its size changes — snapshot size for undo.
+ for key in size_info.applied:
+ var new_val = target_shape.get(key)
+ var old_val = size_info.previous.get(key)
+ _undo_redo.add_do_property(target_shape, key, new_val)
+ _undo_redo.add_undo_property(target_shape, key, old_val)
+ _undo_redo.commit_action()
+
+ return {
+ "data": {
+ "path": node_path,
+ "source_path": McpScenePath.from_node(source, scene_root) if source_path.is_empty() else source_path,
+ "shape_type": shape_type,
+ "shape_class": shape_class,
+ "shape_created": needs_new_shape,
+ "size": size_info.size_response,
+ "undoable": true,
+ }
+ }
+
+
+## Returns `{source: Node}` on success, `{error: }` on failure.
+## Ambiguous tier-2 matches put candidate scene paths in
+## `error.data.candidates` so callers can pick one explicitly.
+static func _find_bounds_visual(collision_node: Node, is_3d: bool, scene_root: Node) -> Dictionary:
+ var parent := collision_node.get_parent()
+ if parent == null:
+ return {"error": _no_visual_error(is_3d)}
+
+ # Tier 1: direct siblings of the collision shape. Uses the broad
+ # VisualInstance3D filter for backwards compatibility — callers who put
+ # the visual directly next to the collision picked it on purpose.
+ var siblings := _measurable_visuals(parent.get_children(), collision_node, is_3d, false)
+ if not siblings.is_empty():
+ return {"source": siblings[0]}
+
+ # Tier 2: parent siblings (uncles). Tighten the filter to
+ # GeometryInstance3D so we don't auto-pick a Light3D / DirectionalLight3D
+ # as a collision source. Auto-pick only when unambiguous; surface
+ # multiple candidates so the agent chooses.
+ var grandparent := parent.get_parent()
+ if grandparent == null:
+ return {"error": _no_visual_error(is_3d)}
+ var uncles := _measurable_visuals(grandparent.get_children(), parent, is_3d, true)
+ if uncles.size() == 1:
+ return {"source": uncles[0]}
+ if uncles.size() > 1:
+ var paths: Array[String] = []
+ for n in uncles:
+ paths.append(McpScenePath.from_node(n, scene_root))
+ var msg := "Multiple visual candidates near %s — pass source_path explicitly. Candidates: %s" % [
+ McpScenePath.from_node(collision_node, scene_root),
+ ", ".join(paths),
+ ]
+ var err := ErrorCodes.make(ErrorCodes.INVALID_PARAMS, msg)
+ err["error"]["data"] = {"candidates": paths}
+ return {"error": err}
+ return {"error": _no_visual_error(is_3d)}
+
+
+## Filter `nodes` for ones we can measure as a collision source. When
+## `strict` is true (tier 2 / uncles) only GeometryInstance3D counts in 3D —
+## avoids picking up lights as accidental sources. 2D filter is already
+## narrow enough that strictness doesn't change behavior.
+static func _measurable_visuals(nodes: Array, exclude: Node, is_3d: bool, strict: bool) -> Array[Node]:
+ var out: Array[Node] = []
+ for n in nodes:
+ if n == exclude:
+ continue
+ if is_3d:
+ if strict:
+ if n is GeometryInstance3D:
+ out.append(n)
+ elif n is VisualInstance3D:
+ out.append(n)
+ elif n is Sprite2D or n is TextureRect:
+ out.append(n)
+ return out
+
+
+static func _no_visual_error(is_3d: bool) -> Dictionary:
+ var hint := "MeshInstance3D" if is_3d else "Sprite2D"
+ return ErrorCodes.make(
+ ErrorCodes.INVALID_PARAMS,
+ "No visual found near collision shape — searched siblings and parent-siblings. Pass source_path explicitly (e.g. a %s)" % hint,
+ )
+
+
+## Measure the visual bounds of `source`. Returns {aabb: AABB} for 3D or
+## {rect: Rect2} for 2D on success, or {error: ...} on failure.
+## Bounds are returned in world-ish size (local extents scaled by the source
+## node's own transform scale) so a MeshInstance3D at scale=(2,2,2) gives an
+## 8× volume collider, not a unit collider.
+static func _measure_bounds(source: Node, is_3d: bool) -> Dictionary:
+ if is_3d:
+ if source is VisualInstance3D:
+ var aabb: AABB = (source as VisualInstance3D).get_aabb()
+ # get_aabb() is local-space; apply the source's scale so the
+ # collider tracks what you actually see in the viewport. Going
+ # through the transform keeps a mirrored (negative) scale a
+ # positive size, which BoxShape3D would otherwise reject.
+ var scale_3d: Vector3 = (source as Node3D).transform.basis.get_scale()
+ return {"aabb": Transform3D(Basis.from_scale(scale_3d), Vector3.ZERO) * aabb}
+ return {"error": ErrorCodes.make(
+ ErrorCodes.WRONG_TYPE,
+ "Source %s has no measurable 3D bounds (must be VisualInstance3D subclass)" % source.get_class()
+ )}
+ # 2D
+ if source is Sprite2D:
+ var s: Sprite2D = source
+ var srect: Rect2 = s.get_rect()
+ # get_rect() reports the local texture rect and ignores scale.
+ srect.position = srect.position * s.scale
+ srect.size = srect.size * s.scale
+ return {"rect": srect}
+ if source is TextureRect:
+ var tr: TextureRect = source
+ # tr.size is the Control's laid-out size, which is Vector2.ZERO
+ # before the first layout pass (e.g. just after the node was created
+ # via MCP). Fall back to the texture's own size when that happens,
+ # so autofit doesn't silently produce a zero-sized shape.
+ var tr_size: Vector2 = tr.size
+ if tr_size.is_zero_approx():
+ if tr.texture != null:
+ tr_size = tr.texture.get_size() * tr.scale
+ else:
+ return {"error": ErrorCodes.make(
+ ErrorCodes.INVALID_PARAMS,
+ "TextureRect at %s has zero layout size and no texture to fall back to — autofit would produce a zero-sized shape" % source.name
+ )}
+ return {"rect": Rect2(Vector2.ZERO, tr_size)}
+ return {"error": ErrorCodes.make(
+ ErrorCodes.WRONG_TYPE,
+ "Source %s has no measurable 2D bounds (must be Sprite2D or TextureRect)" % source.get_class()
+ )}
+
+
+## Apply size to `shape` based on `bounds` and the requested shape_type.
+## Returns {applied: [property_names], previous: {name: old_value}, size_response: dict}.
+static func _apply_shape_size(shape: Resource, shape_type: String, bounds: Dictionary, is_3d: bool) -> Dictionary:
+ var applied: Array[String] = []
+ var previous := {}
+ var size_response := {}
+
+ if is_3d:
+ var aabb: AABB = bounds.aabb
+ var size_v: Vector3 = aabb.size
+ match shape_type:
+ "box":
+ previous["size"] = shape.get("size")
+ (shape as BoxShape3D).size = size_v
+ applied.append("size")
+ size_response = {"x": size_v.x, "y": size_v.y, "z": size_v.z}
+ "sphere":
+ var r := maxf(maxf(size_v.x, size_v.y), size_v.z) * 0.5
+ previous["radius"] = shape.get("radius")
+ (shape as SphereShape3D).radius = r
+ applied.append("radius")
+ size_response = {"radius": r}
+ "capsule":
+ var cap := shape as CapsuleShape3D
+ var r2 := maxf(size_v.x, size_v.z) * 0.5
+ var h := size_v.y
+ previous["radius"] = cap.radius
+ previous["height"] = cap.height
+ # CapsuleShape3D enforces height >= 2*radius and silently
+ # clamps setters that would violate it. Read back the
+ # stored values so the response reflects reality.
+ cap.radius = r2
+ cap.height = h
+ applied.append("radius")
+ applied.append("height")
+ size_response = {"radius": cap.radius, "height": cap.height}
+ "cylinder":
+ var cyl := shape as CylinderShape3D
+ var r3 := maxf(size_v.x, size_v.z) * 0.5
+ var ch := size_v.y
+ previous["radius"] = cyl.radius
+ previous["height"] = cyl.height
+ cyl.radius = r3
+ cyl.height = ch
+ applied.append("radius")
+ applied.append("height")
+ size_response = {"radius": cyl.radius, "height": cyl.height}
+ else:
+ var rect: Rect2 = bounds.rect
+ var sz: Vector2 = rect.size
+ match shape_type:
+ "rectangle":
+ previous["size"] = shape.get("size")
+ (shape as RectangleShape2D).size = sz
+ applied.append("size")
+ size_response = {"x": sz.x, "y": sz.y}
+ "circle":
+ var cr := maxf(sz.x, sz.y) * 0.5
+ previous["radius"] = shape.get("radius")
+ (shape as CircleShape2D).radius = cr
+ applied.append("radius")
+ size_response = {"radius": cr}
+ "capsule":
+ var cap2 := shape as CapsuleShape2D
+ var cr2 := sz.x * 0.5
+ var ch2 := sz.y
+ previous["radius"] = cap2.radius
+ previous["height"] = cap2.height
+ # CapsuleShape2D has the same height >= 2*radius invariant
+ # as its 3D counterpart; read back what Godot actually kept.
+ cap2.radius = cr2
+ cap2.height = ch2
+ applied.append("radius")
+ applied.append("height")
+ size_response = {"radius": cap2.radius, "height": cap2.height}
+
+ return {"applied": applied, "previous": previous, "size_response": size_response}
diff --git a/addons/godot_ai/handlers/physics_shape_handler.gd.uid b/addons/godot_ai/handlers/physics_shape_handler.gd.uid
new file mode 100644
index 0000000..09acb4a
--- /dev/null
+++ b/addons/godot_ai/handlers/physics_shape_handler.gd.uid
@@ -0,0 +1 @@
+uid://cdg8kthqla1cj
diff --git a/addons/godot_ai/handlers/project_handler.gd b/addons/godot_ai/handlers/project_handler.gd
new file mode 100644
index 0000000..45ed197
--- /dev/null
+++ b/addons/godot_ai/handlers/project_handler.gd
@@ -0,0 +1,669 @@
+@tool
+extends "res://addons/godot_ai/handlers/command_handler.gd"
+
+const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
+
+## Handles project settings and filesystem search commands.
+
+const NodeHandler := preload("res://addons/godot_ai/handlers/node_handler.gd")
+const RUN_READY_WAIT_SEC := 3.0
+
+## ProjectSettings keys that decide what the engine EXECUTES at project
+## startup. `McpPathValidator._reject_sensitive_write` already refuses direct
+## writes to `res://project.godot`, `res://override.cfg` and `res://.godot/`
+## for exactly this reason — but `set_project_setting` writes that same
+## manifest through the settings API, so without this list the guard is
+## trivially side-steppable:
+##
+## settings_set key="autoload/Boot" value="*res://../../evil.gd"
+##
+## would land arbitrary code on the next project open, and a `project.godot`
+## diff is easy to miss in review. Blocked keys that DO have a legitimate agent
+## workflow get a narrow validated op of their own instead of an exception
+## carved into this list — `autoload_handler.add_autoload` for `autoload/`,
+## `set_main_scene` below for the main scene — so the denylist stays a flat set
+## of keys with no escape hatches to reason about. The rest are refused
+## outright.
+##
+## Deliberately NARROW. A denylist that over-blocks turns settings_set into a
+## tool agents can't use, so only keys that actually carry code (or a command
+## line) are listed — not their whole section. `application/run/` is an exact
+## entry for `main_scene` precisely because its siblings (`max_fps`,
+## `low_processor_mode`, …) are inert and must stay writable; likewise
+## `application/boot_splash/image` is data, not code, and is NOT blocked.
+##
+## Prefix entries match the key and anything beneath it, and are used only
+## where every key under the prefix is code-bearing. Exact entries match only
+## themselves. Comparison is case-folded — ProjectSettings keys are
+## case-sensitive, but a case variant that Godot would reject is still a
+## clearer error coming from here than from a half-applied save.
+const STARTUP_EXECUTION_KEY_PREFIXES: Array[String] = [
+ "autoload/", ## every key under it is a script path
+ "editor_plugins/", ## enabled-plugin list; each entry is loaded as code
+]
+const STARTUP_EXECUTION_KEYS_EXACT: Array[String] = [
+ "application/run/main_scene", ## the scene the game boots into
+ "editor/script/templates_search_path", ## where the editor loads templates from
+ "editor/run/main_run_args", ## command line for the run
+]
+
+## ProjectSettings key that names the scene the game boots into.
+const MAIN_SCENE_KEY := "application/run/main_scene"
+
+## Validated routes for blocked keys, appended to the refusal. `settings_get`
+## on a blocked key works, so an agent can read the value it needs to change
+## and then be refused the write — without a pointer it reads that asymmetry as
+## "this workflow is closed" and hands a non-bootable project back to the user
+## (#915). Keyed by the lowercased exact entry above.
+const STARTUP_EXECUTION_KEY_ALTERNATIVES := {
+ "application/run/main_scene":
+ "Use project_manage(op='set_main_scene', params={'path': 'res://.tscn'}), "
+ + "which accepts only an existing PackedScene inside the project.",
+}
+
+
+## Returns "" when `key` may be written via set_project_setting, or a
+## human-readable refusal reason otherwise. Static so it is unit-testable
+## without instancing the handler.
+static func startup_execution_key_refusal(key: String) -> String:
+ var lowered := key.strip_edges().to_lower()
+ for prefix in STARTUP_EXECUTION_KEY_PREFIXES:
+ if lowered.begins_with(prefix):
+ if prefix == "autoload/":
+ return (
+ "Refusing to set '%s' — autoloads run code at project startup. " % key
+ + "Use autoload_manage(op='add'), which validates the script path."
+ )
+ return (
+ "Refusing to set '%s' — keys under '%s' are loaded as code " % [key, prefix]
+ + "at project startup."
+ )
+ for exact in STARTUP_EXECUTION_KEYS_EXACT:
+ if lowered == exact:
+ var message := (
+ "Refusing to set '%s' — this key controls what the engine loads " % key
+ + "or executes at project startup."
+ )
+ var alternative := str(STARTUP_EXECUTION_KEY_ALTERNATIVES.get(exact, ""))
+ if not alternative.is_empty():
+ message += " " + alternative
+ return message
+ return ""
+
+var _connection: McpConnection
+var _debugger_plugin
+var _editor_log_buffer
+
+
+func _init(connection: McpConnection = null, debugger_plugin = null, editor_log_buffer = null) -> void:
+ _connection = connection
+ _debugger_plugin = debugger_plugin
+ _editor_log_buffer = editor_log_buffer
+
+
+func get_project_setting(params: Dictionary) -> Dictionary:
+ var key: String = params.get("key", "")
+ if key.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: key")
+
+ if not ProjectSettings.has_setting(key):
+ return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Setting not found: %s" % key)
+
+ var value = ProjectSettings.get_setting(key)
+ return {
+ "data": {
+ "key": key,
+ "value": NodeHandler._serialize_value(value),
+ "type": type_string(typeof(value)),
+ }
+ }
+
+
+func set_project_setting(params: Dictionary) -> Dictionary:
+ var key: String = params.get("key", "")
+ if key.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: key")
+
+ if not params.has("value"):
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: value")
+
+ ## Refuse the startup-execution surface before touching ProjectSettings —
+ ## see STARTUP_EXECUTION_KEY_PREFIXES for why this guard exists here and
+ ## not only in McpPathValidator.
+ var refusal := startup_execution_key_refusal(key)
+ if not refusal.is_empty():
+ return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, refusal)
+
+ var value = params.get("value")
+ var had_setting := ProjectSettings.has_setting(key)
+ var old_value = ProjectSettings.get_setting(key) if had_setting else null
+ # JSON has no distinct int type: Godot parses `1920` as float. If the
+ # existing setting is TYPE_INT, coerce whole-number floats back to int so
+ # we don't silently flip typed-int settings (viewport_width, etc.) to
+ # floats on disk. See issue #31.
+ if had_setting and typeof(old_value) == TYPE_INT and typeof(value) == TYPE_FLOAT and float(int(value)) == value:
+ value = int(value)
+ ProjectSettings.set_setting(key, value)
+ var err := ProjectSettings.save()
+ if err != OK:
+ if had_setting:
+ ProjectSettings.set_setting(key, old_value)
+ else:
+ ProjectSettings.clear(key)
+ return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to save project settings (error %d)" % err)
+
+ return {
+ "data": {
+ "key": key,
+ "value": NodeHandler._serialize_value(value),
+ "old_value": NodeHandler._serialize_value(old_value),
+ "type": type_string(typeof(value)),
+ "undoable": false,
+ "reason": "ProjectSettings changes are saved to disk",
+ }
+ }
+
+
+## The validated route to `application/run/main_scene`, which
+## `set_project_setting` refuses (STARTUP_EXECUTION_KEYS_EXACT) because a
+## generic setter has no way to tell a scene path from an arbitrary payload.
+## Without it an agent can create every scene, script, node and resource for a
+## new project and still not make it bootable: `project_run(mode="main")` has
+## nothing to launch, and the last step — pure mechanical configuration — has
+## to be finished by hand in the editor (#915).
+##
+## The blocklist's intent survives because the value can only ever name a scene
+## the project already contains: the path is confined under `res://` by
+## `McpPathValidator`, must resolve through `ResourceLoader`, and must load as
+## a `PackedScene`. No new startup-execution surface is opened.
+func set_main_scene(params: Dictionary) -> Dictionary:
+ var path: String = params.get("path", "")
+ var path_err = McpPathValidator.path_error(path, "path")
+ if path_err != null:
+ return path_err
+
+ if not ResourceLoader.exists(path):
+ return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Scene not found: %s" % path)
+ ## Load rather than trusting `ResourceLoader.exists(path, "PackedScene")`:
+ ## the type hint there comes from the loader's guess for the extension, so a
+ ## `.tscn` that fails to parse would still pass it. A main scene that cannot
+ ## load is a project that cannot boot, so pay for the real load here instead
+ ## of surfacing the failure as a mystified `project_run`.
+ var resource := ResourceLoader.load(path)
+ if resource == null:
+ return ErrorCodes.make(
+ ErrorCodes.RESOURCE_NOT_FOUND,
+ "Failed to load scene (it may be corrupt or have a broken dependency): %s" % path
+ )
+ if not (resource is PackedScene):
+ return ErrorCodes.make(
+ ErrorCodes.WRONG_TYPE,
+ "path must be a PackedScene, got %s: %s" % [resource.get_class(), path]
+ )
+
+ var had_setting := ProjectSettings.has_setting(MAIN_SCENE_KEY)
+ var old_value = ProjectSettings.get_setting(MAIN_SCENE_KEY) if had_setting else null
+ ProjectSettings.set_setting(MAIN_SCENE_KEY, path)
+ var err := ProjectSettings.save()
+ if err != OK:
+ if had_setting:
+ ProjectSettings.set_setting(MAIN_SCENE_KEY, old_value)
+ else:
+ ProjectSettings.clear(MAIN_SCENE_KEY)
+ return ErrorCodes.make(
+ ErrorCodes.INTERNAL_ERROR,
+ "Failed to save project settings while setting the main scene: %s (error %d)"
+ % [error_string(err), err]
+ )
+
+ return {
+ "data": {
+ "key": MAIN_SCENE_KEY,
+ "path": path,
+ "old_value": NodeHandler._serialize_value(old_value),
+ "undoable": false,
+ "reason": "ProjectSettings changes are saved to disk",
+ }
+ }
+
+
+func run_project(params: Dictionary) -> Dictionary:
+ var mode: String = params.get("mode", "main")
+ var autosave: bool = params.get("autosave", true)
+ # Idempotent: a project that's already running satisfies the caller's intent.
+ # Returning INVALID_PARAMS here punished agents that legitimately called run
+ # to ensure the project is playing (87+ installs/day hit the matching
+ # stop-not-running case in telemetry). Surface state via was_already_running
+ # so a caller wanting a *different* scene can detect and stop+restart.
+ if EditorInterface.is_playing_scene():
+ return _run_project_current_liveness_response(
+ _run_project_base_data(
+ mode,
+ str(params.get("scene", "")),
+ autosave,
+ true,
+ "Project was already running; no action taken"
+ )
+ )
+
+ var validation_error: Variant = null
+ if mode == "custom":
+ var custom_scene: String = params.get("scene", "")
+ if custom_scene.is_empty():
+ validation_error = ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: scene (required when mode='custom')")
+ else:
+ ## play_custom_scene() was the last path-taking op in the plugin
+ ## with no containment check; every sibling that accepts a scene
+ ## path validates it (scene_handler.open_scene,
+ ## node_handler.create_node's scene_path).
+ validation_error = McpPathValidator.loadable_error(custom_scene, "scene")
+ elif mode != "main" and mode != "current":
+ validation_error = ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Invalid mode '%s' — use 'main', 'current', or 'custom'" % mode)
+ if validation_error != null:
+ return validation_error
+
+ # play_*_scene internally triggers try_autosave() → _save_scene_with_preview()
+ # which renders a preview thumbnail and calls frame processing. If our
+ # WebSocket connection's _process() re-enters during that render, the
+ # engine crashes (SIGABRT in _save_scene_with_preview). Pause processing
+ # around the play call — same pattern as SceneHandler.save_scene.
+ if _connection:
+ _connection.pause_processing = true
+
+ # try_autosave() reads run/auto_save/save_before_running every call, so
+ # toggling it off around the play call suppresses the save without
+ # touching the user's persisted preference. Issue #81.
+ var autosave_key := "run/auto_save/save_before_running"
+ var editor_settings: EditorSettings = null
+ if not autosave:
+ editor_settings = EditorInterface.get_editor_settings()
+ var prior_autosave: bool = true
+ var restore_setting := false
+ if editor_settings != null and editor_settings.has_setting(autosave_key):
+ prior_autosave = bool(editor_settings.get_setting(autosave_key))
+ editor_settings.set_setting(autosave_key, false)
+ restore_setting = true
+
+ if _debugger_plugin != null:
+ _debugger_plugin.begin_game_run(_editor_log_cursor(), _game_helper_autoload_expected())
+
+ match mode:
+ "main":
+ EditorInterface.play_main_scene()
+ "current":
+ EditorInterface.play_current_scene()
+ "custom":
+ var scene_path: String = params.get("scene", "")
+ EditorInterface.play_custom_scene(scene_path)
+
+ if restore_setting:
+ editor_settings.set_setting(autosave_key, prior_autosave)
+
+ if _connection:
+ _connection.pause_processing = false
+
+ var base_data := _run_project_base_data(
+ mode,
+ str(params.get("scene", "")),
+ autosave,
+ false,
+ "Play/stop is a runtime action"
+ )
+ var request_id: String = params.get("_request_id", "")
+ if _connection != null and _debugger_plugin != null and not request_id.is_empty():
+ _finish_run_project_deferred(request_id, base_data, _connection, _debugger_plugin)
+ return McpDispatcher.DEFERRED_RESPONSE
+
+ return _run_project_current_liveness_response(base_data)
+
+
+func _editor_log_cursor() -> int:
+ return _editor_log_buffer.appended_total() if _editor_log_buffer != null else 0
+
+
+func _game_helper_autoload_expected() -> bool:
+ return ProjectSettings.has_setting("autoload/_mcp_game_helper")
+
+
+static func _run_project_base_data(
+ mode: String,
+ scene: String,
+ autosave: bool,
+ was_already_running: bool,
+ reason: String
+) -> Dictionary:
+ return {
+ "mode": mode,
+ "scene": scene,
+ "autosave": autosave,
+ "was_already_running": was_already_running,
+ "undoable": false,
+ "reason": reason,
+ }
+
+
+func _run_project_current_liveness_response(base_data: Dictionary) -> Dictionary:
+ if _debugger_plugin == null:
+ return {"data": base_data}
+ var status: Dictionary = _debugger_plugin.get_game_status(-1, RUN_READY_WAIT_SEC)
+ ## One-shot read — force a Debugger-tab scan so boot errors that landed
+ ## after the last gated scan are in this response (#641).
+ var errors_info: Dictionary = _debugger_plugin.recent_editor_errors_since(int(status.get("editor_log_cursor", 0)), true)
+ return _run_project_response(base_data, _run_project_liveness_decision(status, errors_info))
+
+
+## `static` is load-bearing (#712, same rationale as the script/filesystem
+## handlers and editor_handler._do_reload_plugin): this coroutine awaits
+## across frames, and a plugin reload frees this RefCounted handler
+## mid-await — resuming an instance coroutine on a freed object errors.
+## `connection` and `debugger_plugin` are parameterized explicitly and
+## re-validated after every await; the response is dropped silently when
+## either died (the server's command timeout surfaces the failure).
+static func _finish_run_project_deferred(
+ request_id: String, base_data: Dictionary, connection, debugger_plugin
+) -> void:
+ var work := ScriptWork.begin("run_project")
+ await _settle_run_project(request_id, base_data, connection, debugger_plugin)
+ ScriptWork.finish(work)
+
+
+static func _settle_run_project(
+ request_id: String, base_data: Dictionary, connection, debugger_plugin
+) -> void:
+ var tree: SceneTree = connection.get_tree()
+ while true:
+ await tree.process_frame
+ if not is_instance_valid(connection) or not is_instance_valid(debugger_plugin):
+ return
+ var pre_status: Dictionary = debugger_plugin.get_game_status(-1, RUN_READY_WAIT_SEC)
+ if (
+ not EditorInterface.is_playing_scene()
+ and int(pre_status.get("elapsed_msec", 0)) > 100
+ and str(pre_status.get("status", "stopped")) == "launching"
+ ):
+ debugger_plugin.end_game_run()
+ var status: Dictionary = debugger_plugin.get_game_status(-1, RUN_READY_WAIT_SEC)
+ var errors_info: Dictionary = debugger_plugin.recent_editor_errors_since(int(status.get("editor_log_cursor", 0)))
+ var decision := _run_project_liveness_decision(status, errors_info)
+ if not bool(decision.get("resolve", false)):
+ continue
+ ## #641: the loop above polls with gated (cheap) scans; boot parse
+ ## errors can land in the Errors tab in the same frames the run goes
+ ## live. Re-gather once with a forced scan before replying so the
+ ## response reports them instead of leaving them to a later
+ ## logs_read. Rebuilding the decision with strictly-more errors can
+ ## only keep it resolved (errors never un-resolve a decision).
+ errors_info = debugger_plugin.recent_editor_errors_since(int(status.get("editor_log_cursor", 0)), true)
+ decision = _run_project_liveness_decision(status, errors_info)
+ connection.send_deferred_response(request_id, _run_project_response(base_data, decision))
+ return
+
+
+static func _run_project_response(base_data: Dictionary, decision: Dictionary) -> Dictionary:
+ var data := base_data.duplicate(true)
+ var game_status: Dictionary = decision.get("game_status", {})
+ data["game_status"] = game_status
+ data["helper_live"] = bool(game_status.get("helper_live", false))
+ data["session_active"] = bool(game_status.get("session_active", false))
+ if bool(data.get("was_already_running", false)):
+ data["reason"] = _run_project_already_running_message(decision)
+ else:
+ data["reason"] = decision.get("message", data.get("reason", "Play/stop is a runtime action"))
+ data["recent_errors"] = decision.get("recent_errors", [])
+ data["recent_errors_scope"] = decision.get("recent_errors_scope", "none")
+ data["recent_errors_may_predate_run"] = decision.get("recent_errors_may_predate_run", false)
+ data["recent_errors_truncated"] = decision.get("recent_errors_truncated", false)
+ data.merge(McpDebuggerPlugin.split_errors_by_scope(data["recent_errors"], data["recent_errors_scope"]), true)
+ return {"data": data}
+
+
+static func _run_project_already_running_message(decision: Dictionary) -> String:
+ var state := str(decision.get("liveness_status", "unknown"))
+ match state:
+ "live":
+ var live_errors: Array = decision.get("recent_errors", [])
+ if not live_errors.is_empty() and str(decision.get("recent_errors_scope", "none")) == "run":
+ return (
+ "Project was already running; the Godot AI game helper is live, but %d editor error%s surfaced during this run (first: %s). Check logs_read(source='editor', include_details=true)."
+ % [live_errors.size(), "s" if live_errors.size() != 1 else "", _format_editor_error_summary(live_errors[0])]
+ )
+ return "Project was already running; the Godot AI game helper is live."
+ "not_live":
+ var errors: Array = decision.get("recent_errors", [])
+ var scope := str(decision.get("recent_errors_scope", "none"))
+ if not errors.is_empty() and scope == "run":
+ return "Project was already running but failed to load before the Godot AI game helper registered: %s. Check logs_read(source='editor', include_details=true)." % _format_editor_error_summary(errors[0])
+ if not errors.is_empty():
+ return "Project was already running but is not responding. A recent editor error may be related, but may predate this run: %s. Check logs_read(source='editor', include_details=true)." % _format_editor_error_summary(errors[0])
+ return "Project was already running but did not become live before the helper-ready window elapsed. Check logs_read(source='editor', include_details=true) and poll editor_state."
+ "break":
+ var break_errors: Array = decision.get("recent_errors", [])
+ if not break_errors.is_empty() and str(decision.get("recent_errors_scope", "none")) == "run":
+ return "Project was already running but the game is parked at a debugger break: %s. Call project_manage(op='stop') to end the run, fix the error, and relaunch." % _format_editor_error_summary(break_errors[0])
+ if not break_errors.is_empty():
+ return "Project was already running but the game is parked at a debugger break. A recent editor error may be related, but may predate this run: %s. Call project_manage(op='stop') to end the run." % _format_editor_error_summary(break_errors[0])
+ return "Project was already running but the game is parked at a debugger break. Call project_manage(op='stop') to end the run; the break reason is in the editor's Debugger panel."
+ "no_helper":
+ return "Project was already running, but no _mcp_game_helper autoload is expected. Headless or custom-main-loop projects cannot confirm helper liveness."
+ "launching":
+ return "Project was already running and is still waiting for the Godot AI game helper to register. Poll editor_state shortly."
+ "stopped":
+ return "Project was already marked playing by the editor, but no active game liveness run exists."
+ _:
+ return "Project was already running; current liveness status is %s." % state
+
+
+## Static (with the rest of the deferred-finisher chain) so the #712
+## load-bearing-static coroutines above can call it after their owner
+## handler was freed. Uses no instance state.
+static func _run_project_liveness_decision(status: Dictionary, errors_info: Dictionary = {}) -> Dictionary:
+ var enriched_status := McpDebuggerPlugin.with_liveness_flags(status)
+ var state := str(status.get("status", "stopped"))
+ var recent_errors: Array = errors_info.get("errors", [])
+ var errors_scope := str(errors_info.get("scope", "none"))
+ var truncated := bool(errors_info.get("truncated", false))
+ ## Clear errors that predate this run's window — they don't belong in
+ ## a successful launch response and only add noise. They remain
+ ## reachable via logs_read(source='editor') and the retained buffer so
+ ## failed-run debugging is unaffected.
+ ## #635 tradeoff: a genuine in-run error whose Errors-tab row carries
+ ## an empty or byte-identical time text can be misclassified as
+ ## retained_recent and will be dropped here. Still reachable via
+ ## logs_read.
+ if state == "live" and errors_scope == "retained_recent":
+ recent_errors = []
+ errors_scope = "none"
+ var correlated_error := not recent_errors.is_empty() and errors_scope == "run"
+ var elapsed_msec := int(status.get("elapsed_msec", 0))
+ var ready_wait_msec := int(status.get("ready_wait_msec", int(RUN_READY_WAIT_SEC * 1000.0)))
+ var decision := {
+ "resolve": false,
+ "game_status": enriched_status,
+ "liveness_status": state,
+ "recent_errors": recent_errors,
+ "recent_errors_scope": errors_scope,
+ "recent_errors_may_predate_run": errors_scope == "retained_recent",
+ "recent_errors_truncated": truncated,
+ "message": "",
+ }
+ if state == "live":
+ decision["resolve"] = true
+ if correlated_error:
+ ## #641: "live" only means the helper autoload registered — scripts
+ ## can still have failed to parse or load during boot (a broken
+ ## node script does not stop the game from running). Surface those
+ ## errors in the success message so agents don't read a clean
+ ## launch into a run that silently lost scripts.
+ decision["message"] = (
+ "Game launched and the Godot AI game helper is live, but %d editor error%s surfaced during startup (first: %s) — likely a script that failed to parse or load. Check logs_read(source='editor', include_details=true)."
+ % [recent_errors.size(), "s" if recent_errors.size() != 1 else "", _format_editor_error_summary(recent_errors[0])]
+ )
+ if truncated:
+ decision["message"] += " Editor logs since this run may be truncated; showing retained errors."
+ else:
+ decision["message"] = "Game launched and the Godot AI game helper is live."
+ elif state == "break":
+ ## #645: the game process is parked in a remote-debugger break. A
+ ## boot-time parse error (GDScriptLanguage::debug_break_parse) produces
+ ## no Errors-tab row, no Logger entry, and no game-log line — the
+ ## synthesized break record is the only evidence, and it lands a
+ ## moment after the break signal (stack frames arrive async). Wait for
+ ## it (correlated_error) before resolving; the ready window is the
+ ## fallback if synthesis never lands.
+ var break_info: Dictionary = status.get("break", {})
+ var break_reason := str(break_info.get("reason", ""))
+ if bool(break_info.get("pre_live", true)):
+ decision["resolve"] = correlated_error or elapsed_msec >= ready_wait_msec
+ var summary := break_reason
+ if correlated_error:
+ summary = _format_editor_error_summary(recent_errors[0])
+ if summary.is_empty():
+ summary = "script parse/load error (reason not captured)"
+ decision["message"] = "Game hit a script error during startup and is frozen at a debugger break before the Godot AI game helper registered: %s. The run cannot continue; call project_manage(op='stop'), fix the error, and relaunch. Check logs_read(source='editor', include_details=true)." % summary
+ else:
+ var reason_suffix := (": %s" % break_reason) if not break_reason.is_empty() else ""
+ decision["resolve"] = true
+ decision["message"] = "Game is paused at a debugger break%s. Resume it from the editor's Debugger panel or call project_manage(op='stop')." % reason_suffix
+ elif correlated_error:
+ decision["resolve"] = true
+ decision["liveness_status"] = "not_live"
+ decision["message"] = "Game launched but failed to load before the Godot AI game helper registered: %s. Check logs_read(source='editor', include_details=true)." % _format_editor_error_summary(recent_errors[0])
+ if truncated:
+ decision["message"] += " Editor logs since this run may be truncated; showing retained errors."
+ elif state == "not_live":
+ decision["resolve"] = true
+ if not recent_errors.is_empty():
+ decision["message"] = "Game launched but is not responding. A recent editor error may be related, but may predate this run: %s. Check logs_read(source='editor', include_details=true)." % _format_editor_error_summary(recent_errors[0])
+ else:
+ decision["message"] = "Game launched but did not become live before the helper-ready window elapsed. It may still be booting or may have failed silently; check logs_read(source='editor', include_details=true) and poll editor_state."
+ elif state == "no_helper":
+ decision["resolve"] = true
+ decision["message"] = "Game launched, but no _mcp_game_helper autoload is expected. Headless or custom-main-loop projects cannot confirm helper liveness; use editor_state and viewport/editor tools where applicable."
+ elif state == "stopped":
+ decision["resolve"] = true
+ decision["message"] = "The play session stopped, or no active game liveness run exists, before the Godot AI game helper became live."
+ elif state == "launching" and elapsed_msec >= ready_wait_msec:
+ decision["resolve"] = true
+ decision["message"] = "Game launched but is not yet live after %.1fs; it may still be booting. Poll editor_state and check logs_read(source='editor', include_details=true)." % (float(elapsed_msec) / 1000.0)
+ return decision
+
+
+static func _format_editor_error_summary(entry: Dictionary) -> String:
+ return McpSurfacedErrorTracker.format_editor_error_summary(entry)
+
+
+func stop_project(params: Dictionary) -> Dictionary:
+ # Idempotent: a project that's already stopped satisfies the caller's intent.
+ # Returning INVALID_PARAMS here was the largest single source of fleet-wide
+ # project_manage failures (87 installs/24h). was_running=false lets callers
+ # distinguish a no-op stop from one that actually halted a running session.
+ if not EditorInterface.is_playing_scene():
+ return {
+ "data": {
+ "stopped": true,
+ "was_running": false,
+ "undoable": false,
+ "reason": "Project was not running; no action taken",
+ }
+ }
+
+ if _debugger_plugin != null:
+ _debugger_plugin.end_game_run()
+ EditorInterface.stop_playing_scene()
+
+ # stop_playing_scene() is async — is_playing_scene() only flips to false on
+ # the next frame, and readiness_changed follows in _process. Defer the
+ # response so we can reply with authoritative readiness instead of letting
+ # the server poll for the event. Issue #29.
+ var request_id: String = params.get("_request_id", "")
+ if _connection != null and not request_id.is_empty():
+ _finish_stop_project_deferred(request_id, _connection)
+ return McpDispatcher.DEFERRED_RESPONSE
+
+ # Fallback for contexts without a connection (e.g. batch_execute via
+ # dispatch_direct, or unit tests that instantiate the handler with null).
+ return {
+ "data": {
+ "stopped": true,
+ "was_running": true,
+ "undoable": false,
+ "reason": "Play/stop is a runtime action",
+ }
+ }
+
+
+# Wait two frames so Godot can tick the stop-play state change. After this
+# is_playing_scene() reflects truth and get_readiness() is authoritative.
+# If the plugin tears down (_exit_tree frees _connection) during the await,
+# is_instance_valid() goes false and we drop the response silently — the
+# server's 5s request timeout will surface the failure to the caller.
+# `static` is load-bearing (#712): see _finish_run_project_deferred.
+static func _finish_stop_project_deferred(request_id: String, connection) -> void:
+ var work := ScriptWork.begin("stop_project")
+ await _settle_stop_project(request_id, connection)
+ ScriptWork.finish(work)
+
+
+static func _settle_stop_project(request_id: String, connection) -> void:
+ var tree: SceneTree = connection.get_tree()
+ await tree.process_frame
+ await tree.process_frame
+ if not is_instance_valid(connection):
+ return
+ connection.send_deferred_response(request_id, {
+ "data": {
+ "stopped": true,
+ "was_running": true,
+ "undoable": false,
+ "reason": "Play/stop is a runtime action",
+ "readiness_after": McpConnection.get_readiness(),
+ }
+ })
+
+
+func search_filesystem(params: Dictionary) -> Dictionary:
+ var name_filter: String = params.get("name", "")
+ var type_filter: String = params.get("type", "")
+ var path_filter: String = params.get("path", "")
+
+ if name_filter.is_empty() and type_filter.is_empty() and path_filter.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "At least one filter (name, type, path) is required")
+
+ var efs := EditorInterface.get_resource_filesystem()
+ if efs == null:
+ return ErrorCodes.make_not_ready(
+ ErrorCodes.SUB_EDITOR_UNAVAILABLE,
+ "EditorFileSystem not available", false)
+
+ var results: Array[Dictionary] = []
+ _scan_directory(efs.get_filesystem(), name_filter, type_filter, path_filter, results)
+ return {"data": {"files": results, "count": results.size()}}
+
+
+func _scan_directory(dir: EditorFileSystemDirectory, name_filter: String, type_filter: String, path_filter: String, out: Array[Dictionary]) -> void:
+ for i in dir.get_file_count():
+ var file_path := dir.get_file_path(i)
+ var file_type := dir.get_file_type(i)
+
+ var matches := true
+
+ if not name_filter.is_empty():
+ if file_path.get_file().to_lower().find(name_filter.to_lower()) == -1:
+ matches = false
+
+ if matches and not type_filter.is_empty():
+ if file_type != type_filter:
+ matches = false
+
+ if matches and not path_filter.is_empty():
+ if file_path.to_lower().find(path_filter.to_lower()) == -1:
+ matches = false
+
+ if matches:
+ out.append({
+ "path": file_path,
+ "type": file_type,
+ })
+
+ for i in dir.get_subdir_count():
+ _scan_directory(dir.get_subdir(i), name_filter, type_filter, path_filter, out)
diff --git a/addons/godot_ai/handlers/project_handler.gd.uid b/addons/godot_ai/handlers/project_handler.gd.uid
new file mode 100644
index 0000000..ec2b5d7
--- /dev/null
+++ b/addons/godot_ai/handlers/project_handler.gd.uid
@@ -0,0 +1 @@
+uid://brf8u32hvha68
diff --git a/addons/godot_ai/handlers/resource_handler.gd b/addons/godot_ai/handlers/resource_handler.gd
new file mode 100644
index 0000000..c43a6d3
--- /dev/null
+++ b/addons/godot_ai/handlers/resource_handler.gd
@@ -0,0 +1,591 @@
+@tool
+extends "res://addons/godot_ai/handlers/command_handler.gd"
+
+const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
+const ClassIntrospection := preload("res://addons/godot_ai/utils/class_introspection.gd")
+
+## Handles resource search, inspection, and assignment to nodes.
+
+const NodeHandler := preload("res://addons/godot_ai/handlers/node_handler.gd")
+
+var _undo_redo: EditorUndoRedoManager
+var _connection: McpConnection
+
+
+func _init(undo_redo: EditorUndoRedoManager, connection: McpConnection = null) -> void:
+ _undo_redo = undo_redo
+ _connection = connection
+
+
+func search_resources(params: Dictionary) -> Dictionary:
+ var type_filter: String = params.get("type", "")
+ var path_filter: String = params.get("path", "")
+
+ if type_filter.is_empty() and path_filter.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "At least one filter (type, path) is required")
+
+ var efs := EditorInterface.get_resource_filesystem()
+ if efs == null:
+ return ErrorCodes.make_not_ready(
+ ErrorCodes.SUB_EDITOR_UNAVAILABLE,
+ "EditorFileSystem not available", false)
+
+ var results: Array[Dictionary] = []
+ _scan_resources(efs.get_filesystem(), type_filter, path_filter, results)
+ return {"data": {"resources": results, "count": results.size()}}
+
+
+func _scan_resources(dir: EditorFileSystemDirectory, type_filter: String, path_filter: String, out: Array[Dictionary]) -> void:
+ for i in dir.get_file_count():
+ var file_path := dir.get_file_path(i)
+ var file_type := dir.get_file_type(i)
+
+ var matches := true
+
+ if not type_filter.is_empty():
+ # Check if the file type matches or is a subclass of the requested type
+ if file_type != type_filter and not ClassDB.is_parent_class(file_type, type_filter):
+ matches = false
+
+ if matches and not path_filter.is_empty():
+ if file_path.to_lower().find(path_filter.to_lower()) == -1:
+ matches = false
+
+ if matches:
+ out.append({
+ "path": file_path,
+ "type": file_type,
+ })
+
+ for i in dir.get_subdir_count():
+ _scan_resources(dir.get_subdir(i), type_filter, path_filter, out)
+
+
+func load_resource(params: Dictionary) -> Dictionary:
+ var path: String = params.get("path", "")
+
+ if path.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: path")
+
+ var path_err = McpPathValidator.loadable_error(path, "path")
+ if path_err != null:
+ return path_err
+
+ if not ResourceLoader.exists(path):
+ return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Resource not found: %s" % path)
+
+ var res: Resource = load(path)
+ if res == null:
+ return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to load resource: %s" % path)
+
+ var properties: Array[Dictionary] = []
+ for prop in res.get_property_list():
+ var usage: int = prop.get("usage", 0)
+ if not (usage & PROPERTY_USAGE_EDITOR):
+ continue
+ var value = res.get(prop.name)
+ if value == null and prop.type != TYPE_NIL:
+ continue
+ properties.append({
+ "name": prop.name,
+ "type": type_string(prop.type),
+ "value": NodeHandler._serialize_value(value),
+ })
+
+ return {
+ "data": {
+ "path": path,
+ "type": res.get_class(),
+ "properties": properties,
+ "property_count": properties.size(),
+ }
+ }
+
+
+func assign_resource(params: Dictionary) -> Dictionary:
+ var node_path: String = params.get("path", "")
+ var property: String = params.get("property", "")
+ var resource_path: String = params.get("resource_path", "")
+
+ if node_path.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: path")
+
+ if property.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: property")
+
+ if resource_path.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: resource_path")
+
+ var rpath_err = McpPathValidator.loadable_error(resource_path, "resource_path")
+ if rpath_err != null:
+ return rpath_err
+
+ var _resolved := McpNodeValidator.resolve_or_error(node_path, "node_path")
+ if _resolved.has("error"):
+ return _resolved
+ var node: Node = _resolved.node
+ var _scene_root: Node = _resolved.scene_root
+
+ # Verify property exists
+ var found := false
+ for prop in node.get_property_list():
+ if prop.name == property:
+ found = true
+ break
+ if not found:
+ return ErrorCodes.make(ErrorCodes.PROPERTY_NOT_ON_CLASS, McpPropertyErrors.build_message(node, property))
+
+ if not ResourceLoader.exists(resource_path):
+ return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Resource not found: %s" % resource_path)
+
+ var res: Resource = load(resource_path)
+ if res == null:
+ return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to load resource: %s" % resource_path)
+
+ var old_value = node.get(property)
+
+ _undo_redo.create_action("MCP: Assign %s to %s.%s" % [resource_path.get_file(), node.name, property])
+ _undo_redo.add_do_property(node, property, res)
+ _undo_redo.add_undo_property(node, property, old_value)
+ _undo_redo.commit_action()
+
+ return {
+ "data": {
+ "path": node_path,
+ "property": property,
+ "resource_path": resource_path,
+ "resource_type": res.get_class(),
+ "undoable": true,
+ }
+ }
+
+
+## Instantiate a built-in Resource subclass, optionally apply `properties`,
+## and either assign it to a node slot (undoable) or save it to a .tres file
+## (not undoable — mirrors material_create). Exactly one home is required;
+## a resource with no home would be GC'd after the handler returns.
+func create_resource(params: Dictionary) -> Dictionary:
+ var type_str: String = params.get("type", "")
+ if type_str.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: type")
+
+ var properties: Dictionary = params.get("properties", {})
+ var node_path: String = params.get("path", "")
+ var property: String = params.get("property", "")
+ var resource_path: String = params.get("resource_path", "")
+ var overwrite: bool = params.get("overwrite", false)
+
+ var home_err := McpResourceIO.validate_home(params)
+ if home_err != null:
+ return home_err
+ var has_file_target := not resource_path.is_empty()
+
+ var made := _instantiate_resource(type_str)
+ if made is Dictionary:
+ return made
+ var res: Resource = made
+
+ if not properties.is_empty():
+ var apply_err := _apply_resource_properties(res, properties)
+ if apply_err != null:
+ return apply_err
+
+ if has_file_target:
+ return _save_created_resource(res, type_str, resource_path, overwrite, properties.size())
+ return _assign_created_resource(res, type_str, node_path, property, properties.size())
+
+
+## Validate that `type_str` names a concrete Resource subclass that we can
+## instantiate. Returns an error dict on failure, or null on success.
+static func _validate_resource_class(type_str: String) -> Variant:
+ if not ClassDB.class_exists(type_str):
+ return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Unknown resource type: %s" % type_str)
+ if ClassDB.is_parent_class(type_str, "Node"):
+ return ErrorCodes.make(
+ ErrorCodes.WRONG_TYPE,
+ "%s is a Node type, not a Resource — use node_create instead" % type_str
+ )
+ if not ClassDB.is_parent_class(type_str, "Resource"):
+ var parent := ClassDB.get_parent_class(type_str)
+ return ErrorCodes.make(
+ ErrorCodes.WRONG_TYPE,
+ "%s is not a Resource type (extends %s)" % [type_str, parent]
+ )
+ if not ClassDB.can_instantiate(type_str):
+ return ErrorCodes.make(
+ ErrorCodes.WRONG_TYPE,
+ "%s is abstract and cannot be instantiated — use a concrete subclass (e.g. BoxMesh, BoxShape3D, StyleBoxFlat)" % type_str
+ )
+ return null
+
+
+## Build the "Unknown resource type" error with a steer toward op="scan". A type
+## that reaches here is neither an engine built-in (ClassDB) nor a registered
+## project class (the global script-class registry). In an agent-driven workflow
+## the most common cause is a `class_name` script just made via script_create
+## that isn't registered yet — the global class table only rebuilds on a
+## filesystem scan (normally an editor-focus event). Point the caller at the one
+## cheap call that fixes that, so it doesn't fall back to a full plugin reload.
+## See #614 for the headless scan op.
+static func _unknown_resource_type_error(type_str: String) -> Dictionary:
+ return ErrorCodes.make(
+ ErrorCodes.VALUE_OUT_OF_RANGE,
+ (
+ "Unknown resource type: %s — not an engine built-in or a registered project class. "
+ + "If you just created it with script_create, the global class table is stale until a "
+ + "scan: call filesystem_manage(op=\"scan\"), then retry. Otherwise check the spelling."
+ ) % type_str
+ )
+
+
+## Resolve a resource type name to a fresh instance. Handles engine built-ins
+## (ClassDB) and project `class_name` Resources (the global script-class
+## registry). Returns a Resource on success, or an error dict on failure.
+static func _instantiate_resource(type_str: String) -> Variant:
+ if ClassDB.class_exists(type_str):
+ var class_err: Variant = _validate_resource_class(type_str)
+ if class_err != null:
+ return class_err
+ var built_in := ClassDB.instantiate(type_str)
+ if built_in == null or not (built_in is Resource):
+ return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate %s as a Resource" % type_str)
+ return built_in
+ for entry in ProjectSettings.get_global_class_list():
+ if entry.get("class", "") == type_str:
+ var script_path: String = entry.get("path", "")
+ var scr: Variant = load(script_path)
+ # Reject non-Resource script classes BEFORE constructing them:
+ # scr.new() runs _init(), and an @tool class_name extending a
+ # non-RefCounted type (e.g. Node) would otherwise build — and leak —
+ # an orphan instance this path never frees. get_instance_base_type()
+ # resolves to the native base, so multi-level custom Resource
+ # hierarchies (B extends A extends Resource) still pass.
+ var base_or_err: Variant = _script_base_type_or_error(scr, type_str, script_path)
+ if base_or_err is Dictionary:
+ return base_or_err
+ var base_type: StringName = base_or_err
+ if not ClassDB.is_parent_class(base_type, "Resource"):
+ return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "%s is not a Resource type (extends %s)" % [type_str, base_type])
+ if not scr.can_instantiate():
+ return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "%s cannot be instantiated in the editor (abstract, or a non-@tool script — add @tool to instantiate it here)" % type_str)
+ # Reject scripts whose _init() requires arguments BEFORE scr.new():
+ # scr.new() passes no args, so a required-arg _init raises and aborts
+ # this handler mid-call, null-cascading into a generic "malformed
+ # result" error instead of a clean rejection. get_script_method_list()
+ # reports the effective (incl. inherited) _init; required args =
+ # args - default_args. Statically detectable only — a _init that runs
+ # but throws still falls through to scr.new() and the dispatcher catch.
+ for method in scr.get_script_method_list():
+ if method.get("name", "") == "_init":
+ var required_args: int = (method.get("args", []) as Array).size() - (method.get("default_args", []) as Array).size()
+ if required_args > 0:
+ return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "%s cannot be instantiated: its _init() requires arguments" % type_str)
+ break
+ var made: Variant = scr.new()
+ if made == null or not (made is Resource):
+ return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate %s as a Resource" % type_str)
+ return made
+ return _unknown_resource_type_error(type_str)
+
+
+## Maximum nesting depth for the {"__class__": ...} sub-resource shortcut.
+## Caller-supplied dicts recurse through _apply_resource_properties; without a
+## cap a deeply nested payload overflows the GDScript call stack and crashes
+## the editor (#536). 32 is far beyond any legitimate sub-resource chain.
+const MAX_NESTED_RESOURCE_DEPTH := 32
+
+
+## Apply a dict of property values to a freshly-instantiated Resource,
+## reusing NodeHandler's coercion so Vector3/Color/etc. dicts land typed.
+## Returns null on success or an error dict on failure.
+## `depth` is internal recursion bookkeeping for the nested {"__class__": ...}
+## shortcut — external callers use the default of 0.
+static func _apply_resource_properties(res: Resource, properties: Dictionary, depth: int = 0) -> Variant:
+ if depth > MAX_NESTED_RESOURCE_DEPTH:
+ return ErrorCodes.make(
+ ErrorCodes.INVALID_PARAMS,
+ "Nested resource properties exceed the maximum depth of %d — flatten the {\"__class__\": ...} nesting or create the deep sub-resources in separate calls" % MAX_NESTED_RESOURCE_DEPTH
+ )
+ var prop_types := {}
+ for prop in res.get_property_list():
+ prop_types[prop.name] = prop.get("type", TYPE_NIL)
+ for key in properties.keys():
+ if not prop_types.has(key):
+ var valid: Array[String] = []
+ for prop in res.get_property_list():
+ if prop.get("usage", 0) & PROPERTY_USAGE_EDITOR:
+ valid.append(prop.name)
+ valid.sort()
+ # Name the script's class_name (e.g. MyTestResource) rather than the
+ # native base (Resource) so the hint names the type the agent created,
+ # and point at the real MCP verb — resource_manage(op="get_info") now
+ # answers for project class_name Resources too.
+ var type_label := res.get_class()
+ var res_script: Variant = res.get_script()
+ if res_script is Script and not String(res_script.get_global_name()).is_empty():
+ type_label = String(res_script.get_global_name())
+ var err := ErrorCodes.make(
+ ErrorCodes.PROPERTY_NOT_ON_CLASS,
+ "Property '%s' not found on %s. Call resource_manage(op=\"get_info\", params={\"type\": \"%s\"}) to list available properties." % [key, type_label, type_label]
+ )
+ err["error"]["data"] = {"valid_properties": valid}
+ return err
+ var target_type: int = prop_types[key]
+ if target_type == TYPE_NIL:
+ target_type = typeof(res.get(key))
+ var v = properties[key]
+ if target_type == TYPE_OBJECT and v is String:
+ if v == "":
+ v = null
+ else:
+ var vpath_err = McpPathValidator.loadable_error(v, "property '%s'" % key)
+ if vpath_err != null:
+ return vpath_err
+ var loaded := ResourceLoader.load(v)
+ if loaded == null:
+ return ErrorCodes.make(
+ ErrorCodes.INVALID_PARAMS,
+ "Resource not found at path '%s' for property '%s'" % [v, key]
+ )
+ v = loaded
+ elif target_type == TYPE_OBJECT and v is Dictionary and v.has("__class__"):
+ # Nested shortcut: the same {"__class__": "X", ...} form that
+ # node_handler.set_property accepts, now also supported here so
+ # resource_create/environment_create callers can populate
+ # sub-resource slots (ShaderMaterial.shader, etc.) in one shot.
+ var sub_type: String = v.get("__class__", "")
+ # Resolve via the shared helper so the nested shortcut accepts both
+ # engine built-ins (ClassDB) and project `class_name` Resources,
+ # exactly like the top-level resource_create path.
+ var sub_made := _instantiate_resource(sub_type)
+ if sub_made is Dictionary:
+ # Preserve the property-slot context the inline path used to add.
+ sub_made["error"]["message"] = "%s (for property '%s')" % [sub_made["error"]["message"], key]
+ return sub_made
+ var sub_res: Resource = sub_made
+ var remaining: Dictionary = (v as Dictionary).duplicate()
+ remaining.erase("__class__")
+ if not remaining.is_empty():
+ var nested_err := _apply_resource_properties(sub_res, remaining, depth + 1)
+ if nested_err != null:
+ return nested_err
+ v = sub_res
+ else:
+ var slot_value: Variant = res.get(key)
+ if target_type == TYPE_ARRAY and slot_value is Array and (slot_value as Array).is_typed():
+ ## Typed Array[T] slot (#612): mirror set_property's dispatch —
+ ## the generic passthrough would hand an untyped Array to the
+ ## typed setter, which drops it silently while we report success.
+ var typed_out: Variant = NodeHandler._coerce_typed_array(
+ v, slot_value, "Property '%s'" % key
+ )
+ if typed_out is Dictionary:
+ return typed_out
+ v = typed_out
+ elif (
+ target_type == TYPE_DICTIONARY
+ and slot_value is Dictionary
+ and (slot_value as Dictionary).is_typed()
+ ):
+ ## Typed Dictionary[K, V] slot (#612 stage 3): success is a
+ ## typed duplicate of the slot; the error envelope is untyped.
+ var typed_dict_out: Dictionary = NodeHandler._coerce_typed_dictionary(
+ v, slot_value, "Property '%s'" % key
+ )
+ if not typed_dict_out.is_typed():
+ return typed_dict_out
+ v = typed_dict_out
+ else:
+ v = NodeHandler._coerce_value(v, target_type)
+ ## Mirror set_property's coerce check: wrong-shape dicts (#123) and
+ ## non-dict inputs that don't land as the target compound Variant
+ ## (#191) both error here instead of writing zero-filled Variants.
+ var coerce_err := NodeHandler._check_coerced(v, target_type, "Property '%s'" % key)
+ if coerce_err != null:
+ return coerce_err
+ res.set(key, v)
+ return null
+
+
+func _assign_created_resource(res: Resource, type_str: String, node_path: String, property: String, applied_count: int) -> Dictionary:
+ var _resolved := McpNodeValidator.resolve_or_error(node_path, "node_path")
+ if _resolved.has("error"):
+ return _resolved
+ var node: Node = _resolved.node
+ var _scene_root: Node = _resolved.scene_root
+
+ var found := false
+ var prop_type: int = TYPE_NIL
+ for prop in node.get_property_list():
+ if prop.name == property:
+ found = true
+ prop_type = prop.get("type", TYPE_NIL)
+ break
+ if not found:
+ return ErrorCodes.make(
+ ErrorCodes.PROPERTY_NOT_ON_CLASS,
+ McpPropertyErrors.build_message(node, property)
+ )
+ if prop_type != TYPE_NIL and prop_type != TYPE_OBJECT:
+ return ErrorCodes.make(
+ ErrorCodes.PROPERTY_NOT_ON_CLASS,
+ "Property '%s' on %s is not an Object slot (type %s)" % [property, node.get_class(), type_string(prop_type)]
+ )
+
+ var old_value = node.get(property)
+
+ _undo_redo.create_action("MCP: Create %s for %s.%s" % [type_str, node.name, property])
+ _undo_redo.add_do_property(node, property, res)
+ _undo_redo.add_undo_property(node, property, old_value)
+ _undo_redo.add_do_reference(res)
+ _undo_redo.commit_action()
+
+ return {
+ "data": {
+ "path": node_path,
+ "property": property,
+ "type": type_str,
+ "resource_class": res.get_class(),
+ "properties_applied": applied_count,
+ "undoable": true,
+ }
+ }
+
+
+func _save_created_resource(res: Resource, type_str: String, resource_path: String, overwrite: bool, applied_count: int) -> Dictionary:
+ return McpResourceIO.save_to_disk(res, resource_path, overwrite, "Resource", {
+ "type": type_str,
+ "resource_class": res.get_class(),
+ "properties_applied": applied_count,
+ }, _connection)
+
+
+## Introspect a Resource class — return its editor-visible properties, parent,
+## whether it's abstract, and (for abstract bases) the list of concrete
+## subclasses that resource_create can instantiate. Read-only.
+func get_resource_info(params: Dictionary) -> Dictionary:
+ var type_str: String = params.get("type", "")
+ if type_str.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: type")
+
+ if not ClassDB.class_exists(type_str):
+ # Project class_name Resources aren't in ClassDB; resolve them through the
+ # global script-class registry so get_info answers for the same custom
+ # types resource_create can make. Read-only — never instantiates.
+ var custom_info: Variant = _custom_resource_info(type_str)
+ if custom_info != null:
+ return custom_info
+ return _unknown_resource_type_error(type_str)
+ if ClassDB.is_parent_class(type_str, "Node"):
+ return ErrorCodes.make(
+ ErrorCodes.WRONG_TYPE,
+ "%s is a Node type, not a Resource — use node_* tools for node introspection" % type_str
+ )
+ if not ClassDB.is_parent_class(type_str, "Resource") and type_str != "Resource":
+ var parent := ClassDB.get_parent_class(type_str)
+ return ErrorCodes.make(
+ ErrorCodes.WRONG_TYPE,
+ "%s is not a Resource type (extends %s)" % [type_str, parent]
+ )
+
+ var can_instantiate: bool = ClassDB.can_instantiate(type_str)
+ var class_info := ClassIntrospection.build(type_str, {
+ "sections": ["properties"],
+ "include_inherited": true,
+ "include_inheritors": not can_instantiate,
+ "limit": 0,
+ })
+ var data: Dictionary = {
+ "type": type_str,
+ "parent_class": class_info.parent_class,
+ "can_instantiate": can_instantiate,
+ "is_abstract": not can_instantiate,
+ "properties": class_info.properties,
+ "property_count": class_info.property_count,
+ }
+
+ # For abstract bases (Shape3D, Material, Texture, StyleBox, ...) surface
+ # the concrete Resource subclasses an agent could try next.
+ if not can_instantiate:
+ data["concrete_subclasses"] = class_info.concrete_inheritors
+
+ return {"data": data}
+
+
+## Resolve a loaded global-class script to its native base type, or an error if
+## the script failed to load (not a Script) or to compile (empty base type).
+## Shared by the create and get_info custom-Resource paths so both report a
+## compile failure rather than a misleading "is not a Resource type (extends )".
+static func _script_base_type_or_error(scr: Variant, type_str: String, script_path: String) -> Variant:
+ if not (scr is Script):
+ return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to load script class %s from %s" % [type_str, script_path])
+ var base_type: StringName = scr.get_instance_base_type()
+ if String(base_type).is_empty():
+ return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "%s failed to compile or parse (script %s)" % [type_str, script_path])
+ return base_type
+
+
+## get_info for a project `class_name` Resource (not in ClassDB). Returns an info
+## dict, an error dict (for a class_name whose native base is not a Resource), or
+## null if `type_str` is not a registered global class. Read-only: resolves
+## properties from the script + its native base WITHOUT instantiating (no _init()).
+static func _custom_resource_info(type_str: String) -> Variant:
+ for entry in ProjectSettings.get_global_class_list():
+ if entry.get("class", "") != type_str:
+ continue
+ var script_path: String = entry.get("path", "")
+ var scr: Variant = load(script_path)
+ var base_or_err: Variant = _script_base_type_or_error(scr, type_str, script_path)
+ if base_or_err is Dictionary:
+ return base_or_err
+ var base_type: StringName = base_or_err
+ if not ClassDB.is_parent_class(base_type, "Resource"):
+ return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "%s is not a Resource type (extends %s)" % [type_str, base_type])
+ var can_instantiate: bool = scr.can_instantiate()
+ # Inherited (native) properties come from the engine base via ClassDB...
+ var class_info := ClassIntrospection.build(String(base_type), {
+ "sections": ["properties"],
+ "include_inherited": true,
+ "limit": 0,
+ })
+ var props: Array = []
+ for native_prop in class_info.properties:
+ props.append(native_prop)
+ # ...and the script's own (and inherited script) exported properties come
+ # from the Script itself, so we never construct the resource. A real
+ # default isn't available without instantiating, so script props carry an
+ # explicit null — keeping one uniform key set across the array (native
+ # props carry their real default).
+ for raw_prop in scr.get_script_property_list():
+ var prop: Dictionary = raw_prop
+ var usage := int(prop.get("usage", 0))
+ if not (usage & PROPERTY_USAGE_EDITOR):
+ continue
+ props.append({
+ "name": str(prop.get("name", "")),
+ "type": type_string(int(prop.get("type", TYPE_NIL))),
+ "class_name": str(prop.get("class_name", "")),
+ "hint": int(prop.get("hint", PROPERTY_HINT_NONE)),
+ "hint_string": str(prop.get("hint_string", "")),
+ "usage": usage,
+ "default": null,
+ })
+ props.sort_custom(func(a, b): return a.name < b.name)
+ # parent_class is the immediate script parent when there is one (so a
+ # multi-level chain B -> A -> Resource reports A), else the native base.
+ var parent_name := String(base_type)
+ var base_script: Variant = scr.get_base_script()
+ if base_script is Script and not String(base_script.get_global_name()).is_empty():
+ parent_name = String(base_script.get_global_name())
+ return {"data": {
+ "type": type_str,
+ "parent_class": parent_name,
+ "can_instantiate": can_instantiate,
+ # is_abstract reflects real abstractness (the @abstract annotation),
+ # NOT editor-instantiability — a non-@tool concrete Resource has
+ # can_instantiate()==false in-editor but is not abstract.
+ "is_abstract": scr.is_abstract(),
+ "properties": props,
+ "property_count": props.size(),
+ }}
+ return null
diff --git a/addons/godot_ai/handlers/resource_handler.gd.uid b/addons/godot_ai/handlers/resource_handler.gd.uid
new file mode 100644
index 0000000..d79d3cf
--- /dev/null
+++ b/addons/godot_ai/handlers/resource_handler.gd.uid
@@ -0,0 +1 @@
+uid://dwwd0n3c56ir
diff --git a/addons/godot_ai/handlers/scene_handler.gd b/addons/godot_ai/handlers/scene_handler.gd
new file mode 100644
index 0000000..f34f5fc
--- /dev/null
+++ b/addons/godot_ai/handlers/scene_handler.gd
@@ -0,0 +1,429 @@
+@tool
+extends "res://addons/godot_ai/handlers/command_handler.gd"
+
+const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
+
+## Handles scene tree reading and node search.
+
+var _connection: McpConnection
+var _save_scene_callable: Callable = Callable()
+var _save_scene_as_callable: Callable = Callable()
+
+
+func _init(connection: McpConnection = null) -> void:
+ _connection = connection
+
+
+func get_scene_tree(params: Dictionary) -> Dictionary:
+ var max_depth: int = params.get("depth", 10)
+ var offset: int = maxi(0, int(params.get("offset", 0)))
+ # limit <= 0 means "no limit" (the hierarchy resource reads the whole tree);
+ # the scene_get_hierarchy tool passes an explicit positive limit. Paginating
+ # here — rather than walking + serializing the full tree and slicing on the
+ # Python side — means only the requested window builds node dicts and clean
+ # scene paths, and only the window crosses the WebSocket.
+ var limit: int = int(params.get("limit", 0))
+ var scene_root := EditorInterface.get_edited_scene_root()
+ if scene_root == null:
+ return {"data": {
+ "nodes": [],
+ "total_count": 0,
+ "offset": offset,
+ "limit": limit,
+ "has_more": false,
+ "message": "No scene open",
+ }}
+
+ var nodes: Array[Dictionary] = []
+ # index_ref[0] is the running DFS index shared across the recursion (Arrays
+ # pass by reference in GDScript). The walk still visits every node to get an
+ # accurate total_count, but only materializes those inside the window.
+ var index_ref: Array[int] = [0]
+ # _walk_tree self-seeds the root's path for full reads; pass "" explicitly.
+ _walk_tree(scene_root, nodes, 0, max_depth, scene_root, offset, limit, index_ref, "")
+ var total: int = index_ref[0]
+ return {"data": {
+ "nodes": nodes,
+ "total_count": total,
+ "offset": offset,
+ "limit": limit,
+ "has_more": limit > 0 and offset + limit < total,
+ }}
+
+
+func get_open_scenes(_params: Dictionary) -> Dictionary:
+ var scene_paths := EditorInterface.get_open_scenes()
+ var scene_root := EditorInterface.get_edited_scene_root()
+ var current := scene_root.scene_file_path if scene_root else ""
+ return {
+ "data": {
+ "scenes": scene_paths,
+ "current_scene": current,
+ "count": scene_paths.size(),
+ }
+ }
+
+
+func find_nodes(params: Dictionary) -> Dictionary:
+ var name_filter: String = params.get("name", "")
+ var type_filter: String = params.get("type", "")
+ var group_filter: String = params.get("group", "")
+
+ if name_filter.is_empty() and type_filter.is_empty() and group_filter.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "At least one filter (name, type, group) is required")
+
+ var _scene_check := McpNodeValidator.require_scene_or_error()
+ if _scene_check.has("error"):
+ return _scene_check
+ var scene_root: Node = _scene_check.scene_root
+
+ var results: Array[Dictionary] = []
+ _find_recursive(scene_root, scene_root, name_filter, type_filter, group_filter, results)
+ return {"data": {"nodes": results, "count": results.size()}}
+
+
+func _find_recursive(node: Node, scene_root: Node, name_filter: String, type_filter: String, group_filter: String, out: Array[Dictionary]) -> void:
+ var matches := true
+
+ if not name_filter.is_empty():
+ if node.name.to_lower().find(name_filter.to_lower()) == -1:
+ matches = false
+
+ if matches and not type_filter.is_empty():
+ if node.get_class() != type_filter:
+ matches = false
+
+ if matches and not group_filter.is_empty():
+ if not node.is_in_group(group_filter):
+ matches = false
+
+ if matches:
+ out.append({
+ "name": node.name,
+ "type": node.get_class(),
+ "path": McpScenePath.from_node(node, scene_root),
+ })
+
+ for child in node.get_children():
+ _find_recursive(child, scene_root, name_filter, type_filter, group_filter, out)
+
+
+## Create a new scene with the given root node type, save to disk, and open it.
+func create_scene(params: Dictionary) -> Dictionary:
+ var root_type: String = params.get("root_type", "Node3D")
+ var path: String = params.get("path", "")
+
+ if path.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: path")
+
+ var path_err = McpPathValidator.path_error(path, "path", true)
+ if path_err != null:
+ return path_err
+
+ if not path.ends_with(".tscn") and not path.ends_with(".scn"):
+ path += ".tscn"
+
+ if not ClassDB.class_exists(root_type):
+ return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Unknown node type: %s" % root_type)
+ if not ClassDB.is_parent_class(root_type, "Node"):
+ return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "%s is not a Node type" % root_type)
+
+ # Ensure parent directory exists
+ var dir_path := path.get_base_dir()
+ if not DirAccess.dir_exists_absolute(dir_path):
+ var err := DirAccess.make_dir_recursive_absolute(dir_path)
+ if err != OK:
+ return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to create directory: %s" % dir_path)
+
+ var root: Node = ClassDB.instantiate(root_type)
+ if root == null:
+ return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate %s" % root_type)
+
+ var root_name: String = params.get("root_name", "")
+ if root_name.is_empty():
+ root_name = path.get_file().get_basename()
+ root.name = root_name
+
+ if _connection:
+ _connection.pause_processing = true
+ var err := _pack_and_save_with_uid(root, path)
+ if err == OK:
+ EditorInterface.open_scene_from_path(path)
+ if _connection:
+ _connection.pause_processing = false
+
+ if err != OK:
+ return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to save scene: %s" % error_string(err))
+
+ return {
+ "data": {
+ "path": path,
+ "root_type": root_type,
+ "root_name": root_name,
+ "undoable": false,
+ "reason": "Scene creation involves file system operations",
+ }
+ }
+
+
+## Pack `root` and save it to `path`, embedding a fresh uid or preserving the
+## one `path` already had — the exact save sequence `create_scene` runs,
+## minus the `pause_processing` guard (the caller owns that, since it also
+## needs to bracket `open_scene_from_path`) and minus opening the scene
+## (switching the editor's active scene isn't safe inside the shared test
+## runner, so tests call this directly instead of going through
+## `create_scene` end-to-end). Frees `root`. Returns `OK`, or the first
+## `Error` encountered.
+func _pack_and_save_with_uid(root: Node, path: String) -> Error:
+ var packed := PackedScene.new()
+ packed.pack(root)
+ root.free()
+
+ # Captured BEFORE the save below overwrites the file — see
+ # McpResourceIO.ensure_uid's doc comment.
+ var prior_uid := ResourceLoader.get_resource_uid(path) if FileAccess.file_exists(path) else ResourceUID.INVALID_ID
+
+ var err := ResourceSaver.save(packed, path)
+ if err == OK:
+ err = McpResourceIO.ensure_uid(path, prior_uid)
+ return err
+
+
+## How long open_scene waits for the editor to actually switch to the
+## requested scene before replying switched=false. Tab switches normally land
+## within a few frames; keep this under the dispatcher's 4500 ms deferred
+## default so the coroutine always answers before DEFERRED_TIMEOUT fires.
+const _OPEN_SETTLE_MAX_MSEC := 3000
+
+
+## Open an existing scene by file path.
+func open_scene(params: Dictionary) -> Dictionary:
+ var path: String = params.get("path", "")
+ var force_reload: bool = params.get("force_reload", false)
+ if path.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: path")
+
+ var path_err = McpPathValidator.loadable_error(path, "path")
+ if path_err != null:
+ return path_err
+
+ if not ResourceLoader.exists(path):
+ return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Scene not found: %s" % path)
+
+ var scene_root := EditorInterface.get_edited_scene_root()
+ var current_path := scene_root.scene_file_path if scene_root else ""
+ ## Instance id of the root at call time. A completed open OR reload always
+ ## replaces the edited-scene root with a NEW instance, so this is the
+ ## reliable completion signal — unlike scene_file_path, which is unchanged
+ ## across a force_reload of the already-open scene (#633 review).
+ var prev_root_id := scene_root.get_instance_id() if scene_root else 0
+ var payload := {
+ "path": path,
+ "force_reload": force_reload,
+ "reloaded_from_disk": false,
+ "previous_scene_path": current_path,
+ "undoable": false,
+ "reason": "Scene navigation cannot be undone via editor undo",
+ }
+
+ if current_path == path and not force_reload:
+ ## Already the edited scene — nothing switches, reply immediately.
+ payload["switched"] = true
+ payload["settle"] = "already_current"
+ return {"data": payload}
+
+ if force_reload and current_path == path:
+ EditorInterface.reload_scene_from_path(path)
+ payload["reloaded_from_disk"] = true
+ else:
+ EditorInterface.open_scene_from_path(path)
+
+ ## The tab switch completes asynchronously; replying now lets an immediate
+ ## follow-up write land on the PREVIOUS scene (#633 — a scene_save issued
+ ## right after open_scene saved the old scene). Defer the reply until the
+ ## edited scene actually is `path` AND its root is a fresh instance, so
+ ## success means "the editor is now editing the (re)loaded scene".
+ var request_id: String = params.get("_request_id", "")
+ if _connection != null and not request_id.is_empty():
+ _finish_open_scene_deferred(_connection, request_id, path, prev_root_id, payload)
+ return McpDispatcher.DEFERRED_RESPONSE
+
+ ## Synchronous fallback (batch_execute and unit-test contexts can't await):
+ ## preserve the old reply-immediately behavior, flagged as not waited on.
+ payload["switched"] = false
+ payload["settle"] = "not_waited"
+ return {"data": payload}
+
+
+## `static` is load-bearing (same reason as FilesystemHandler's deferred scan
+## finish): the coroutine must outlive this RefCounted handler, which can be
+## freed mid-await by an editor_reload_plugin. Parameterise everything;
+## reference no instance state.
+static func _finish_open_scene_deferred(
+ connection: McpConnection,
+ request_id: String,
+ path: String,
+ prev_root_id: int,
+ payload: Dictionary,
+) -> void:
+ var work := ScriptWork.begin("open_scene")
+ await _settle_open_scene(connection, request_id, path, prev_root_id, payload)
+ ScriptWork.finish(work)
+
+
+static func _settle_open_scene(
+ connection: McpConnection, request_id: String, path: String,
+ prev_root_id: int, payload: Dictionary,
+) -> void:
+ if not is_instance_valid(connection):
+ return
+ var tree := connection.get_tree()
+ if tree == null:
+ return
+ # Hand back a frame so _dispatch() registers this request as deferred
+ # before the coroutine can push a reply.
+ await tree.process_frame
+ var deadline_ms := Time.get_ticks_msec() + _OPEN_SETTLE_MAX_MSEC
+ while Time.get_ticks_msec() < deadline_ms:
+ var root := EditorInterface.get_edited_scene_root()
+ # Require BOTH the target path AND a fresh root instance: a
+ # force_reload keeps scene_file_path == path across the reload, so the
+ # instance swap is what proves the (re)load actually completed rather
+ # than the coroutine settling on the stale pre-reload root.
+ if root != null and root.scene_file_path == path and root.get_instance_id() != prev_root_id:
+ if not is_instance_valid(connection):
+ return
+ payload["switched"] = true
+ payload["settle"] = "settled"
+ connection.send_deferred_response(request_id, {"data": payload})
+ return
+ await tree.process_frame
+ if not is_instance_valid(connection):
+ return
+ payload["switched"] = false
+ payload["settle"] = "timeout"
+ connection.send_deferred_response(request_id, {"data": payload})
+
+
+## Save the currently edited scene.
+## Pauses WebSocket processing during save to prevent re-entrant _process()
+## calls during EditorNode::_save_scene_with_preview's thumbnail render.
+func save_scene(_params: Dictionary) -> Dictionary:
+ var _scene_check := McpNodeValidator.require_scene_or_error()
+ if _scene_check.has("error"):
+ return _scene_check
+ var scene_root: Node = _scene_check.scene_root
+
+ var path := scene_root.scene_file_path
+ if path.is_empty():
+ return ErrorCodes.make(
+ ErrorCodes.INVALID_PARAMS,
+ "Current scene has never been saved; call scene_manage(op='save_as') with a res://... path ending in .tscn or .scn."
+ )
+
+ if _connection:
+ _connection.pause_processing = true
+ var err := _save_current_scene()
+ if _connection:
+ _connection.pause_processing = false
+
+ if err != OK:
+ return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to save scene: %s" % error_string(err))
+
+ return {
+ "data": {
+ "path": path,
+ "undoable": false,
+ "reason": "File save cannot be undone via editor undo",
+ }
+ }
+
+
+## Save the currently edited scene to a new file path.
+func save_scene_as(params: Dictionary) -> Dictionary:
+ var path: String = params.get("path", "")
+ if path.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: path")
+
+ var path_err = McpPathValidator.path_error(path, "path", true)
+ if path_err != null:
+ return path_err
+
+ if not path.ends_with(".tscn") and not path.ends_with(".scn"):
+ path += ".tscn"
+
+ var _scene_check := McpNodeValidator.require_scene_or_error()
+ if _scene_check.has("error"):
+ return _scene_check
+ var scene_root: Node = _scene_check.scene_root
+
+ # Ensure parent directory exists
+ var dir_path := path.get_base_dir()
+ if not DirAccess.dir_exists_absolute(dir_path):
+ var err := DirAccess.make_dir_recursive_absolute(dir_path)
+ if err != OK:
+ return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to create directory: %s" % dir_path)
+
+ if _connection:
+ _connection.pause_processing = true
+ _save_current_scene_as(path)
+ if _connection:
+ _connection.pause_processing = false
+
+ return {
+ "data": {
+ "path": path,
+ "undoable": false,
+ "reason": "File save cannot be undone via editor undo",
+ }
+ }
+
+
+func _save_current_scene() -> int:
+ if _save_scene_callable.is_valid():
+ return int(_save_scene_callable.call())
+ return EditorInterface.save_scene()
+
+
+func _save_current_scene_as(path: String) -> void:
+ if _save_scene_as_callable.is_valid():
+ _save_scene_as_callable.call(path)
+ return
+ EditorInterface.save_scene_as(path)
+
+
+func _walk_tree(node: Node, out: Array[Dictionary], depth: int, max_depth: int, scene_root: Node, offset: int, limit: int, index_ref: Array[int], node_path: String) -> void:
+ if depth > max_depth:
+ return
+ var idx: int = index_ref[0]
+ index_ref[0] = idx + 1
+ # Materialize only nodes inside the [offset, offset+limit) window. Outside
+ # it we still recurse (to count total_count) but skip the per-node dict.
+ #
+ # Path build strategy depends on the read shape (identical output either way):
+ # * A whole-tree read (offset == 0 and limit <= 0 — the resource-style read
+ # backing godot://scene/hierarchy) threads the parent's clean path down the
+ # DFS: each node's path is one O(1) concat reusing the descent, instead of
+ # McpScenePath.from_node's two native walks back up (is_ancestor_of +
+ # get_path_to). Benchmarked ~1.8x faster on a ~1.5k-node tree, up to ~5x on
+ # deep chains.
+ # * Any windowed read (limit > 0, or an offset > 0 skip) keeps from_node for
+ # just the emitted nodes: threading would concatenate a path for every node
+ # visited for total_count, which benchmarks ~20% slower for a small window.
+ #
+ # `node_path` is self-seeded at the scene root below, so a caller cannot leave
+ # a full read unseeded (it has no default — pass "" for windowed reads).
+ var incremental := limit <= 0 and offset == 0
+ if incremental and node == scene_root:
+ node_path = "/" + String(scene_root.name)
+ var in_window := idx >= offset and (limit <= 0 or idx < offset + limit)
+ if in_window:
+ out.append({
+ "name": node.name,
+ "type": node.get_class(),
+ "path": node_path if incremental else McpScenePath.from_node(node, scene_root),
+ "children_count": node.get_child_count(),
+ })
+ for child in node.get_children():
+ var child_path := (node_path + "/" + String(child.name)) if incremental else ""
+ _walk_tree(child, out, depth + 1, max_depth, scene_root, offset, limit, index_ref, child_path)
diff --git a/addons/godot_ai/handlers/scene_handler.gd.uid b/addons/godot_ai/handlers/scene_handler.gd.uid
new file mode 100644
index 0000000..af3711f
--- /dev/null
+++ b/addons/godot_ai/handlers/scene_handler.gd.uid
@@ -0,0 +1 @@
+uid://7ms40gm6t2r4
diff --git a/addons/godot_ai/handlers/script_handler.gd b/addons/godot_ai/handlers/script_handler.gd
new file mode 100644
index 0000000..050b243
--- /dev/null
+++ b/addons/godot_ai/handlers/script_handler.gd
@@ -0,0 +1,572 @@
+@tool
+extends "res://addons/godot_ai/handlers/command_handler.gd"
+
+const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
+const DiagnosticsCapture := preload("res://addons/godot_ai/utils/diagnostics_capture.gd")
+const ValidationLogger := preload("res://addons/godot_ai/runtime/validation_logger.gd")
+
+## Handles script creation, reading, attaching, detaching, and symbol inspection.
+
+var _undo_redo: EditorUndoRedoManager
+var _connection: McpConnection
+
+# The bounded import-settle window and the deferred completion coroutine
+# live on McpResourceIO since #714 — write_file's fresh-`.gd` path shares
+# them, so create_script and write_file can't drift apart again (#261).
+
+func _init(undo_redo: EditorUndoRedoManager, connection: McpConnection = null) -> void:
+ _undo_redo = undo_redo
+ _connection = connection
+
+
+func create_script(params: Dictionary) -> Dictionary:
+ var path: String = params.get("path", "")
+ var content: String = params.get("content", "")
+
+ var path_err = McpPathValidator.path_error(path, "path", true)
+ if path_err != null:
+ return path_err
+
+ if not path.ends_with(".gd"):
+ return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Path must end with .gd")
+
+ var existed_before := FileAccess.file_exists(path)
+
+ # Shared write path (#714): parent mkdir + write/flush + explicit error
+ # check live on McpResourceIO so write_file can't drift from this again.
+ var write_failure: Variant = McpResourceIO.write_text_to_disk(path, content)
+ if write_failure != null:
+ return write_failure
+
+ var data := {
+ "path": path,
+ "size": content.length(),
+ "committed": true,
+ "import_settled": existed_before,
+ "import_settle": "already_known" if existed_before else "not_waited",
+ "undoable": false,
+ "reason": "File system operations cannot be undone via editor undo",
+ }
+ _attach_gdscript_diagnostics(data, path, content)
+
+ # A freshly-declared `class_name` is NOT in the global class table until a
+ # filesystem scan runs — update_file() below registers the file with the
+ # resource pipeline but not the class registry (see the scan() comment).
+ # Surface that precisely (only when the class isn't already registered) so a
+ # headless caller knows to follow up with filesystem_manage(op="scan")
+ # instead of hitting a confusing "Unknown type" / "Unknown resource type" on
+ # the very next call. We don't scan here — a scan() per create is the exact
+ # SIGABRT race documented below; the explicit op is single-flight.
+ # Skip the hint when the script failed to parse: a scan won't register a
+ # class from a broken script, so pointing at op="scan" would steer the caller
+ # away from the real fix (the parse error already attached above).
+ var declared_class := _extract_class_name(content)
+ if (
+ not declared_class.is_empty()
+ and not _script_has_error_diagnostics(data)
+ and not _class_name_registered(declared_class)
+ ):
+ data["class_name"] = declared_class
+ data["class_registration"] = "scan_required"
+ data["class_registration_hint"] = (
+ "New class_name '%s' isn't in the global class table yet. " % declared_class
+ + "Call filesystem_manage(op=\"scan\") if it won't resolve on the next "
+ + "call (e.g. resource_manage op=\"create\", or used as a type in another "
+ + "script). The editor also registers it on its next filesystem scan or "
+ + "when its window regains focus."
+ )
+
+ # An overwrite can target a script that is already loaded (attached to a
+ # node, preloaded, open in the script editor). Registering the bytes with
+ # the editor leaves that live GDScript on the old source (#937), so the very
+ # next call would run stale code after a "successful" write. Refresh it
+ # BEFORE registering the file: a GUI editor loads the script inside
+ # update_file() (see _refresh_loaded_gdscript for the ordering contract).
+ if existed_before:
+ _refresh_loaded_gdscript(data, path, content)
+
+ _register_written_file(path)
+
+ # `.gd.uid` is the sidecar Godot generates on scan; list both so the caller
+ # can rm the full set in one go.
+ McpResourceIO.attach_cleanup_hint(data, existed_before, [path, path + ".uid"])
+
+ # scan() is async — ResourceLoader.exists(path) returns false until Godot's
+ # filesystem pipeline finishes. If we reply now, an immediate attach_script
+ # races and 404s (#261). Defer the response until the resource is visible
+ # (or a bounded timeout elapses). For freshly-created files we wait; on
+ # overwrite the resource was already known to ResourceLoader, so reply now.
+ var request_id: String = params.get("_request_id", "")
+ if not existed_before and _connection != null and not request_id.is_empty():
+ McpResourceIO.finish_text_write_deferred(_connection, request_id, path, data)
+ return McpDispatcher.DEFERRED_RESPONSE
+
+ # Synchronous fallback: batch_execute (no request_id) and unit-test contexts
+ # (no connection) get the immediate reply that the previous behaviour gave.
+ return {"data": data}
+
+
+## Extract the `class_name` a script declares, or "" if none. A cheap line scan
+## (no full parse) for create_script's "scan_required" hint. Stops at the first
+## space/tab or comma so all three valid forms yield just the name:
+## `class_name Foo`, `class_name Foo extends Bar`, and the icon form
+## `class_name Foo, "res://icon.svg"`.
+static func _extract_class_name(content: String) -> String:
+ for raw_line in content.split("\n"):
+ var line := raw_line.strip_edges()
+ if line.begins_with("class_name "):
+ var rest := line.substr(11).strip_edges()
+ var cut := rest.length()
+ for i in rest.length():
+ var ch := rest[i]
+ if ch == " " or ch == "\t" or ch == ",":
+ cut = i
+ break
+ return rest.substr(0, cut)
+ return ""
+
+
+## True if create_script's diagnostics captured a parse error for this script.
+## Used to suppress the "scan_required" hint when the class can't register
+## anyway — see create_script.
+static func _script_has_error_diagnostics(data: Dictionary) -> bool:
+ for diag in data.get("diagnostics", []):
+ if diag is Dictionary and diag.get("level", "") == "error":
+ return true
+ return false
+
+
+## True if `cn` is already usable as a type — an engine built-in (ClassDB) or an
+## already-registered project global class. A brand-new class_name returns false
+## until a filesystem scan registers it.
+static func _class_name_registered(cn: String) -> bool:
+ if ClassDB.class_exists(cn):
+ return true
+ for entry in ProjectSettings.get_global_class_list():
+ if entry.get("class", "") == cn:
+ return true
+ return false
+
+
+func read_script(params: Dictionary) -> Dictionary:
+ var path: String = params.get("path", "")
+
+ var path_err = McpPathValidator.path_error(path, "path")
+ if path_err != null:
+ return path_err
+
+ if not FileAccess.file_exists(path):
+ return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "File not found: %s" % path)
+
+ var file := FileAccess.open(path, FileAccess.READ)
+ if file == null:
+ return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to open file: %s" % path)
+
+ var content := file.get_as_text()
+ file.close()
+
+ return {
+ "data": {
+ "path": path,
+ "content": content,
+ "size": content.length(),
+ "line_count": content.count("\n") + (1 if not content.is_empty() else 0),
+ }
+ }
+
+
+## Instance (not static) despite using no instance state: tests stub
+## `_capture_gdscript_load_diagnostics` via subclass override, and static
+## calls bind lexically — see test_script.gd. filesystem_handler shares
+## this by instantiating a bare ScriptHandler (#714).
+func _attach_gdscript_diagnostics(data: Dictionary, path: String, content: String) -> void:
+ var validation := _validate_gdscript_source(content)
+ var diagnostics: Array = []
+ var diagnostics_detail := "none"
+ var diagnostics_status := "checked"
+
+ if not validation.get("ok", true):
+ var capture := _capture_gdscript_load_diagnostics(path)
+ diagnostics = capture.get("diagnostics", [])
+ diagnostics_detail = capture.get("diagnostics_detail", "none")
+ diagnostics_status = capture.get("diagnostics_status", "checked")
+ if not validation.get("ok", true) and diagnostics.is_empty():
+ diagnostics.append(_fallback_gdscript_diagnostic(path, validation.get("error_code", FAILED), content))
+ diagnostics_detail = "fallback"
+ data["diagnostics"] = diagnostics
+ data["diagnostics_detail"] = diagnostics_detail
+ data["diagnostics_scope"] = "this_file"
+ data["diagnostics_status"] = diagnostics_status
+
+
+## Register just this file with the editor instead of a full recursive
+## scan(). A scan() per write stacks `update_scripts_classes` /
+## `update_script_paths_documentation` WorkerThreadPool tasks under concurrent
+## script creation ("Task ... already exists" / "!tasks.has(p_task)"), which
+## races the global-class registry and can SIGABRT in
+## ScriptServer::remove_global_class_by_path (see dsarno/godot#6).
+## update_file() is the single-file path the rest of the plugin already uses.
+##
+## Call it only after `_refresh_loaded_gdscript` has taken its decision (see
+## the ordering contract there). Instance (not static) so a test can stand in
+## for what a GUI editor does inside update_file() — load or reload the
+## script — and lock that ordering from a headless run.
+func _register_written_file(path: String) -> void:
+ var efs := EditorInterface.get_resource_filesystem()
+ if efs != null:
+ efs.update_file(path)
+
+
+## Bring an already-loaded GDScript back in step with the bytes just written.
+##
+## ResourceLoader caches GDScript by path, and registering the write with
+## EditorFileSystem does not refresh that cache in a headless editor — so after
+## a successful write, a script that a node, a preload(), or the script editor
+## already holds keeps executing the previous source (#937). When the path is
+## cached and the new source parsed, push the source into the live object and
+## reload it in place (keep_state so existing instances survive). Reports
+## `reloaded` plus a `reload_reason` when it did not, so a caller can tell
+## "the file changed" from "the code changed".
+##
+## Ordering contract: this runs BEFORE `_register_written_file`. A GUI editor
+## (not a headless one — EditorNode's cmdline mode skips the step) runs its
+## script-documentation pass synchronously inside update_file(), which
+## ResourceLoader.load()s the script, caching a never-loaded one, and
+## reload_from_file()s a cached one that is not open in the script editor.
+## Deciding after that call reports a false `already_current` for a script
+## nobody held (instead of `not_loaded`) and for one this helper should have
+## refreshed itself. The question is what was loaded before the write, so it
+## is answered first; the editor's own pass then meets the same bytes.
+##
+## Skipped when validation failed: the diagnostics capture above has already
+## reloaded the shared GDScriptCache entry with the broken source, so there is
+## no good code to push; `reload_reason: parse_error` tells the caller the
+## loaded code did NOT change to something runnable.
+static func _refresh_loaded_gdscript(data: Dictionary, path: String, content: String) -> void:
+ data["reloaded"] = false
+ if _script_has_error_diagnostics(data):
+ data["reload_reason"] = "parse_error"
+ return
+ if not ResourceLoader.has_cached(path):
+ data["reload_reason"] = "not_loaded"
+ return
+ var loaded := ResourceLoader.load(path)
+ if not (loaded is GDScript):
+ data["reload_reason"] = "not_gdscript"
+ return
+ var script := loaded as GDScript
+ if script.source_code == content:
+ data["reloaded"] = true
+ data["reload_reason"] = "already_current"
+ return
+ script.source_code = content
+ var err := script.reload(true)
+ if err != OK:
+ data["reload_reason"] = "reload_failed"
+ data["reload_error"] = err
+ return
+ data["reloaded"] = true
+
+
+static func _validate_gdscript_source(content: String) -> Dictionary:
+ var script := GDScript.new()
+ script.source_code = content
+ ## Keep validation off the live cached resource: assigning resource_path to
+ ## this ephemeral Script can collide with loaded instances. reload() still
+ ## performs normal GDScript analysis, including static initializer work, so
+ ## this check is intentionally scoped to `.gd` writes where the editor would
+ ## compile the file on scan anyway.
+ var err := script.reload()
+ return {
+ "ok": err == OK,
+ "error_code": err,
+ }
+
+
+func _capture_gdscript_load_diagnostics(path: String) -> Dictionary:
+ var buffer := McpEditorLogBuffer.new()
+ var logger := ValidationLogger.new(buffer)
+ var capture := DiagnosticsCapture.capture_this_file(buffer, path, func() -> Dictionary:
+ OS.add_logger(logger)
+ # ResourceLoader.load() reports parse failure instead of throwing, and
+ # a failed GDScript parse does not execute user code; remove immediately
+ # after the synchronous load to keep the private capture window tiny.
+ ResourceLoader.load(path, "", ResourceLoader.CACHE_MODE_IGNORE)
+ OS.remove_logger(logger)
+ return {}
+ )
+ return capture
+
+
+static func _fallback_gdscript_diagnostic(path: String, error_code: int, content: String) -> Dictionary:
+ var line := _fallback_gdscript_error_line(content)
+ return {
+ "source": "editor",
+ "level": "error",
+ "text": "GDScript reload failed with error code %d." % error_code,
+ "path": path,
+ "line": line,
+ "function": "GDScript::reload",
+ "details": {
+ "code": "gdscript_reload_failed",
+ "error_code": error_code,
+ "fallback_line": true,
+ "source": {
+ "path": path,
+ "line": line,
+ },
+ },
+ }
+
+
+static func _fallback_gdscript_error_line(content: String) -> int:
+ var lines := content.split("\n")
+ for i in range(lines.size() - 1, -1, -1):
+ if not str(lines[i]).strip_edges().is_empty():
+ return i + 1
+ return 1
+
+
+func patch_script(params: Dictionary) -> Dictionary:
+ var path: String = params.get("path", "")
+ var old_text: String = params.get("old_text", "")
+ var new_text: String = params.get("new_text", "")
+ var replace_all: bool = params.get("replace_all", false)
+
+ var path_err = McpPathValidator.path_error(path, "path", true)
+ if path_err != null:
+ return path_err
+ if not "old_text" in params:
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: old_text")
+ if not "new_text" in params:
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: new_text")
+ if not path.ends_with(".gd"):
+ return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Path must end with .gd (use filesystem_write_text for other text files)")
+ if old_text.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "old_text must not be empty")
+
+ var read := FileAccess.open(path, FileAccess.READ)
+ if read == null:
+ return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "File not found or unreadable: %s" % path)
+ var content := read.get_as_text()
+ read.close()
+
+ var match_count := content.count(old_text)
+ if match_count == 0:
+ return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "old_text not found in %s" % path)
+ if match_count > 1 and not replace_all:
+ return ErrorCodes.make(
+ ErrorCodes.INVALID_PARAMS,
+ "old_text matches %d times; pass replace_all=true or provide a more specific snippet" % match_count,
+ )
+
+ var new_content: String
+ var replacements: int
+ if replace_all:
+ new_content = content.replace(old_text, new_text)
+ replacements = match_count
+ else:
+ var idx := content.find(old_text)
+ new_content = content.substr(0, idx) + new_text + content.substr(idx + old_text.length())
+ replacements = 1
+
+ # Shared write path (#714). No import-settle deferral here: the file
+ # already exists, so ResourceLoader knows it and there is no scan to wait
+ # for — same rationale as create_script's overwrite arm.
+ var write_failure: Variant = McpResourceIO.write_text_to_disk(path, new_content)
+ if write_failure != null:
+ return write_failure
+
+ var data := {
+ "path": path,
+ "replacements": replacements,
+ "size": new_content.length(),
+ "old_size": content.length(),
+ "undoable": false,
+ "reason": "File system operations cannot be undone via editor undo",
+ }
+ _attach_gdscript_diagnostics(data, path, new_content)
+
+ # The file is fresh but any already-loaded GDScript for it is not (#937);
+ # make "patch succeeded" mean the loaded code changed, not just the bytes.
+ # Decide and refresh before registering the file with the editor — a GUI
+ # editor loads the script inside update_file() (see _refresh_loaded_gdscript).
+ _refresh_loaded_gdscript(data, path, new_content)
+ _register_written_file(path)
+
+ return {"data": data}
+
+
+func attach_script(params: Dictionary) -> Dictionary:
+ var node_path: String = params.get("path", "")
+ var script_path: String = params.get("script_path", "")
+
+ if node_path.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: path")
+
+ if script_path.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: script_path")
+
+ var spath_err = McpPathValidator.loadable_error(script_path, "script_path")
+ if spath_err != null:
+ return spath_err
+
+ var _resolved := McpNodeValidator.resolve_or_error(node_path, "node_path")
+ if _resolved.has("error"):
+ return _resolved
+ var node: Node = _resolved.node
+ var _scene_root: Node = _resolved.scene_root
+
+ if not ResourceLoader.exists(script_path):
+ return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Script not found: %s" % script_path)
+
+ var script: Script = load(script_path)
+ if script == null:
+ return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to load script: %s" % script_path)
+
+ var old_script: Script = node.get_script()
+
+ _undo_redo.create_action("MCP: Attach script to %s" % node.name)
+ _undo_redo.add_do_method(node, "set_script", script)
+ _undo_redo.add_undo_method(node, "set_script", old_script)
+ _undo_redo.commit_action()
+
+ return {
+ "data": {
+ "path": node_path,
+ "script_path": script_path,
+ "had_previous_script": old_script != null,
+ "undoable": true,
+ }
+ }
+
+
+func detach_script(params: Dictionary) -> Dictionary:
+ var node_path: String = params.get("path", "")
+
+ if node_path.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: path")
+
+ var _resolved := McpNodeValidator.resolve_or_error(node_path, "node_path")
+ if _resolved.has("error"):
+ return _resolved
+ var node: Node = _resolved.node
+ var _scene_root: Node = _resolved.scene_root
+
+ var old_script: Script = node.get_script()
+ if old_script == null:
+ return {"data": {"path": node_path, "had_script": false, "undoable": false, "reason": "No script attached"}}
+
+ _undo_redo.create_action("MCP: Detach script from %s" % node.name)
+ _undo_redo.add_do_method(node, "set_script", null)
+ _undo_redo.add_undo_method(node, "set_script", old_script)
+ _undo_redo.commit_action()
+
+ return {
+ "data": {
+ "path": node_path,
+ "removed_script": old_script.resource_path if old_script.resource_path else "(inline)",
+ "undoable": true,
+ }
+ }
+
+
+func find_symbols(params: Dictionary) -> Dictionary:
+ var path: String = params.get("path", "")
+
+ var path_err = McpPathValidator.path_error(path, "path")
+ if path_err != null:
+ return path_err
+
+ if not FileAccess.file_exists(path):
+ return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "File not found: %s" % path)
+
+ var file := FileAccess.open(path, FileAccess.READ)
+ if file == null:
+ return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to open file: %s" % path)
+
+ var content := file.get_as_text()
+ file.close()
+
+ var functions: Array[Dictionary] = []
+ var signals_list: Array[String] = []
+ var exports: Array[Dictionary] = []
+ var class_name_str := ""
+ var extends_str := ""
+
+ var lines := content.split("\n")
+ for i in lines.size():
+ var line := lines[i].strip_edges()
+
+ # class_name — same cut logic as _extract_class_name so the
+ # `extends Bar` / icon-form tails don't leak into the symbol name.
+ if line.begins_with("class_name "):
+ var cn_rest := line.substr(11).strip_edges()
+ var cn_cut := cn_rest.length()
+ for ci in cn_rest.length():
+ var cn_ch := cn_rest[ci]
+ if cn_ch == " " or cn_ch == "\t" or cn_ch == ",":
+ cn_cut = ci
+ break
+ class_name_str = cn_rest.substr(0, cn_cut)
+
+ # extends
+ if line.begins_with("extends "):
+ extends_str = line.substr(8).strip_edges()
+
+ # signal
+ if line.begins_with("signal "):
+ var sig_text := line.substr(7).strip_edges()
+ # Strip any parameters for the name
+ var paren_idx := sig_text.find("(")
+ if paren_idx >= 0:
+ signals_list.append(sig_text.substr(0, paren_idx).strip_edges())
+ else:
+ signals_list.append(sig_text)
+
+ # func (including `static func` — strip the leading `static ` first)
+ var func_line := line.substr(7).strip_edges() if line.begins_with("static func ") else line
+ if func_line.begins_with("func "):
+ var func_text := func_line.substr(5).strip_edges()
+ var paren_idx := func_text.find("(")
+ if paren_idx >= 0:
+ functions.append({
+ "name": func_text.substr(0, paren_idx).strip_edges(),
+ "line": i + 1,
+ })
+
+ # @export
+ if line.begins_with("@export"):
+ # Next non-empty line should have the var declaration
+ # But often export and var are on the same logical flow
+ # Try to find "var" on the same line or the next line
+ var var_line := line
+ if var_line.find("var ") == -1 and i + 1 < lines.size():
+ var_line = lines[i + 1].strip_edges()
+ var var_idx := var_line.find("var ")
+ if var_idx >= 0:
+ var rest := var_line.substr(var_idx + 4).strip_edges()
+ # Extract variable name (up to : or = or end)
+ var end_idx := rest.length()
+ for ch_idx in rest.length():
+ if rest[ch_idx] == ":" or rest[ch_idx] == "=" or rest[ch_idx] == " ":
+ end_idx = ch_idx
+ break
+ exports.append({
+ "name": rest.substr(0, end_idx),
+ "line": i + 1,
+ })
+
+ return {
+ "data": {
+ "path": path,
+ "class_name": class_name_str,
+ "extends": extends_str,
+ "functions": functions,
+ "signals": signals_list,
+ "exports": exports,
+ "function_count": functions.size(),
+ "signal_count": signals_list.size(),
+ "export_count": exports.size(),
+ }
+ }
diff --git a/addons/godot_ai/handlers/script_handler.gd.uid b/addons/godot_ai/handlers/script_handler.gd.uid
new file mode 100644
index 0000000..6be1ac4
--- /dev/null
+++ b/addons/godot_ai/handlers/script_handler.gd.uid
@@ -0,0 +1 @@
+uid://dhub87454jxb3
diff --git a/addons/godot_ai/handlers/signal_handler.gd b/addons/godot_ai/handlers/signal_handler.gd
new file mode 100644
index 0000000..76656f0
--- /dev/null
+++ b/addons/godot_ai/handlers/signal_handler.gd
@@ -0,0 +1,274 @@
+@tool
+extends "res://addons/godot_ai/handlers/command_handler.gd"
+
+const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
+
+## Handles signal listing, connecting, and disconnecting on scene nodes.
+
+var _undo_redo: EditorUndoRedoManager
+
+
+func _init(undo_redo: EditorUndoRedoManager) -> void:
+ _undo_redo = undo_redo
+
+
+func list_signals(params: Dictionary) -> Dictionary:
+ var path_value: Variant = params.get("path", "")
+ var path_type_err = McpParamValidators.require_string("path", path_value)
+ if path_type_err != null:
+ return path_type_err
+ ## String(...) conversion: require_string accepts StringName too, and
+ ## a bare typed assignment from StringName would defeat the guard.
+ var path: String = String(path_value)
+ if path.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: path")
+
+ var _resolved := McpNodeValidator.resolve_or_error(path, "path")
+ if _resolved.has("error"):
+ return _resolved
+ var node: Node = _resolved.node
+ var scene_root: Node = _resolved.scene_root
+
+ ## Default: hide editor-internal connections (SceneTreeEditor observers
+ ## live on every scene node and would otherwise dominate the response).
+ ## Pass include_editor=true to see them. See #213.
+ var include_editor: bool = params.get("include_editor", false)
+
+ var signals: Array[Dictionary] = []
+ for sig in node.get_signal_list():
+ var args: Array[Dictionary] = []
+ for arg in sig.get("args", []):
+ args.append({"name": arg.get("name", ""), "type": type_string(arg.get("type", 0))})
+ signals.append({
+ "name": sig.get("name", ""),
+ "args": args,
+ })
+
+ var connections: Array[Dictionary] = []
+ var editor_connection_count := 0
+ for sig in signals:
+ for conn in node.get_signal_connection_list(sig.name):
+ var callable: Callable = conn.get("callable", Callable())
+ var target := callable.get_object()
+ if target == null:
+ continue # skip connections to freed objects
+ if not include_editor and _is_editor_internal_target(target, scene_root):
+ editor_connection_count += 1
+ continue
+ connections.append({
+ "signal": sig.name,
+ "target": _format_target_path(target, scene_root),
+ "method": callable.get_method(),
+ })
+
+ return {
+ "data": {
+ "path": McpScenePath.from_node(node, scene_root),
+ "signals": signals,
+ "signal_count": signals.size(),
+ "connections": connections,
+ "connection_count": connections.size(),
+ "editor_connection_count": editor_connection_count,
+ }
+ }
+
+
+## A target is "editor-internal" when it's a Node sitting outside the edited
+## scene tree AND not anywhere under a declared autoload — typical case is
+## the SceneTreeEditor dock listening for visibility/script/state changes on
+## every scene node. Connections to autoloads (declared under ``autoload/*``
+## in ProjectSettings) are user-authored even though they live under
+## ``/root/`` rather than under the edited scene root, so the autoload
+## root *and* any descendant of it stay visible. Non-Node targets
+## (anonymous Callables, RefCounted listeners etc.) also stay visible — we
+## can't reliably classify them.
+func _is_editor_internal_target(target: Object, scene_root: Node) -> bool:
+ if not (target is Node):
+ return false
+ var node_target: Node = target
+ if node_target == scene_root:
+ return false
+ if scene_root.is_ancestor_of(node_target):
+ return false
+ if _is_under_autoload(node_target):
+ return false
+ return true
+
+
+## True if `node` is a declared autoload root or sits anywhere under one.
+## When the node is in the SceneTree we read its absolute path
+## (``/root//...``) and check the first segment after ``/root/``;
+## this covers connections to deep descendants of editor-instanced
+## autoloads (e.g. ``/root/MyAutoload/Foo/Bar``). When the node isn't in
+## the tree (test fixtures often construct nodes in isolation), we walk
+## the parent chain and match each ancestor's ``name`` against the
+## autoload key as a best-effort fallback.
+static func _is_under_autoload(node: Node) -> bool:
+ if node.is_inside_tree():
+ var path := str(node.get_path())
+ if not path.begins_with("/root/"):
+ return false
+ var first_segment := path.substr(6).split("/", true, 1)[0]
+ return ProjectSettings.has_setting("autoload/" + first_segment)
+ var cursor: Node = node
+ while cursor != null:
+ if ProjectSettings.has_setting("autoload/" + str(cursor.name)):
+ return true
+ cursor = cursor.get_parent()
+ return false
+
+
+## Serialize a connection's target path. Descendants of (or equal to) the
+## edited scene root render as the usual scene-relative form
+## (``/Main/Camera3D``). Non-descendants — autoload subtrees in particular
+## — render as their canonical absolute SceneTree path
+## (``/root/MyAutoload/Child``) instead of a scene-relative path full of
+## ``..`` segments, which agents can't navigate back to. Non-Node targets
+## (anonymous Callables, etc.) fall back to their string representation.
+static func _format_target_path(target: Object, scene_root: Node) -> String:
+ if not (target is Node):
+ return str(target)
+ var node_target: Node = target
+ if node_target == scene_root or scene_root.is_ancestor_of(node_target):
+ return McpScenePath.from_node(node_target, scene_root)
+ if node_target.is_inside_tree():
+ return str(node_target.get_path())
+ return McpScenePath.from_node(node_target, scene_root)
+
+
+func connect_signal(params: Dictionary) -> Dictionary:
+ var resolved := _resolve_signal_params(params)
+ if resolved.has("error"):
+ return resolved
+
+ var source: Node = resolved.source
+ var target: Node = resolved.target
+ var signal_name: String = resolved.signal_name
+ var method: String = resolved.method
+ var scene_root: Node = resolved.scene_root
+
+ if not source.has_signal(signal_name):
+ return ErrorCodes.make(ErrorCodes.PROPERTY_NOT_ON_CLASS, "Signal '%s' not found on %s" % [signal_name, params.path])
+
+ if not target.has_method(method):
+ return ErrorCodes.make(ErrorCodes.PROPERTY_NOT_ON_CLASS, "Method '%s' not found on %s" % [method, params.target])
+
+ var callable := Callable(target, method)
+ if source.is_connected(signal_name, callable):
+ return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "Signal '%s' already connected to %s.%s" % [signal_name, params.target, method])
+
+ _undo_redo.create_action("MCP: Connect signal %s" % signal_name)
+ _undo_redo.add_do_method(source, "connect", signal_name, callable, Object.CONNECT_PERSIST)
+ _undo_redo.add_undo_method(source, "disconnect", signal_name, callable)
+ _undo_redo.commit_action()
+
+ return {"data": _signal_response(source, signal_name, target, method, scene_root)}
+
+
+func disconnect_signal(params: Dictionary) -> Dictionary:
+ var resolved := _resolve_signal_params(params)
+ if resolved.has("error"):
+ return resolved
+
+ var source: Node = resolved.source
+ var target: Node = resolved.target
+ var signal_name: String = resolved.signal_name
+ var method: String = resolved.method
+ var scene_root: Node = resolved.scene_root
+
+ var callable := Callable(target, method)
+ if not source.is_connected(signal_name, callable):
+ return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "Signal '%s' is not connected to %s.%s" % [signal_name, params.target, method])
+
+ # Capture the connection's current flags so undo restores it exactly as it
+ # was, not unconditionally as CONNECT_PERSIST. Hardcoding PERSIST here would
+ # silently promote a runtime-only connection into one that serializes on the
+ # next save. (The connection still exists at this point — checked above.)
+ var reconnect_flags := 0
+ for conn in source.get_signal_connection_list(signal_name):
+ if conn.get("callable", Callable()) == callable:
+ reconnect_flags = int(conn.get("flags", 0))
+ break
+
+ _undo_redo.create_action("MCP: Disconnect signal %s" % signal_name)
+ _undo_redo.add_do_method(source, "disconnect", signal_name, callable)
+ _undo_redo.add_undo_method(source, "connect", signal_name, callable, reconnect_flags)
+ _undo_redo.commit_action()
+
+ return {"data": _signal_response(source, signal_name, target, method, scene_root)}
+
+
+func _resolve_signal_params(params: Dictionary) -> Dictionary:
+ for key in ["path", "signal", "target", "method"]:
+ ## Type-check before calling .is_empty(): a non-string value (e.g. an
+ ## int or dict) has no is_empty() and would crash the handler, which
+ ## the dispatcher only reports as an opaque "malformed result" (#210).
+ var value = params.get(key, "")
+ var type_err = McpParamValidators.require_string(key, value)
+ if type_err != null:
+ return type_err
+ if value.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: %s" % key)
+
+ var _scene_check := McpNodeValidator.require_scene_or_error()
+ if _scene_check.has("error"):
+ return _scene_check
+ var scene_root: Node = _scene_check.scene_root
+
+ var source_result := _resolve_node_or_autoload(params.path, scene_root, "Source")
+ if source_result.has("error"):
+ return source_result
+ var source: Node = source_result.node
+
+ var target_result := _resolve_node_or_autoload(params.target, scene_root, "Target")
+ if target_result.has("error"):
+ return target_result
+ var target: Node = target_result.node
+
+ return {
+ "source": source,
+ "target": target,
+ "signal_name": params.signal,
+ "method": params.method,
+ "scene_root": scene_root,
+ }
+
+
+## Resolve a path to a Node, with three distinct outcomes:
+## 1. Found in the edited scene tree → returns {node}
+## 2. Declared as an autoload AND instantiated at edit time → returns {node}
+## 3. Declared as an autoload but NOT instantiated at edit time → returns
+## INVALID_PARAMS with guidance. Most autoloads are runtime-only, so a
+## silent "not found" hides the real reason the connection can't be made.
+## 4. Not in scene and not a declared autoload → returns INVALID_PARAMS.
+func _resolve_node_or_autoload(path: String, scene_root: Node, role: String) -> Dictionary:
+ var node := McpScenePath.resolve(path, scene_root)
+ if node != null:
+ return {"node": node}
+
+ var name := path.trim_prefix("/")
+ if ProjectSettings.has_setting("autoload/" + name):
+ # Autoload is declared — see if the editor has it instanced.
+ var tree := Engine.get_main_loop()
+ if tree is SceneTree:
+ var live := (tree as SceneTree).root.get_node_or_null(name)
+ if live != null:
+ return {"node": live}
+ return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
+ "%s '%s' is a declared autoload but isn't instantiated in the editor. " % [role, name] +
+ "Most autoloads are runtime-only; edit-time signal connection isn't supported for them. " +
+ "Connect it from a script attached to the scene using @onready + connect(), " +
+ "or enable editor-instancing for this autoload in Project Settings > Autoload.")
+
+ return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND,
+ "%s node not found: %s (not in scene tree or autoloads)" % [role, path])
+
+
+func _signal_response(source: Node, signal_name: String, target: Node, method: String, scene_root: Node) -> Dictionary:
+ return {
+ "source": McpScenePath.from_node(source, scene_root),
+ "signal": signal_name,
+ "target": McpScenePath.from_node(target, scene_root),
+ "method": method,
+ "undoable": true,
+ }
diff --git a/addons/godot_ai/handlers/signal_handler.gd.uid b/addons/godot_ai/handlers/signal_handler.gd.uid
new file mode 100644
index 0000000..d95e1d3
--- /dev/null
+++ b/addons/godot_ai/handlers/signal_handler.gd.uid
@@ -0,0 +1 @@
+uid://b4n8byjeqeddm
diff --git a/addons/godot_ai/handlers/test_handler.gd b/addons/godot_ai/handlers/test_handler.gd
new file mode 100644
index 0000000..d04f47c
--- /dev/null
+++ b/addons/godot_ai/handlers/test_handler.gd
@@ -0,0 +1,373 @@
+@tool
+extends "res://addons/godot_ai/handlers/command_handler.gd"
+
+## Discovers and runs McpTestSuite scripts from res://tests/.
+## Exposes run_tests and get_test_results as MCP commands.
+##
+## Live MCP runs service the WebSocket transport between tests
+## (McpConnection.service_transport_during_exclusive_run) so a long suite
+## can no longer starve the server's keepalive, and abort at a per-call
+## ceiling derived from the server-provided time budget. Direct callers,
+## unit-test fixtures, and batch contexts (no request id / no connection)
+## keep the legacy fully-synchronous behavior with no ceiling.
+## See docs/test-run-transport-starvation-plan.md.
+
+const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
+
+const CACHE_WARNING := (
+ "Preloaded GDScript dependencies may be stale after source edits. "
+ + "Restart the editor before treating this run as validation of dependency changes. "
+ + "ResourceLoader cache modes do not invalidate GDScript's preload cache."
+)
+
+## Clamp bounds for the server-provided ``timeout_budget_sec`` param. The
+## floor is purely defensive (a malformed or buggy server value must not
+## abort every run instantly); the param is not user-facing.
+const BUDGET_MIN_SEC := 30.0
+const BUDGET_MAX_SEC := 3600.0
+## Conservative default when the server sent no (or an invalid) budget: an
+## old server's own test_run timeout is 120s, and the plugin must abort
+## and reply before that future expires.
+const BUDGET_DEFAULT_SEC := 110.0
+## Abort this long before the server would time the call out, so the
+## partial-results reply beats the server-side timeout.
+const CEILING_MARGIN_SEC := 10.0
+
+var _runner: McpTestRunner
+var _undo_redo: EditorUndoRedoManager
+var _log_buffer: McpLogBuffer
+## Live plugin dispatcher, exposed to suites via ctx so tests can prove the
+## lazy handler registrations (#736) materialize with their real ctor args.
+## Optional third arg keeps old two-arg fixtures working; untyped because
+## the dispatcher constructs this handler (avoids a load-time type cycle).
+var _dispatcher
+## Live connection for exclusive-run transport servicing. Null in unit-test
+## fixtures and batch contexts, which keep the legacy synchronous path.
+var _connection: McpConnection
+
+
+func _init(
+ undo_redo: EditorUndoRedoManager,
+ log_buffer: McpLogBuffer,
+ dispatcher = null,
+ connection: McpConnection = null,
+) -> void:
+ _runner = McpTestRunner.new()
+ _undo_redo = undo_redo
+ _log_buffer = log_buffer
+ _dispatcher = dispatcher
+ _connection = connection
+
+
+func run_tests(params: Dictionary) -> Dictionary:
+ var suite_filter: String = params.get("suite", "")
+ var test_filter: String = params.get("test_name", "")
+ var exclude_test_filter: String = params.get("exclude_test_name", "")
+ var verbose: bool = params.get("verbose", false)
+
+ var request_id: String = params.get("_request_id", "")
+ var live := _connection != null and not request_id.is_empty()
+ var service_cb := Callable()
+ var deadline_ticks_ms := 0
+ var budget_sec := 0.0
+ var started_ms := Time.get_ticks_msec()
+ var run_state := {}
+ if live:
+ budget_sec = _validated_budget_sec(params)
+ service_cb = Callable(_connection, "service_transport_during_exclusive_run")
+ deadline_ticks_ms = started_ms + int((budget_sec - CEILING_MARGIN_SEC) * 1000.0)
+
+ ## Clear the previous run's results BEFORE discovery so an abort at any
+ ## later point can never expose a stale prior run via get_test_results.
+ _runner.clear()
+
+ var discovery := _discover_suites(service_cb, deadline_ticks_ms, run_state)
+ var discovery_outcome: String = discovery.get("outcome", "")
+ if not discovery_outcome.is_empty():
+ ## Aborted during discovery: no suite has begun, so there is no
+ ## suite teardown to run. Same outcome mapping as the run itself.
+ var empty_results: Dictionary = _runner.get_results(verbose)
+ if not discovery.errors.is_empty():
+ empty_results["load_errors"] = discovery.errors
+ return _map_outcome(
+ discovery_outcome, "discovery", empty_results, 0, started_ms, budget_sec
+ )
+
+ var suites: Array = discovery.suites
+ ## #882: an explicit `suite` filter that matches nothing must say so, not
+ ## return {"total": 0} — a caller cannot otherwise distinguish "suite has
+ ## no tests" from "suite was never registered" (truncated discovery).
+ ## Checked BEFORE the no-suites branch so an empty discovery still names
+ ## the unmatched filter (with suites_available: []).
+ var unknown := unknown_suite_error(suite_filter, suites)
+ if not unknown.is_empty():
+ if not discovery.errors.is_empty():
+ unknown["error"]["data"]["load_errors"] = discovery.errors
+ return unknown
+
+ if suites.is_empty():
+ var msg := "No test suites found in res://tests/"
+ if not discovery.errors.is_empty():
+ msg += " (%d script(s) failed to load: %s)" % [
+ discovery.errors.size(),
+ ", ".join(discovery.errors),
+ ]
+ var no_suites := {"error": msg, "total": 0, "load_errors": discovery.errors, "cache_warning": CACHE_WARNING}
+ ## Keep the edited_scene annotation on the no-suites error payload too,
+ ## so the response contract is consistent across every return path.
+ _annotate_edited_scene(no_suites)
+ return {"data": no_suites}
+
+ var ctx := {
+ "undo_redo": _undo_redo,
+ "log_buffer": _log_buffer,
+ "dispatcher": _dispatcher,
+ }
+
+ var run: Dictionary = _runner.run_suites_serviced(
+ suites, suite_filter, test_filter, ctx, verbose, exclude_test_filter,
+ service_cb, deadline_ticks_ms, run_state
+ )
+ var results: Dictionary = run["results"]
+ if not discovery.errors.is_empty():
+ results["load_errors"] = discovery.errors
+ return _map_outcome(
+ run["outcome"], "run", results, run["tests_not_run"], started_ms, budget_sec
+ )
+
+
+## Empty when `suite_filter` is empty or names a discovered suite; otherwise
+## the INVALID_PARAMS protocol error run_tests returns (same ErrorCodes.make
+## shape as the paused/timeout aborts — never an error inside a success
+## envelope). Static + pure so the suite can pin it without calling
+## run_tests() end-to-end (which would recursively re-run the whole corpus
+## from inside itself — see test_serviced_test_run.gd).
+static func unknown_suite_error(suite_filter: String, suites: Array) -> Dictionary:
+ if suite_filter.is_empty():
+ return {}
+ var names := PackedStringArray()
+ for suite: McpTestSuite in suites:
+ names.append(suite.suite_name())
+ if names.has(suite_filter):
+ return {}
+ var err := ErrorCodes.make(
+ ErrorCodes.INVALID_PARAMS,
+ (
+ "No suite named '%s' is registered (%d discovered)."
+ + " If it should exist, discovery may be truncated —"
+ + " re-import and restart the editor (#882)."
+ ) % [suite_filter, names.size()],
+ )
+ err["error"]["data"] = {
+ "suite": suite_filter,
+ "suites_available": Array(names),
+ "cache_warning": CACHE_WARNING,
+ }
+ return err
+
+
+## Map a runner/discovery outcome onto the response envelope. Ownership is
+## deliberately here, not in the runner: the runner reports WHAT happened,
+## the handler decides how it goes over the wire (plan D2).
+func _map_outcome(
+ outcome: String,
+ phase: String,
+ results: Dictionary,
+ tests_not_run: int,
+ started_ms: int,
+ budget_sec: float,
+) -> Dictionary:
+ results["cache_warning"] = CACHE_WARNING
+ var elapsed_ms := Time.get_ticks_msec() - started_ms
+ match outcome:
+ "completed":
+ _annotate_edited_scene(results)
+ return {"data": results}
+ "transport_lost":
+ ## The peer is gone (or flood-closed); the send will fail against
+ ## the dead socket regardless, but a sync handler must return an
+ ## envelope. Partials stay retrievable via get_test_results after
+ ## the plugin reconnects.
+ results["aborted"] = "transport_lost"
+ results["tests_not_run"] = tests_not_run
+ _annotate_edited_scene(results)
+ return {"data": results}
+ "paused":
+ var depth := _connection.pause_depth() if _connection != null else 0
+ if _log_buffer != null:
+ _log_buffer.log(
+ "[error] test run aborted in %s: transport paused at checkpoint (depth %d)"
+ % [phase, depth]
+ )
+ var paused_err := ErrorCodes.make(
+ ErrorCodes.INTERNAL_ERROR,
+ (
+ "Test run aborted: the MCP transport was paused at a between-test "
+ + "checkpoint (pause depth %d) — a paused transport cannot service "
+ + "the WebSocket heartbeat, so continuing would starve the session. "
+ + "Partial results: test_manage(op=\"results_get\")."
+ ) % depth
+ )
+ paused_err["error"]["data"] = _abort_data(
+ phase, results, tests_not_run, elapsed_ms, budget_sec, {"pause_depth": depth}
+ )
+ return paused_err
+ "timeout":
+ var timeout_err := ErrorCodes.make(
+ ErrorCodes.TEST_RUN_TIMEOUT,
+ (
+ "Test run hit its abort ceiling after %.1fs (budget %.0fs, ceiling = "
+ + "budget - %.0fs): %d passed, %d failed, %d of the selected tests "
+ + "never ran. Narrow the run with suite=/test_name= filters, or fetch "
+ + "the partial results with test_manage(op=\"results_get\")."
+ ) % [
+ elapsed_ms / 1000.0, budget_sec, CEILING_MARGIN_SEC,
+ int(results.get("passed", 0)), int(results.get("failed", 0)),
+ tests_not_run,
+ ]
+ )
+ timeout_err["error"]["data"] = _abort_data(
+ phase, results, tests_not_run, elapsed_ms, budget_sec, {}
+ )
+ return timeout_err
+ ## Unknown outcome is a runner bug — surface it loudly.
+ return ErrorCodes.make(
+ ErrorCodes.INTERNAL_ERROR, "Unknown test run outcome '%s'" % outcome
+ )
+
+
+func _abort_data(
+ phase: String,
+ results: Dictionary,
+ tests_not_run: int,
+ elapsed_ms: int,
+ budget_sec: float,
+ extra: Dictionary,
+) -> Dictionary:
+ var data := {
+ "phase": phase,
+ "cache_warning": CACHE_WARNING,
+ "elapsed_ms": elapsed_ms,
+ "budget_sec": budget_sec,
+ "passed": int(results.get("passed", 0)),
+ "failed": int(results.get("failed", 0)),
+ "skipped": int(results.get("skipped", 0)),
+ "total": int(results.get("total", 0)),
+ "tests_not_run": tests_not_run,
+ }
+ data.merge(extra)
+ return data
+
+
+## Strict validation of the server-provided per-call budget: numeric,
+## finite, positive, then clamped to [BUDGET_MIN_SEC, BUDGET_MAX_SEC].
+## Everything else (missing, wrong type, NaN/inf, non-positive) falls back
+## to BUDGET_DEFAULT_SEC. typeof() so bool never sneaks through as int.
+func _validated_budget_sec(params: Dictionary) -> float:
+ var raw: Variant = params.get("timeout_budget_sec", null)
+ var t := typeof(raw)
+ if t == TYPE_FLOAT or t == TYPE_INT:
+ var v := float(raw)
+ if is_finite(v) and v > 0.0:
+ return clampf(v, BUDGET_MIN_SEC, BUDGET_MAX_SEC)
+ return BUDGET_DEFAULT_SEC
+
+
+## Many suites assume the project's main scene is the edited scene (they read
+## /Main/... nodes directly). Running with another scene open produces a flood
+## of phantom failures that look like real regressions. Surface the edited
+## scene and a warning when it differs from run/main_scene so the failures are
+## attributable at a glance instead of costing a debugging round (#635).
+func _annotate_edited_scene(results: Dictionary) -> void:
+ var scene_root := EditorInterface.get_edited_scene_root()
+ var edited := scene_root.scene_file_path if scene_root else ""
+ results["edited_scene"] = edited
+ var main_scene := str(ProjectSettings.get_setting("application/run/main_scene", ""))
+ if main_scene.is_empty() or edited == main_scene:
+ return
+ if int(results.get("failed", 0)) <= 0:
+ return
+ results["scene_warning"] = (
+ "Edited scene is '%s' but the project main scene is '%s'. Many suites "
+ % [edited if not edited.is_empty() else "", main_scene]
+ + "assume the main scene is open and will report phantom failures "
+ + "otherwise. If these failures are unexpected, scene_open('%s') and re-run." % main_scene
+ )
+
+
+func get_test_results(params: Dictionary) -> Dictionary:
+ var verbose: bool = params.get("verbose", false)
+ var results := _runner.get_results(verbose)
+ results["cache_warning"] = CACHE_WARNING
+ return {"data": results}
+
+
+## Returns {"suites": Array, "errors": Array[String], "outcome": String}.
+## Resilient: a broken script doesn't kill discovery of the rest. A
+## non-empty outcome ("timeout" / "transport_lost" / "paused") means a
+## between-load checkpoint aborted discovery — script loading is itself an
+## atomic phase, and a directory of heavy scripts must neither starve the
+## heartbeat nor escape the run budget.
+func _discover_suites(
+ service_cb: Callable = Callable(),
+ deadline_ticks_ms: int = 0,
+ run_state: Dictionary = {},
+) -> Dictionary:
+ var suites := []
+ var errors: Array[String] = []
+ var dir := DirAccess.open("res://tests")
+ if dir == null:
+ return {
+ "suites": suites,
+ "errors": ["DirAccess.open('res://tests') returned null — directory may not exist"],
+ "outcome": "",
+ }
+
+ dir.list_dir_begin()
+ var file_name := dir.get_next()
+ while not file_name.is_empty():
+ if file_name.begins_with("test_") and file_name.ends_with(".gd"):
+ var stop := _discovery_checkpoint(service_cb, deadline_ticks_ms, run_state)
+ if not stop.is_empty():
+ return {"suites": suites, "errors": errors, "outcome": stop}
+ var path := "res://tests/" + file_name
+ var script = ResourceLoader.load(path, "", ResourceLoader.CACHE_MODE_IGNORE)
+ if script == null:
+ errors.append("%s (load failed — check for parse errors or duplicate methods)" % file_name)
+ elif script.can_instantiate():
+ var instance = script.new()
+ if instance is McpTestSuite:
+ suites.append(instance)
+ else:
+ errors.append("%s (not a McpTestSuite subclass)" % file_name)
+ else:
+ ## Name the cause: a fresh reload prints the parse or compile error
+ ## the cached load swallowed and returns its code, so a CI log says
+ ## more than "abstract or broken".
+ var reload_error: Error = script.reload(true)
+ var base: Variant = script.get_base_script()
+ errors.append(
+ "%s (cannot instantiate — abstract or broken; reload=%s, base=%s, valid=%s)"
+ % [
+ file_name,
+ error_string(reload_error),
+ str(base.resource_path) if base != null else "none",
+ str(script.can_instantiate()),
+ ]
+ )
+ file_name = dir.get_next()
+
+ ## Sort by suite name for deterministic order.
+ suites.sort_custom(func(a, b) -> bool:
+ return a.suite_name() < b.suite_name()
+ )
+ return {"suites": suites, "errors": errors, "outcome": ""}
+
+
+## Discovery-phase twin of McpTestRunner._checkpoint. Both delegate to the
+## shared McpConnection.exclusive_run_checkpoint so the outcome mapping
+## cannot drift between the discovery and between-test paths.
+func _discovery_checkpoint(
+ service_cb: Callable, deadline_ticks_ms: int, run_state: Dictionary
+) -> String:
+ return McpConnection.exclusive_run_checkpoint(service_cb, deadline_ticks_ms, run_state)
diff --git a/addons/godot_ai/handlers/test_handler.gd.uid b/addons/godot_ai/handlers/test_handler.gd.uid
new file mode 100644
index 0000000..e56fce1
--- /dev/null
+++ b/addons/godot_ai/handlers/test_handler.gd.uid
@@ -0,0 +1 @@
+uid://bfg3c6iinhwmx
diff --git a/addons/godot_ai/handlers/texture_handler.gd b/addons/godot_ai/handlers/texture_handler.gd
new file mode 100644
index 0000000..5668890
--- /dev/null
+++ b/addons/godot_ai/handlers/texture_handler.gd
@@ -0,0 +1,198 @@
+@tool
+extends "res://addons/godot_ai/handlers/command_handler.gd"
+
+const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
+
+## Creates procedural textures — GradientTexture2D (wrapping a Gradient)
+## and NoiseTexture2D (wrapping a FastNoiseLite). Assigns to a node slot
+## (undoable, bundles sub-resources) or saves to a .tres file.
+
+const NodeHandler := preload("res://addons/godot_ai/handlers/node_handler.gd")
+
+var _undo_redo: EditorUndoRedoManager
+var _connection: McpConnection
+
+
+func _init(undo_redo: EditorUndoRedoManager, connection: McpConnection = null) -> void:
+ _undo_redo = undo_redo
+ _connection = connection
+
+
+const _FILL_MODES := {
+ "linear": GradientTexture2D.FILL_LINEAR,
+ "radial": GradientTexture2D.FILL_RADIAL,
+ "square": GradientTexture2D.FILL_SQUARE,
+}
+
+const _NOISE_TYPES := {
+ "simplex": FastNoiseLite.TYPE_SIMPLEX,
+ "simplex_smooth": FastNoiseLite.TYPE_SIMPLEX_SMOOTH,
+ "perlin": FastNoiseLite.TYPE_PERLIN,
+ "cellular": FastNoiseLite.TYPE_CELLULAR,
+ "value": FastNoiseLite.TYPE_VALUE,
+ "value_cubic": FastNoiseLite.TYPE_VALUE_CUBIC,
+}
+
+
+# ============================================================================
+# gradient_texture_create
+# ============================================================================
+
+func create_gradient_texture(params: Dictionary) -> Dictionary:
+ var stops: Array = params.get("stops", [])
+ var width: int = params.get("width", 256)
+ var height: int = params.get("height", 1)
+ var fill: String = params.get("fill", "linear")
+
+ if stops.size() < 2:
+ return ErrorCodes.make(
+ ErrorCodes.VALUE_OUT_OF_RANGE,
+ "gradient_texture_create requires at least 2 stops, got %d" % stops.size()
+ )
+ if not _FILL_MODES.has(fill):
+ return ErrorCodes.make(
+ ErrorCodes.VALUE_OUT_OF_RANGE,
+ "Invalid fill '%s'. Valid: %s" % [fill, ", ".join(_FILL_MODES.keys())]
+ )
+
+ var home_err := McpResourceIO.validate_home(params)
+ if home_err != null:
+ return home_err
+
+ var gradient := Gradient.new()
+ var offsets := PackedFloat32Array()
+ var colors := PackedColorArray()
+ for i in range(stops.size()):
+ var stop = stops[i]
+ if not stop is Dictionary:
+ return ErrorCodes.make(
+ ErrorCodes.WRONG_TYPE,
+ "stops[%d] must be a dict with 'offset' and 'color' keys" % i
+ )
+ if not stop.has("offset") or not stop.has("color"):
+ return ErrorCodes.make(
+ ErrorCodes.INVALID_PARAMS,
+ "stops[%d] missing 'offset' or 'color' key" % i
+ )
+ offsets.append(float(stop["offset"]))
+ var color_value = NodeHandler._coerce_value(stop["color"], TYPE_COLOR)
+ var color_err := NodeHandler._check_coerced(color_value, TYPE_COLOR, "stops[%d].color" % i)
+ if color_err != null:
+ return color_err
+ colors.append(color_value)
+ gradient.offsets = offsets
+ gradient.colors = colors
+
+ var tex := GradientTexture2D.new()
+ tex.gradient = gradient
+ tex.width = width
+ tex.height = height
+ tex.fill = _FILL_MODES[fill]
+
+ return _finalize(tex, [gradient], params, "Gradient texture", {
+ "texture_class": "GradientTexture2D",
+ "gradient_class": "Gradient",
+ "stop_count": stops.size(),
+ "fill": fill,
+ })
+
+
+# ============================================================================
+# noise_texture_create
+# ============================================================================
+
+func create_noise_texture(params: Dictionary) -> Dictionary:
+ var noise_type: String = params.get("noise_type", "simplex_smooth")
+ var width: int = params.get("width", 512)
+ var height: int = params.get("height", 512)
+ var frequency: float = params.get("frequency", 0.01)
+ var seed_value: int = params.get("seed", 0)
+ var fractal_octaves: int = params.get("fractal_octaves", 0) # 0 = leave default
+
+ if not _NOISE_TYPES.has(noise_type):
+ return ErrorCodes.make(
+ ErrorCodes.VALUE_OUT_OF_RANGE,
+ "Invalid noise_type '%s'. Valid: %s" % [noise_type, ", ".join(_NOISE_TYPES.keys())]
+ )
+
+ var home_err := McpResourceIO.validate_home(params)
+ if home_err != null:
+ return home_err
+
+ var noise := FastNoiseLite.new()
+ noise.noise_type = _NOISE_TYPES[noise_type]
+ noise.frequency = frequency
+ noise.seed = seed_value
+ if fractal_octaves > 0:
+ noise.fractal_octaves = fractal_octaves
+
+ var tex := NoiseTexture2D.new()
+ tex.noise = noise
+ tex.width = width
+ tex.height = height
+
+ return _finalize(tex, [noise], params, "Noise texture", {
+ "texture_class": "NoiseTexture2D",
+ "noise_class": "FastNoiseLite",
+ "noise_type": noise_type,
+ })
+
+
+# ============================================================================
+# shared helpers
+# ============================================================================
+
+func _finalize(tex: Resource, sub_resources: Array, params: Dictionary, label: String, extra: Dictionary) -> Dictionary:
+ var node_path: String = params.get("path", "")
+ var property: String = params.get("property", "")
+ var resource_path: String = params.get("resource_path", "")
+ var overwrite: bool = params.get("overwrite", false)
+
+ if not resource_path.is_empty():
+ return McpResourceIO.save_to_disk(tex, resource_path, overwrite, label, extra, _connection)
+ return _assign_texture(tex, sub_resources, node_path, property, label, extra)
+
+
+func _assign_texture(tex: Resource, sub_resources: Array, node_path: String, property: String, label: String, extra: Dictionary) -> Dictionary:
+ var _resolved := McpNodeValidator.resolve_or_error(node_path, "node_path")
+ if _resolved.has("error"):
+ return _resolved
+ var node: Node = _resolved.node
+ var _scene_root: Node = _resolved.scene_root
+
+ var found := false
+ var prop_type: int = TYPE_NIL
+ for prop in node.get_property_list():
+ if prop.name == property:
+ found = true
+ prop_type = prop.get("type", TYPE_NIL)
+ break
+ if not found:
+ return ErrorCodes.make(
+ ErrorCodes.PROPERTY_NOT_ON_CLASS,
+ McpPropertyErrors.build_message(node, property)
+ )
+ if prop_type != TYPE_NIL and prop_type != TYPE_OBJECT:
+ return ErrorCodes.make(
+ ErrorCodes.PROPERTY_NOT_ON_CLASS,
+ "Property '%s' on %s is not an Object slot" % [property, node.get_class()]
+ )
+
+ var old_value = node.get(property)
+
+ _undo_redo.create_action("MCP: Create %s for %s.%s" % [label, node.name, property])
+ _undo_redo.add_do_property(node, property, tex)
+ _undo_redo.add_undo_property(node, property, old_value)
+ _undo_redo.add_do_reference(tex)
+ for sub in sub_resources:
+ _undo_redo.add_do_reference(sub)
+ _undo_redo.commit_action()
+
+ var data := {
+ "path": node_path,
+ "property": property,
+ "undoable": true,
+ }
+ data.merge(extra)
+ return {"data": data}
+
diff --git a/addons/godot_ai/handlers/texture_handler.gd.uid b/addons/godot_ai/handlers/texture_handler.gd.uid
new file mode 100644
index 0000000..a0a0f73
--- /dev/null
+++ b/addons/godot_ai/handlers/texture_handler.gd.uid
@@ -0,0 +1 @@
+uid://cmloikhre8lhe
diff --git a/addons/godot_ai/handlers/theme_handler.gd b/addons/godot_ai/handlers/theme_handler.gd
new file mode 100644
index 0000000..519989c
--- /dev/null
+++ b/addons/godot_ai/handlers/theme_handler.gd
@@ -0,0 +1,476 @@
+@tool
+extends "res://addons/godot_ai/handlers/command_handler.gd"
+
+const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
+
+## Handles Theme resource authoring: creating, modifying color/constant/font-size/
+## stylebox slots, and applying a theme to a Control subtree.
+##
+## Themes are Godot's equivalent of USS: a Theme holds (class, name) -> value
+## entries (colors, constants, fonts, font_sizes, styleboxes, icons) which
+## cascade down a Control subtree when the theme is assigned at any ancestor.
+## One well-authored theme replaces hundreds of per-node property sets.
+
+const _COLOR_HINT := "expected hex #rrggbb, named color, or {r,g,b,a} dict"
+
+var _undo_redo: EditorUndoRedoManager
+var _connection: McpConnection
+
+
+func _init(undo_redo: EditorUndoRedoManager, connection: McpConnection = null) -> void:
+ _undo_redo = undo_redo
+ _connection = connection
+
+
+# ============================================================================
+# theme_create
+# ============================================================================
+
+func create_theme(params: Dictionary) -> Dictionary:
+ var path: String = params.get("path", "")
+ var overwrite: bool = params.get("overwrite", false)
+
+ var err := _validate_res_path(path, ".tres", "path", true)
+ if err != null:
+ return err
+
+ # Capture whether the file was already there BEFORE the save so we can
+ # report `overwritten` accurately (after save the file always exists).
+ var existed_before := FileAccess.file_exists(path)
+ if existed_before and not overwrite:
+ return ErrorCodes.make(
+ ErrorCodes.INVALID_PARAMS,
+ "Theme already exists at %s (pass overwrite=true to replace)" % path
+ )
+
+ # Ensure parent directory exists. make_dir_recursive is idempotent —
+ # no need to check dir_exists first (avoids TOCTOU race).
+ var dir_path := path.get_base_dir()
+ var mkdir_err := DirAccess.make_dir_recursive_absolute(dir_path)
+ if mkdir_err != OK and mkdir_err != ERR_ALREADY_EXISTS:
+ return ErrorCodes.make(
+ ErrorCodes.INTERNAL_ERROR,
+ "Failed to create directory: %s (error %d)" % [dir_path, mkdir_err]
+ )
+
+ var theme := Theme.new()
+ var save_err := McpResourceIO.guarded_save(theme, path, _connection)
+ if save_err != OK:
+ return ErrorCodes.make(
+ ErrorCodes.INTERNAL_ERROR,
+ "Failed to save theme to %s: %s (error %d)" % [path, error_string(save_err), save_err]
+ )
+
+ # Make sure the editor's filesystem picks up the new file.
+ var efs := EditorInterface.get_resource_filesystem()
+ if efs != null:
+ efs.update_file(path)
+
+ return {
+ "data": {
+ "path": path,
+ "overwritten": existed_before,
+ "undoable": false,
+ "reason": "File creation is persistent; delete the file manually to revert",
+ }
+ }
+
+
+# ============================================================================
+# theme_set_color / theme_set_constant / theme_set_font_size
+# ============================================================================
+
+func set_color(params: Dictionary) -> Dictionary:
+ return _set_scalar(params, "color", func(theme, name, cls): return theme.get_color(name, cls),
+ func(theme, name, cls, val): theme.set_color(name, cls, val),
+ func(theme, name, cls): theme.clear_color(name, cls),
+ func(theme, name, cls): return theme.has_color(name, cls),
+ func(v): return _parse_color(v))
+
+
+# constant / font_size parsers validate before coercing: int("abc")/int({})/int([])
+# all return 0 in GDScript (never null), so a bare `int(v)` would silently store
+# garbage as 0 and report success. Returning null for non-numeric input lets
+# _set_scalar's null guard surface a VALUE_OUT_OF_RANGE error, matching the
+# color path's contract.
+func set_constant(params: Dictionary) -> Dictionary:
+ return _set_scalar(params, "constant", func(theme, name, cls): return theme.get_constant(name, cls),
+ func(theme, name, cls, val): theme.set_constant(name, cls, int(val)),
+ func(theme, name, cls): theme.clear_constant(name, cls),
+ func(theme, name, cls): return theme.has_constant(name, cls),
+ func(v): return int(v) if (v is int or v is float or (v is String and v.is_valid_int())) else null)
+
+
+func set_font_size(params: Dictionary) -> Dictionary:
+ return _set_scalar(params, "font_size", func(theme, name, cls): return theme.get_font_size(name, cls),
+ func(theme, name, cls, val): theme.set_font_size(name, cls, int(val)),
+ func(theme, name, cls): theme.clear_font_size(name, cls),
+ func(theme, name, cls): return theme.has_font_size(name, cls),
+ func(v): return int(v) if (v is int or v is float or (v is String and v.is_valid_int())) else null)
+
+
+# Shared implementation for scalar Theme slots (color, constant, font_size).
+# Captures old value, applies new value, saves to disk, registers undo that
+# restores the old value and saves again.
+func _set_scalar(
+ params: Dictionary,
+ kind: String,
+ getter: Callable,
+ setter: Callable,
+ clearer: Callable,
+ has_fn: Callable,
+ parser: Callable,
+) -> Dictionary:
+ var load_result := _load_theme_from_params(params)
+ if load_result.has("error"):
+ return load_result
+ var theme: Theme = load_result.theme
+ var theme_path: String = load_result.path
+
+ var class_name_param: String = params.get("class_name", "")
+ if class_name_param.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: class_name")
+
+ var name: String = params.get("name", "")
+ if name.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: name")
+
+ if not "value" in params:
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: value")
+
+ var raw_value = params.get("value")
+ if raw_value == null:
+ return ErrorCodes.make(
+ ErrorCodes.VALUE_OUT_OF_RANGE,
+ "Invalid %s value: null (pass a concrete value; use the appropriate clear command to remove a slot)" % kind
+ )
+ var parsed = parser.call(raw_value)
+ if parsed == null:
+ ## color slots want a color hint; constant/font_size are integer slots.
+ var hint := _COLOR_HINT if kind == "color" else "expected an integer"
+ return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
+ "Invalid %s value: %s (%s)" % [kind, raw_value, hint])
+
+ var had_before: bool = has_fn.call(theme, name, class_name_param)
+ var before_value = getter.call(theme, name, class_name_param) if had_before else null
+
+ _undo_redo.create_action("MCP: Theme set %s %s/%s" % [kind, class_name_param, name])
+ _undo_redo.add_do_method(self, "_apply_scalar", theme_path, setter, name, class_name_param, parsed)
+ if had_before:
+ _undo_redo.add_undo_method(self, "_apply_scalar", theme_path, setter, name, class_name_param, before_value)
+ else:
+ _undo_redo.add_undo_method(self, "_clear_scalar", theme_path, clearer, name, class_name_param)
+ _undo_redo.commit_action()
+
+ return {
+ "data": {
+ "path": theme_path,
+ "kind": kind,
+ "class_name": class_name_param,
+ "name": name,
+ "value": _serialize_value(parsed),
+ "previous_value": _serialize_value(before_value) if had_before else null,
+ "undoable": true,
+ }
+ }
+
+
+func _apply_scalar(theme_path: String, setter: Callable, name: String, class_name_param: String, value: Variant) -> void:
+ var theme: Theme = ResourceLoader.load(theme_path)
+ if theme == null:
+ push_warning("MCP: Failed to load theme for undo/redo: %s" % theme_path)
+ return
+ setter.call(theme, name, class_name_param, value)
+ McpResourceIO.guarded_save(theme, theme_path, _connection)
+
+
+func _clear_scalar(theme_path: String, clearer: Callable, name: String, class_name_param: String) -> void:
+ var theme: Theme = ResourceLoader.load(theme_path)
+ if theme == null:
+ push_warning("MCP: Failed to load theme for undo/redo: %s" % theme_path)
+ return
+ clearer.call(theme, name, class_name_param)
+ McpResourceIO.guarded_save(theme, theme_path, _connection)
+
+
+# ============================================================================
+# theme_set_stylebox_flat
+# ============================================================================
+
+## Compose a StyleBoxFlat and assign it to a theme slot.
+##
+## Parameters (beyond theme_path / class_name / name):
+## bg_color (Color, "#rrggbb", "#rrggbbaa", or {r,g,b,a})
+## border_color (Color)
+## border {all|top|bottom|left|right: int} — side keys override `all`
+## corners {all|top_left|top_right|bottom_left|bottom_right: int}
+## margins {all|top|bottom|left|right: float}
+## shadow {color, size: int, offset_x: float, offset_y: float}
+## anti_aliasing (bool)
+##
+## Unknown keys inside any nested dict are rejected with INVALID_PARAMS so
+## typos fail loudly instead of silently being ignored.
+func set_stylebox_flat(params: Dictionary) -> Dictionary:
+ var load_result := _load_theme_from_params(params)
+ if load_result.has("error"):
+ return load_result
+ var theme: Theme = load_result.theme
+ var theme_path: String = load_result.path
+
+ var class_name_param: String = params.get("class_name", "")
+ if class_name_param.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: class_name")
+
+ var name: String = params.get("name", "")
+ if name.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: name")
+
+ var sb := StyleBoxFlat.new()
+ if params.has("bg_color"):
+ var bg := _parse_color(params.bg_color)
+ if bg == null:
+ return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Invalid bg_color: %s (%s)" % [str(params.bg_color), _COLOR_HINT])
+ sb.bg_color = bg
+ if params.has("border_color"):
+ var bc := _parse_color(params.border_color)
+ if bc == null:
+ return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Invalid border_color: %s (%s)" % [str(params.border_color), _COLOR_HINT])
+ sb.border_color = bc
+
+ # border: {all, top, bottom, left, right} — int widths
+ if params.has("border"):
+ var err := _apply_sides(sb, params.border, "border",
+ ["top", "bottom", "left", "right"],
+ "border_width_",
+ TYPE_INT)
+ if err != "":
+ return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, err)
+
+ # corners: {all, top_left, top_right, bottom_left, bottom_right} — int radii
+ if params.has("corners"):
+ var err2 := _apply_sides(sb, params.corners, "corners",
+ ["top_left", "top_right", "bottom_left", "bottom_right"],
+ "corner_radius_",
+ TYPE_INT)
+ if err2 != "":
+ return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, err2)
+
+ # margins: {all, top, bottom, left, right} — float padding
+ if params.has("margins"):
+ var err3 := _apply_sides(sb, params.margins, "margins",
+ ["top", "bottom", "left", "right"],
+ "content_margin_",
+ TYPE_FLOAT)
+ if err3 != "":
+ return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, err3)
+
+ # shadow: {color, size, offset_x, offset_y}
+ if params.has("shadow"):
+ if typeof(params.shadow) != TYPE_DICTIONARY:
+ return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "'shadow' must be a dict with color/size/offset_x/offset_y")
+ var shadow: Dictionary = params.shadow
+ var allowed_shadow_keys := {"color": true, "size": true, "offset_x": true, "offset_y": true}
+ for k in shadow.keys():
+ if not allowed_shadow_keys.has(k):
+ return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
+ "Unknown key in 'shadow': %s (valid: color, size, offset_x, offset_y)" % k)
+ if shadow.has("color"):
+ var sc := _parse_color(shadow.color)
+ if sc == null:
+ return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
+ "Invalid shadow.color: %s (%s)" % [str(shadow.color), _COLOR_HINT])
+ sb.shadow_color = sc
+ if shadow.has("size"):
+ sb.shadow_size = int(shadow.size)
+ if shadow.has("offset_x") or shadow.has("offset_y"):
+ sb.shadow_offset = Vector2(
+ float(shadow.get("offset_x", 0)),
+ float(shadow.get("offset_y", 0)),
+ )
+
+ if params.has("anti_aliasing"):
+ sb.anti_aliasing = bool(params.anti_aliasing)
+
+ var had_before := theme.has_stylebox(name, class_name_param)
+ var before_sb: StyleBox = theme.get_stylebox(name, class_name_param) if had_before else null
+
+ _undo_redo.create_action("MCP: Theme set stylebox %s/%s" % [class_name_param, name])
+ _undo_redo.add_do_method(self, "_apply_stylebox", theme_path, name, class_name_param, sb)
+ if had_before:
+ _undo_redo.add_undo_method(self, "_apply_stylebox", theme_path, name, class_name_param, before_sb)
+ else:
+ _undo_redo.add_undo_method(self, "_clear_stylebox", theme_path, name, class_name_param)
+ _undo_redo.commit_action()
+
+ return {
+ "data": {
+ "path": theme_path,
+ "class_name": class_name_param,
+ "name": name,
+ "stylebox_class": "StyleBoxFlat",
+ "bg_color": _serialize_value(sb.bg_color),
+ "border": {
+ "top": sb.border_width_top,
+ "bottom": sb.border_width_bottom,
+ "left": sb.border_width_left,
+ "right": sb.border_width_right,
+ },
+ "corners": {
+ "top_left": sb.corner_radius_top_left,
+ "top_right": sb.corner_radius_top_right,
+ "bottom_left": sb.corner_radius_bottom_left,
+ "bottom_right": sb.corner_radius_bottom_right,
+ },
+ "margins": {
+ "top": sb.content_margin_top,
+ "bottom": sb.content_margin_bottom,
+ "left": sb.content_margin_left,
+ "right": sb.content_margin_right,
+ },
+ "undoable": true,
+ }
+ }
+
+
+## Parse a {all, , , ...} dict and apply it to StyleBoxFlat via
+## its set_ properties. Returns "" on success, an error
+## message on failure. Validates that only known keys are present.
+func _apply_sides(sb: StyleBoxFlat, sides_dict: Variant, dict_name: String,
+ side_names: Array, prop_prefix: String, value_type: int) -> String:
+ if typeof(sides_dict) != TYPE_DICTIONARY:
+ return "'%s' must be a dict with 'all' and/or side-specific keys" % dict_name
+ var valid_keys := {"all": true}
+ for s in side_names:
+ valid_keys[s] = true
+ for k in sides_dict.keys():
+ if not valid_keys.has(k):
+ return "Unknown key in '%s': %s (valid: all, %s)" % [
+ dict_name, k, ", ".join(side_names)
+ ]
+ # Apply `all` first, then override with side-specific keys.
+ if sides_dict.has("all"):
+ var all_val: Variant = sides_dict.all
+ for s in side_names:
+ var v: Variant = int(all_val) if value_type == TYPE_INT else float(all_val)
+ sb.set(prop_prefix + s, v)
+ for s in side_names:
+ if sides_dict.has(s):
+ var v2: Variant = int(sides_dict[s]) if value_type == TYPE_INT else float(sides_dict[s])
+ sb.set(prop_prefix + s, v2)
+ return ""
+
+
+func _apply_stylebox(theme_path: String, name: String, class_name_param: String, sb: StyleBox) -> void:
+ var theme: Theme = ResourceLoader.load(theme_path)
+ if theme == null:
+ push_warning("MCP: Failed to load theme for undo/redo: %s" % theme_path)
+ return
+ theme.set_stylebox(name, class_name_param, sb)
+ McpResourceIO.guarded_save(theme, theme_path, _connection)
+
+
+func _clear_stylebox(theme_path: String, name: String, class_name_param: String) -> void:
+ var theme: Theme = ResourceLoader.load(theme_path)
+ if theme == null:
+ push_warning("MCP: Failed to load theme for undo/redo: %s" % theme_path)
+ return
+ theme.clear_stylebox(name, class_name_param)
+ McpResourceIO.guarded_save(theme, theme_path, _connection)
+
+
+# ============================================================================
+# theme_apply — assign a theme to a Control
+# ============================================================================
+
+func apply_theme(params: Dictionary) -> Dictionary:
+ var node_path: String = params.get("node_path", "")
+ if node_path.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: node_path")
+
+ var theme_path: String = params.get("theme_path", "")
+ var theme: Theme = null
+ if not theme_path.is_empty():
+ var path_err := _validate_res_path(theme_path, ".tres")
+ if path_err != null:
+ return path_err
+ if not ResourceLoader.exists(theme_path):
+ return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Theme not found: %s" % theme_path)
+ theme = ResourceLoader.load(theme_path)
+ if theme == null or not theme is Theme:
+ return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Resource at %s is not a Theme" % theme_path)
+
+ var _resolved := McpNodeValidator.resolve_or_error(node_path, "node_path")
+ if _resolved.has("error"):
+ return _resolved
+ var node: Node = _resolved.node
+ var _scene_root: Node = _resolved.scene_root
+ if not node is Control and not node is Window:
+ return ErrorCodes.make(
+ ErrorCodes.WRONG_TYPE,
+ "Node %s is not a Control or Window (got %s)" % [node_path, node.get_class()]
+ )
+
+ var before_theme: Theme = node.theme
+ _undo_redo.create_action("MCP: Apply theme to %s" % node.name)
+ _undo_redo.add_do_property(node, "theme", theme)
+ _undo_redo.add_undo_property(node, "theme", before_theme)
+ _undo_redo.commit_action()
+
+ return {
+ "data": {
+ "node_path": node_path,
+ "theme_path": theme_path if theme != null else "",
+ "cleared": theme == null,
+ "undoable": true,
+ }
+ }
+
+
+# ============================================================================
+# Helpers
+# ============================================================================
+
+func _load_theme_from_params(params: Dictionary) -> Dictionary:
+ var theme_path: String = params.get("theme_path", "")
+ var err := _validate_res_path(theme_path, ".tres", "theme_path", true)
+ if err != null:
+ return err
+ if not ResourceLoader.exists(theme_path):
+ return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Theme not found: %s" % theme_path)
+ var theme: Theme = ResourceLoader.load(theme_path)
+ if theme == null or not theme is Theme:
+ return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Resource at %s is not a Theme" % theme_path)
+ return {"theme": theme, "path": theme_path}
+
+
+static func _validate_res_path(path: String, required_suffix: String, param_name: String = "theme_path", for_write: bool = false) -> Variant:
+ if path.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: %s" % param_name)
+ var path_err := McpPathValidator.validate_resource_path(path, for_write)
+ if not path_err.is_empty():
+ return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "%s: %s" % [param_name, path_err])
+ if not path.ends_with(required_suffix):
+ return ErrorCodes.make(
+ ErrorCodes.VALUE_OUT_OF_RANGE,
+ "%s must end with %s (got %s)" % [param_name, required_suffix, path]
+ )
+ return null
+
+
+## Parse a color from Color, "#rrggbb", "#rrggbbaa", named (red/blue/...) or dict.
+## Returns null if the input cannot be parsed.
+## Delegates to the canonical parser (#714) — gains [r,g,b(,a)] array
+## support and strict key/component checking, same shapes as every other
+## color-accepting handler.
+static func _parse_color(value: Variant) -> Variant:
+ return McpJsonValues.parse_color(value)
+
+
+static func _serialize_value(value: Variant) -> Variant:
+ if value == null:
+ return null
+ if value is Color:
+ return {"r": value.r, "g": value.g, "b": value.b, "a": value.a}
+ if value is Vector2:
+ return {"x": value.x, "y": value.y}
+ return value
diff --git a/addons/godot_ai/handlers/theme_handler.gd.uid b/addons/godot_ai/handlers/theme_handler.gd.uid
new file mode 100644
index 0000000..b77af13
--- /dev/null
+++ b/addons/godot_ai/handlers/theme_handler.gd.uid
@@ -0,0 +1 @@
+uid://gjyldaddj7mu
diff --git a/addons/godot_ai/handlers/tilemap_handler.gd b/addons/godot_ai/handlers/tilemap_handler.gd
new file mode 100644
index 0000000..cc80da4
--- /dev/null
+++ b/addons/godot_ai/handlers/tilemap_handler.gd
@@ -0,0 +1,163 @@
+@tool
+extends "res://addons/godot_ai/handlers/command_handler.gd"
+
+## TileMap / TileMapLayer authoring — set, fill, clear, and read tile cells
+## directly in the editor scene with full undo/redo support.
+##
+## All ops target TileMapLayer nodes in the currently edited scene by
+## scene-relative path (e.g. "/LavaLake20x20/Ground").
+
+const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
+const MAX_RECT_FILL_CELLS := 4096
+
+var _undo_redo: EditorUndoRedoManager
+
+
+func _init(undo_redo: EditorUndoRedoManager) -> void:
+ _undo_redo = undo_redo
+
+
+## Set a single tile cell.
+## params: {path, source_id, atlas_col, atlas_row, map_x, map_y}
+## Returns: {map_x, map_y, source_id, atlas_col, atlas_row}
+func set_cell(params: Dictionary) -> Dictionary:
+ var layer := _resolve_layer(params)
+ if layer.has("error"): return layer
+ var node: TileMapLayer = layer.node
+ var pos := Vector2i(params.get("map_x", 0), params.get("map_y", 0))
+ var src := int(params.get("source_id", 0))
+ var atlas := Vector2i(params.get("atlas_col", 0), params.get("atlas_row", 0))
+ var prev := _capture_cell_state(node, pos)
+ _undo_redo.create_action("MCP: TileMap set_cell")
+ _undo_redo.add_do_method(node, "set_cell", pos, src, atlas)
+ _undo_redo.add_undo_method(self, "_restore_cell_state", node, pos, prev)
+ _undo_redo.commit_action()
+ return {"data": {"map_x": pos.x, "map_y": pos.y, "source_id": src,
+ "atlas_col": atlas.x, "atlas_row": atlas.y, "undoable": true}}
+
+
+## Fill a rectangular region with one tile type in a single undo action.
+## params: {path, source_id, atlas_col, atlas_row, rect_x, rect_y, rect_w, rect_h}
+## Returns: {cells_filled, rect: {x, y, w, h}}
+func set_cells_rect(params: Dictionary) -> Dictionary:
+ var layer := _resolve_layer(params)
+ if layer.has("error"): return layer
+ var node: TileMapLayer = layer.node
+ var src := int(params.get("source_id", 0))
+ var atlas := Vector2i(params.get("atlas_col", 0), params.get("atlas_row", 0))
+ var rx := int(params.get("rect_x", 0)); var ry := int(params.get("rect_y", 0))
+ var rw := int(params.get("rect_w", 1)); var rh := int(params.get("rect_h", 1))
+ if rw <= 0 or rh <= 0:
+ return ErrorCodes.make(
+ ErrorCodes.VALUE_OUT_OF_RANGE,
+ "rect_w and rect_h must be > 0 (got %d x %d)" % [rw, rh]
+ )
+ var cell_count := rw * rh
+ if cell_count > MAX_RECT_FILL_CELLS:
+ return ErrorCodes.make(
+ ErrorCodes.VALUE_OUT_OF_RANGE,
+ "Rect too large: %d cells exceeds max %d" % [cell_count, MAX_RECT_FILL_CELLS]
+ )
+ var cells: Array[Vector2i] = []
+ var snapshot: Array[Dictionary] = []
+ for x in range(rx, rx + rw):
+ for y in range(ry, ry + rh):
+ var pos := Vector2i(x, y)
+ cells.append(pos)
+ snapshot.append({"pos": pos, "state": _capture_cell_state(node, pos)})
+ _undo_redo.create_action("MCP: TileMap set_cells_rect %dx%d" % [rw, rh])
+ for pos in cells:
+ _undo_redo.add_do_method(node, "set_cell", pos, src, atlas)
+ _undo_redo.add_undo_method(self, "_restore_rect_snapshot", node, snapshot)
+ _undo_redo.commit_action()
+ return {"data": {"cells_filled": cells.size(),
+ "rect": {"x": rx, "y": ry, "w": rw, "h": rh}, "undoable": true}}
+
+
+## Remove all tiles from a TileMapLayer.
+## params: {path}
+## Returns: {cleared: true}
+func clear_layer(params: Dictionary) -> Dictionary:
+ var layer := _resolve_layer(params)
+ if layer.has("error"): return layer
+ var node: TileMapLayer = layer.node
+ var snapshot := _capture_used_cells_snapshot(node)
+ _undo_redo.create_action("MCP: TileMap clear")
+ _undo_redo.add_do_method(node, "clear")
+ _undo_redo.add_undo_method(self, "_restore_cells_snapshot", node, snapshot)
+ _undo_redo.commit_action()
+ return {"data": {"cleared": true, "undoable": true}}
+
+
+## Return all used cell coordinates.
+## params: {path}
+## Returns: {cells: [{x, y}, ...], count: int}
+func get_used_cells(params: Dictionary) -> Dictionary:
+ var layer := _resolve_layer(params)
+ if layer.has("error"): return layer
+ var node: TileMapLayer = layer.node
+ var cells := node.get_used_cells()
+ var result: Array = []
+ for c in cells:
+ result.append({"x": c.x, "y": c.y})
+ return {"data": {"cells": result, "count": result.size()}}
+
+
+## Resolve a TileMapLayer node from params["path"] in the currently edited
+## scene. Returns {"node": TileMapLayer} on success, or an error dict.
+func _resolve_layer(params: Dictionary) -> Dictionary:
+ var path: String = params.get("path", "")
+ var scene_file: String = params.get("scene_file", "")
+ var resolved := McpNodeValidator.resolve_or_error(path, "path", scene_file)
+ if resolved.has("error"):
+ return resolved
+ var node: Node = resolved.node
+ if not node is TileMapLayer:
+ return ErrorCodes.make(ErrorCodes.WRONG_TYPE,
+ "Node is not a TileMapLayer: %s" % path)
+ return {"node": node}
+
+
+func _capture_cell_state(node: TileMapLayer, pos: Vector2i) -> Dictionary:
+ var source_id := node.get_cell_source_id(pos)
+ if source_id == -1:
+ return {"has_tile": false}
+ var atlas: Vector2i = node.get_cell_atlas_coords(pos)
+ var alternative := node.get_cell_alternative_tile(pos)
+ return {
+ "has_tile": true,
+ "source_id": source_id,
+ "atlas_col": atlas.x,
+ "atlas_row": atlas.y,
+ "alternative": alternative,
+ }
+
+
+func _capture_used_cells_snapshot(node: TileMapLayer) -> Array[Dictionary]:
+ var snapshot: Array[Dictionary] = []
+ for pos in node.get_used_cells():
+ snapshot.append({"pos": pos, "state": _capture_cell_state(node, pos)})
+ return snapshot
+
+
+func _restore_cells_snapshot(node: TileMapLayer, snapshot: Array[Dictionary]) -> void:
+ node.clear()
+ for entry in snapshot:
+ _restore_cell_state(node, entry.pos, entry.state)
+
+
+func _restore_rect_snapshot(node: TileMapLayer, snapshot: Array[Dictionary]) -> void:
+ for entry in snapshot:
+ _restore_cell_state(node, entry.pos, entry.state)
+
+
+func _restore_cell_state(node: TileMapLayer, pos: Vector2i, state: Dictionary) -> void:
+ if not state.get("has_tile", false):
+ node.erase_cell(pos)
+ return
+ node.set_cell(
+ pos,
+ int(state.get("source_id", -1)),
+ Vector2i(int(state.get("atlas_col", -1)), int(state.get("atlas_row", -1))),
+ int(state.get("alternative", 0))
+ )
diff --git a/addons/godot_ai/handlers/tilemap_handler.gd.uid b/addons/godot_ai/handlers/tilemap_handler.gd.uid
new file mode 100644
index 0000000..8cbed53
--- /dev/null
+++ b/addons/godot_ai/handlers/tilemap_handler.gd.uid
@@ -0,0 +1 @@
+uid://cm8s7a0ey2q6k
diff --git a/addons/godot_ai/handlers/tileset_handler.gd b/addons/godot_ai/handlers/tileset_handler.gd
new file mode 100644
index 0000000..f3c5756
--- /dev/null
+++ b/addons/godot_ai/handlers/tileset_handler.gd
@@ -0,0 +1,178 @@
+@tool
+extends "res://addons/godot_ai/handlers/command_handler.gd"
+
+## TileSet management — atlas inspection helpers.
+
+const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
+
+
+func _init() -> void:
+ pass
+
+
+## Query all occupied atlas tile positions for a single source.
+##
+## params:
+## tileset_path — res:// path to the TileSet resource (required, non-empty)
+## source_id — raw TileSet source id (required)
+##
+## Returns:
+## {"data": {"tiles": [{"col": int, "row": int}, ...], "count": int}}
+## on success (including empty sources, where tiles=[] and count=0)
+## ErrorCodes.make(code, message) on any validation or load failure
+##
+## Error codes:
+## MISSING_REQUIRED_PARAM — tileset_path absent/empty, or source_id absent
+## RESOURCE_NOT_FOUND — ResourceLoader.exists(tileset_path) is false
+## WRONG_TYPE — loaded resource is not a TileSet, or source is
+## not a TileSetAtlasSource
+## VALUE_OUT_OF_RANGE — source_id not present in TileSet
+##
+## This method is read-only: it never calls ResourceSaver or modifies any resource.
+func get_atlas_tiles(params: Dictionary) -> Dictionary:
+ var resolved := _resolve_atlas_source(params)
+ if resolved.has("error"):
+ return resolved
+ var src: TileSetAtlasSource = resolved.src
+
+ var tiles: Array = []
+ for i in range(src.get_tiles_count()):
+ var v: Vector2i = src.get_tile_id(i)
+ tiles.append({"col": v.x, "row": v.y})
+
+ return {"data": {"tiles": tiles, "count": tiles.size()}}
+
+
+## Return the atlas texture of a TileSetAtlasSource as a Base64-encoded PNG.
+##
+## params:
+## tileset_path — res:// path to the TileSet resource (required, non-empty)
+## source_id — raw TileSet source id (required)
+## max_size — optional int; if > 0, the image is scaled so its longest
+## edge is at most max_size pixels (default 0 = full res)
+##
+## Returns:
+## {"data": {"image_base64": String, "width": int, "height": int,
+## "original_width": int, "original_height": int, "format": "png"}}
+## on success
+## ErrorCodes.make(code, message) on any validation or load failure
+##
+## Error codes:
+## MISSING_REQUIRED_PARAM — tileset_path absent/empty, or source_id absent
+## RESOURCE_NOT_FOUND — ResourceLoader.exists(tileset_path) is false
+## WRONG_TYPE — loaded resource is not a TileSet, or source is
+## not a TileSetAtlasSource, or texture is null
+## VALUE_OUT_OF_RANGE — source_id not present in TileSet
+##
+## This method is read-only: it never calls ResourceSaver or modifies anything.
+func get_atlas_image(params: Dictionary) -> Dictionary:
+ var resolved := _resolve_atlas_source(params)
+ if resolved.has("error"):
+ return resolved
+ var source_id: int = resolved.source_id
+ var src: TileSetAtlasSource = resolved.src
+
+ var tex: Texture2D = src.texture
+ if tex == null:
+ return ErrorCodes.make(
+ ErrorCodes.WRONG_TYPE,
+ "Source %d has no texture assigned" % source_id
+ )
+
+ var img: Image = tex.get_image()
+ if img == null:
+ return ErrorCodes.make(
+ ErrorCodes.WRONG_TYPE,
+ "Could not retrieve image data from texture of source %d" % source_id
+ )
+ if img.is_compressed():
+ var decompress_err := img.decompress()
+ if decompress_err != OK:
+ return ErrorCodes.make(
+ ErrorCodes.INTERNAL_ERROR,
+ "Could not decompress texture of source %d: %s" % [source_id, error_string(decompress_err)]
+ )
+
+ var original_width: int = img.get_width()
+ var original_height: int = img.get_height()
+
+ var max_size: int = params.get("max_size", 0)
+ if max_size > 0:
+ var longest_edge: int = max(original_width, original_height)
+ if longest_edge > max_size:
+ var scale: float = float(max_size) / float(longest_edge)
+ var new_w: int = max(1, int(original_width * scale))
+ var new_h: int = max(1, int(original_height * scale))
+ img.resize(new_w, new_h, Image.INTERPOLATE_LANCZOS)
+
+ var png_bytes: PackedByteArray = img.save_png_to_buffer()
+ if png_bytes.is_empty():
+ return ErrorCodes.make(
+ ErrorCodes.INTERNAL_ERROR,
+ "PNG encoding produced empty output for source %d" % source_id
+ )
+ var b64: String = Marshalls.raw_to_base64(png_bytes)
+
+ return {
+ "data": {
+ "image_base64": b64,
+ "width": img.get_width(),
+ "height": img.get_height(),
+ "original_width": original_width,
+ "original_height": original_height,
+ "format": "png",
+ }
+ }
+
+
+func _resolve_atlas_source(params: Dictionary) -> Dictionary:
+ var tileset_path: String = params.get("tileset_path", "")
+ if tileset_path.is_empty():
+ return ErrorCodes.make(
+ ErrorCodes.MISSING_REQUIRED_PARAM,
+ "'tileset_path' parameter is required and must not be empty"
+ )
+
+ if not params.has("source_id"):
+ return ErrorCodes.make(
+ ErrorCodes.MISSING_REQUIRED_PARAM,
+ "'source_id' parameter is required"
+ )
+
+ var tileset_path_err = McpPathValidator.loadable_error(tileset_path, "tileset_path")
+ if tileset_path_err != null:
+ return tileset_path_err
+
+ if not ResourceLoader.exists(tileset_path):
+ return ErrorCodes.make(
+ ErrorCodes.RESOURCE_NOT_FOUND,
+ "TileSet resource not found: %s" % tileset_path
+ )
+
+ var ts = load(tileset_path)
+ if not ts is TileSet:
+ var loaded_type := "null" if ts == null else ts.get_class()
+ return ErrorCodes.make(
+ ErrorCodes.WRONG_TYPE,
+ "Resource at '%s' is not a TileSet (got %s)" % [tileset_path, loaded_type]
+ )
+
+ var source_id: int = int(params.get("source_id", -999))
+ if source_id < 0 or not ts.has_source(source_id):
+ return ErrorCodes.make(
+ ErrorCodes.VALUE_OUT_OF_RANGE,
+ "source_id %d does not exist in TileSet" % source_id
+ )
+
+ var src = ts.get_source(source_id)
+ if not src is TileSetAtlasSource:
+ var source_type: String = "null" if src == null else src.get_class()
+ return ErrorCodes.make(
+ ErrorCodes.WRONG_TYPE,
+ "Source %d is not a TileSetAtlasSource (got %s)" % [source_id, source_type]
+ )
+
+ return {
+ "source_id": source_id,
+ "src": src,
+ }
diff --git a/addons/godot_ai/handlers/tileset_handler.gd.uid b/addons/godot_ai/handlers/tileset_handler.gd.uid
new file mode 100644
index 0000000..643cfd3
--- /dev/null
+++ b/addons/godot_ai/handlers/tileset_handler.gd.uid
@@ -0,0 +1 @@
+uid://de3v4m1pnk7tr
diff --git a/addons/godot_ai/handlers/ui_handler.gd b/addons/godot_ai/handlers/ui_handler.gd
new file mode 100644
index 0000000..f183751
--- /dev/null
+++ b/addons/godot_ai/handlers/ui_handler.gd
@@ -0,0 +1,525 @@
+@tool
+extends "res://addons/godot_ai/handlers/command_handler.gd"
+
+const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
+
+## Handles UI-specific (Control) layout helpers: anchor presets, etc.
+##
+## Anchors/offsets are the worst part of Control layout to set one-property-at-a-time.
+## This handler wraps Godot's built-in presets (FULL_RECT, CENTER, TOP_LEFT, ...) so
+## callers can set a whole layout with one command, with proper undo.
+
+var _undo_redo: EditorUndoRedoManager
+
+
+const _PRESETS := {
+ "top_left": Control.PRESET_TOP_LEFT,
+ "top_right": Control.PRESET_TOP_RIGHT,
+ "bottom_left": Control.PRESET_BOTTOM_LEFT,
+ "bottom_right": Control.PRESET_BOTTOM_RIGHT,
+ "center_left": Control.PRESET_CENTER_LEFT,
+ "center_top": Control.PRESET_CENTER_TOP,
+ "center_right": Control.PRESET_CENTER_RIGHT,
+ "center_bottom": Control.PRESET_CENTER_BOTTOM,
+ "center": Control.PRESET_CENTER,
+ "left_wide": Control.PRESET_LEFT_WIDE,
+ "top_wide": Control.PRESET_TOP_WIDE,
+ "right_wide": Control.PRESET_RIGHT_WIDE,
+ "bottom_wide": Control.PRESET_BOTTOM_WIDE,
+ "vcenter_wide": Control.PRESET_VCENTER_WIDE,
+ "hcenter_wide": Control.PRESET_HCENTER_WIDE,
+ "full_rect": Control.PRESET_FULL_RECT,
+}
+
+const _RESIZE_MODES := {
+ "minsize": Control.PRESET_MODE_MINSIZE,
+ "keep_width": Control.PRESET_MODE_KEEP_WIDTH,
+ "keep_height": Control.PRESET_MODE_KEEP_HEIGHT,
+ "keep_size": Control.PRESET_MODE_KEEP_SIZE,
+}
+
+const _ANCHOR_OFFSET_PROPS := [
+ "anchor_left", "anchor_top", "anchor_right", "anchor_bottom",
+ "offset_left", "offset_top", "offset_right", "offset_bottom",
+]
+
+
+func _init(undo_redo: EditorUndoRedoManager) -> void:
+ _undo_redo = undo_redo
+
+
+## Apply a Control layout preset (anchors + offsets) to a UI node.
+##
+## Params:
+## path - scene path to a Control node (required)
+## preset - preset name: full_rect, center, top_left, ... (required)
+## resize_mode - minsize | keep_width | keep_height | keep_size (default: minsize)
+## margin - integer margin in pixels from the anchor edges (default: 0)
+func set_anchor_preset(params: Dictionary) -> Dictionary:
+ var node_path: String = params.get("path", "")
+ if node_path.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: path")
+
+ var preset_name: String = str(params.get("preset", "")).to_lower()
+ if preset_name.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: preset")
+ if not _PRESETS.has(preset_name):
+ var names := _PRESETS.keys()
+ names.sort()
+ return ErrorCodes.make(
+ ErrorCodes.VALUE_OUT_OF_RANGE,
+ "Unknown preset '%s'. Valid: %s" % [preset_name, ", ".join(names)]
+ )
+
+ var resize_mode_name: String = str(params.get("resize_mode", "minsize")).to_lower()
+ if not _RESIZE_MODES.has(resize_mode_name):
+ var names := _RESIZE_MODES.keys()
+ names.sort()
+ return ErrorCodes.make(
+ ErrorCodes.VALUE_OUT_OF_RANGE,
+ "Unknown resize_mode '%s'. Valid: %s" % [resize_mode_name, ", ".join(names)]
+ )
+
+ var margin: int = int(params.get("margin", 0))
+
+ var _resolved := McpNodeValidator.resolve_or_error(node_path, "node_path")
+ if _resolved.has("error"):
+ return _resolved
+ var node: Node = _resolved.node
+ var scene_root: Node = _resolved.scene_root
+ if not node is Control:
+ var got_class: String = node.get_class()
+ return ErrorCodes.make(
+ ErrorCodes.WRONG_TYPE,
+ "Node %s is not a Control (got %s)%s" % [
+ node_path, got_class, _canvas_layer_overlay_hint(got_class)
+ ]
+ )
+
+ var control := node as Control
+ var preset_value: int = _PRESETS[preset_name]
+ var resize_mode_value: int = _RESIZE_MODES[resize_mode_name]
+
+ # Snapshot before so we can undo every property the preset may have touched.
+ var before: Dictionary = {}
+ for prop in _ANCHOR_OFFSET_PROPS:
+ before[prop] = control.get(prop)
+
+ _undo_redo.create_action("MCP: Set %s anchor preset %s" % [control.name, preset_name])
+ _undo_redo.add_do_method(
+ control, "set_anchors_and_offsets_preset", preset_value, resize_mode_value, margin
+ )
+ for prop in _ANCHOR_OFFSET_PROPS:
+ _undo_redo.add_undo_property(control, prop, before[prop])
+ _undo_redo.commit_action()
+
+ var after: Dictionary = {}
+ for prop in _ANCHOR_OFFSET_PROPS:
+ after[prop] = control.get(prop)
+
+ return {
+ "data": {
+ "path": node_path,
+ "preset": preset_name,
+ "resize_mode": resize_mode_name,
+ "margin": margin,
+ "anchors": {
+ "left": after.anchor_left,
+ "top": after.anchor_top,
+ "right": after.anchor_right,
+ "bottom": after.anchor_bottom,
+ },
+ "offsets": {
+ "left": after.offset_left,
+ "top": after.offset_top,
+ "right": after.offset_right,
+ "bottom": after.offset_bottom,
+ },
+ "undoable": true,
+ }
+ }
+
+
+## Set the visible `text` property on a UI Control (Label, Button + subclasses,
+## LineEdit, TextEdit, RichTextLabel, LinkButton). Undoable.
+func set_text(params: Dictionary) -> Dictionary:
+ var node_path: String = params.get("path", "")
+ if node_path.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: path")
+
+ if not params.has("text"):
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: text")
+ var text_value: Variant = params["text"]
+ if typeof(text_value) != TYPE_STRING:
+ return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "text must be a string")
+
+ var _resolved := McpNodeValidator.resolve_or_error(node_path, "node_path")
+ if _resolved.has("error"):
+ return _resolved
+ var node: Node = _resolved.node
+ var scene_root: Node = _resolved.scene_root
+ var node_type := node.get_class()
+ if not node is Control:
+ return ErrorCodes.make(
+ ErrorCodes.WRONG_TYPE,
+ "Node %s is not a Control (got %s)" % [node_path, node_type]
+ )
+ # Scan get_property_list() (matches set_property / _apply_property in this
+ # repo) so we can both confirm `text` exists and that it's actually a String
+ # — guards against a custom Control whose `text` happens to be some other
+ # type, where set()-ing a String would silently mis-coerce.
+ var text_prop_type := TYPE_NIL
+ var has_text := false
+ for prop in node.get_property_list():
+ if prop.get("name", "") == "text":
+ has_text = true
+ text_prop_type = prop.get("type", TYPE_NIL)
+ break
+ if not has_text:
+ return ErrorCodes.make(
+ ErrorCodes.PROPERTY_NOT_ON_CLASS,
+ "Control %s has no 'text' property (got %s)" % [node_path, node_type]
+ )
+ if text_prop_type != TYPE_STRING:
+ return ErrorCodes.make(
+ ErrorCodes.PROPERTY_NOT_ON_CLASS,
+ "Control %s has a non-string 'text' property (got %s)" % [node_path, node_type]
+ )
+
+ var old_value: String = node.get("text")
+
+ _undo_redo.create_action("MCP: Set %s text" % node.name)
+ _undo_redo.add_do_property(node, "text", text_value)
+ _undo_redo.add_undo_property(node, "text", old_value)
+ _undo_redo.commit_action()
+
+ return {
+ "data": {
+ "path": node_path,
+ "text": text_value,
+ "old_text": old_value,
+ "node_type": node_type,
+ "undoable": true,
+ }
+ }
+
+
+# ============================================================================
+# build_layout — declarative nested-dict → Control tree in one undo action
+# ============================================================================
+
+## Build a tree of Control nodes atomically.
+##
+## Params:
+## tree - Dictionary describing the root node. Required fields: "type".
+## Optional: "name", "properties" (dict), "anchor_preset",
+## "anchor_margin", "theme" (res://, uid:// or user:// path), "children" (array).
+## parent_path - Parent scene path. Empty or "/" = scene root.
+##
+## Validation is done before any scene mutation: class names, property
+## existence, and res:// paths are all checked up-front. If anything is
+## invalid, no node is created.
+func build_layout(params: Dictionary) -> Dictionary:
+ var tree = params.get("tree")
+ if not params.has("tree"):
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: tree")
+ if typeof(tree) != TYPE_DICTIONARY:
+ return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "tree must be a dictionary")
+
+ var _scene_check := McpNodeValidator.require_scene_or_error()
+ if _scene_check.has("error"):
+ return _scene_check
+ var scene_root: Node = _scene_check.scene_root
+
+ var parent_path: String = params.get("parent_path", "")
+ var parent: Node = scene_root
+ if not parent_path.is_empty() and parent_path != "/":
+ parent = McpScenePath.resolve(parent_path, scene_root)
+ if parent == null:
+ return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, McpScenePath.format_parent_error(parent_path, scene_root))
+
+ # Validate + build in memory first; if anything fails, free and bail.
+ var built := _build_subtree(tree)
+ if built.has("error"):
+ return built
+ var root_node: Node = built.node
+ var created: Array[Node] = built.created
+
+ _undo_redo.create_action("MCP: Build UI layout (%d nodes)" % created.size())
+ _undo_redo.add_do_method(parent, "add_child", root_node, true)
+ _undo_redo.add_do_method(root_node, "set_owner", scene_root)
+ for n in created:
+ _undo_redo.add_do_method(n, "set_owner", scene_root)
+ _undo_redo.add_do_reference(n)
+ _undo_redo.add_undo_method(parent, "remove_child", root_node)
+ _undo_redo.commit_action()
+
+ return {
+ "data": {
+ "root_path": McpScenePath.from_node(root_node, scene_root),
+ "node_count": created.size(),
+ "undoable": true,
+ }
+ }
+
+
+## Recursively instantiate + configure a node and its children in memory.
+## Returns {"node": root, "created": [all descendants incl. root]} or {"error": ...}.
+func _build_subtree(spec: Dictionary) -> Dictionary:
+ var node_type: String = spec.get("type", "")
+ if node_type.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Every layout node requires a 'type'")
+ if not ClassDB.class_exists(node_type):
+ return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Unknown type: %s" % node_type)
+ if not ClassDB.is_parent_class(node_type, "Node"):
+ return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "%s is not a Node type" % node_type)
+
+ var node: Node = ClassDB.instantiate(node_type)
+ if node == null:
+ return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate %s" % node_type)
+
+ var node_name: String = spec.get("name", "")
+ if not node_name.is_empty():
+ node.name = node_name
+
+ # Properties.
+ if spec.has("properties"):
+ var props = spec.get("properties")
+ if typeof(props) != TYPE_DICTIONARY:
+ node.free()
+ return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "properties must be a dictionary")
+ for key in props:
+ var value = props[key]
+ var apply_err := _apply_property(node, str(key), value)
+ if apply_err != null:
+ node.free()
+ return apply_err
+
+ # Theme (res:// / uid:// / user:// path -> Resource).
+ if spec.has("theme"):
+ var theme_path: String = str(spec.get("theme", ""))
+ if not theme_path.is_empty():
+ var theme_path_err = McpPathValidator.loadable_error(theme_path, "theme")
+ if theme_path_err != null:
+ node.free()
+ return theme_path_err
+ if not ResourceLoader.exists(theme_path):
+ node.free()
+ return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Theme not found: %s" % theme_path)
+ var theme_res: Resource = ResourceLoader.load(theme_path)
+ if theme_res == null or not theme_res is Theme:
+ node.free()
+ return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "theme path must point to a Theme resource: %s" % theme_path)
+ if not node is Control and not node is Window:
+ node.free()
+ return ErrorCodes.make(
+ ErrorCodes.INVALID_PARAMS,
+ "theme can only be set on Control / Window (got %s)%s" % [
+ node_type, _canvas_layer_overlay_hint(node_type)
+ ]
+ )
+ node.theme = theme_res as Theme
+
+ # Anchor preset — applied before children so children inherit sensible anchors.
+ if spec.has("anchor_preset"):
+ var preset_name: String = str(spec.get("anchor_preset", "")).to_lower()
+ if not _PRESETS.has(preset_name):
+ node.free()
+ return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Unknown anchor_preset: %s" % preset_name)
+ if not node is Control:
+ node.free()
+ return ErrorCodes.make(
+ ErrorCodes.INVALID_PARAMS,
+ "anchor_preset requires a Control (got %s)%s" % [
+ node_type, _canvas_layer_overlay_hint(node_type)
+ ]
+ )
+ var preset_value: int = _PRESETS[preset_name]
+ var margin: int = int(spec.get("anchor_margin", 0))
+ (node as Control).set_anchors_and_offsets_preset(preset_value, Control.PRESET_MODE_MINSIZE, margin)
+
+ var created: Array[Node] = [node]
+ if spec.has("children"):
+ var children = spec.get("children")
+ if typeof(children) != TYPE_ARRAY:
+ node.free()
+ return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "children must be an array")
+ for child_spec in children:
+ if typeof(child_spec) != TYPE_DICTIONARY:
+ node.free()
+ return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "each child must be a dictionary")
+ var child_result := _build_subtree(child_spec)
+ if child_result.has("error"):
+ node.free()
+ return child_result
+ var child_node: Node = child_result.node
+ node.add_child(child_node)
+ for n in child_result.created:
+ created.append(n)
+ return {"node": node, "created": created}
+
+
+## Mapping from theme_override_* property prefixes to their add/remove methods.
+const _THEME_OVERRIDE_MAP := {
+ "theme_override_colors/": {
+ "add": "add_theme_color_override",
+ "remove": "remove_theme_color_override",
+ "coerce_type": TYPE_COLOR,
+ },
+ "theme_override_constants/": {
+ "add": "add_theme_constant_override",
+ "remove": "remove_theme_constant_override",
+ "coerce_type": TYPE_INT,
+ },
+ "theme_override_font_sizes/": {
+ "add": "add_theme_font_size_override",
+ "remove": "remove_theme_font_size_override",
+ "coerce_type": TYPE_INT,
+ },
+ "theme_override_styles/": {
+ "add": "add_theme_stylebox_override",
+ "remove": "remove_theme_stylebox_override",
+ "coerce_type": TYPE_OBJECT,
+ },
+}
+
+
+## Apply a property to a newly-instantiated node. Handles Color/Vector2/NodePath
+## coercion from JSON-friendly forms. Returns null on success, error dict on failure.
+func _apply_property(node: Node, prop: String, value: Variant) -> Variant:
+ # Handle theme_override_* pseudo-properties before the regular property scan.
+ for prefix in _THEME_OVERRIDE_MAP:
+ if prop.begins_with(prefix):
+ if not node is Control:
+ return ErrorCodes.make(
+ ErrorCodes.INVALID_PARAMS,
+ "theme_override_* requires a Control node (got %s)" % node.get_class()
+ )
+ var override_name := prop.substr(prefix.length())
+ var info: Dictionary = _THEME_OVERRIDE_MAP[prefix]
+ var coerce_type: int = info.coerce_type
+
+ # For stylebox overrides, load from a res:// / uid:// / user:// path.
+ if coerce_type == TYPE_OBJECT:
+ if value is String and (value.begins_with("res://") or value.begins_with("uid://") or value.begins_with("user://")):
+ var style_path_err = McpPathValidator.loadable_error(value, "stylebox")
+ if style_path_err != null:
+ return style_path_err
+ var res := ResourceLoader.load(value)
+ if res == null or not res is StyleBox:
+ return ErrorCodes.make(
+ ErrorCodes.INVALID_PARAMS,
+ "Style resource not found or not a StyleBox: %s" % value
+ )
+ node.call(info.add, override_name, res)
+ else:
+ return ErrorCodes.make(
+ ErrorCodes.INVALID_PARAMS,
+ "theme_override_styles/ expects a res:// / uid:// / user:// path to a StyleBox"
+ )
+ else:
+ var coercion := _coerce_for_type(value, coerce_type)
+ if not coercion.ok:
+ return ErrorCodes.make(
+ ErrorCodes.WRONG_TYPE,
+ "Cannot coerce '%s' for %s" % [value, prop]
+ )
+ node.call(info.add, override_name, coercion.value)
+ return null
+
+ var found := false
+ var prop_type := TYPE_NIL
+ for p in node.get_property_list():
+ if p.name == prop:
+ found = true
+ prop_type = p.get("type", TYPE_NIL)
+ break
+ if not found:
+ return ErrorCodes.make(
+ ErrorCodes.PROPERTY_NOT_ON_CLASS,
+ McpPropertyErrors.build_message(node, prop)
+ )
+
+ var coercion := _coerce_for_type(value, prop_type)
+ if not coercion.ok:
+ return ErrorCodes.make(
+ ErrorCodes.WRONG_TYPE,
+ "Property '%s' on %s expects type %s (cannot coerce %s)" % [
+ prop, node.get_class(), type_string(prop_type), value
+ ]
+ )
+ node.set(prop, coercion.value)
+ return null
+
+
+## Coerce a JSON-friendly value to the target Godot type. Returns
+## {"ok": true, "value": coerced} on success, {"ok": false} on failure.
+## For types we don't explicitly coerce, the value is returned as-is
+## (Godot will typecheck at set() time and fail loudly if it disagrees).
+static func _coerce_for_type(value: Variant, prop_type: int) -> Dictionary:
+ match prop_type:
+ TYPE_COLOR:
+ ## Canonical parser (#714): adds [r,g,b(,a)] array support and
+ ## strict key/component checking, same shapes everywhere.
+ var parsed_color = McpJsonValues.parse_color(value)
+ if parsed_color != null:
+ return {"ok": true, "value": parsed_color}
+ return {"ok": false}
+ TYPE_VECTOR2:
+ ## Same canonical parser as TYPE_COLOR (CodeRabbit review):
+ ## keeping the inline copy here would re-introduce exactly the
+ ## permissive-vs-strict drift this PR removes elsewhere.
+ var parsed_v2 = McpJsonValues.parse_vector2(value)
+ if parsed_v2 != null:
+ return {"ok": true, "value": parsed_v2}
+ return {"ok": false}
+ TYPE_VECTOR2I:
+ if value is Vector2i:
+ return {"ok": true, "value": value}
+ if value is Dictionary and value.has("x") and value.has("y"):
+ return {"ok": true, "value": Vector2i(int(value.x), int(value.y))}
+ if value is Array and value.size() == 2:
+ return {"ok": true, "value": Vector2i(int(value[0]), int(value[1]))}
+ return {"ok": false}
+ TYPE_RECT2:
+ if value is Rect2:
+ return {"ok": true, "value": value}
+ if value is Array and value.size() == 4:
+ return {
+ "ok": true,
+ "value":
+ Rect2(float(value[0]), float(value[1]), float(value[2]), float(value[3])),
+ }
+ if value is Dictionary:
+ if value.has("x") and value.has("y") and value.has("w") and value.has("h"):
+ return {
+ "ok": true,
+ "value":
+ Rect2(float(value.x), float(value.y), float(value.w), float(value.h)),
+ }
+ if value.has("position") and value.has("size"):
+ var pos := _coerce_for_type(value.position, TYPE_VECTOR2)
+ var sz := _coerce_for_type(value.size, TYPE_VECTOR2)
+ if pos.ok and sz.ok:
+ return {"ok": true, "value": Rect2(pos.value, sz.value)}
+ return {"ok": false}
+ TYPE_NODE_PATH:
+ if value is NodePath:
+ return {"ok": true, "value": value}
+ if value is String:
+ return {"ok": true, "value": NodePath(value)}
+ return {"ok": false}
+ return {"ok": true, "value": value}
+
+
+# CanvasLayer is the canonical HUD parent but isn't a Control, so applying
+# Control-only properties (theme, anchor_preset) to it is a common mistake.
+# The recovery shape is always the same: nest a Control child under the layer.
+static func _canvas_layer_overlay_hint(node_class: String) -> String:
+ if node_class != "CanvasLayer":
+ return ""
+ return (
+ ". CanvasLayer is not a Control — add a Control (e.g. Panel or Control "
+ + "with anchor_preset=full_rect) as its child and apply theme / "
+ + "anchor_preset to that overlay."
+ )
diff --git a/addons/godot_ai/handlers/ui_handler.gd.uid b/addons/godot_ai/handlers/ui_handler.gd.uid
new file mode 100644
index 0000000..f48f841
--- /dev/null
+++ b/addons/godot_ai/handlers/ui_handler.gd.uid
@@ -0,0 +1 @@
+uid://ckm6f1objpgvw
diff --git a/addons/godot_ai/mcp_dock.gd b/addons/godot_ai/mcp_dock.gd
new file mode 100644
index 0000000..1d90189
--- /dev/null
+++ b/addons/godot_ai/mcp_dock.gd
@@ -0,0 +1,2849 @@
+@tool
+class_name McpDock
+extends VBoxContainer
+
+## Editor dock panel showing MCP connection status, client config, and command log.
+##
+## Audit-v2 #360 partial extraction. Two cohesive subpanels live in
+## res://addons/godot_ai/dock_panels/:
+## - log_viewer.gd: MCP request/response log (dev-mode only).
+## - port_picker_panel.gd: spawn-failure escape hatch nested in the crash panel.
+##
+## The audit also called for ServerStatusPanel and ClientRowController
+## extractions; those were *deliberately deferred*. Their UI scatters across
+## the dock layout (status icon at top, crash panel mid, setup section lower;
+## client rows + drift banner + scroll grid spread similarly), so a clean
+## extract-by-panel needs either visible UI reorganization or a coordinator-
+## Node pattern with property-accessor façades on McpDock that re-tangle the
+## very state they claim to move.
+##
+## Client workers and update orchestration have plugin lifetime and are owned
+## by plugin.gd. This replaceable view emits intents and paints copied
+## snapshots/outcomes; detaching it cannot destroy domain work.
+
+const ServerStateScript := preload("res://addons/godot_ai/utils/mcp_server_state.gd")
+const ClientRefreshStateScript := preload("res://addons/godot_ai/utils/mcp_client_refresh_state.gd")
+const Client := preload("res://addons/godot_ai/clients/_base.gd")
+const ClientConfigurator := preload("res://addons/godot_ai/client_configurator.gd")
+const ClientRegistry := preload("res://addons/godot_ai/clients/_registry.gd")
+const ToolCatalog := preload("res://addons/godot_ai/tool_catalog.gd")
+const LogViewerScript := preload("res://addons/godot_ai/dock_panels/log_viewer.gd")
+const PortPickerPanelScript := preload("res://addons/godot_ai/dock_panels/port_picker_panel.gd")
+const VisionRoutingScript := preload("res://addons/godot_ai/vision_routing.gd")
+
+const DEV_MODE_SETTING := "godot_ai/dev_mode"
+## "Change the port + reconfigure your clients" guide. Surfaced from the crash
+## panel when a foreign process holds the HTTP port — the one piece of recovery
+## (per-client config rewrite) that doesn't fit in the inline crash body.
+## Resolved against the installed plugin version at click time (see
+## `_port_conflict_docs_url`) so a shipped build opens the guide as it shipped,
+## not tip-of-main, which may have drifted from that build's UI.
+const PORT_CONFLICT_DOCS_PATH := "docs/port-conflicts.md"
+const REPO_BLOB_BASE := "https://github.com/hi-godot/godot-ai/blob"
+const RELEASES_PAGE := "https://github.com/hi-godot/godot-ai/releases/latest"
+## Opened by the "How to install uv" button. See _on_install_uv for why the
+## dock links here instead of running an installer itself.
+const UV_INSTALL_DOCS_URL := "https://docs.astral.sh/uv/getting-started/installation/"
+static var COLOR_MUTED := Color(0.7, 0.7, 0.7)
+static var COLOR_HEADER := Color(0.95, 0.95, 0.95)
+## Used for "in-progress" / "stale, action needed" UI: the startup-grace
+## status icon, the spawn-failure suggested-port hint, the drift banner,
+## and the per-row mismatch dot. One constant so a future palette tweak
+## doesn't have to find every literal.
+static var COLOR_AMBER := Color(1.0, 0.75, 0.25)
+
+signal update_requested
+signal client_action_requested(client_id: String, action: String)
+signal client_status_refresh_requested(client_ids: Array[String], force: bool)
+signal status_snapshot_requested
+signal live_server_probe_requested(port: int)
+signal lifecycle_action_requested(action: int)
+signal dev_server_action_requested(action: int)
+signal mcp_logging_changed(enabled: bool)
+signal log_snapshot_requested(after_sequence: int)
+signal plugin_reload_requested(reason: String)
+signal settings_apply_requested(changes: Dictionary, reload: bool)
+signal post_update_action_requested(action: String)
+
+enum LifecycleAction { RECOVER_INCOMPATIBLE, RESTART_SERVER }
+enum DevServerAction { START_OR_RESTART, STOP }
+
+## Copied root-owned values only. The Dock never retains Connection, lifecycle,
+## plugin, log-buffer, or process owners.
+var _transport_snapshot: Dictionary = {
+ "connected": false,
+ "server_version": "",
+ "status": {},
+}
+var _lifecycle_snapshot: Dictionary = {
+ "state": ServerStateScript.UNINITIALIZED,
+ "server_pid": -1,
+ "resolved_ws_port": 0,
+ "can_restart_managed": false,
+ "can_recover_incompatible": false,
+ "normal_start_released": false,
+}
+var _live_server_probe_result: Dictionary = {}
+
+# Always visible
+var _redock_btn: Button
+var _status_icon: ColorRect
+var _status_label: Label
+var _body_scroll: ScrollContainer
+var _body: VBoxContainer
+var _client_grid: VBoxContainer
+var _client_configure_all_btn: Button
+var _client_empty_cta_btn: Button
+var _clients_summary_label: Label
+var _clients_window: Window
+var _dev_mode_toggle: CheckButton
+var _install_label: Label
+
+# Tools tab (secondary window, Tab 2) — domain-exclusion UI for clients
+# that cap total tool count (Antigravity: 100). Pending set is mutated by
+# checkbox clicks; saved set reflects what the spawned server actually
+# sees. `Apply and Restart Server` writes pending → setting and triggers a
+# plugin reload so the new server comes up with the trimmed list.
+var _tools_pending_excluded: PackedStringArray = PackedStringArray()
+var _tools_saved_excluded: PackedStringArray = PackedStringArray()
+## Custom (addon-registered) tools list — rebuilt live on registry
+## tools_changed; per-tool checkboxes apply immediately (no restart).
+var _custom_tools_list: VBoxContainer
+var _custom_tools_count_label: Label
+var _tools_domain_checkboxes: Dictionary = {}
+var _tools_count_label: Label
+var _tools_apply_btn: Button
+var _tools_reset_btn: Button
+var _tools_dirty_warning: Label
+var _tools_close_confirm: ConfirmationDialog
+## Update saves the project and relaunches the editor; the user says so first.
+var _update_confirm: ConfirmationDialog
+var _update_candidate_version := ""
+var _telemetry_toggle: CheckButton
+var _telemetry_pending_enabled: bool = true
+var _telemetry_saved_enabled: bool = true
+
+# Settings tab (secondary window, Tab 3) — Vision Routing section plus the
+# LAN opt-in (#507): "Allow remote hosts (CIDR)" behind a collapsed
+# "Remote access (advanced)" disclosure (auto-expands when a non-empty
+# allowlist is configured). The value feeds `--allow-host` at server spawn
+# (see plugin.gd::_build_server_flags). The LineEdit's live text is the
+# pending state; `_allow_hosts_saved` mirrors the persisted EditorSetting,
+# same pending/saved shape as the Tools tab above.
+var _allow_hosts_section: VBoxContainer
+var _allow_hosts_fold: FoldableContainer
+var _allow_hosts_edit: LineEdit
+var _allow_hosts_hint: Label
+var _allow_hosts_apply_btn: Button
+var _allow_hosts_saved: String = ""
+
+## Per-client UI handles, keyed by client id. Each entry holds the row's
+## status dot, configure/remove buttons, config-file buttons, and manual panel.
+var _client_rows: Dictionary = {}
+
+# Drift banner — surfaced near the Clients section when one or more clients
+# have a stored entry whose URL no longer matches `http_url()` (typical after
+# the user changes `godot_ai/http_port`). Refreshes are stale-while-refreshing:
+# cached row dots/banner remain visible while a background worker performs the
+# potentially blocking config/CLI probes, then the main thread applies results.
+# Automatic focus-in refreshes use a short cooldown to avoid repeated sweeps
+# during tab-away/tab-back churn. See #166 and #226.
+var _drift_banner: VBoxContainer
+var _drift_label: Label
+## Set when the user clicks "How to install uv"; consumed by the next
+## application focus-in so the uv row is re-probed after the user has had a
+## chance to install, not immediately. See _on_install_uv and _notification.
+## (Deliberately spelled without the focus-in constant name: the guard in
+## tests/unit/test_editor_focus_refocus.py locates the notification handler
+## by first occurrence of that token.)
+var _uv_recheck_pending := false
+## Handles for the Setup section's "Server" row. `_update_status_label` keeps
+## the label text/color in sync with `McpConnection.server_version` so the
+## dock reports the TRUE running server version, not the plugin's
+## expected version. See #174 follow-up — a plugin upgrade via self-
+## update can leave the plugin connected to an older adopted server
+## (foreign-port branch never sets `_server_pid`, so `_stop_server`
+## can't kill it); the line has to show the mismatch honestly.
+var _setup_server_label: Label
+## Last rendered server-version string. `_update_status_label` runs every
+## frame; early-outs text repaint when nothing changed. Empty means
+## "no line rendered yet" (dev-checkout branch doesn't render a
+## user-mode Server line).
+var _last_rendered_server_text: String = ""
+## Restart-server button shown next to the Setup container when
+## `McpConnection.server_version` drifts from the plugin version. Hidden
+## in the match case so the UI stays calm.
+var _version_restart_btn: Button
+var _server_restart_in_progress := false
+## Sorted snapshot of the most recent mismatched-client set. Powers two things:
+## (a) the Reconfigure button reuses this list instead of re-running
+## `check_status` per row (saves ~18 filesystem reads per click), and
+## (b) `_refresh_drift_banner` early-returns when the set is unchanged so
+## repeated explicit refreshes don't repaint identical text. Mirrors the
+## `_last_server_status` pattern used by the crash panel.
+var _last_mismatched_ids: Array[String] = []
+## Copied plugin-lifetime client-job state. This contains no Thread, mutex,
+## cancellation map, generation, or mutable owner record.
+var _client_work_snapshot: Dictionary = {
+ "accepting_work": false,
+ "refresh_state": ClientRefreshStateScript.IDLE,
+ "refresh_completed": false,
+ "busy_actions": [],
+ "action_names": {},
+ "action_phases": {},
+}
+var _update_install_in_flight := false
+
+# Dev-mode only
+var _dev_section: VBoxContainer
+var _server_label: Label
+var _reload_btn: Button
+var _setup_section: VBoxContainer
+var _setup_container: VBoxContainer
+## Developer controls are a view of the ordinary lifecycle owner. They never
+## start a second process topology or kill an externally launched server.
+var _dev_primary_btn: Button
+## Small "✕" affordance next to the primary — enabled only for the exact
+## plugin-owned process grant.
+var _dev_stop_btn: Button
+var _log_viewer: LogViewerScript
+## Vision Routing (optional) - set by plugin.gd; builds the "Vision Routing"
+## tab in Clients & Tools and the quick toggle under Developer mode.
+var vision_routing: VisionRoutingScript = null
+
+var _last_connected := false
+var _last_status_text := ""
+var _last_status_tooltip := ""
+var _startup_grace_until_msec: int = 0
+
+# Spawn-failure panel — rendered when `get_server_status` reports a
+# non-OK `state`. One panel, one body paragraph per state, no cascading
+# booleans. See `_crash_body_for_state`.
+var _crash_panel: VBoxContainer
+var _crash_output: RichTextLabel
+var _crash_restart_btn: Button
+var _crash_reload_btn: Button
+## Help link — visible only for the genuinely-foreign-occupant INCOMPATIBLE
+## case (no `can_recover_incompatible` proof). The inline body names a free
+## port; this button carries the per-client reconfigure steps that don't fit
+## inline. See `PORT_CONFLICT_DOCS` and `_update_crash_panel`.
+var _crash_docs_btn: Button
+## Port-picker escape hatch — visible inside the crash panel when the root
+## cause is port contention (PORT_EXCLUDED or FOREIGN_PORT). The dock writes
+## the EditorSetting and reloads the plugin in response to the panel's
+## `port_apply_requested` signal.
+var _port_picker_panel: PortPickerPanelScript
+## Last status Dict rendered into the panel — used to skip re-population
+## when nothing changed, which would otherwise reset the user's scroll
+## position on every frame. GDScript Dicts compare by value with `==`.
+var _last_server_status: Dictionary = {}
+
+# First-run grace: uvx installs 60+ Python packages on first run (can take
+# 10-30s on a slow connection). Don't scare users with "Disconnected" during
+# that window — show "Starting server…" instead. After this expires, fall
+# back to the normal disconnect UI.
+const STARTUP_GRACE_MSEC := 60 * 1000
+
+# Update banner — visible UI only. Release polling, ZIP download, and
+# installation remain root-owned and arrive here as copied presentation state.
+var _update_banner: VBoxContainer
+var _update_label: Label
+var _update_status_label: Label
+var _update_btn: Button
+## The button is an action, never a status line: progress and failure text
+## goes to `_update_status_label`, and the button only enables or disables.
+const _UPDATE_ACTION_TEXT := "Update"
+const _UPDATE_LABEL_COLOR := Color(1.0, 0.85, 0.3)
+var _post_update_action := ""
+## True from the moment an update starts its client migration until the
+## server it then starts is connected. The transport reads "blocked" for
+## that whole window (the migration barrier, then the launch), which is
+## not a fault: name the phase instead of alarming the user (#999).
+var _post_update_server_pending := false
+
+func _ready() -> void:
+ _startup_grace_until_msec = Time.get_ticks_msec() + STARTUP_GRACE_MSEC
+ _build_ui()
+
+
+func _process(_delta: float) -> void:
+ _update_status()
+ if _log_viewer != null and _log_viewer.visible:
+ log_snapshot_requested.emit(_log_viewer.sequence())
+
+
+func present_transport_snapshot(snapshot: Dictionary) -> void:
+ _transport_snapshot = snapshot.duplicate(true)
+
+
+func present_lifecycle_snapshot(snapshot: Dictionary) -> void:
+ _lifecycle_snapshot = snapshot.duplicate(true)
+
+
+func present_live_server_probe_result(result: Dictionary) -> void:
+ _live_server_probe_result = result.duplicate(true)
+
+
+func present_log_snapshot(snapshot: Dictionary) -> void:
+ if _log_viewer != null:
+ _log_viewer.present_snapshot(snapshot)
+
+
+func _notification(what: int) -> void:
+ # Detect dock/undock by watching for reparenting events.
+ if what == NOTIFICATION_PARENTED or what == NOTIFICATION_UNPARENTED:
+ _update_redock_visibility.call_deferred()
+ elif what == NOTIFICATION_APPLICATION_FOCUS_IN:
+ if _should_refresh_client_statuses_on_focus_in():
+ _request_client_status_refresh(false)
+ ## Re-probe uv only when the user actually went off to install it
+ ## (see _on_install_uv). `check_uv_version()` is cached, so an
+ ## ungated refresh here would usually be free — but after the
+ ## button invalidated that cache it costs one blocking
+ ## `uvx --version`, and this notification must not grow a probe on
+ ## the common focus-in path. One-shot: clear before refreshing.
+ if _uv_recheck_pending:
+ _uv_recheck_pending = false
+ _refresh_setup_status.call_deferred()
+
+
+## Godot can leave its shared progress dialog under one of our modal windows.
+## Return it before removing the dock: freeing it leaves the editor's pointer dangling.
+func release_editor_progress_dialog() -> void:
+ if not is_inside_tree():
+ return
+ for dialog in find_children("*", "ProgressDialog", true, false):
+ dialog.reparent(get_tree().root)
+
+
+func _should_refresh_client_statuses_on_focus_in() -> bool:
+ ## Focus-in is part of Godot/editor window activation. Keep automatic refresh,
+ ## but only through the async/cooldown-protected path; never run a blocking
+ ## client-status sweep directly from this notification.
+ return true
+
+
+func _is_floating() -> bool:
+ var p := get_parent()
+ while p != null:
+ if p is Window:
+ return p != get_tree().root
+ p = p.get_parent()
+ return false
+
+
+func _update_redock_visibility() -> void:
+ if _redock_btn == null:
+ return
+ var floating := _is_floating()
+ if _redock_btn.visible != floating:
+ _redock_btn.visible = floating
+
+
+func _on_redock() -> void:
+ # When floating, our Window is NOT the editor root. Closing it triggers
+ # Godot's internal dock-return logic (same as clicking the window's X).
+ var win := get_window()
+ if win != null and win != get_tree().root:
+ win.close_requested.emit()
+
+
+func _build_margin_container(margin: int = 12) -> MarginContainer:
+ var margin_container := MarginContainer.new()
+ margin_container.add_theme_constant_override("margin_left", margin)
+ margin_container.add_theme_constant_override("margin_right", margin)
+ margin_container.add_theme_constant_override("margin_top", margin)
+ margin_container.add_theme_constant_override("margin_bottom", margin)
+ return margin_container
+
+
+func _build_ui() -> void:
+ add_theme_constant_override("separation", 8)
+
+ # --- Top row: status indicator + redock button (when floating) ---
+ var status_row := HBoxContainer.new()
+ status_row.add_theme_constant_override("separation", 8)
+
+ _status_icon = ColorRect.new()
+ _status_icon.custom_minimum_size = Vector2(14, 14)
+ # Amber on first paint — matches the "Starting server…" label text and
+ # distinguishes from a real disconnect (red).
+ _status_icon.color = COLOR_AMBER
+ var icon_center := CenterContainer.new()
+ icon_center.add_child(_status_icon)
+ status_row.add_child(icon_center)
+
+ _status_label = Label.new()
+ # Start in grace state — _update_status_label will take over on the next frame
+ # once the connection is available. Never show bare "Disconnected" on
+ # first paint because that's misleading while the server is still
+ # spinning up.
+ _status_label.text = "Starting server…"
+ _status_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ status_row.add_child(_status_label)
+
+ _redock_btn = Button.new()
+ _redock_btn.text = "Dock"
+ _redock_btn.tooltip_text = "Return this panel to the editor dock"
+ _redock_btn.visible = false
+ _redock_btn.pressed.connect(_on_redock)
+ status_row.add_child(_redock_btn)
+
+ add_child(status_row)
+
+ # Install-mode line — so a git-clone user doesn't press the yellow Update
+ # banner below and silently downgrade from main to the last release tag.
+ # See #144.
+ _install_label = Label.new()
+ _install_label.add_theme_color_override("font_color", COLOR_MUTED)
+ _install_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
+ _install_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ _install_label.text = _install_mode_text()
+ _install_label.tooltip_text = _install_mode_tooltip()
+ _install_label.mouse_filter = Control.MOUSE_FILTER_STOP
+ add_child(_install_label)
+
+ _body_scroll = ScrollContainer.new()
+ _body_scroll.name = "DockBodyScroll"
+ _body_scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ _body_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
+ _body_scroll.custom_minimum_size = Vector2(0, 48)
+ _body_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
+ add_child(_body_scroll)
+
+ _body = VBoxContainer.new()
+ _body.name = "DockBody"
+ _body.add_theme_constant_override("separation", 8)
+ _body.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ _body_scroll.add_child(_body)
+
+ # --- Spawn-failure panel (shown when `_start_server` reports a non-OK
+ # state via `get_server_status`). One body paragraph + the matching
+ # action; the top status label already carries the state headline.
+ _crash_panel = VBoxContainer.new()
+ _crash_panel.add_theme_constant_override("separation", 6)
+ _crash_panel.visible = false
+
+ _crash_output = RichTextLabel.new()
+ _crash_output.custom_minimum_size = Vector2(0, 60)
+ _crash_output.bbcode_enabled = false
+ _crash_output.selection_enabled = true
+ _crash_output.scroll_following = false
+ _crash_output.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
+ _crash_output.fit_content = true
+ _crash_panel.add_child(_crash_output)
+
+ _port_picker_panel = PortPickerPanelScript.new()
+ _port_picker_panel.setup()
+ _port_picker_panel.port_apply_requested.connect(_on_port_apply_requested)
+ _crash_panel.add_child(_port_picker_panel)
+
+ _crash_restart_btn = Button.new()
+ _crash_restart_btn.text = "Restart Server"
+ _crash_restart_btn.tooltip_text = "Stop the old server on this port and start the bundled godot-ai server"
+ _crash_restart_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ _crash_restart_btn.add_theme_color_override("font_color", Color.WHITE)
+ _crash_restart_btn.add_theme_color_override("font_hover_color", Color.WHITE)
+ _crash_restart_btn.add_theme_color_override("font_pressed_color", Color.WHITE)
+ _crash_restart_btn.pressed.connect(_on_restart_stale_server)
+ _crash_restart_btn.visible = false
+ _crash_panel.add_child(_crash_restart_btn)
+
+ _crash_reload_btn = Button.new()
+ _crash_reload_btn.text = "Reload Plugin"
+ _crash_reload_btn.tooltip_text = "Re-run the spawn after fixing the underlying issue"
+ _crash_reload_btn.pressed.connect(_on_reload_plugin)
+ _crash_panel.add_child(_crash_reload_btn)
+
+ _crash_docs_btn = Button.new()
+ _crash_docs_btn.text = "How to change the port"
+ _crash_docs_btn.tooltip_text = "Open the guide: change godot_ai/http_port and reconfigure your MCP clients"
+ _crash_docs_btn.visible = false
+ _crash_docs_btn.pressed.connect(func(): OS.shell_open(_port_conflict_docs_url()))
+ _crash_panel.add_child(_crash_docs_btn)
+
+ _crash_panel.add_child(HSeparator.new())
+ _body.add_child(_crash_panel)
+
+ # --- Update banner (top of dock, hidden until check finds a newer version) ---
+ _update_banner = VBoxContainer.new()
+ _update_banner.add_theme_constant_override("separation", 4)
+ _update_banner.visible = false
+
+ _update_label = Label.new()
+ _update_label.add_theme_font_size_override("font_size", 15)
+ _update_label.add_theme_color_override("font_color", _UPDATE_LABEL_COLOR)
+ ## Wrap long banner text (e.g. the < 4.5 support-floor guidance) instead
+ ## of letting a single line stretch the whole dock wide. The dock is a
+ ## fixed-width side panel, so constrain horizontally and wrap.
+ _update_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
+ _update_label.size_flags_horizontal = Control.SIZE_FILL
+ _update_label.custom_minimum_size = Vector2(0, 0)
+ _update_banner.add_child(_update_label)
+
+ _update_status_label = Label.new()
+ _update_status_label.add_theme_font_size_override("font_size", 13)
+ _update_status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
+ _update_status_label.size_flags_horizontal = Control.SIZE_FILL
+ _update_status_label.custom_minimum_size = Vector2(0, 0)
+ _update_status_label.visible = false
+ _update_banner.add_child(_update_status_label)
+
+ var update_btn_row := HBoxContainer.new()
+ update_btn_row.add_theme_constant_override("separation", 6)
+
+ _update_btn = Button.new()
+ _update_btn.text = _UPDATE_ACTION_TEXT
+ _update_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ _update_btn.pressed.connect(_on_update_pressed)
+ update_btn_row.add_child(_update_btn)
+
+ var release_link := Button.new()
+ release_link.text = "Release notes"
+ release_link.pressed.connect(func(): OS.shell_open(RELEASES_PAGE))
+ update_btn_row.add_child(release_link)
+
+ _update_banner.add_child(update_btn_row)
+ _update_banner.add_child(HSeparator.new())
+
+ _body.add_child(_update_banner)
+
+ # --- Dev-only connection extras (server label + reload button) ---
+ _dev_section = VBoxContainer.new()
+ _dev_section.add_theme_constant_override("separation", 6)
+ _body.add_child(_dev_section)
+
+ _server_label = Label.new()
+ _server_label.add_theme_color_override("font_color", COLOR_MUTED)
+ _dev_section.add_child(_server_label)
+ _refresh_server_label()
+
+ var btn_row := HBoxContainer.new()
+ btn_row.add_theme_constant_override("separation", 6)
+
+ _reload_btn = Button.new()
+ _reload_btn.text = "Dev: Reload Plugin"
+ _reload_btn.tooltip_text = "Developer utility: reload the GDScript plugin. This does not restart or replace the server."
+ _reload_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ _reload_btn.pressed.connect(_on_reload_plugin)
+ btn_row.add_child(_reload_btn)
+
+ _dev_section.add_child(btn_row)
+
+ # --- Setup section (dev-only or when uv missing) ---
+ _setup_section = VBoxContainer.new()
+ _setup_section.add_theme_constant_override("separation", 6)
+ _body.add_child(_setup_section)
+
+ _setup_section.add_child(HSeparator.new())
+ _setup_section.add_child(_make_header("Setup"))
+ _setup_container = VBoxContainer.new()
+ _setup_container.add_theme_constant_override("separation", 6)
+ _setup_section.add_child(_setup_container)
+
+ _body.add_child(HSeparator.new())
+
+ # --- Clients ---
+ var clients_header_row := HBoxContainer.new()
+ clients_header_row.add_theme_constant_override("separation", 8)
+
+ var clients_header := _make_header("Clients")
+ clients_header_row.add_child(clients_header)
+
+ _clients_summary_label = Label.new()
+ _clients_summary_label.add_theme_color_override("font_color", COLOR_MUTED)
+ _clients_summary_label.clip_text = true
+ _clients_summary_label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
+ _clients_summary_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ clients_header_row.add_child(_clients_summary_label)
+
+ var clients_actions := HFlowContainer.new()
+ clients_actions.add_theme_constant_override("h_separation", 8)
+ clients_actions.add_theme_constant_override("v_separation", 4)
+
+ var clients_refresh_btn := Button.new()
+ clients_refresh_btn.text = "Refresh"
+ clients_refresh_btn.tooltip_text = "Refresh client status in the background. Cached status stays visible while checks run."
+ clients_refresh_btn.pressed.connect(_on_refresh_clients_pressed)
+ clients_actions.add_child(clients_refresh_btn)
+
+ var clients_open_btn := Button.new()
+ clients_open_btn.text = "Clients & Tools"
+ clients_open_btn.tooltip_text = "Open the Clients & Tools window — configure AI clients, choose telemetry preferences, or disable tool domains to fit under a client's hard tool-count cap (e.g. Antigravity's 100)."
+ clients_open_btn.pressed.connect(_on_open_clients_window)
+ clients_actions.add_child(clients_open_btn)
+
+ _body.add_child(clients_header_row)
+ _body.add_child(clients_actions)
+
+ _client_empty_cta_btn = Button.new()
+ _client_empty_cta_btn.text = "Configure an AI client ->"
+ _client_empty_cta_btn.tooltip_text = "Open the Clients tab to configure an AI coding client for this Godot AI server."
+ _client_empty_cta_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ _client_empty_cta_btn.visible = false
+ _client_empty_cta_btn.pressed.connect(_on_open_clients_window)
+ _body.add_child(_client_empty_cta_btn)
+
+ # Drift banner — hidden until a sweep finds at least one mismatched client.
+ _drift_banner = VBoxContainer.new()
+ _drift_banner.add_theme_constant_override("separation", 4)
+ _drift_banner.visible = false
+ _drift_label = Label.new()
+ _drift_label.add_theme_color_override("font_color", COLOR_AMBER)
+ _drift_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
+ _drift_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ _drift_banner.add_child(_drift_label)
+ var drift_btn := Button.new()
+ drift_btn.text = "Reconfigure mismatched"
+ drift_btn.tooltip_text = "Re-run Configure on every client whose stored URL doesn't match the current server URL."
+ drift_btn.pressed.connect(_on_reconfigure_mismatched)
+ _drift_banner.add_child(drift_btn)
+ _body.add_child(_drift_banner)
+
+ _clients_window = Window.new()
+ _clients_window.title = "Godot AI Settings"
+ ## `Vector2i * float` yields Vector2; wrap the result back to Vector2i.
+ _clients_window.min_size = Vector2i(Vector2(560, 460) * EditorInterface.get_editor_scale())
+ _clients_window.visible = false
+ _clients_window.close_requested.connect(_on_clients_window_close_requested)
+ add_child(_clients_window)
+
+ ## Tabbed secondary window: Clients (per-client rows), Tools (domain-
+ ## exclusion checkboxes for clients that cap total tool count, like
+ ## Antigravity at 100), and Settings (allow-host LAN opt-in, #507).
+ ## Adding another tab is one more _build_*_tab call — no surgery on the
+ ## rest of the window.
+ var tabs := TabContainer.new()
+ tabs.anchor_right = 1.0
+ tabs.anchor_bottom = 1.0
+ _clients_window.add_child(tabs)
+
+ var clients_tab := VBoxContainer.new()
+ clients_tab.add_theme_constant_override("separation", 8)
+ var clients_margin := _build_margin_container()
+ clients_margin.name = "Clients"
+ clients_margin.add_child(clients_tab)
+ tabs.add_child(clients_margin)
+
+ _client_configure_all_btn = Button.new()
+ _client_configure_all_btn.text = "Configure all"
+ _client_configure_all_btn.tooltip_text = "Configure every client that isn't already pointing at this server"
+ _client_configure_all_btn.size_flags_horizontal = Control.SIZE_SHRINK_END
+ _client_configure_all_btn.pressed.connect(_on_configure_all_clients)
+ clients_tab.add_child(_client_configure_all_btn)
+
+ var clients_scroll := ScrollContainer.new()
+ clients_scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ clients_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
+ clients_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
+ clients_tab.add_child(clients_scroll)
+
+ _client_grid = VBoxContainer.new()
+ _client_grid.add_theme_constant_override("separation", 4)
+ _client_grid.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ clients_scroll.add_child(_client_grid)
+
+ for client_id in ClientConfigurator.client_ids():
+ _build_client_row(client_id)
+
+ _build_tools_tab(tabs)
+ _build_settings_tab(tabs)
+
+ _body.add_child(HSeparator.new())
+
+ # --- Dev mode toggle (always visible) ---
+ var dev_toggle_row := HBoxContainer.new()
+ var dev_toggle_label := Label.new()
+ dev_toggle_label.text = "Developer mode"
+ dev_toggle_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ dev_toggle_row.add_child(dev_toggle_label)
+
+ _dev_mode_toggle = CheckButton.new()
+ _dev_mode_toggle.button_pressed = _load_dev_mode()
+ _dev_mode_toggle.toggled.connect(_on_dev_mode_toggled)
+ dev_toggle_row.add_child(_dev_mode_toggle)
+ _body.add_child(dev_toggle_row)
+
+ # --- Log section (dev-only) ---
+ _log_viewer = LogViewerScript.new()
+ _log_viewer.setup()
+ _log_viewer.logging_enabled_changed.connect(_on_log_logging_enabled_changed)
+ _body.add_child(_log_viewer)
+
+ # Apply initial dev-mode visibility
+ _apply_dev_mode_visibility()
+ _refresh_setup_status.call_deferred()
+ _perform_initial_client_status_refresh()
+
+
+## Static so `dock_panels/*.gd` subpanels can call it via `McpDock._make_header(...)`
+## without re-declaring identical helpers + COLOR_HEADER constants.
+static func _make_header(text: String) -> Label:
+ var label := Label.new()
+ label.text = text
+ label.add_theme_font_size_override("font_size", 18)
+ label.add_theme_color_override("font_color", COLOR_HEADER)
+ return label
+
+
+func _build_client_row(client_id: String) -> void:
+ var row := HBoxContainer.new()
+ row.add_theme_constant_override("separation", 6)
+ row.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+
+ var dot := ColorRect.new()
+ dot.custom_minimum_size = Vector2(10, 10)
+ dot.color = COLOR_MUTED
+ var dot_center := CenterContainer.new()
+ dot_center.add_child(dot)
+ row.add_child(dot_center)
+
+ var name_label := Label.new()
+ name_label.text = ClientConfigurator.client_display_name(client_id)
+ name_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ ## Every advertised client uses the authenticated local attach bridge.
+ var transport_tag := Label.new()
+ transport_tag.text = _client_transport_tag(client_id)
+ transport_tag.add_theme_color_override("font_color", COLOR_MUTED)
+ transport_tag.tooltip_text = "Configure writes a local `godot-ai attach` launch command for this client."
+ ## Long error messages from `_verify_post_state` (e.g. "reported remove ok
+ ## but verification still reads configured…") used to push the Retry /
+ ## Configure button off-screen — the row's Label wanted its full text
+ ## width as minimum size, so the buttons got squeezed out. Wrap onto
+ ## multiple lines instead so the row keeps its right edge stable and
+ ## the buttons remain visible; the user can also read the whole message
+ ## without resizing the window.
+ name_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
+ name_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
+ row.add_child(name_label)
+ row.add_child(transport_tag)
+
+ var configure_btn := Button.new()
+ configure_btn.text = "Configure"
+ configure_btn.pressed.connect(_on_configure_client.bind(client_id))
+ row.add_child(configure_btn)
+
+ var remove_btn := Button.new()
+ remove_btn.text = "Remove"
+ remove_btn.visible = false
+ remove_btn.pressed.connect(_on_remove_client.bind(client_id))
+ row.add_child(remove_btn)
+
+ # F-3-4: use the authoritative facade so Open/Reveal land on the same
+ # file `_check_status_merged` drives (last-wins across project tiers,
+ # matching the F2 status fix).
+ var config_path := ClientConfigurator.effective_authoritative_path(client_id)
+ var open_config_btn := Button.new()
+ _apply_editor_icon(open_config_btn, "ExternalLink", "Open")
+ open_config_btn.custom_minimum_size = Vector2(28, 28)
+ open_config_btn.visible = not config_path.is_empty()
+ open_config_btn.pressed.connect(_on_open_config_file.bind(client_id))
+ row.add_child(open_config_btn)
+
+ var reveal_btn := Button.new()
+ _apply_editor_icon(reveal_btn, "Folder", "Reveal")
+ reveal_btn.custom_minimum_size = Vector2(28, 28)
+ reveal_btn.visible = not config_path.is_empty()
+ reveal_btn.pressed.connect(_on_reveal_config_folder.bind(client_id))
+ row.add_child(reveal_btn)
+
+ _client_grid.add_child(row)
+
+ var manual_panel := VBoxContainer.new()
+ manual_panel.add_theme_constant_override("separation", 4)
+ manual_panel.visible = false
+
+ var manual_hint := Label.new()
+ manual_hint.text = "Run this manually:"
+ manual_hint.add_theme_color_override("font_color", COLOR_MUTED)
+ manual_panel.add_child(manual_hint)
+
+ var manual_text := TextEdit.new()
+ manual_text.editable = false
+ manual_text.custom_minimum_size = Vector2(0, 60)
+ manual_text.wrap_mode = TextEdit.LINE_WRAPPING_BOUNDARY
+ manual_panel.add_child(manual_text)
+
+ var copy_btn := Button.new()
+ copy_btn.text = "Copy"
+ copy_btn.pressed.connect(_on_copy_manual_command.bind(client_id))
+ manual_panel.add_child(copy_btn)
+
+ _client_grid.add_child(manual_panel)
+
+ _client_rows[client_id] = {
+ "dot": dot,
+ "status": Client.Status.NOT_CONFIGURED,
+ "name_label": name_label,
+ "configure_btn": configure_btn,
+ "remove_btn": remove_btn,
+ "open_config_btn": open_config_btn,
+ "reveal_btn": reveal_btn,
+ "config_path": config_path,
+ "manual_panel": manual_panel,
+ "manual_text": manual_text,
+ }
+ _refresh_client_config_file_buttons(client_id)
+
+
+func _apply_editor_icon(button: Button, icon_name: String, fallback_text: String) -> void:
+ if has_theme_icon(icon_name, "EditorIcons"):
+ button.icon = get_theme_icon(icon_name, "EditorIcons")
+ else:
+ button.text = fallback_text
+
+
+# --- Status updates ---
+
+func _update_status() -> void:
+ ## Signal delivery is synchronous: the composition root copies current
+ ## transport/lifecycle values into the presentation methods below before
+ ## this frame renders. If no root is attached, the last snapshot remains.
+ status_snapshot_requested.emit()
+ var connected := bool(_transport_snapshot.get("connected", false))
+ var transport_status: Dictionary = _transport_snapshot.get("status", {})
+ var server_status := _lifecycle_snapshot
+ var state: int = int(server_status.get("state", ServerStateScript.UNINITIALIZED))
+ if ServerStateScript.blocks_client_health(state):
+ connected = false
+ if connected:
+ _post_update_server_pending = false
+
+ ## One `match`/`elif` chain, one source of truth. Adding a new
+ ## spawn outcome = one `ServerStateScript` constant + one arm here +
+ ## one body string in `_crash_body_for_state`.
+ ## Default covers both a missing/old Connection instance and an unknown
+ ## future transport phase. Every recognized state below overrides it, so
+ ## startup grace and settled disconnect have one rendering path.
+ var inside_startup_grace := Time.get_ticks_msec() < _startup_grace_until_msec
+ var status_text := "Starting server…" if inside_startup_grace else "Disconnected"
+ var status_color := COLOR_AMBER if inside_startup_grace else Color.RED
+ if _server_restart_in_progress:
+ status_text = "Restarting server..."
+ status_color = COLOR_AMBER
+ elif connected:
+ status_text = _connected_status_text()
+ status_color = Color.GREEN
+ elif bool(server_status.get("handoff_retry_pending", false)):
+ status_text = "Recovering after update…"
+ status_color = COLOR_AMBER
+ elif state == ServerStateScript.CRASHED:
+ var exit_ms: int = server_status.get("exit_ms", 0)
+ status_text = "Server exited after %.1fs" % (exit_ms / 1000.0)
+ status_color = Color.RED
+ elif state == ServerStateScript.PORT_EXCLUDED:
+ status_text = "Port %d reserved by Windows" % ClientConfigurator.http_port()
+ status_color = Color.RED
+ elif state == ServerStateScript.INCOMPATIBLE:
+ status_text = "Incompatible server on port %d" % ClientConfigurator.http_port()
+ status_color = Color.RED
+ elif state == ServerStateScript.FOREIGN_PORT:
+ ## #647: the post-crash probe names the actual conflicting port
+ ## (HTTP or WS) — don't blame port 8000 when 9500 is the occupant.
+ var conflict_port: int = int(server_status.get("conflict_port", 0))
+ if conflict_port <= 0:
+ conflict_port = ClientConfigurator.http_port()
+ status_text = "Port %d held by another process" % conflict_port
+ status_color = Color.RED
+ elif state == ServerStateScript.NO_COMMAND:
+ status_text = "No server command found"
+ status_color = Color.RED
+ elif _post_update_server_pending:
+ ## Every terminal spawn failure matched above; what is left is the
+ ## post-update window where the server is being brought back.
+ status_text = "Finishing update — starting server…"
+ status_color = COLOR_AMBER
+ elif state == ServerStateScript.SPAWNING:
+ status_text = "Starting server…"
+ status_color = COLOR_AMBER
+ elif not transport_status.is_empty():
+ var transport_phase := str(transport_status.get("phase", ""))
+ if transport_phase == "connecting":
+ status_text = _transport_status_text(transport_status)
+ status_color = COLOR_AMBER
+ elif transport_phase == "retrying":
+ status_text = _transport_status_text(transport_status)
+ status_color = COLOR_AMBER
+ elif transport_phase == "closing":
+ status_text = _transport_status_text(transport_status)
+ status_color = COLOR_AMBER
+ elif transport_phase == "blocked":
+ ## Exact terminal labels come from lifecycle state above. This is a
+ ## generic fallback for a blocked connection without a diagnosis.
+ status_text = _transport_status_text(transport_status)
+ status_color = Color.RED
+
+ ## keep_server_on_exit (#800): the reaper env opt-outs are staged at
+ ## spawn, so a mid-session toggle only lands on the next server start —
+ ## say so while the running server still carries the old behavior.
+ if connected and ClientConfigurator.keep_server_on_exit() != bool(server_status.get("keep_alive", false)):
+ status_text += " — keep-server-on-exit applies after Restart"
+
+ _update_crash_panel(server_status)
+ _refresh_server_version_label(server_status)
+ _refresh_server_label(server_status)
+
+ ## A transient disconnect reason remains in the transport snapshot until
+ ## handshake_ack. Once the dock renders the connection as OPEN, do not pair
+ ## its green label with the previous peer's recovery diagnostic.
+ var status_tooltip := "" if connected else str(transport_status.get("reason", ""))
+ var changed: bool = (
+ connected != _last_connected
+ or status_text != _last_status_text
+ or status_tooltip != _last_status_tooltip
+ )
+ if not changed:
+ return
+ var just_connected: bool = connected and not _last_connected
+ _last_connected = connected
+ _last_status_text = status_text
+ _last_status_tooltip = status_tooltip
+ _status_icon.color = status_color
+ _status_label.text = status_text
+ _status_label.tooltip_text = status_tooltip
+ if just_connected:
+ ## #739: the server just came up. If the startup uv probe failed
+ ## (the reporter's screenshot: green "Server connected" beside a
+ ## red "uv: not found" row), the failure was transient — re-probe
+ ## instead of pinning the red row for the whole session. Runs
+ ## AFTER the label writes above and via the deferred queue, so the
+ ## status-machine state is committed before the probe can block.
+ _schedule_uv_reprobe()
+
+ ## Status transitions are exactly when "is the launch still settling?"
+ ## can change (Starting server… -> connected / Disconnected / terminal
+ ## diagnosis), so re-evaluate the Setup section's visibility here (#744).
+ ## Cheap: runs only on `changed`, and the uv probe result is cached.
+ _apply_dev_mode_visibility()
+
+ _update_dev_section_buttons()
+
+
+## Render the diagnostic panel body for a given spawn state. The top
+## status label already names the problem; this answers "what do I do?".
+## Panel shows for any non-OK state; picker shows only when moving the HTTP
+## port alone is a valid recovery. Incompatible godot-ai servers commonly
+## hold both HTTP and WS ports, so their message points to Editor Settings
+## instead of offering the HTTP-only quick picker.
+func _update_crash_panel(server_status: Dictionary) -> void:
+ var state: int = int(server_status.get("state", ServerStateScript.UNINITIALIZED))
+ if not ServerStateScript.is_terminal_diagnosis(state):
+ if _crash_panel.visible:
+ _crash_panel.visible = false
+ _last_server_status = {}
+ return
+ if server_status == _last_server_status:
+ return
+ _last_server_status = server_status.duplicate()
+ _crash_panel.visible = true
+ _crash_output.clear()
+ _crash_output.add_text(_crash_body_for_state(state, server_status))
+ var show_recovery_restart := (
+ state == ServerStateScript.INCOMPATIBLE
+ and bool(server_status.get("can_recover_incompatible", false))
+ )
+ if _crash_restart_btn != null:
+ _crash_restart_btn.visible = show_recovery_restart
+ _crash_restart_btn.disabled = _server_restart_in_progress
+ _crash_restart_btn.text = "Restarting..." if _server_restart_in_progress else "Restart Server"
+ if _crash_reload_btn != null:
+ _crash_reload_btn.visible = (
+ not show_recovery_restart
+ and state != ServerStateScript.INCOMPATIBLE
+ )
+ ## Docs link only for the genuinely-foreign occupant: a recoverable
+ ## (older godot-ai) server gets Restart Server instead, and the inline
+ ## body already names a free port — the link carries the per-client
+ ## reconfigure steps that don't fit inline.
+ if _crash_docs_btn != null:
+ _crash_docs_btn.visible = (
+ state == ServerStateScript.INCOMPATIBLE
+ and not bool(server_status.get("can_recover_incompatible", false))
+ )
+
+ ## The picker moves both ports (#647 hid it for a WebSocket-side
+ ## conflict when it could only move the HTTP port), seeded with the
+ ## diagnosed conflict so only the contested port changes.
+ var conflict_port := int(server_status.get("conflict_port", 0))
+ var port_picker_visible := (
+ state == ServerStateScript.PORT_EXCLUDED or state == ServerStateScript.FOREIGN_PORT
+ or (state == ServerStateScript.INCOMPATIBLE
+ and not bool(server_status.get("can_recover_incompatible", false)))
+ )
+ _port_picker_panel.visible = port_picker_visible
+ if port_picker_visible:
+ _port_picker_panel.seed_suggested_ports(conflict_port)
+
+
+static func _crash_body_for_state(state: int, server_status: Dictionary = {}) -> String:
+ ## Single sentence per state. The top status label already names the
+ ## problem; don't repeat it here. This copy answers "what do I do?".
+ var port := ClientConfigurator.http_port()
+ match state:
+ ServerStateScript.PORT_EXCLUDED:
+ return "Windows (Hyper-V / WSL2 / Docker) reserved port %d. Pick a free port or try `net stop winnat; net start winnat` in an admin shell." % port
+ ServerStateScript.INCOMPATIBLE:
+ var message := str(server_status.get("message", ""))
+ if bool(server_status.get("can_recover_incompatible", false)):
+ var expected := str(server_status.get("expected_version", ""))
+ if expected.is_empty():
+ expected = ClientConfigurator.get_plugin_version()
+ if not message.is_empty():
+ return "%s Click Restart Server below to replace it with godot-ai v%s." % [message, expected]
+ return "Port %d is occupied by an older godot-ai server. Click Restart Server below to replace it with godot-ai v%s." % [port, expected]
+ ## Genuinely foreign occupant (no recovery proof). Name a concrete
+ ## free port so the user doesn't have to hunt for one, and let the
+ ## crash panel's "How to change the port" link carry the per-client
+ ## reconfigure steps. `suggest_free_port` already routes through the
+ ## Windows reservation table, so the named port won't itself fail
+ ## with WinError 10013.
+ var hint := _free_port_hint(port)
+ if not message.is_empty():
+ return "%s %s" % [message, hint]
+ return "Port %d is occupied by an incompatible server. %s" % [port, hint]
+ ServerStateScript.FOREIGN_PORT:
+ ## #647: prefer the lifecycle's diagnosis (it names the right
+ ## port — HTTP vs WS — and the Editor Setting to change) over
+ ## the generic HTTP-port fallback.
+ var foreign_message := str(server_status.get("message", ""))
+ if not foreign_message.is_empty():
+ return foreign_message
+ return "Another process is already bound to port %d. Pick a free port or stop the other process." % port
+ ServerStateScript.CRASHED:
+ ## #805: a specific crash diagnosis from the lifecycle (e.g. the
+ ## flapping-occupant latch) beats the generic launch-mode copy.
+ ## Generic crash paths clear the message, so stale text from an
+ ## earlier state can't leak in here.
+ var crash_message := str(server_status.get("message", ""))
+ if not crash_message.is_empty():
+ return crash_message
+ ## Both spawn attempts failed on the uvx tier — stock releases:
+ ## PyPI lag. Local builds (version with +metadata): almost always the
+ ## dev venv was not found (unresolved junction/symlink) so uvx tried
+ ## a pin that may lack checkout-local extras.
+ if ClientConfigurator.get_server_launch_mode() == "uvx":
+ var version := ClientConfigurator.get_plugin_version()
+ var pin := ClientConfigurator._pypi_pin_version(version)
+ if pin != version:
+ ## `%` binds tighter than `+` in GDScript — format the fully
+ ## concatenated string, never the last fragment alone.
+ return (
+ "The server exited before the WebSocket handshake. "
+ + "Local plugin version is %s (PEP 440 local build metadata) — uvx pins PyPI godot-ai==%s. "
+ + "If you need checkout-local server code, ensure addons/godot_ai resolves to your "
+ + "dev tree (symlink/junction) with a `.venv`, or set GODOT_AI_VENV_PYTHON to that "
+ + "venv's python binary, then Reload Plugin. Log should show 'MCP | using dev venv: ...'."
+ ) % [version, pin]
+ return (
+ "The server exited before the WebSocket handshake, even after a `uvx --refresh` retry. "
+ + "If this is a brand-new release, PyPI's index may still be propagating (~10 min). "
+ + "Wait a moment and click Reload Plugin to retry, or check Godot's output log for Python's traceback. "
+ + "Target: godot-ai==%s."
+ ) % pin
+ return "The server exited before the WebSocket handshake. Check Godot's output log (bottom panel) for Python's traceback."
+ ServerStateScript.NO_COMMAND:
+ return "No godot-ai server found. Install `uv` via the Setup panel above, or run `pip install godot-ai`."
+ _:
+ return ""
+
+
+## One sentence naming concrete free ports for the user to switch to. Names
+## BOTH http and ws: this branch also fires for an incompatible godot-ai
+## server we can't prove we own, which commonly holds both ports — moving only
+## http would then leave the new server unable to bind ws. Both suggestions are
+## routed through `suggest_free_port` so they clear Windows' winnat reservation
+## table (no point suggesting a port that 10013s on bind). Both ports reach
+## the client's attach command, so clients must be reconfigured afterwards.
+## The per-client reconfigure steps live behind the crash panel's docs link.
+static func _free_port_hint(port: int) -> String:
+ var free_http := ClientConfigurator.suggest_free_port(port + 1)
+ var free_ws := ClientConfigurator.suggest_free_port(ClientConfigurator.ws_port() + 1)
+ return "Suggested ports: %d (HTTP) and %d (WS). Choose both ports below, click Apply + Reload, then Configure your AI clients to use the new pair." % [free_http, free_ws]
+
+
+## URL for the port-conflict guide, pinned to the release tag that matches the
+## installed plugin version (releases are tagged `v`). The crash-panel
+## button only exists in builds that ship `docs/port-conflicts.md`, so the
+## versioned ref always resolves — and a shipped build never points users at a
+## tip-of-main guide that has drifted from its own UI.
+static func _port_conflict_docs_url() -> String:
+ var version := ClientConfigurator.get_plugin_version()
+ var git_ref := ("v%s" % version) if not version.is_empty() else "main"
+ return "%s/%s/%s" % [REPO_BLOB_BASE, git_ref, PORT_CONFLICT_DOCS_PATH]
+
+
+## Signal handler for the extracted LogViewer — the panel owns its own
+## display visibility, the dock owns logging routing. Routes to BOTH the
+## dispatcher (gates [recv]/[send] recording) and the log buffer's console
+## echo — the connection logs [event]/[defer] lines directly to the buffer,
+## bypassing the dispatcher, so gating only `mcp_logging` left the console
+## spamming with the toggle off (#626). Ring recording is unaffected, so
+## the dock's log panel keeps working while the console stays quiet.
+func _on_log_logging_enabled_changed(enabled: bool) -> void:
+ mcp_logging_changed.emit(enabled)
+
+
+## Signal handler for the extracted PortPickerPanel. The replaceable Dock emits
+## a copied value intent; the composition root owns persistence and reload.
+func _on_port_apply_requested(new_http_port: int, new_ws_port: int) -> void:
+ settings_apply_requested.emit({"http_port": new_http_port, "ws_port": new_ws_port}, true)
+
+
+func _refresh_server_label(server_status: Dictionary = {}) -> void:
+ if _server_label == null:
+ return
+ var ws_port := int(_lifecycle_snapshot.get("resolved_ws_port", 0))
+ if ws_port <= 0:
+ ws_port = ClientConfigurator.ws_port()
+ var text := "WS: %d HTTP: %d" % [ws_port, ClientConfigurator.http_port()]
+ if server_status.is_empty():
+ server_status = _lifecycle_snapshot
+ var ownership := _server_ownership_tag(
+ int(server_status.get("state", ServerStateScript.UNINITIALIZED)),
+ int(server_status.get("server_pid", -1)),
+ )
+ if not ownership.is_empty():
+ text += " · %s" % ownership
+ _server_label.text = text
+
+
+## #838/#816 step 11: name which backend flavor the editor is riding.
+## Diagnostic display only — never kill proof (external adoption clears PID
+## authority, see server_lifecycle.gd::adopt_compatible_server / #669).
+static func _server_ownership_tag(state: int, server_pid: int) -> String:
+ if state != ServerStateScript.READY:
+ return ""
+ return "plugin-managed backend" if server_pid > 0 else "externally adopted backend"
+
+
+static func _client_transport_tag(client_id: String) -> String:
+ var client := ClientRegistry.get_by_id(client_id)
+ if client == null:
+ return ""
+ return "attach"
+
+
+# --- Telemetry setting persistence ---
+
+
+## Returns true if GODOT_AI_DISABLE_TELEMETRY or DISABLE_TELEMETRY is set
+## to a truthy value, false if either is set and non-truthy, null if neither
+## env var is present at all.
+func _is_telemetry_disabled_via_env() -> Variant:
+ if not (OS.has_environment("GODOT_AI_DISABLE_TELEMETRY") or OS.has_environment("DISABLE_TELEMETRY")):
+ return null
+ return McpSettings.env_truthy("GODOT_AI_DISABLE_TELEMETRY") or McpSettings.env_truthy("DISABLE_TELEMETRY")
+
+
+## Reads the telemetry preference, applying env-var override when present.
+## Initialises _telemetry_pending_enabled / _telemetry_saved_enabled and
+## sets the checkbox state + locked tooltip. Call after _telemetry_toggle
+## has been created.
+func _load_telemetry_setting() -> void:
+ var env_disabled = _is_telemetry_disabled_via_env()
+
+ var enabled: bool
+ if env_disabled != null:
+ ## Environment policy controls this activation but does not rewrite the
+ ## user's persisted preference.
+ enabled = not bool(env_disabled)
+ else:
+ ## Defaults are registered by the composition root before Dock creation.
+ var es := EditorInterface.get_editor_settings()
+ if es != null and es.has_setting(McpSettings.SETTING_TELEMETRY_ENABLED):
+ enabled = bool(es.get_setting(McpSettings.SETTING_TELEMETRY_ENABLED))
+ else:
+ enabled = true
+
+ _telemetry_pending_enabled = enabled
+ _telemetry_saved_enabled = enabled
+
+ if _telemetry_toggle == null:
+ return
+ _telemetry_toggle.set_pressed_no_signal(enabled)
+ if env_disabled != null:
+ _telemetry_toggle.disabled = true
+ _telemetry_toggle.tooltip_text = (
+ "Telemetry is controlled by an environment variable "
+ + "(GODOT_AI_DISABLE_TELEMETRY / DISABLE_TELEMETRY)."
+ )
+ else:
+ _telemetry_toggle.disabled = false
+ _telemetry_toggle.tooltip_text = _live_telemetry_tooltip(enabled)
+
+
+func _on_telemetry_toggled(pressed: bool) -> void:
+ _telemetry_pending_enabled = pressed
+ if _telemetry_toggle != null:
+ _telemetry_toggle.tooltip_text = _live_telemetry_tooltip(pressed)
+ _refresh_tools_ui_state()
+
+
+## Report the running server's telemetry state, not just this editor's
+## checkbox, so the two can never silently disagree (#913). Applying an
+## opt-out reaches the live server over the authenticated WebSocket and
+## latches there, so both directions of a disagreement are real states worth
+## explaining — not just the unreachable one.
+func _live_telemetry_tooltip(local_enabled: bool) -> String:
+ _live_server_probe_result = {}
+ live_server_probe_requested.emit(ClientConfigurator.http_port())
+ var live := _live_server_probe_result
+ if not (live.get("telemetry_enabled") is bool):
+ ## Absent means "too old to publish it", not false. Say nothing.
+ return ""
+ var server_enabled: bool = live.get("telemetry_enabled")
+ if server_enabled == local_enabled:
+ return "Running server telemetry is %s." % ("on" if server_enabled else "off")
+ if local_enabled:
+ return (
+ "The running server has telemetry off and will stay off until it is "
+ + "replaced. An opt-out — this editor's earlier one, another editor "
+ + "sharing this server, or GODOT_AI_DISABLE_TELEMETRY in its "
+ + "environment — latched it. Restart the server to apply this."
+ )
+ return (
+ "The running server still has telemetry on. Applying sends it an "
+ + "opt-out over the authenticated connection, which it honors for the "
+ + "rest of its life."
+ )
+
+
+# --- Dev mode persistence ---
+
+
+func _load_dev_mode() -> bool:
+ # Default OFF for every install (including dev checkouts). Contributors
+ # who want the extra diagnostic UI (Reload Plugin, MCP log
+ # panel, Start/Stop Dev Server) can flip the toggle once — editor
+ # settings persist across sessions.
+ var es := EditorInterface.get_editor_settings()
+ if es == null:
+ return false
+ if not es.has_setting(DEV_MODE_SETTING):
+ es.set_setting(DEV_MODE_SETTING, false)
+ return false
+ return bool(es.get_setting(DEV_MODE_SETTING))
+
+
+func _on_dev_mode_toggled(enabled: bool) -> void:
+ var es := EditorInterface.get_editor_settings()
+ if es != null:
+ es.set_setting(DEV_MODE_SETTING, enabled)
+ _apply_dev_mode_visibility()
+ _refresh_setup_status()
+
+
+func _apply_dev_mode_visibility() -> void:
+ if _dev_mode_toggle == null:
+ return ## dock UI not built yet (unit tests, teardown window)
+ var dev := _dev_mode_toggle.button_pressed
+ _dev_section.visible = dev
+ if _log_viewer != null:
+ _log_viewer.visible = dev
+ # Setup section: visible in dev mode, OR in user mode when uv is missing
+ # (so users can install uv from the dock) — but not while the server
+ # launch is still settling (#744): mid-launch a red "uv: not found" row
+ # is usually a transient probe failure (#739) or irrelevant because the
+ # launch is succeeding via the .venv or system tiers. `_update_status_label`
+ # re-applies visibility on every status transition, so the section
+ # appears the moment the launch outcome makes it relevant.
+ var is_dev := ClientConfigurator.is_dev_checkout()
+ var uv_missing := not is_dev and ClientConfigurator.check_uv_version().is_empty()
+ _setup_section.visible = _setup_section_should_show(dev, uv_missing, _server_launch_pending())
+
+
+## Pure visibility decision for the Setup section (#744). Split out so the
+## truth table is unit-testable without faking the uv probe or a dev
+## checkout: dev toggle always shows the section; a missing uv only shows
+## it once the server launch has settled.
+static func _setup_section_should_show(
+ dev_toggle: bool, uv_missing: bool, launch_pending: bool
+) -> bool:
+ return dev_toggle or (uv_missing and not launch_pending)
+
+
+## True while the server launch outcome is still unknown: not connected,
+## no terminal diagnosis yet, and the startup grace window ("Starting
+## server…" in the status row) is still running. Mirrors the status-label
+## logic in `_update_status_label` so the Setup section and the amber status
+## text agree on what "still launching" means.
+func _server_launch_pending() -> bool:
+ if _last_connected:
+ return false
+ var state := int(_lifecycle_snapshot.get("state", ServerStateScript.UNINITIALIZED))
+ if ServerStateScript.is_terminal_diagnosis(state):
+ return false
+ return Time.get_ticks_msec() < _startup_grace_until_msec
+
+
+# --- Button handlers ---
+
+
+func _on_reload_plugin() -> void:
+ plugin_reload_requested.emit("dock_button")
+
+
+## Setup-section "Server" row: always report the TRUE running server
+## version (from the handshake_ack) rather than the plugin's expected
+## version, and highlight the mismatch so self-update drift is visible
+## at a glance instead of silently masked by a green label.
+##
+## Render states, keyed off live version metadata:
+## - empty (pre-ack): show the expected version only as an unverified target
+## - matches plugin: show it green, no Restart button
+## - dev mismatch: show amber with an explicit dev marker
+## - release mismatch: show actual vs expected; only surface Restart when the
+## plugin has ownership proof for the process
+func _refresh_server_version_label(server_status: Dictionary = {}) -> void:
+ if _setup_server_label == null:
+ return
+ var plugin_ver := ClientConfigurator.get_plugin_version()
+ if server_status.is_empty():
+ server_status = _lifecycle_snapshot
+ var server_ver := str(_transport_snapshot.get("server_version", ""))
+ if server_ver.is_empty():
+ server_ver = str(server_status.get("actual_version", ""))
+ var expected_ver := str(server_status.get("expected_version", ""))
+ if expected_ver.is_empty():
+ expected_ver = plugin_ver
+ var state: int = int(server_status.get("state", ServerStateScript.UNINITIALIZED))
+ if _server_restart_in_progress and (
+ server_ver == expected_ver
+ or (
+ ServerStateScript.is_terminal_diagnosis(state)
+ and state != ServerStateScript.INCOMPATIBLE
+ )
+ ):
+ _server_restart_in_progress = false
+ var text: String
+ var color: Color
+ var show_restart := false
+ if _server_restart_in_progress:
+ text = "restarting server..."
+ color = COLOR_AMBER
+ show_restart = true
+ elif server_ver.is_empty():
+ text = "checking live version (expected godot-ai == %s)" % expected_ver
+ color = COLOR_MUTED
+ elif server_ver == expected_ver:
+ text = "godot-ai == %s" % server_ver
+ color = Color.GREEN
+ else:
+ text = "godot-ai == %s (expected %s)" % [server_ver, expected_ver]
+ var is_incompatible: bool = state == ServerStateScript.INCOMPATIBLE
+ color = Color.RED if is_incompatible else COLOR_AMBER
+ var has_managed_proof := bool(server_status.get("can_restart_managed", false))
+ var can_recover: bool = bool(server_status.get("can_recover_incompatible", false))
+ show_restart = (
+ (not is_incompatible and has_managed_proof)
+ ## Recoverable incompatible servers get the primary action in
+ ## the top error panel. Duplicating it in Setup made the UI
+ ## look like it had multiple restart paths.
+ or (is_incompatible and can_recover and _crash_restart_btn == null)
+ )
+ if text == _last_rendered_server_text:
+ _setup_server_label.add_theme_color_override("font_color", color)
+ _update_restart_button(show_restart)
+ return
+ _last_rendered_server_text = text
+ _setup_server_label.text = text
+ _setup_server_label.add_theme_color_override("font_color", color)
+ _update_restart_button(show_restart)
+
+
+func _update_restart_button(visible: bool) -> void:
+ if _version_restart_btn != null:
+ _version_restart_btn.visible = visible
+ _version_restart_btn.disabled = _server_restart_in_progress
+ _version_restart_btn.text = "Restarting..." if _server_restart_in_progress else "Restart"
+ if _crash_restart_btn != null:
+ _crash_restart_btn.disabled = _server_restart_in_progress
+ _crash_restart_btn.text = "Restarting..." if _server_restart_in_progress else "Restart Server"
+
+
+func _on_restart_stale_server() -> void:
+ if _server_restart_in_progress:
+ return
+ _server_restart_in_progress = true
+ _last_rendered_server_text = ""
+ _refresh_server_version_label()
+ if not is_inside_tree():
+ _dispatch_stale_server_restart()
+ _server_restart_in_progress = false
+ _last_rendered_server_text = ""
+ _refresh_server_version_label()
+ return
+ call_deferred("_restart_stale_server_after_feedback")
+
+
+func _restart_stale_server_after_feedback() -> void:
+ await get_tree().create_timer(0.15).timeout
+ if not _dispatch_stale_server_restart():
+ _server_restart_in_progress = false
+ _last_rendered_server_text = ""
+ _refresh_server_version_label()
+
+
+func _dispatch_stale_server_restart() -> bool:
+ if int(_lifecycle_snapshot.get("state", ServerStateScript.UNINITIALIZED)) == ServerStateScript.INCOMPATIBLE:
+ lifecycle_action_requested.emit(LifecycleAction.RECOVER_INCOMPATIBLE)
+ return true
+ if not bool(_lifecycle_snapshot.get("can_restart_managed", false)):
+ return false
+ lifecycle_action_requested.emit(LifecycleAction.RESTART_SERVER)
+ return true
+
+
+## Root acknowledgement for an asynchronous lifecycle intent. Successful
+## restarts stay busy until the next lifecycle/transport snapshot settles;
+## rejection restores the controls immediately.
+func present_lifecycle_action_result(accepted: bool) -> void:
+ if accepted:
+ return
+ _server_restart_in_progress = false
+ _last_rendered_server_text = ""
+ _refresh_server_version_label()
+
+
+# --- Setup section ---
+
+## #739: a `uvx --version` probe that failed once at editor startup used
+## to pin "uv: not found" for the whole session — the Install-uv click
+## was the only invalidation path, so the fix users discovered was
+## re-clicking Install on every launch. Re-probe on events that suggest
+## the failure was transient (server-connect transition, manual Refresh).
+## No-op once uv has been found, so this costs nothing in the healthy
+## steady state; when uv is genuinely absent, the re-probe is a fast
+## negative (CliFinder's well-known-dir walk plus one bounded `where`).
+## Runs on the main thread like the initial probe — same wall-clock
+## bound, and the triggering events are rare (once per connect / click).
+##
+## Callers go through _schedule_uv_reprobe() rather than calling this
+## inline: the cache-miss probe shells out (bounded at 3s) on the
+## calling thread, and both call sites sit mid-flow in UI handlers —
+## the connect transition wants its status-label writes committed
+## first, and the Refresh click wants the client sweep dispatched
+## without waiting on the probe. Same deferred convention as
+## _on_install_uv. (The deferred queue still flushes on the main
+## thread, so a worst-case 3s probe delays that frame — acceptable for
+## a rare, bounded event; a worker thread would be the heavier cure.)
+func _schedule_uv_reprobe() -> void:
+ _reprobe_uv_if_negative.call_deferred()
+
+
+func _reprobe_uv_if_negative() -> void:
+ if not ClientConfigurator.uv_probe_negative():
+ return
+ ClientConfigurator.invalidate_uv_detection()
+ _refresh_setup_status()
+ _apply_dev_mode_visibility()
+
+
+func _refresh_setup_status() -> void:
+ if _setup_container == null:
+ return
+ for child in _setup_container.get_children():
+ child.queue_free()
+ _dev_primary_btn = null
+ _dev_stop_btn = null
+
+ var is_dev := ClientConfigurator.is_dev_checkout()
+ if is_dev:
+ _setup_container.add_child(_make_status_row("Mode", "Dev (venv)", Color.CYAN))
+
+ var btn_row := HBoxContainer.new()
+ btn_row.add_theme_constant_override("separation", 4)
+ btn_row.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+
+ _dev_primary_btn = Button.new()
+ _dev_primary_btn.text = "Restart Managed Server"
+ _dev_primary_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ _dev_primary_btn.pressed.connect(_on_dev_primary_pressed)
+ btn_row.add_child(_dev_primary_btn)
+
+ _dev_stop_btn = Button.new()
+ _dev_stop_btn.text = "✕"
+ _dev_stop_btn.tooltip_text = "Stop the exact plugin-owned server."
+ _dev_stop_btn.pressed.connect(_on_dev_stop_pressed)
+ btn_row.add_child(_dev_stop_btn)
+
+ _setup_container.add_child(btn_row)
+ _update_dev_section_buttons()
+ return
+
+ # User mode — check for uv
+ var uv_version := ClientConfigurator.check_uv_version()
+ if not uv_version.is_empty():
+ var compact_uv_version := _compact_uv_version_text(uv_version)
+ var uv_tooltip := uv_version if compact_uv_version != uv_version else ""
+ _setup_container.add_child(_make_status_row("uv", compact_uv_version, Color.GREEN, uv_tooltip))
+ ## Build the Server row with a placeholder label we can update every
+ ## frame. `_refresh_server_version_label` replaces the text + color
+ ## once `McpConnection.server_version` lands via `handshake_ack`, and
+ ## flips to amber + "(plugin X)" on drift. Pre-ack we show the
+ ## plugin's expected version so the row isn't blank.
+ var server_row := HBoxContainer.new()
+ server_row.add_theme_constant_override("separation", 8)
+ var key_label := Label.new()
+ key_label.text = "Server"
+ key_label.add_theme_color_override("font_color", COLOR_MUTED)
+ key_label.custom_minimum_size = Vector2(60, 0)
+ server_row.add_child(key_label)
+ _setup_server_label = Label.new()
+ _setup_server_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ server_row.add_child(_setup_server_label)
+ _version_restart_btn = Button.new()
+ _version_restart_btn.text = "Restart"
+ _version_restart_btn.tooltip_text = "Kill the server on port %d and respawn with the plugin's bundled version" % ClientConfigurator.http_port()
+ _version_restart_btn.pressed.connect(_on_restart_stale_server)
+ _version_restart_btn.visible = false
+ server_row.add_child(_version_restart_btn)
+ _setup_container.add_child(server_row)
+ _last_rendered_server_text = ""
+ _refresh_server_version_label()
+ else:
+ _setup_container.add_child(_make_status_row("uv", "not found", Color.RED))
+ var install_btn := Button.new()
+ install_btn.text = "How to install uv"
+ install_btn.tooltip_text = (
+ "Opens the official uv installation docs. Godot AI deliberately does "
+ + "not run the installer for you — see _on_install_uv."
+ )
+ install_btn.pressed.connect(_on_install_uv)
+ _setup_container.add_child(install_btn)
+
+
+func _install_mode_text() -> String:
+ if ClientConfigurator.is_dev_checkout():
+ return "Install: dev checkout — update via git pull"
+ return "Install: v%s" % ClientConfigurator.get_plugin_version()
+
+
+func _install_mode_tooltip() -> String:
+ if not ClientConfigurator.is_dev_checkout():
+ return "Plugin installed from a release ZIP, Asset Library, or source copy. Update button in this dock downloads the latest GitHub release."
+ var target := _resolve_plugin_symlink_target()
+ if target.is_empty():
+ return "Plugin source tree resolved via local .venv — press Reload Plugin after editing."
+ return "Plugin source: %s\nPress Reload Plugin after editing." % target
+
+
+func _resolve_plugin_symlink_target() -> String:
+ var logical := ProjectSettings.globalize_path("res://addons/godot_ai").rstrip("/").rstrip("\\")
+ var resolved := ClientConfigurator.resolve_addons_realpath()
+ if resolved.is_empty() or resolved == logical:
+ return ""
+ return resolved
+
+
+static func _compact_uv_version_text(uv_version: String) -> String:
+ var text := uv_version.strip_edges()
+ if text.ends_with(")"):
+ var metadata_start := text.rfind(" (")
+ if metadata_start >= 0:
+ return text.substr(0, metadata_start).strip_edges()
+ return text
+
+
+func _make_status_row(
+ label_text: String,
+ value_text: String,
+ value_color: Color,
+ tooltip_text: String = ""
+) -> HBoxContainer:
+ var row := HBoxContainer.new()
+ row.add_theme_constant_override("separation", 6)
+ if not tooltip_text.is_empty():
+ row.tooltip_text = tooltip_text
+
+ var label := Label.new()
+ label.text = label_text
+ label.add_theme_color_override("font_color", COLOR_MUTED)
+ label.custom_minimum_size.x = 60
+ if not tooltip_text.is_empty():
+ label.tooltip_text = tooltip_text
+ row.add_child(label)
+
+ var value := Label.new()
+ value.text = value_text
+ value.add_theme_color_override("font_color", value_color)
+ if not tooltip_text.is_empty():
+ value.tooltip_text = tooltip_text
+ row.add_child(value)
+
+ return row
+
+
+## Pure view of the lifecycle's process authority. "External" means transport
+## may be adopted, but this plugin has no right to stop its launcher.
+static func _dev_primary_btn_state(managed: bool, external: bool) -> Dictionary:
+ var port := ClientConfigurator.http_port()
+ if managed:
+ return {
+ "text": "Restart Managed Server",
+ "enabled": true,
+ "tooltip": "Restart the exact plugin-owned server on port %d." % port,
+ }
+ if external:
+ return {
+ "text": "External Server Running",
+ "enabled": false,
+ "tooltip": "Stop the server on port %d from the process that launched it." % port,
+ }
+ return {
+ "text": "Start Managed Server",
+ "enabled": true,
+ "tooltip": "Start a plugin-owned server on port %d from the current checkout." % port,
+ }
+
+
+static func _dev_stop_btn_state(managed: bool) -> Dictionary:
+ if managed:
+ return {"enabled": true, "tooltip": "Stop the exact plugin-owned server."}
+ return {"enabled": false, "tooltip": "No plugin-owned server to stop."}
+
+
+func _on_dev_primary_pressed() -> void:
+ if _server_restart_in_progress:
+ return
+ _server_restart_in_progress = true
+ _update_dev_section_buttons()
+ if not is_inside_tree():
+ ## Test path — no scene tree means no timer; run synchronously
+ ## so suite assertions see the dispatch without `await`.
+ dev_server_action_requested.emit(DevServerAction.START_OR_RESTART)
+ _server_restart_in_progress = false
+ return
+ call_deferred("_perform_dev_restart_after_feedback")
+
+
+func _on_dev_stop_pressed() -> void:
+ dev_server_action_requested.emit(DevServerAction.STOP)
+ _update_dev_section_buttons.call_deferred()
+
+
+func _perform_dev_restart_after_feedback() -> void:
+ ## Brief paint cycle so the user sees "Restarting..." before dispatch.
+ await get_tree().create_timer(0.15).timeout
+ dev_server_action_requested.emit(DevServerAction.START_OR_RESTART)
+ ## Lifecycle snapshots normally repaint first; this is a bounded UI fallback.
+ await get_tree().create_timer(2.0).timeout
+ _server_restart_in_progress = false
+ _update_dev_section_buttons()
+
+
+## Derive both buttons from the already-copied lifecycle value. No second port
+## scan or dev-only ownership state exists.
+func _update_dev_section_buttons() -> void:
+ var managed := int(_lifecycle_snapshot.get("server_pid", -1)) > 1
+ var normal_start_released := bool(
+ _lifecycle_snapshot.get("normal_start_released", false)
+ )
+ var state := int(_lifecycle_snapshot.get("state", ServerStateScript.UNINITIALIZED))
+ var external := not managed and (
+ str(_lifecycle_snapshot.get("ready_kind", "")) == "adopted"
+ or state in [ServerStateScript.INCOMPATIBLE, ServerStateScript.FOREIGN_PORT]
+ )
+ if _dev_primary_btn != null:
+ if _server_restart_in_progress:
+ _dev_primary_btn.disabled = true
+ _dev_primary_btn.text = "Restarting..."
+ _dev_primary_btn.tooltip_text = "Restarting the plugin-owned server..."
+ elif not normal_start_released:
+ _dev_primary_btn.disabled = true
+ _dev_primary_btn.text = "Server Start Blocked"
+ _dev_primary_btn.tooltip_text = (
+ "Complete post-update client migration before starting the server."
+ )
+ else:
+ var primary_state := _dev_primary_btn_state(managed, external)
+ _dev_primary_btn.disabled = not bool(primary_state["enabled"])
+ _dev_primary_btn.text = primary_state["text"]
+ _dev_primary_btn.tooltip_text = primary_state["tooltip"]
+ if _dev_stop_btn != null:
+ var stop_state := _dev_stop_btn_state(managed)
+ _dev_stop_btn.disabled = (not stop_state["enabled"]) or _server_restart_in_progress
+ _dev_stop_btn.tooltip_text = stop_state["tooltip"]
+
+
+func _client_status_refresh_has_completed() -> bool:
+ return bool(_client_work_snapshot.get("refresh_completed", false))
+
+
+func _client_refresh_state() -> int:
+ return int(_client_work_snapshot.get("refresh_state", ClientRefreshStateScript.IDLE))
+
+
+func _connected_status_text() -> String:
+ return "Server connected"
+
+
+static func _transport_status_text(snapshot: Dictionary) -> String:
+ ## Total over the transport enum for isolated consumers/tests. The dock's
+ ## connected fast path renders `_connected_status_text()` before calling it.
+ var phase := str(snapshot.get("phase", ""))
+ var attempt := maxi(1, int(snapshot.get("attempt", 0)))
+ match phase:
+ "connected":
+ return "Server connected"
+ "connecting":
+ return "Connecting — attempt %d" % attempt
+ "retrying":
+ var retry_in_sec := ceili(maxf(0.0, float(snapshot.get("retry_in_sec", 0.0))))
+ return "Retrying in %ds — attempt %d" % [retry_in_sec, attempt]
+ "closing":
+ return "Disconnecting…"
+ "blocked":
+ return "Connection blocked"
+ return "Disconnected"
+
+
+## Open uv's official install documentation rather than executing an
+## installer on the user's behalf.
+##
+## This used to shell out to `curl -LsSf https://astral.sh/uv/install.sh | sh`
+## (and the PowerShell `irm … | iex` equivalent). That is arbitrary remote
+## code execution as the editor user, one dock click deep, with no version
+## pin, no checksum, and no signature — while this same plugin verifies its
+## OWN updates with an RSA-4096 signature over a SHA-256 sidecar, pinned to a
+## GitHub host and this repo's release-asset path. Holding a third-party
+## installer to a weaker standard than our own payload is the wrong trade,
+## and pinning a digest here would only cover the bootstrap script, not the
+## uv binary it goes on to fetch.
+##
+## Opening the docs keeps the discovery value of the button (the user still
+## learns uv is missing and how to get it) while leaving the decision to
+## install — and the choice of install method — with the user. Mirrors the
+## dock's existing "Run this manually" fallback for client CLIs.
+func _on_install_uv() -> void:
+ OS.shell_open(UV_INSTALL_DOCS_URL)
+ ## Drop the cached uvx path AND the cached `uvx --version` so that once
+ ## the user has installed uv (in a terminal, from the docs we just
+ ## opened), the dock finds the new binary instead of replaying the
+ ## cached "not found" result for the rest of the session.
+ ## Routing through the configurator matters on Windows, where the
+ ## CLI-finder cache key is `uvx.exe` — invalidating just `"uvx"`
+ ## would leave the cache stale and the dock would keep showing
+ ## "uv: not found" for the rest of the session.
+ ClientConfigurator.invalidate_uv_detection()
+ ## Deliberately do NOT refresh here. `OS.shell_open` returns as soon as
+ ## the browser is handed the URL, so an immediate refresh would run long
+ ## before the user could install anything and would simply re-cache
+ ## "not found" — undoing the invalidation above. (The old shell-out was
+ ## a blocking `OS.execute`, so refreshing straight after it was correct
+ ## then; it stopped being correct when the installer call went away.)
+ ## Re-probe when the editor regains focus instead — see _notification.
+ _uv_recheck_pending = true
+
+
+# --- Client section ---
+
+func _on_configure_client(client_id: String) -> void:
+ ## Configure writes an explicit url + live plugin version; it does not
+ ## need a healthy occupant. INCOMPATIBLE only suppresses status
+ ## interpretation (#916).
+ _dispatch_client_action(client_id, "configure")
+
+
+func _on_remove_client(client_id: String) -> void:
+ _dispatch_client_action(client_id, "remove")
+
+
+## Emit a value intent; plugin.gd routes it to the plugin-lifetime job owner.
+## The Dock updates only view-local button state and never holds a Thread.
+func _dispatch_client_action(client_id: String, action: String) -> void:
+ if _is_self_update_in_progress():
+ return
+ if _busy_client_actions().has(client_id):
+ return
+ var row: Dictionary = _client_rows.get(client_id, {})
+ if row.is_empty():
+ return
+ _set_row_action_in_flight(client_id, action)
+ client_action_requested.emit(client_id, action)
+
+
+func present_client_action_result(
+ client_id: String,
+ action: String,
+ result: Dictionary,
+ prewarm: Dictionary = {},
+) -> void:
+ _report_prewarm_outcome(client_id, prewarm)
+ _finalize_action_buttons(client_id)
+ var success_status := Client.Status.NOT_CONFIGURED if action == "remove" else Client.Status.CONFIGURED
+ if result.get("status") == "ok":
+ ## #877: Remove targets only the selected scope, so a configure is the
+ ## only action with an all-scope sweep to disclose. The manual panel
+ ## that lists those removes is shown on the failure path below, which
+ ## left the success path — where the sweep actually ran — silent.
+ var sweep_note := (
+ ClientConfigurator.configure_sweep_note(client_id) if action == "configure" else ""
+ )
+ _apply_row_status(client_id, success_status, sweep_note)
+ var row: Dictionary = _client_rows.get(client_id, {})
+ if not row.is_empty():
+ (row["manual_panel"] as VBoxContainer).visible = false
+ else:
+ _apply_row_status(client_id, Client.Status.ERROR, str(result.get("message", "failed")))
+ if action == "configure":
+ _show_manual_command_for(client_id)
+ _refresh_clients_summary()
+
+
+func present_client_action_timeout(client_id: String, _action: String, detail: String) -> void:
+ _apply_row_status(client_id, Client.Status.ERROR, detail)
+ _refresh_clients_summary()
+
+
+func present_client_work_snapshot(snapshot: Dictionary) -> void:
+ _client_work_snapshot = snapshot.duplicate(true)
+ var busy := _busy_client_actions()
+ var names: Dictionary = _client_work_snapshot.get("action_names", {})
+ var phases: Dictionary = _client_work_snapshot.get("action_phases", {})
+ for client_id in _client_rows:
+ var id := String(client_id)
+ if busy.has(id):
+ _set_row_action_in_flight(id, String(names.get(id, "configure")))
+ if String(phases.get(id, "")) == "prewarm":
+ (_client_rows[id]["configure_btn"] as Button).text = "Installing…"
+ else:
+ _finalize_action_buttons(id)
+ _refresh_clients_summary()
+
+
+func _busy_client_actions() -> Array[String]:
+ var result: Array[String] = []
+ result.assign(_client_work_snapshot.get("busy_actions", []))
+ return result
+
+
+## One line per Configure so a cold build is attributable after the fact —
+## the dock label is transient, and #851's symptom (a terminal window on
+## Windows) is easiest to correlate against a timestamped log entry. Silent
+## on the skip paths: a dev-venv/system tier having no env to warm is the
+## normal case, not news.
+func _report_prewarm_outcome(client_id: String, prewarm: Variant) -> void:
+ if not (prewarm is Dictionary):
+ return
+ var data := prewarm as Dictionary
+ if data.is_empty() or bool(data.get("skipped", false)):
+ return
+ if bool(data.get("timed_out", false)):
+ print(
+ "MCP | %s: package pre-warm timed out; the first client launch will build the environment"
+ % client_id
+ )
+ return
+ if int(data.get("exit_code", -1)) != 0:
+ print(
+ "MCP | %s: package pre-warm failed (exit %d); the first client launch will build the environment"
+ % [client_id, int(data.get("exit_code", -1))]
+ )
+ return
+ print("MCP | %s: package environment pre-warmed" % client_id)
+
+
+## In-flight visual: rewrite the verb onto the button the user just
+## clicked ("Configuring…" / "Removing…") so the feedback lands where
+## their attention already is. Don't pollute the row label — that'd
+## clobber any drift hint ("URL out of date") still relevant to the row.
+## The dot turns amber so the row reads as "busy" at a glance, not as
+## green (premature success) or red (premature failure). Both buttons
+## go disabled so a double-click or second action can't queue stale
+## work behind the in-flight worker.
+func _set_row_action_in_flight(client_id: String, action: String) -> void:
+ var row: Dictionary = _client_rows.get(client_id, {})
+ if row.is_empty():
+ return
+ var configure_btn: Button = row["configure_btn"]
+ var remove_btn: Button = row["remove_btn"]
+ configure_btn.disabled = true
+ remove_btn.disabled = true
+ if action == "remove":
+ remove_btn.text = "Removing…"
+ else:
+ configure_btn.text = "Configuring…"
+ (row["dot"] as ColorRect).color = COLOR_AMBER
+
+
+## Re-enable both buttons and reset their text back to canonical labels.
+## Restore labels from cached row status as well as enabling the controls.
+## This matters when a timed-out orphan finishes: the owner publishes a new
+## snapshot but has no action result to repaint the row for us.
+func _finalize_action_buttons(client_id: String) -> void:
+ var row: Dictionary = _client_rows.get(client_id, {})
+ if row.is_empty():
+ return
+ var configure_btn := row["configure_btn"] as Button
+ configure_btn.disabled = false
+ match row.get("status", Client.Status.NOT_CONFIGURED):
+ Client.Status.CONFIGURED, Client.Status.CONFIGURED_MISMATCH:
+ configure_btn.text = "Reconfigure"
+ Client.Status.NOT_CONFIGURED:
+ configure_btn.text = "Configure"
+ _:
+ configure_btn.text = "Retry"
+ var remove_btn: Button = row["remove_btn"]
+ remove_btn.disabled = false
+ remove_btn.text = "Remove"
+
+
+func _on_refresh_clients_pressed() -> void:
+ ## Explicit user action — also give a failed uv probe another chance
+ ## (#739), mirroring how the same click already re-sweeps client CLIs.
+ _schedule_uv_reprobe()
+ _request_client_status_refresh(true)
+
+
+func _on_configure_all_clients() -> void:
+ ## Per-row Configure already bypasses the RUNNING gate. INCOMPATIBLE
+ ## skips health interpretation (#916), so Configure all must do the same
+ ## even if a status sweep is still in flight — `_set_incompatible_server`
+ ## does not reset `_refresh_state`.
+ if (
+ ClientRefreshStateScript.should_disable_client_actions(_client_refresh_state())
+ and not _server_blocks_client_health()
+ ):
+ return
+ for client_id in _client_rows:
+ var status: Client.Status = _client_rows[client_id].get("status", Client.Status.NOT_CONFIGURED)
+ if status == Client.Status.CONFIGURED:
+ continue
+ _on_configure_client(String(client_id))
+ _refresh_clients_summary()
+
+
+func _on_open_clients_window() -> void:
+ if _clients_window == null:
+ return
+ ## Re-sweep before the user has time to act on stale dot colors. The request
+ ## is async/stale-while-refreshing so the popup paints immediately with
+ ## last-known state; the fresh colors land when the background worker returns.
+ ## This is an explicit user action, so it bypasses the focus-in cooldown.
+ _request_client_status_refresh(true)
+ ## Also re-sync the Tools tab from the persisted setting — another
+ ## editor instance (or a hand-edit of editor_settings-4.tres) may have
+ ## changed the excluded list while the window was closed.
+ _reset_tools_pending_from_setting()
+ _refresh_tools_ui_state()
+ if vision_routing != null:
+ vision_routing.refresh_ui()
+ # popup_centered() with a minsize forces the window to that size and
+ # centers on the parent viewport. Setting .size on a hidden Window
+ # doesn't always take effect, so we force it at popup time here.
+ _clients_window.popup_centered(Vector2i(640, 600))
+
+
+func _settings_are_dirty() -> bool:
+ return (
+ _tools_pending_excluded != _tools_saved_excluded
+ or _telemetry_pending_enabled != _telemetry_saved_enabled
+ or _allow_hosts_is_dirty()
+ )
+
+
+func _on_clients_window_close_requested() -> void:
+ if _clients_window == null:
+ return
+ ## If the user has unapplied settings, a close would silently throw the
+ ## pending state away. Prompt before discarding current options and if
+ ## they confirm, reset pending → saved so the window shows the persisted
+ ## state the next time they open it.
+ if _settings_are_dirty():
+ _show_tools_close_confirm()
+ return
+ _clients_window.hide()
+
+
+# --- Tools tab (domain exclusion) ---
+
+func _build_tools_tab(tabs: TabContainer) -> void:
+ ## Tab 2 — domain-exclusion checkboxes. Rendered once, on dock construction.
+ ## `_reset_tools_pending_from_setting()` re-syncs checkbox state from the
+ ## saved setting each time the window opens.
+ var tools_tab := VBoxContainer.new()
+ tools_tab.add_theme_constant_override("separation", 8)
+ var tools_margin := _build_margin_container()
+ tools_margin.name = "Tools"
+ tools_margin.add_child(tools_tab)
+ tabs.add_child(tools_margin)
+
+ var intro := Label.new()
+ intro.text = (
+ "Some MCP clients cap tools per connection (Antigravity: 100). "
+ + "Uncheck a domain to drop its non-core tools from this server. "
+ + "Core tools stay on. Changes require a server restart."
+ )
+ intro.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
+ intro.add_theme_color_override("font_color", COLOR_MUTED)
+ intro.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ tools_tab.add_child(intro)
+
+ var count_row := HBoxContainer.new()
+ count_row.add_theme_constant_override("separation", 8)
+ var count_header := Label.new()
+ count_header.text = "Tools Enabled:"
+ count_header.add_theme_color_override("font_color", COLOR_MUTED)
+ count_row.add_child(count_header)
+ _tools_count_label = Label.new()
+ _tools_count_label.add_theme_font_size_override("font_size", 15)
+ count_row.add_child(_tools_count_label)
+ _tools_dirty_warning = Label.new()
+ _tools_dirty_warning.add_theme_color_override("font_color", COLOR_AMBER)
+ _tools_dirty_warning.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ _tools_dirty_warning.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
+ _tools_dirty_warning.visible = false
+ _tools_dirty_warning.text = "Unapplied changes"
+ count_row.add_child(_tools_dirty_warning)
+ tools_tab.add_child(count_row)
+
+ tools_tab.add_child(HSeparator.new())
+
+ var scroll := ScrollContainer.new()
+ scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
+ scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
+ tools_tab.add_child(scroll)
+
+ var grid := VBoxContainer.new()
+ grid.add_theme_constant_override("separation", 4)
+ grid.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ scroll.add_child(grid)
+
+ ## Core pseudo-row — disabled checkbox, always checked. Shows the 5
+ ## always-loaded tools as a single line item so the user can see where
+ ## their baseline tool budget goes without listing individual core names
+ ## inline (tooltip has them).
+ var core_row := HBoxContainer.new()
+ core_row.add_theme_constant_override("separation", 8)
+ var core_chk := CheckBox.new()
+ core_chk.button_pressed = true
+ core_chk.disabled = true
+ core_chk.focus_mode = Control.FOCUS_NONE
+ core_row.add_child(core_chk)
+ var core_label := Label.new()
+ core_label.text = "Core (always on)"
+ core_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ core_row.add_child(core_label)
+ var core_count := Label.new()
+ core_count.text = "%d tools" % (ToolCatalog.CORE_TOOLS.size() + ToolCatalog.ALWAYS_ON_TOOLS.size())
+ core_count.add_theme_color_override("font_color", COLOR_MUTED)
+ core_row.add_child(core_count)
+ core_row.tooltip_text = "%s · always on: %s" % [
+ ", ".join(ToolCatalog.CORE_TOOLS),
+ ", ".join(ToolCatalog.ALWAYS_ON_TOOLS),
+ ]
+ grid.add_child(core_row)
+
+ grid.add_child(HSeparator.new())
+
+ _tools_domain_checkboxes.clear()
+ for entry in ToolCatalog.DOMAINS:
+ _build_tools_domain_row(grid, entry)
+
+ ## Custom (addon-registered) tools — unlike the domain rows above,
+ ## toggles apply LIVE: the registry re-pushes the filtered catalog to
+ ## the server on every change, no restart needed.
+ grid.add_child(HSeparator.new())
+ var custom_header_row := HBoxContainer.new()
+ custom_header_row.add_theme_constant_override("separation", 8)
+ var custom_header := Label.new()
+ custom_header.text = "Custom tools (addon-registered)"
+ custom_header.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ custom_header_row.add_child(custom_header)
+ _custom_tools_count_label = Label.new()
+ _custom_tools_count_label.add_theme_color_override("font_color", COLOR_MUTED)
+ custom_header_row.add_child(_custom_tools_count_label)
+ grid.add_child(custom_header_row)
+ var custom_hint := Label.new()
+ custom_hint.text = "Applies immediately — disabled tools are hidden from agents and rejected if called."
+ custom_hint.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
+ custom_hint.add_theme_color_override("font_color", COLOR_MUTED)
+ custom_hint.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ grid.add_child(custom_hint)
+ _custom_tools_list = VBoxContainer.new()
+ _custom_tools_list.add_theme_constant_override("separation", 4)
+ _custom_tools_list.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ grid.add_child(_custom_tools_list)
+ _connect_custom_tool_registry()
+ _refresh_custom_tools_rows()
+
+ tools_tab.add_child(HSeparator.new())
+
+ var telemetry_row := HBoxContainer.new()
+ telemetry_row.add_theme_constant_override("separation", 8)
+ var telemetry_label := Label.new()
+ telemetry_label.text = "Telemetry"
+ telemetry_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ telemetry_row.add_child(telemetry_label)
+ _telemetry_toggle = CheckButton.new()
+ _telemetry_toggle.toggled.connect(_on_telemetry_toggled)
+ telemetry_row.add_child(_telemetry_toggle)
+ tools_tab.add_child(telemetry_row)
+
+ tools_tab.add_child(HSeparator.new())
+
+ var footer := HBoxContainer.new()
+ footer.add_theme_constant_override("separation", 8)
+
+ _tools_apply_btn = Button.new()
+ _tools_apply_btn.text = "Apply and Restart Server"
+ _tools_apply_btn.tooltip_text = "Save the excluded list to Editor Settings and reload the plugin so the server respawns with --exclude-domains."
+ _tools_apply_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ _tools_apply_btn.pressed.connect(_on_tools_apply)
+ footer.add_child(_tools_apply_btn)
+
+ _tools_reset_btn = Button.new()
+ _tools_reset_btn.text = "Reset to defaults"
+ _tools_reset_btn.tooltip_text = "Re-enable every domain (no --exclude-domains flag). Still needs Apply."
+ _tools_reset_btn.pressed.connect(_on_tools_reset)
+ footer.add_child(_tools_reset_btn)
+
+ tools_tab.add_child(footer)
+
+ _tools_close_confirm = ConfirmationDialog.new()
+ _tools_close_confirm.title = "Discard unapplied changes?"
+ ## Generic wording: _settings_are_dirty() covers domain toggles, the
+ ## telemetry switch, AND the Settings tab's allow-host field (#507) —
+ ## the old "checked/unchecked domains" text misled non-domain edits.
+ _tools_close_confirm.dialog_text = (
+ "You have unapplied changes in this window.\n"
+ + "Close it and discard those changes?"
+ )
+ _tools_close_confirm.ok_button_text = "Discard"
+ _tools_close_confirm.confirmed.connect(_on_tools_discard_confirmed)
+ add_child(_tools_close_confirm)
+
+ _update_confirm = ConfirmationDialog.new()
+ _update_confirm.title = "Update Godot AI?"
+ _update_confirm.ok_button_text = "Update plugin"
+ _update_confirm.cancel_button_text = "Later"
+ _update_confirm.confirmed.connect(_on_update_confirmed)
+ add_child(_update_confirm)
+
+ _reset_tools_pending_from_setting()
+ _refresh_tools_ui_state()
+
+
+## --- Custom (addon-registered) tools section ---
+
+func _connect_custom_tool_registry() -> void:
+ var registry := McpToolRegistry.get_instance()
+ if registry == null:
+ return
+ if not registry.tools_changed.is_connected(_on_custom_tool_registry_changed):
+ registry.tools_changed.connect(_on_custom_tool_registry_changed)
+
+
+func _on_custom_tool_registry_changed() -> void:
+ ## Deferred: tools_changed can fire from inside a checkbox toggle in
+ ## this very list — rebuilding synchronously would free the emitting
+ ## control mid-signal.
+ _refresh_custom_tools_rows.call_deferred()
+
+
+func _refresh_custom_tools_rows() -> void:
+ if _custom_tools_list == null or not is_instance_valid(_custom_tools_list):
+ return
+ for child in _custom_tools_list.get_children():
+ child.queue_free()
+ var registry := McpToolRegistry.get_instance()
+ var specs: Array = [] if registry == null else registry.all()
+ specs.sort_custom(func(a, b): return String(a.name) < String(b.name))
+ var enabled_count := 0
+ for spec in specs:
+ if registry.is_tool_enabled(spec.name):
+ enabled_count += 1
+ _custom_tools_list.add_child(_build_custom_tool_row(registry, spec))
+ if _custom_tools_count_label != null and is_instance_valid(_custom_tools_count_label):
+ _custom_tools_count_label.text = "%d/%d enabled" % [enabled_count, specs.size()]
+ if specs.is_empty():
+ var empty := Label.new()
+ empty.text = "None registered. Addons add tools via McpToolRegistry — see docs/plugin-architecture.md."
+ empty.add_theme_color_override("font_color", COLOR_MUTED)
+ empty.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
+ empty.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ _custom_tools_list.add_child(empty)
+
+
+func _build_custom_tool_row(registry: McpToolRegistry, spec: McpCustomToolSpec) -> HBoxContainer:
+ var row := HBoxContainer.new()
+ row.add_theme_constant_override("separation", 8)
+ var chk := CheckBox.new()
+ chk.button_pressed = registry.is_tool_enabled(spec.name)
+ chk.toggled.connect(func(pressed: bool): registry.set_tool_enabled(spec.name, pressed))
+ row.add_child(chk)
+ var name_label := Label.new()
+ name_label.text = spec.name + (" · promoted" if spec.promoted else "")
+ name_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ row.add_child(name_label)
+ var source_label := Label.new()
+ source_label.text = spec.source if not spec.source.is_empty() else spec.source_path.get_base_dir().get_file()
+ source_label.add_theme_color_override("font_color", COLOR_MUTED)
+ row.add_child(source_label)
+ ## Tooltip = the agent-facing description plus provenance, so the user
+ ## can judge what they're enabling without leaving the dock.
+ row.tooltip_text = "%s\n\nsource: %s%s" % [
+ spec.description,
+ spec.source_path,
+ "\npromoted: registers as first-class MCP tool custom_%s" % spec.name if spec.promoted else "",
+ ]
+ name_label.tooltip_text = row.tooltip_text
+ return row
+
+
+func _build_tools_domain_row(parent: VBoxContainer, entry: Dictionary) -> void:
+ var row := HBoxContainer.new()
+ row.add_theme_constant_override("separation", 8)
+
+ var chk := CheckBox.new()
+ chk.button_pressed = true # default; `_reset_tools_pending_from_setting` corrects
+ chk.toggled.connect(_on_tools_domain_toggled.bind(String(entry["id"])))
+ row.add_child(chk)
+
+ var name_label := Label.new()
+ name_label.text = String(entry["label"])
+ name_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ row.add_child(name_label)
+
+ var count_label := Label.new()
+ count_label.text = "%d tools" % int(entry["count"])
+ count_label.add_theme_color_override("font_color", COLOR_MUTED)
+ row.add_child(count_label)
+
+ ## Hover tooltip = flat list of tool names in this domain. Lets the
+ ## user decide without leaving the dock (e.g. "I just want to drop
+ ## `animation_preset_*` — do I lose anything else?").
+ var tools_list: Array = entry.get("tools", [])
+ row.tooltip_text = ", ".join(tools_list)
+ name_label.tooltip_text = row.tooltip_text
+ count_label.tooltip_text = row.tooltip_text
+
+ parent.add_child(row)
+ _tools_domain_checkboxes[String(entry["id"])] = chk
+
+
+func _reset_tools_pending_from_setting() -> void:
+ ## Read the saved setting → pending/saved arrays, then sync checkbox state.
+ ## Unknown domain names in the setting (e.g. from an older plugin
+ ## version) are dropped from the display here (only ids with a checkbox
+ ## survive). The startup path is protected separately:
+ ## `ClientConfigurator.excluded_domains()` filters unknown names before
+ ## they reach `--exclude-domains`, whose `parse_exclude_list` hard-fails
+ ## on them.
+ var saved_raw := ClientConfigurator.excluded_domains()
+ var saved := PackedStringArray()
+ if not saved_raw.is_empty():
+ for part in saved_raw.split(","):
+ var t := part.strip_edges()
+ if t.is_empty():
+ continue
+ if _tools_domain_checkboxes.has(t) and saved.find(t) == -1:
+ saved.append(t)
+ saved.sort()
+ _tools_saved_excluded = saved
+ _tools_pending_excluded = saved.duplicate()
+ for id in _tools_domain_checkboxes:
+ var chk: CheckBox = _tools_domain_checkboxes[id]
+ ## `set_pressed_no_signal` — mutating programmatically should not
+ ## fire the toggled handler, which would mutate pending back.
+ chk.set_pressed_no_signal(_tools_pending_excluded.find(id) == -1)
+ ## Also reset telemetry pending state from the persisted setting.
+ if _telemetry_toggle != null:
+ _load_telemetry_setting()
+ ## And the Settings tab's allow-host field (#507) — same window-open /
+ ## discard-confirm re-sync contract as the tools checkboxes.
+ _reset_allow_hosts_from_setting()
+
+
+func _on_tools_domain_toggled(pressed: bool, domain_id: String) -> void:
+ var idx := _tools_pending_excluded.find(domain_id)
+ if pressed and idx != -1:
+ _tools_pending_excluded.remove_at(idx)
+ elif not pressed and idx == -1:
+ _tools_pending_excluded.append(domain_id)
+ _tools_pending_excluded.sort()
+ _refresh_tools_ui_state()
+
+
+func _refresh_tools_ui_state() -> void:
+ if _tools_count_label == null:
+ return
+ var enabled := ToolCatalog.enabled_tool_count(_tools_pending_excluded)
+ var total := ToolCatalog.total_tool_count()
+ _tools_count_label.text = "%d / %d" % [enabled, total]
+ var dirty := _settings_are_dirty()
+ _tools_dirty_warning.visible = dirty
+ _tools_apply_btn.disabled = not dirty
+ ## Color the count when the user is over Antigravity's cap — a soft
+ ## signal that their selection still won't fit. 100 is the Antigravity
+ ## limit; other clients may cap higher, so this is advisory only.
+ if enabled > 100:
+ _tools_count_label.add_theme_color_override("font_color", COLOR_AMBER)
+ else:
+ _tools_count_label.remove_theme_color_override("font_color")
+
+
+func _on_tools_apply() -> void:
+ var canonical_excluded := ToolCatalog.canonical(_tools_pending_excluded)
+ _tools_saved_excluded = _tools_pending_excluded.duplicate()
+ _telemetry_saved_enabled = _telemetry_pending_enabled
+ _refresh_tools_ui_state()
+ settings_apply_requested.emit({
+ "excluded_domains": canonical_excluded,
+ "telemetry_enabled": _telemetry_pending_enabled,
+ }.duplicate(true), true)
+
+
+func _on_tools_reset() -> void:
+ ## Resets only the tool-domain exclusions, not the telemetry toggle.
+ ## Telemetry is a privacy preference users typically want to set once
+ ## and have honored — flipping it back to "on" via a generic Reset
+ ## button would be a surprising privacy regression. The button label
+ ## is scoped to tools accordingly.
+ _tools_pending_excluded = PackedStringArray()
+ for id in _tools_domain_checkboxes:
+ var chk: CheckBox = _tools_domain_checkboxes[id]
+ chk.set_pressed_no_signal(true)
+ _refresh_tools_ui_state()
+
+
+func _show_tools_close_confirm() -> void:
+ if _tools_close_confirm == null:
+ return
+ _tools_close_confirm.popup_centered()
+
+
+func _on_tools_discard_confirmed() -> void:
+ _reset_tools_pending_from_setting()
+ _refresh_tools_ui_state()
+ if _clients_window != null:
+ _clients_window.hide()
+
+
+# --- Settings tab (allow-host LAN opt-in, #507) ---
+
+func _build_settings_tab(tabs: TabContainer) -> void:
+ ## Tab 3 — settings-style controls that don't fit Clients or Tools: the
+ ## Vision Routing section plus the `--allow-host` LAN opt-in behind a
+ ## collapsed "Remote access (advanced)" disclosure, so its security
+ ## warning renders exactly at the point of configuration. Rendered once
+ ## on dock construction, mirroring `_build_tools_tab`;
+ ## `_reset_allow_hosts_from_setting()` and `vision_routing.refresh_ui()`
+ ## re-sync each time the window opens (via
+ ## `_reset_tools_pending_from_setting` / `_on_open_clients_window`).
+ var settings_tab := VBoxContainer.new()
+ settings_tab.add_theme_constant_override("separation", 8)
+ var settings_margin := _build_margin_container()
+ settings_margin.name = "Settings"
+ settings_margin.add_child(settings_tab)
+ tabs.add_child(settings_margin)
+
+ ## Vision Routing is configuration, not status — it lives here rather
+ ## than in the dock. Not dev-gated: it is the Settings tab's primary
+ ## content and must work for every user.
+ if vision_routing != null:
+ vision_routing.build_section(settings_tab)
+
+ ## Remote access (advanced): collapsed by default; auto-expands when a
+ ## non-empty CIDR allowlist is already configured so an active
+ ## off-loopback bind is never hidden behind a collapsed header. The
+ ## disclosure replaces the former developer-mode gate for this block.
+ _allow_hosts_fold = FoldableContainer.new()
+ _allow_hosts_fold.title = "Remote access (advanced)"
+ _allow_hosts_fold.folded = true
+ settings_tab.add_child(_allow_hosts_fold)
+
+ _allow_hosts_section = VBoxContainer.new()
+ _allow_hosts_section.add_theme_constant_override("separation", 6)
+ _allow_hosts_fold.add_child(_allow_hosts_section)
+
+ _allow_hosts_section.add_child(_make_header("Allow remote hosts (CIDR)"))
+
+ var intro := Label.new()
+ intro.text = (
+ "Comma-separated CIDRs or bare IPs (e.g. 192.168.1.0/24, 10.0.0.5). "
+ + "When non-empty, the server binds off loopback and accepts MCP "
+ + "connections from these ranges (--allow-host)."
+ )
+ intro.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
+ intro.add_theme_color_override("font_color", COLOR_MUTED)
+ intro.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ _allow_hosts_section.add_child(intro)
+
+ ## Warning banner — the DNS-rebinding guard is widened to every machine
+ ## in the named ranges, so make the user name a network they trust
+ ## instead of offering a blanket "expose everything" toggle (#507).
+ var warning := Label.new()
+ warning.text = (
+ "Warning: every machine in these ranges can drive this Godot editor, "
+ + "and the DNS-rebinding guard's Host allowlist is widened to match. "
+ + "Only name networks you trust. On untrusted or shared networks, "
+ + "prefer an SSH tunnel or Tailscale instead of exposing the port."
+ )
+ warning.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
+ warning.add_theme_color_override("font_color", COLOR_AMBER)
+ warning.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ _allow_hosts_section.add_child(warning)
+
+ _allow_hosts_edit = LineEdit.new()
+ _allow_hosts_edit.placeholder_text = "e.g. 192.168.1.0/24, 10.0.0.5 — empty = loopback only"
+ _allow_hosts_edit.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ _allow_hosts_edit.text_changed.connect(_on_allow_hosts_text_changed)
+ _allow_hosts_section.add_child(_allow_hosts_edit)
+
+ _allow_hosts_hint = Label.new()
+ _allow_hosts_hint.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
+ _allow_hosts_hint.add_theme_color_override("font_color", Color.RED)
+ _allow_hosts_hint.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ _allow_hosts_hint.visible = false
+ _allow_hosts_section.add_child(_allow_hosts_hint)
+
+ _allow_hosts_apply_btn = Button.new()
+ _allow_hosts_apply_btn.text = "Apply and Restart Server"
+ _allow_hosts_apply_btn.tooltip_text = (
+ "Save the allowlist to Editor Settings and reload the plugin so the "
+ + "server respawns with --allow-host. Clear the field and Apply to "
+ + "return to loopback-only."
+ )
+ _allow_hosts_apply_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ _allow_hosts_apply_btn.pressed.connect(_on_allow_hosts_apply)
+ _allow_hosts_section.add_child(_allow_hosts_apply_btn)
+
+ _reset_allow_hosts_from_setting()
+
+
+func _reset_allow_hosts_from_setting() -> void:
+ _allow_hosts_saved = ClientConfigurator.allow_hosts()
+ _refresh_allow_hosts_fold_state()
+ if _allow_hosts_edit == null:
+ return
+ _allow_hosts_edit.text = _allow_hosts_saved
+ _refresh_allow_hosts_ui_state()
+
+
+## Auto-expands the "Remote access (advanced)" disclosure whenever a
+## non-empty allowlist is configured, so an active off-loopback bind is
+## never hidden behind a collapsed header.
+func _refresh_allow_hosts_fold_state() -> void:
+ if _allow_hosts_fold == null:
+ return
+ if ClientConfigurator.allow_hosts().is_empty():
+ _allow_hosts_fold.fold()
+ else:
+ _allow_hosts_fold.expand()
+
+
+func _allow_hosts_is_dirty() -> bool:
+ if _allow_hosts_edit == null:
+ return false
+ return McpAllowHosts.normalize(_allow_hosts_edit.text) != _allow_hosts_saved
+
+
+func _on_allow_hosts_text_changed(_new_text: String) -> void:
+ _refresh_allow_hosts_ui_state()
+
+
+func _refresh_allow_hosts_ui_state() -> void:
+ if _allow_hosts_edit == null or _allow_hosts_apply_btn == null:
+ return
+ var invalid := McpAllowHosts.invalid_tokens(_allow_hosts_edit.text)
+ if invalid.is_empty():
+ _allow_hosts_hint.visible = false
+ else:
+ ## Name the accepted syntax in the hint — matches the server's
+ ## `parse_allow_hosts` (CIDR / bare IP, comma-separated).
+ _allow_hosts_hint.text = (
+ "Invalid entries (must be a CIDR like 192.168.1.0/24 or a bare IP, comma-separated): %s"
+ % ", ".join(invalid)
+ )
+ _allow_hosts_hint.visible = true
+ _allow_hosts_apply_btn.disabled = not _allow_hosts_is_dirty() or not invalid.is_empty()
+
+
+func _on_allow_hosts_apply() -> void:
+ if _allow_hosts_edit == null:
+ return
+ var normalized := McpAllowHosts.normalize(_allow_hosts_edit.text)
+ if not McpAllowHosts.invalid_tokens(normalized).is_empty():
+ return
+ _allow_hosts_saved = normalized
+ _allow_hosts_edit.text = normalized
+ _refresh_allow_hosts_ui_state()
+ settings_apply_requested.emit({"allow_hosts": normalized}, true)
+
+
+func _refresh_clients_summary() -> void:
+ # Count from cached row status values — `_apply_row_status` is the single
+ # source of truth, and reading cached status avoids re-running
+ # filesystem/CLI-hitting checks on every refresh. The same cache re-derives
+ # the drift banner so per-row mutations (Configure/Reconfigure/Remove on a
+ # row in the Clients & Tools window) keep the dock-level banner in sync
+ # without an extra sweep. See #166 and #226.
+ if _clients_summary_label == null:
+ return
+ var configured := 0
+ var mismatched_ids: Array[String] = []
+ for client_id in _client_rows:
+ var status: Client.Status = _client_rows[client_id].get("status", Client.Status.NOT_CONFIGURED)
+ if status == Client.Status.CONFIGURED:
+ configured += 1
+ elif status == Client.Status.CONFIGURED_MISMATCH:
+ mismatched_ids.append(client_id)
+ var text := "%d / %d configured" % [configured, _client_rows.size()]
+ if mismatched_ids.size() > 0:
+ text += " (%d stale)" % mismatched_ids.size()
+ var refresh_state := _client_refresh_state()
+ if ClientRefreshStateScript.should_show_checking_badge(refresh_state):
+ text += (
+ " (checking...)"
+ if refresh_state != ClientRefreshStateScript.RUNNING_TIMED_OUT
+ else " (client probe still running)"
+ )
+ _clients_summary_label.text = text
+ if _client_configure_all_btn != null:
+ _client_configure_all_btn.disabled = (
+ ClientRefreshStateScript.should_disable_client_actions(refresh_state)
+ and not _server_blocks_client_health()
+ )
+ if _client_empty_cta_btn != null:
+ _client_empty_cta_btn.visible = configured == 0 and _client_status_refresh_has_completed()
+ _refresh_drift_banner(mismatched_ids)
+ _update_status()
+
+
+func _show_manual_command_for(client_id: String) -> void:
+ var row: Dictionary = _client_rows.get(client_id, {})
+ if row.is_empty():
+ return
+ var cmd := ClientConfigurator.manual_command(client_id)
+ if cmd.is_empty():
+ row["manual_panel"].visible = false
+ return
+ row["manual_text"].text = cmd
+ row["manual_panel"].visible = true
+ ## #680: for rows low in the list the panel materializes below the
+ ## visible scroll area and the Configure click looks like a no-op.
+ ## Deferred so the just-shown panel has a settled rect to scroll to.
+ _scroll_manual_panel_into_view.call_deferred(row["manual_panel"])
+
+
+func _scroll_manual_panel_into_view(panel: Control) -> void:
+ if panel == null or not panel.is_inside_tree():
+ return
+ var ancestor := panel.get_parent()
+ while ancestor != null and not (ancestor is ScrollContainer):
+ ancestor = ancestor.get_parent()
+ if ancestor != null:
+ (ancestor as ScrollContainer).ensure_control_visible(panel)
+
+
+func _on_copy_manual_command(client_id: String) -> void:
+ var row: Dictionary = _client_rows.get(client_id, {})
+ if row.is_empty():
+ return
+ DisplayServer.clipboard_set(row["manual_text"].text)
+
+
+func _on_open_config_file(client_id: String) -> void:
+ var path := _client_config_path_for_row(client_id)
+ if path.is_empty():
+ return
+ if FileAccess.file_exists(path):
+ OS.shell_open(path)
+ return
+ _reveal_config_folder(path)
+
+
+func _on_reveal_config_folder(client_id: String) -> void:
+ var path := _client_config_path_for_row(client_id)
+ if path.is_empty():
+ return
+ _reveal_config_folder(path)
+
+
+func _client_config_path_for_row(client_id: String) -> String:
+ var row: Dictionary = _client_rows.get(client_id, {})
+ if row.is_empty():
+ return ""
+ return String(row.get("config_path", ""))
+
+
+func _reveal_config_folder(path: String) -> void:
+ var dir := path.get_base_dir()
+ if dir.is_empty():
+ return
+ OS.shell_open(dir)
+
+
+func _refresh_all_client_statuses() -> void:
+ _request_client_status_refresh(true)
+
+
+func _perform_initial_client_status_refresh() -> void:
+ if not is_inside_tree():
+ return
+ _request_client_status_refresh(false)
+
+
+func _request_client_status_refresh(force: bool = false) -> bool:
+ if _server_blocks_client_health():
+ return false
+ if _is_self_update_in_progress() or _client_rows.is_empty():
+ return false
+ var ids: Array[String] = []
+ for client_id in _client_rows:
+ ids.append(String(client_id))
+ client_status_refresh_requested.emit(ids, force)
+ return true
+
+
+func present_client_status_refresh_results(results: Dictionary) -> void:
+ if _server_blocks_client_health():
+ return
+ var busy := _busy_client_actions()
+ for client_id in results:
+ if busy.has(String(client_id)):
+ continue
+ var result: Dictionary = results[client_id]
+ _apply_row_status(
+ String(client_id),
+ result.get("status", Client.Status.NOT_CONFIGURED),
+ str(result.get("error_msg", "")),
+ result.get("installed", false)
+ )
+ _refresh_clients_summary()
+
+
+func _server_blocks_client_health() -> bool:
+ return ServerStateScript.blocks_client_health(
+ int(_lifecycle_snapshot.get("state", ServerStateScript.UNINITIALIZED))
+ )
+
+
+func _server_blocked_client_message() -> String:
+ var message := str(_lifecycle_snapshot.get("message", ""))
+ return message if not message.is_empty() else "server incompatible"
+
+
+func _refresh_drift_banner(mismatched_ids: Array[String]) -> void:
+ if _drift_banner == null:
+ return
+ ## Sort so set-equality is order-independent — `_client_rows` iteration
+ ## order is dict-insertion order, but a future change to the iteration
+ ## site shouldn't make us repaint identical content.
+ mismatched_ids = mismatched_ids.duplicate()
+ mismatched_ids.sort()
+ if mismatched_ids == _last_mismatched_ids:
+ return
+ _last_mismatched_ids = mismatched_ids
+ if mismatched_ids.is_empty():
+ _drift_banner.visible = false
+ return
+ var names: Array[String] = []
+ for id in mismatched_ids:
+ names.append(ClientConfigurator.client_display_name(id))
+ ## Active server URL is already shown on the WS:/HTTP: line above the
+ ## Clients section, so it doesn't need to repeat here. Lead with the
+ ## client names — that's the only thing the user can act on.
+ var verb := "needs" if mismatched_ids.size() == 1 else "need"
+ _drift_label.text = "%s %s to be reconfigured." % [", ".join(names), verb]
+ _drift_banner.visible = true
+
+
+func _on_reconfigure_mismatched() -> void:
+ ## Re-Configure every client whose URL is currently stale. Iterates the
+ ## cached list from the most recent sweep instead of re-running
+ ## `check_status` per row (saves ~18 filesystem reads per click). The
+ ## trailing `_refresh_all_client_statuses()` re-sweeps anyway, so any
+ ## entries the user manually fixed between sweep and click get re-counted
+ ## as CONFIGURED there.
+ for client_id in _last_mismatched_ids:
+ if _client_rows.has(client_id):
+ _on_configure_client(client_id)
+ _refresh_all_client_statuses()
+
+
+func _apply_row_status(
+ client_id: String,
+ status: Client.Status,
+ error_msg: String = "",
+ installed_override: Variant = null,
+) -> void:
+ var row: Dictionary = _client_rows.get(client_id, {})
+ if row.is_empty():
+ return
+ row["status"] = status
+ var dot: ColorRect = row["dot"]
+ var configure_btn: Button = row["configure_btn"]
+ var remove_btn: Button = row["remove_btn"]
+ var name_label: Label = row["name_label"]
+ var base_name := ClientConfigurator.client_display_name(client_id)
+ _refresh_client_config_file_buttons(client_id)
+ match status:
+ Client.Status.CONFIGURED:
+ dot.color = Color.GREEN
+ configure_btn.text = "Reconfigure"
+ remove_btn.visible = true
+ ## `error_msg` doubles as a detail slot on the green path: a
+ ## successful configure passes the sweep note (#877). Transient by
+ ## design — the next status refresh re-applies CONFIGURED with no
+ ## detail, so the row settles back to its plain name.
+ name_label.text = (
+ "%s (%s)" % [base_name, error_msg] if not error_msg.is_empty() else base_name
+ )
+ Client.Status.NOT_CONFIGURED:
+ dot.color = COLOR_MUTED
+ configure_btn.text = "Configure"
+ remove_btn.visible = false
+ var installed: bool = installed_override if installed_override != null else ClientConfigurator.is_installed(client_id)
+ name_label.text = base_name if installed else "%s (not detected)" % base_name
+ Client.Status.CONFIGURED_MISMATCH:
+ ## Amber matches the dock-level drift banner so a glance at the
+ ## row + the banner read as the same condition.
+ dot.color = COLOR_AMBER
+ configure_btn.text = "Reconfigure"
+ remove_btn.visible = true
+ ## Drift is usually a stale URL, but a `{scope}` client can also be
+ ## registered in a scope the user did not select (#872), and
+ ## "URL out of date" would be a wrong description of that. Prefer
+ ## the probe's own words whenever it supplied any.
+ name_label.text = (
+ "%s (%s)" % [base_name, error_msg]
+ if not error_msg.is_empty()
+ else "%s (URL out of date)" % base_name
+ )
+ _:
+ dot.color = Color.RED
+ configure_btn.text = "Retry"
+ remove_btn.visible = false
+ name_label.text = "%s — %s" % [base_name, error_msg] if not error_msg.is_empty() else base_name
+
+
+func _refresh_client_config_file_buttons(client_id: String) -> void:
+ var row: Dictionary = _client_rows.get(client_id, {})
+ if row.is_empty():
+ return
+ # Re-resolve on every refresh so the Open/Reveal buttons follow the entry
+ # when it lands in (or moves to) a higher-precedence merge tier (codex F3).
+ # F-3-4: switched from `effective_config_path` to the authoritative facade
+ # so multi-project-tier cases resolve to the LATEST tier (matching F2
+ # status semantics) instead of failing closed to `path_template`.
+ row["config_path"] = ClientConfigurator.effective_authoritative_path(client_id)
+ var config_path := String(row["config_path"])
+ var has_path := not config_path.is_empty()
+ var open_config_btn: Button = row["open_config_btn"]
+ var reveal_btn: Button = row["reveal_btn"]
+ open_config_btn.visible = has_path
+ reveal_btn.visible = has_path
+ open_config_btn.disabled = not has_path
+ reveal_btn.disabled = not has_path
+ if has_path:
+ open_config_btn.tooltip_text = "Open config file:\n%s" % config_path
+ reveal_btn.tooltip_text = "Reveal in folder:\n%s" % config_path.get_base_dir()
+ else:
+ open_config_btn.tooltip_text = ""
+ reveal_btn.tooltip_text = ""
+
+
+# --- Update check & self-update ---
+
+## Tolerates a null manager so test fixtures that build the dock without
+## `_build_ui()` don't false-positive on the worker-spawn gate.
+func _is_self_update_in_progress() -> bool:
+ return _update_install_in_flight
+
+
+func _on_update_pressed() -> void:
+ if not _post_update_action.is_empty():
+ post_update_action_requested.emit(_post_update_action)
+ return
+ ## Updating briefly disconnects AI tools while the add-on is replaced.
+ ## A dock outside the scene tree has no dialog and proceeds directly.
+ if _update_confirm != null and is_inside_tree():
+ _update_confirm.dialog_text = update_confirm_text(
+ _update_candidate_version, ClientConfigurator.get_plugin_version()
+ )
+ _update_confirm.popup_centered()
+ return
+ update_requested.emit()
+
+
+func _on_update_confirmed() -> void:
+ update_requested.emit()
+
+
+static func update_confirm_text(version: String, current_version: String) -> String:
+ var target := "Godot AI v%s" % version if not version.is_empty() else "the new Godot AI"
+ var text := "Update to %s? Unsaved changes are kept." % target
+ if McpServerVersionCheck.attached_bridges_follow(current_version, version):
+ text += "\n\nRestart AI clients older than v4.0.4."
+ else:
+ text += "\n\nRestart your AI client after updating."
+ return text
+
+
+func present_update_check(result: Dictionary) -> void:
+ _update_candidate_version = String(result.get("version", ""))
+ _update_label.text = String(result.get("label_text", ""))
+ _update_label.add_theme_color_override("font_color", _UPDATE_LABEL_COLOR)
+ _update_banner.visible = true
+ ## A fresh candidate re-arms the action. The restarted editor after an
+ ## update shows "Godot AI installed" with the button disabled; a newer release
+ ## found later in that same session must still be installable. A running
+ ## install or a pending post-update action keeps ownership of the button.
+ if _update_install_in_flight or not _post_update_action.is_empty():
+ return
+ _set_update_status("")
+ if _update_btn != null:
+ _update_btn.text = _UPDATE_ACTION_TEXT
+ _update_btn.disabled = false
+
+
+## Apply only the keys present so the manager can ship partial updates
+## (e.g. status-only during the download phase) without clobbering banner
+## state. `button_text` names an action ("Retry client migration");
+## progress and failure messages arrive as `status_text` and never replace
+## the button label.
+func present_update_state(state: Dictionary) -> void:
+ if state.has("post_update_action"):
+ _post_update_action = String(state["post_update_action"])
+ if _post_update_action.is_empty() and _update_btn != null:
+ _update_btn.text = _UPDATE_ACTION_TEXT
+ if state.has("install_in_flight"):
+ _update_install_in_flight = bool(state["install_in_flight"])
+ if String(state.get("post_update_action", "")) == "retry":
+ ## The migration barrier refused: the connection really is blocked.
+ _post_update_server_pending = false
+ elif bool(state.get("install_in_flight", false)) or String(state.get("outcome", "")) == "success":
+ _post_update_server_pending = true
+ if _status_label != null:
+ _update_status()
+ elif state.has("install_in_flight"):
+ ## The install ended without a swap (`_fail_update`): the previous
+ ## version is live and the transport status is the truth again.
+ _post_update_server_pending = false
+ if _status_label != null:
+ _update_status()
+ if state.has("button_text") and _update_btn != null:
+ _update_btn.text = String(state["button_text"])
+ if state.has("button_disabled") and _update_btn != null:
+ _update_btn.disabled = bool(state["button_disabled"])
+ if state.has("status_text"):
+ _set_update_status(String(state["status_text"]))
+ if state.has("label_text") and _update_label != null:
+ _update_label.text = String(state["label_text"])
+ if state.has("banner_visible") and _update_banner != null:
+ _update_banner.visible = bool(state["banner_visible"])
+ if state.has("label_text") and _update_label != null:
+ ## Installation is distinct from server/client readiness. Instructions
+ ## can still name failed migrations or clients that must reconnect.
+ _update_label.add_theme_color_override("font_color", _UPDATE_LABEL_COLOR)
+
+
+func _set_update_status(text: String) -> void:
+ if _update_status_label == null:
+ return
+ _update_status_label.text = text
+ _update_status_label.visible = not text.is_empty()
diff --git a/addons/godot_ai/mcp_dock.gd.uid b/addons/godot_ai/mcp_dock.gd.uid
new file mode 100644
index 0000000..5868719
--- /dev/null
+++ b/addons/godot_ai/mcp_dock.gd.uid
@@ -0,0 +1 @@
+uid://b8yknttdjanm5
diff --git a/addons/godot_ai/migration_bridge.gd b/addons/godot_ai/migration_bridge.gd
index 50874db..7783bc2 100644
--- a/addons/godot_ai/migration_bridge.gd
+++ b/addons/godot_ai/migration_bridge.gd
@@ -1,212 +1,8 @@
@tool
extends Node
-## Verifies and stages the embedded canonical v4 release while the migration
-## capsule is still the enabled plugin. Every step runs in GDScript on the
-## editor's main thread through the same installer scripts the v4 tree ships
-## (`utils/release_verifier.gd`, `utils/update_installer.gd`); nothing here
-## spawns a process, and nothing here writes a live add-on file.
-
-const PAYLOAD_ROOT := "res://addons/godot_ai/migration_payload"
-const ARCHIVE_NAME := "godot-ai-v4-plugin.zip"
-const MANIFEST_NAME := "godot-ai-v4-plugin.manifest.json"
-const SIGNATURE_NAME := "godot-ai-v4-plugin.manifest.sig"
-const VERIFIER_SCRIPT := "res://addons/godot_ai/utils/release_verifier.gd"
-const INSTALLER_SCRIPT := "res://addons/godot_ai/utils/update_installer.gd"
-const REPOSITORY := "hi-godot/godot-ai"
-const CHANNEL := "stable"
-const MAX_MANIFEST_BYTES := 1024 * 1024
-const SIGNATURE_BYTES := 512
-const PENDING_V3_UPDATE_SETTING := "godot_ai/pending_self_update_event"
-const INCOMPLETE_CAPSULE := (
- "The migration capsule is incomplete (%s). Download the release again, then click Retry migration."
-)
-
-## Kept byte-identical to `utils/update_manager.gd::RELEASE_SIGNING_PUBLIC_KEY_PEM`
-## in the v4 tree; `tests/unit/test_release_support.py` pins the two copies
-## together. The capsule cannot preload the v4 updater to read it there.
-const RELEASE_SIGNING_PUBLIC_KEY_PEM := """-----BEGIN PUBLIC KEY-----
-MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAr4OmbONFTONGFcXSUQ2p
-e54YaUhWDA75wxeDWhOc476vsdo53YnXEFT7EPr2hUKqeNxv++LqKOkFuAsxSNZy
-wBe6P1tmQA4Og6Ezv4CGnZdEj1uhlDJFK9ShQ29oWfC6bf/84625SvvBxZos2Br9
-yPKl7h5wzqDoeUSpv+f0ynTiC0i/HAUo/NQBlkgGwkomK2Fr3pP1VDxxq2xvgHSk
-lU6Qcomr9WjJxI+HkDN5tRPPn0pDrg6YFx2J18OfD8KIa/kMGxuXOcHlPyRYpjyu
-qTtg2oL0NyUIG+1TmJ3DcN4GlKC55eOrkfJ04vudS5pxdnUIFRmkGBXZLdaetoPc
-ixtlD4w6gi8KIH1CTG+/TtHP1KVdOogCWDcjRCAmMJPFZe6eEKXmGQUZDb9wfnbx
-h++XiVe5tq83BTLWmaFTy+fZbNo12uhNCNS1LJ42/yj+S1xvo0yMbkkNr1hIYk0P
-584XnBQeBSVJDf3667NZXaxnWv94K9zbb+1OvOvPwhbOdgi2Ymcw5QEOQIavtg86
-XLLcWzG+SJsycz1imikjv6sStWh8WHneKSTMq6A7V6PBj7oJyEJp10696BDw287k
-YlH+9VGqowPEMXpWX57wOBKiWb4K1kw1LfxjT8W1e/pcX9pJqiv0DkjTXUxo9CDG
-1X1+ZXBBR3MkGuFAOCjy0x8CAwEAAQ==
------END PUBLIC KEY-----
-"""
-
-signal state_changed(message: String, failed: bool)
-signal prepared(package: Dictionary)
-
-enum Step { IDLE, VERIFY, STAGE, DONE }
-
-var _step := Step.IDLE
-var _manifest: Dictionary = {}
-var _manifest_sha256 := ""
-var _from_version := ""
-
-
+## Retained capsule path: Godot can finish an old parser's dependency list
+## while loading the new plugin. Migration is complete in the canonical tree;
+## the verified capsule carries the active implementation at this same path.
func start() -> void:
- if _step != Step.IDLE:
- return
- _from_version = _previous_version()
- _step = Step.VERIFY
- state_changed.emit("Verifying the signed Godot AI v4 release…", false)
- set_process(true)
-
-
-func _process(_delta: float) -> void:
- ## One step per frame so the dock renders each status line before the
- ## next synchronous hash/extract pass runs.
- match _step:
- Step.VERIFY:
- _verify()
- Step.STAGE:
- _stage()
- _:
- set_process(false)
-
-
-func _verify() -> void:
- var verifier := _load_script_with(VERIFIER_SCRIPT, "verify_manifest")
- if verifier == null or not _script_declares(verifier, "verify_archive"):
- _fail(INCOMPLETE_CAPSULE % "utils/release_verifier.gd is missing or invalid")
- return
- var manifest_bytes := _read_bounded(PAYLOAD_ROOT.path_join(MANIFEST_NAME), MAX_MANIFEST_BYTES)
- var signature := _read_bounded(PAYLOAD_ROOT.path_join(SIGNATURE_NAME), SIGNATURE_BYTES)
- if manifest_bytes.is_empty() or signature.size() != SIGNATURE_BYTES:
- _fail(INCOMPLETE_CAPSULE % "the embedded v4 manifest or signature is unreadable")
- return
- var expected := {"repository": REPOSITORY, "channel": CHANNEL}
- if not _from_version.is_empty():
- ## Only the final v3 runners publish the pre-capsule plugin.cfg
- ## version; older ones cannot, and the verifier must not be told a
- ## guess.
- expected["current_version"] = _from_version
- var verified: Variant = verifier.call(
- "verify_manifest", manifest_bytes, signature, expected, RELEASE_SIGNING_PUBLIC_KEY_PEM
- )
- if not verified is Dictionary or not bool(verified.get("ok", false)):
- _fail(_error_of(verified, "The embedded v4 release manifest could not be verified."))
- return
- var manifest: Variant = (verified as Dictionary).get("manifest", {})
- if not manifest is Dictionary or str(manifest.get("version", "")).is_empty():
- _fail("The verified v4 manifest is missing its release version.")
- return
- _manifest = (manifest as Dictionary).duplicate(true)
- _manifest_sha256 = _sha256_hex(manifest_bytes)
- var archive_checked: Variant = verifier.call(
- "verify_archive", PAYLOAD_ROOT.path_join(ARCHIVE_NAME), _manifest
- )
- if not archive_checked is Dictionary or not bool(archive_checked.get("ok", false)):
- _fail(_error_of(archive_checked, "The embedded v4 archive does not match its signed manifest."))
- return
- _step = Step.STAGE
- state_changed.emit("Staging the verified Godot AI v4 tree…", false)
-
-
-func _stage() -> void:
- var installer := _load_script_with(INSTALLER_SCRIPT, "stage")
- if installer == null:
- _fail(INCOMPLETE_CAPSULE % "utils/update_installer.gd is missing or invalid")
- return
- var staged: Variant = installer.call("stage", PAYLOAD_ROOT.path_join(ARCHIVE_NAME), _manifest)
- if not staged is Dictionary or not bool(staged.get("ok", false)):
- _fail(_error_of(staged, "The verified v4 tree could not be staged."))
- return
- var stage_root := str((staged as Dictionary).get("stage_root", ""))
- var tree_sha256 := str((staged as Dictionary).get("tree_sha256", ""))
- if stage_root.is_empty() or tree_sha256.is_empty():
- _fail("The installer staged the v4 tree without reporting where or what it wrote.")
- return
- _step = Step.DONE
- set_process(false)
- state_changed.emit("Activating the verified Godot AI v4 tree…", false)
- prepared.emit({
- "stage_root": stage_root,
- "expected_tree_sha256": tree_sha256,
- "manifest_sha256": _manifest_sha256,
- "from_version": _from_version,
- "to_version": str(_manifest.get("version", "")),
- })
-
-
-func _fail(message: String) -> void:
- _step = Step.DONE
- set_process(false)
- state_changed.emit(message, true)
-
-
-## The pre-capsule plugin.cfg version, as recorded by the final v3 self-update
-## runner before it extracted the capsule over the tree. Empty when unknown.
-static func _previous_version() -> String:
- var settings := EditorInterface.get_editor_settings()
- if settings == null or not settings.has_setting(PENDING_V3_UPDATE_SETTING):
- return ""
- var parsed: Variant = JSON.parse_string(str(settings.get_setting(PENDING_V3_UPDATE_SETTING)))
- if not parsed is Dictionary:
- return ""
- var value := str(parsed.get("from_version", ""))
- return value if _is_pre_v4_version(value) else ""
-
-
-static func _is_pre_v4_version(value: String) -> bool:
- var expression := RegEx.new()
- return expression.compile("^[0-3]\\.\\d+\\.\\d+$") == OK and expression.search(value) != null
-
-
-## Load one of the v4 installer scripts by path. The capsule cannot name them
-## by `class_name`: those globals only exist once the v4 tree is live, and the
-## bridge must still parse when a truncated capsule lacks them entirely.
-static func _load_script_with(path: String, method: String) -> Script:
- if not ResourceLoader.exists(path):
- return null
- ## Final v3 ships older files at some of these paths (utils/port_resolver.gd),
- ## already compiled into the resource cache before the capsule was extracted
- ## over the tree. Ignore the cache, deeply, so the capsule's copies and
- ## their preloads are the ones that run.
- var loaded: Variant = ResourceLoader.load(path, "", ResourceLoader.CACHE_MODE_IGNORE_DEEP)
- if not loaded is Script or not _script_declares(loaded, method):
- return null
- return loaded
-
-
-static func _script_declares(script: Script, method: String) -> bool:
- for entry in script.get_script_method_list():
- if str(entry.get("name", "")) == method:
- return true
- return false
-
-
-static func _read_bounded(path: String, maximum: int) -> PackedByteArray:
- var file := FileAccess.open(path, FileAccess.READ)
- if file == null:
- return PackedByteArray()
- var length := file.get_length()
- if length <= 0 or length > maximum:
- file.close()
- return PackedByteArray()
- var data := file.get_buffer(length)
- file.close()
- return data if data.size() == length else PackedByteArray()
-
-
-static func _sha256_hex(data: PackedByteArray) -> String:
- var context := HashingContext.new()
- context.start(HashingContext.HASH_SHA256)
- context.update(data)
- return context.finish().hex_encode()
-
-
-static func _error_of(result: Variant, fallback: String) -> String:
- if result is Dictionary:
- var error := str((result as Dictionary).get("error", "")).strip_edges()
- if not error.is_empty():
- return error
- return fallback
+ queue_free()
diff --git a/addons/godot_ai/migration_coordinator.gd b/addons/godot_ai/migration_coordinator.gd
index ab2ab35..afb8775 100644
--- a/addons/godot_ai/migration_coordinator.gd
+++ b/addons/godot_ai/migration_coordinator.gd
@@ -1,200 +1,15 @@
@tool
extends Node
-## Prepare the capsule's value-only handoff to the shared activation runner.
-## This coordinator finishes before the runner disables the capsule or swaps
-## its source. The runner alone owns the scan and in-editor activation.
-
-const INSTALLER_SCRIPT := "res://addons/godot_ai/utils/update_installer.gd"
-const ACTIVATION_SCRIPT := "res://addons/godot_ai/utils/update_activation_runner.gd"
-const PORT_RESOLVER_SCRIPT := "res://addons/godot_ai/utils/port_resolver.gd"
+## Compatibility path for cached capsule dependencies after a signed update.
+## The canonical plugin has no remaining capsule installation to coordinate.
const STATUS_SETTING := "godot_ai/v4_migration_bridge_status"
-## v3's `plugin.gd::_ensure_game_helper_autoload` wrote
-## `ProjectSettings.set_setting("autoload/_mcp_game_helper", "*res://addons/godot_ai/runtime/game_helper.gd")`
-## and saved project.godot. The capsule never re-registers it, so the entry
-## dangles for the whole migration window and points at a file the v4 swap
-## moves away (#946). Remove it before the swap, matching the exact target so
-## a user-owned autoload of the same name is left alone.
-const GAME_HELPER_AUTOLOAD_SETTING := "autoload/_mcp_game_helper"
-const GAME_HELPER_AUTOLOAD_TARGET := "res://addons/godot_ai/runtime/game_helper.gd"
-const REQUIRED_INSTALLER_METHODS := [
- "acquire_lock", "release_lock", "discard_stage",
-]
-const PRE_DISABLE_DRAIN_FRAMES := 2
-## Unknown pre-v4 versions still describe a major crossing. Manual-major
-## client repinning replaces any owned pre-v4 entry, so this fallback is
-## identity metadata rather than a client-selection authority.
-const UNKNOWN_V3_VERSION := "3.0.0"
-
-signal state_changed(message: String, failed: bool)
-
-enum Phase { IDLE, DRAIN, DONE }
-
-var _package: Dictionary = {}
-var _phase := Phase.IDLE
-var _frames := 0
-var _installer: Script
-var _lock_held := false
-func start(package: Dictionary) -> void:
- if _phase != Phase.IDLE:
- return
- if not _valid_package(package):
- _fail_before_swap("The prepared v4 migration handoff is invalid.")
- return
- _package = package.duplicate(true)
- _frames = PRE_DISABLE_DRAIN_FRAMES
- _phase = Phase.DRAIN
- set_process(true)
-
-
-func _process(_delta: float) -> void:
- match _phase:
- Phase.DRAIN:
- _frames -= 1
- if _frames <= 0:
- _prepare_swap()
- _:
- set_process(false)
-
-
-## Everything that can still refuse without touching the live tree.
-func _prepare_swap() -> void:
- _installer = _load_script_with(INSTALLER_SCRIPT, "swap")
- for method in REQUIRED_INSTALLER_METHODS:
- if _installer == null or not _script_declares(_installer, method):
- _installer = null
- _fail_before_swap(
- "The migration capsule is incomplete (utils/update_installer.gd is missing or invalid)."
- )
- return
- var activation := GDScript.new()
- activation.source_code = FileAccess.get_file_as_string(ACTIVATION_SCRIPT)
- if activation.source_code.is_empty() or activation.reload() != OK or not _script_declares(activation, "start"):
- _fail_before_swap("The migration capsule's activation runner is missing or invalid.")
- return
- var cleared := _remove_v3_game_helper_autoload()
- if cleared != OK:
- _fail_before_swap(
- "Could not remove the v3 game-helper autoload from project.godot (%s)."
- % error_string(cleared)
- )
- return
- var fingerprint := _editor_fingerprint()
- if fingerprint.is_empty():
- _fail_before_swap("Could not fingerprint this editor process for the update lock.")
- return
- var locked: Variant = _installer.call("acquire_lock", OS.get_process_id(), fingerprint)
- if not locked is Dictionary or not bool(locked.get("ok", false)):
- _fail_before_swap(_error_of(locked, "Another editor holds the Godot AI update lock."))
- return
- _lock_held = true
- var from_version := str(_package.get("from_version", ""))
- var record := {
- "from_version": from_version if not from_version.is_empty() else UNKNOWN_V3_VERSION,
- "to_version": str(_package.get("to_version", "")),
- "manifest_sha256": str(_package.get("manifest_sha256", "")),
- "expected_tree_sha256": str(_package.get("expected_tree_sha256", "")),
- "editor_nonce": Crypto.new().generate_random_bytes(16).hex_encode(),
- ## The v4 plugin reads this after activation: a v3-to-v4 crossing
- ## may replace owned client-config entries broadly, which an
- ## ordinary v4-to-v4 update must not do.
- "replace_owned_mismatches": true,
- }
- var runner: Node = activation.new()
- get_tree().root.add_child(runner)
- var accepted: Variant = runner.call("start", {"stage_root": str(_package.stage_root), "record": record})
- if not accepted is bool or not accepted:
- var reason := str(runner.get("refusal_reason"))
- runner.queue_free()
- _fail_before_swap(reason if not reason.is_empty() else "The migration activation runner refused the prepared handoff.")
- return
- ## No capsule Script or suspended callback crosses into the scan. The
- ## runner holds only its independent source and copied package values.
- _lock_held = false
- _installer = null
- _phase = Phase.DONE
- set_process(false)
- print("MCP | v3 bridge handing verified tree to in-editor activation")
+func start(_package: Dictionary) -> void:
queue_free()
-func _fail_before_swap(message: String) -> void:
- _release_lock()
- _discard_stage()
- record_status(message)
- push_error("Godot AI v4 migration: %s" % message)
- _phase = Phase.DONE
- set_process(false)
- state_changed.emit(message, true)
- queue_free()
-
-
-func _release_lock() -> void:
- if not _lock_held:
- return
- _lock_held = false
- if _installer != null:
- _installer.call("release_lock")
-
-
-## A refused attempt leaves no staged tree behind; `stage()` would clear it
-## on Retry anyway, so this is tidiness, not a contract the bridge relies on.
-func _discard_stage() -> void:
- if _installer != null and _script_declares(_installer, "discard_stage"):
- _installer.call("discard_stage")
-
-
-static func _remove_v3_game_helper_autoload() -> Error:
- if not ProjectSettings.has_setting(GAME_HELPER_AUTOLOAD_SETTING):
- return OK
- var target := str(ProjectSettings.get_setting(GAME_HELPER_AUTOLOAD_SETTING, "")).trim_prefix("*")
- if target != GAME_HELPER_AUTOLOAD_TARGET:
- return OK
- ProjectSettings.clear(GAME_HELPER_AUTOLOAD_SETTING)
- return ProjectSettings.save()
-
-
-## `McpPortResolver.process_fingerprint` from the v4 `utils/port_resolver.gd`
-## the capsule carries; final v3's copy of that file predates the method, so
-## the lookup is by path and method, never by class_name.
-static func _editor_fingerprint() -> String:
- var resolver := _load_script_with(PORT_RESOLVER_SCRIPT, "process_fingerprint")
- if resolver == null:
- return ""
- var value: Variant = resolver.call("process_fingerprint", OS.get_process_id())
- return str(value) if value is String else ""
-
-
-static func _load_script_with(path: String, method: String) -> Script:
- if not ResourceLoader.exists(path):
- return null
- ## Final v3 ships older files at some of these paths (utils/port_resolver.gd),
- ## already compiled into the resource cache before the capsule was extracted
- ## over the tree. Ignore the cache, deeply, so the capsule's copies and
- ## their preloads are the ones that run.
- var loaded: Variant = ResourceLoader.load(path, "", ResourceLoader.CACHE_MODE_IGNORE_DEEP)
- if not loaded is Script or not _script_declares(loaded, method):
- return null
- return loaded
-
-
-static func _script_declares(script: Script, method: String) -> bool:
- for entry in script.get_script_method_list():
- if str(entry.get("name", "")) == method:
- return true
- return false
-
-
-static func _error_of(result: Variant, fallback: String) -> String:
- if result is Dictionary:
- var error := str((result as Dictionary).get("error", "")).strip_edges()
- if not error.is_empty():
- return error
- return fallback
-
-
static func record_status(error: String) -> void:
var settings := EditorInterface.get_editor_settings()
if settings != null:
@@ -202,12 +17,4 @@ static func record_status(error: String) -> void:
static func status_setting() -> String:
- ## A failed migration in one project must not block a different project.
return STATUS_SETTING + "_" + ProjectSettings.globalize_path("res://").sha256_text()
-
-
-static func _valid_package(package: Dictionary) -> bool:
- for name in ["stage_root", "expected_tree_sha256", "manifest_sha256", "to_version"]:
- if str(package.get(name, "")).is_empty():
- return false
- return true
diff --git a/addons/godot_ai/migration_fallback.gd b/addons/godot_ai/migration_fallback.gd
index 5030a4d..eb59fcd 100644
--- a/addons/godot_ai/migration_fallback.gd
+++ b/addons/godot_ai/migration_fallback.gd
@@ -1,217 +1,11 @@
@tool
extends Node
-## Puts the final v3 add-on back when this Godot is below v4's floor. The
-## user's own updater already overlaid the capsule over their v3 tree and
-## discarded its per-file backups, so without this the plugin stays dead
-## until they upgrade Godot. Parented outside the EditorPlugin so it survives
-## the capsule being disabled. Leaves with the capsule once the fleet is on
-## v4 (docs/self-update.md).
-
-const PLUGIN_CFG := "res://addons/godot_ai/plugin.cfg"
-const LIVE_ROOT := "res://addons/godot_ai"
-const FALLBACK_ZIP := "res://addons/godot_ai/migration_payload/godot-ai-v3-plugin.zip"
-const LIVE_PREFIX := "addons/godot_ai/"
-## Every file is written and read back here before the live tree is touched.
-## The update directory is hidden from Godot's scanner and from git exactly
-## as utils/update_installer.gd hides it.
-const UPDATE_ROOT := "res://addons/.godot_ai_update"
-const STAGE_ROOT := UPDATE_ROOT + "/v3-restore"
-const UPDATE_IGNORE_FILES := [[".gdignore", ""], [".gitignore", "*\n"]]
-const MAX_FILES := 4096
-const MAX_TOTAL_BYTES := 64 * 1024 * 1024
-## Capsule-only files; the restored v3 tree must not carry them.
-const CAPSULE_ONLY := [
- "migration_bridge.gd", "migration_bridge.gd.uid",
- "migration_coordinator.gd", "migration_coordinator.gd.uid",
- "migration_fallback.gd", "migration_fallback.gd.uid",
- "utils/release_verifier.gd", "utils/release_verifier.gd.uid",
- "utils/update_installer.gd", "utils/update_installer.gd.uid",
- "migration_payload",
-]
-
-
+## Retained capsule dependency path. The canonical v4 tree has no embedded
+## v3 fallback; keeping this path must never initiate a downgrade.
static func available() -> bool:
- return FileAccess.file_exists(FALLBACK_ZIP)
+ return false
func start() -> void:
- _restore.call_deferred()
-
-
-func _restore() -> void:
- var entries: Array = []
- var error := _read_payload(entries)
- if error.is_empty():
- error = _prepare_update_root()
- if error.is_empty():
- error = _write_all(STAGE_ROOT, entries)
- if error.is_empty():
- error = _write_all(LIVE_ROOT, entries)
- if not error.is_empty():
- ## The capsule payload and any stage stay for a retry; nothing is
- ## removed until the restored plugin has been enabled.
- push_error("Godot AI v4 migration: could not restore the previous version: %s" % error)
- queue_free()
- return
- print(
- "MCP | v3 bridge restored Godot AI v%s; update again on Godot 4.7 or newer"
- % _restored_version()
- )
- ## Disabling the capsule removed its dock; re-enabling after a scan loads
- ## the restored plugin.gd, exactly as final v3's own updater re-enables.
- EditorInterface.set_plugin_enabled(PLUGIN_CFG, false)
- var filesystem := EditorInterface.get_resource_filesystem()
- if filesystem == null:
- _enable_after_scan.call_deferred()
- return
- filesystem.filesystem_changed.connect(_enable_after_scan, CONNECT_ONE_SHOT)
- filesystem.scan()
-
-
-func _enable_after_scan() -> void:
- EditorInterface.set_plugin_enabled(PLUGIN_CFG, true)
- if not EditorInterface.is_plugin_enabled(PLUGIN_CFG):
- push_error(
- "Godot AI v4 migration: the restored plugin could not be enabled;"
- + " the capsule payload is kept for another attempt"
- )
- queue_free()
- return
- ## Only a running restored plugin makes the capsule material disposable.
- _remove_capsule_only()
- _discard_stage()
queue_free()
-
-
-## Every entry must be a regular file under the live add-on path: no absolute
-## paths, no `..`, nothing outside `addons/godot_ai/`. Read everything before
-## writing anything, so a bad archive changes nothing.
-func _read_payload(entries: Array) -> String:
- var reader := ZIPReader.new()
- if reader.open(FALLBACK_ZIP) != OK:
- return "cannot open %s" % FALLBACK_ZIP
- var files := reader.get_files()
- if files.size() > MAX_FILES:
- reader.close()
- return "too many entries"
- var total := 0
- for name in files:
- if name.ends_with("/"):
- continue
- var segments := name.split("/")
- if (
- not name.begins_with(LIVE_PREFIX)
- or name.contains("\\")
- or segments.has("..")
- or segments.has(".")
- or segments.has("")
- ):
- reader.close()
- return "unsafe entry %s" % name
- var data := reader.read_file(name)
- total += data.size()
- if total > MAX_TOTAL_BYTES:
- reader.close()
- return "payload too large"
- entries.append([name, data])
- reader.close()
- if entries.is_empty():
- return "empty archive"
- return ""
-
-
-static func _prepare_update_root() -> String:
- var root := ProjectSettings.globalize_path(UPDATE_ROOT)
- if DirAccess.make_dir_recursive_absolute(root) != OK:
- return "cannot create %s" % root
- for marker in UPDATE_IGNORE_FILES:
- var path := root.path_join(str(marker[0]))
- if FileAccess.file_exists(path):
- continue
- var file := FileAccess.open(path, FileAccess.WRITE)
- if file == null:
- return "cannot write %s" % path
- file.store_string(str(marker[1]))
- file.close()
- _remove_dir(ProjectSettings.globalize_path(STAGE_ROOT))
- return ""
-
-
-## Drop the stage; when nothing but the ignore markers is left, drop the
-## update directory too, so a refused or restored crossing leaves no trace.
-static func _discard_stage() -> void:
- _remove_dir(ProjectSettings.globalize_path(STAGE_ROOT))
- var root := ProjectSettings.globalize_path(UPDATE_ROOT)
- var dir := DirAccess.open(root)
- if dir == null:
- return
- dir.include_hidden = true
- dir.include_navigational = false
- if not dir.get_directories().is_empty():
- return
- var markers := PackedStringArray()
- for marker in UPDATE_IGNORE_FILES:
- markers.append(str(marker[0]))
- for name in dir.get_files():
- if not markers.has(name):
- return
- _remove_dir(root)
-
-
-## Write every entry under `root`, checking each write and reading it back,
-## so a full disk or a refused file stops before anything else is touched.
-static func _write_all(root: String, entries: Array) -> String:
- for entry in entries:
- var relative: String = str(entry[0]).trim_prefix(LIVE_PREFIX)
- var error := _write_checked(root + "/" + relative, entry[1])
- if not error.is_empty():
- return error
- return ""
-
-
-static func _write_checked(target: String, data: PackedByteArray) -> String:
- var parent := ProjectSettings.globalize_path(target.get_base_dir())
- if DirAccess.make_dir_recursive_absolute(parent) != OK:
- return "cannot create %s" % parent
- var file := FileAccess.open(target, FileAccess.WRITE)
- if file == null:
- return "cannot write %s (%s)" % [target, error_string(FileAccess.get_open_error())]
- file.store_buffer(data)
- var error := file.get_error()
- file.close()
- if error != OK:
- return "write failed for %s (%s)" % [target, error_string(error)]
- var written := FileAccess.get_file_as_bytes(target)
- if written.size() != data.size():
- return "short write for %s (%d of %d bytes)" % [target, written.size(), data.size()]
- return ""
-
-
-func _remove_capsule_only() -> void:
- for relative in CAPSULE_ONLY:
- var path := ProjectSettings.globalize_path(LIVE_ROOT + "/" + str(relative))
- if DirAccess.dir_exists_absolute(path):
- _remove_dir(path)
- elif FileAccess.file_exists(path):
- DirAccess.remove_absolute(path)
-
-
-static func _remove_dir(path: String) -> void:
- var dir := DirAccess.open(path)
- if dir == null:
- return
- dir.include_hidden = true
- dir.include_navigational = false
- for name in dir.get_directories():
- _remove_dir(path.path_join(name))
- for name in dir.get_files():
- DirAccess.remove_absolute(path.path_join(name))
- DirAccess.remove_absolute(path)
-
-
-static func _restored_version() -> String:
- var config := ConfigFile.new()
- if config.load(PLUGIN_CFG) != OK:
- return "?"
- return str(config.get_value("plugin", "version", "?"))
diff --git a/addons/godot_ai/migration_payload/godot-ai-v3-plugin.zip b/addons/godot_ai/migration_payload/godot-ai-v3-plugin.zip
deleted file mode 100644
index be3f088..0000000
Binary files a/addons/godot_ai/migration_payload/godot-ai-v3-plugin.zip and /dev/null differ
diff --git a/addons/godot_ai/migration_payload/godot-ai-v4-plugin.manifest.json b/addons/godot_ai/migration_payload/godot-ai-v4-plugin.manifest.json
deleted file mode 100644
index 0f5e238..0000000
--- a/addons/godot_ai/migration_payload/godot-ai-v4-plugin.manifest.json
+++ /dev/null
@@ -1 +0,0 @@
-{"asset":{"name":"godot-ai-v4-plugin.zip","sha256":"535d8a7541871af8d991b07fe5031550dd6121a31b844400476b334a70612831","size":1971643},"channel":"stable","inventory":[{"path":"addons/godot_ai/LICENSE","sha256":"f2afe4af4ec80c453ca34704bc61d5d24a9bd948d365ca22699cabb8b4a18308","size":1078},{"path":"addons/godot_ai/README.md","sha256":"73ade921edaa0d7fe41278d07d40caaa8ba1d6358368c1954784dd1b119391a2","size":1742},{"path":"addons/godot_ai/client_configurator.gd","sha256":"fd283d5c66d097fd970374c249d75e680540b7704901b2c9d7305abb8efe416e","size":93044},{"path":"addons/godot_ai/client_configurator.gd.uid","sha256":"b11391096156780923df61e627d9241a691efd13330aaa0d69c44de3e91f9ccd","size":19},{"path":"addons/godot_ai/clients/_atomic_write.gd","sha256":"23042ee1ef57f72c102cadbfefdaf6cc2adf1d875a9f7e2f3c499dcbf74f91a8","size":12381},{"path":"addons/godot_ai/clients/_atomic_write.gd.uid","sha256":"69d170b100e1477dc969d937664bf32e9f1a41448fbddae997616e4d549f857b","size":19},{"path":"addons/godot_ai/clients/_base.gd","sha256":"1fbe00b374efebe4cd2b7c02418608102e2dc4b2bd25406471fc681a4a80e367","size":29069},{"path":"addons/godot_ai/clients/_base.gd.uid","sha256":"6ad5fd0be314b164e5a501aaf4afff8c17d3c406e4931b3d490fa55ed9399d75","size":20},{"path":"addons/godot_ai/clients/_cli_exec.gd","sha256":"1f49fcf5c2ed63e575858a13066745f5489ad096f7691261dd768da49e4ca6a5","size":8426},{"path":"addons/godot_ai/clients/_cli_exec.gd.uid","sha256":"0a011c6ccf204f647bb410e812452397b8d9d3f82e98e9c8c43255e8a8db43be","size":20},{"path":"addons/godot_ai/clients/_cli_finder.gd","sha256":"66ec387838fe5c574188ff6d57655ca30c32e992e445e7e1ab2fc957d1b3190c","size":6820},{"path":"addons/godot_ai/clients/_cli_finder.gd.uid","sha256":"844ed9a1cb168c6fa4a2ad7369a0b9e2b317a8845780fa551f0ddeb31035d4c7","size":20},{"path":"addons/godot_ai/clients/_cli_strategy.gd","sha256":"be3772eaa3c9bfbdd269310d58cf286a45008bf60e4d76493c651635466fc73c","size":20313},{"path":"addons/godot_ai/clients/_cli_strategy.gd.uid","sha256":"7ea94aa0a18f18001abe6c039d3f9101a20211dafe616bde1171de42e8878188","size":20},{"path":"addons/godot_ai/clients/_dsh_strategy.gd","sha256":"883adc4181073e1e7d7adacda749f55de0ef8b7f53de518bdecb23a8fa25b067","size":31296},{"path":"addons/godot_ai/clients/_dsh_strategy.gd.uid","sha256":"b603679cc9d3071b0029da2200a85ecbe367bc9f03fb1e352eceb544ebe1f4a1","size":20},{"path":"addons/godot_ai/clients/_json_strategy.gd","sha256":"6781f728f3b8f8498abbdfe694c6a49c50424ba9e7e278d7739919c60e298e30","size":48846},{"path":"addons/godot_ai/clients/_json_strategy.gd.uid","sha256":"0d7613d1a25a2637491759c219cdad8610156f840258238df5728eb18523a49d","size":19},{"path":"addons/godot_ai/clients/_manual_command.gd","sha256":"d77adc6b6f66f700ea1ae26adb5ede8b0f3e70620e071d8a4b15e79b24e64358","size":15256},{"path":"addons/godot_ai/clients/_manual_command.gd.uid","sha256":"323e682575c1bf3b1841ec517b59888bf304b3ada97dfd861ec317b8f714635a","size":20},{"path":"addons/godot_ai/clients/_path_template.gd","sha256":"bc3f01879b1469dc8a72e3025f698441e60f52207282c9fa978156c94fcf98bc","size":9696},{"path":"addons/godot_ai/clients/_path_template.gd.uid","sha256":"b110326fea223f45902def5a53b35522f269ad55848718b7ddd143cd63293b86","size":19},{"path":"addons/godot_ai/clients/_registry.gd","sha256":"0b7f45367f8b208bbee2c521b4513aee3d716c02cad2544f45743d5f815e5442","size":5956},{"path":"addons/godot_ai/clients/_registry.gd.uid","sha256":"0fb18ab129e0fba2eb85e7e5b784ce566c586b1711f76f8ab429610172a203ab","size":19},{"path":"addons/godot_ai/clients/_toml_strategy.gd","sha256":"f1f8481dcd568ef408b9de29a23792921a51a81ab70865463fbb77fcf1efe912","size":25296},{"path":"addons/godot_ai/clients/_toml_strategy.gd.uid","sha256":"3f581fce945ceabf62ce7397f0da3c9a64888f7d071fe59edeec9126735c9837","size":20},{"path":"addons/godot_ai/clients/_yaml_strategy.gd","sha256":"9ca3a596c973603192cb05ebc107d06e54552eeaef61ea3c252bd98c8ec05963","size":22431},{"path":"addons/godot_ai/clients/_yaml_strategy.gd.uid","sha256":"bee0844c9cd2c399c1105d4f741e804ac433a0dd89c338a63da9d00b1b983885","size":20},{"path":"addons/godot_ai/clients/antigravity.gd","sha256":"af2cb95aa3b1e132ac90dbd3eac2d5b993a0c8642368c245644b59b3afb63fbb","size":1815},{"path":"addons/godot_ai/clients/antigravity.gd.uid","sha256":"d247b7f9da8ed483178c3f12e207494060181af364ef829951a6de2dec8fb482","size":20},{"path":"addons/godot_ai/clients/claude_code.gd","sha256":"2660ee8d70f11d4d3d55b12a29551fcdbb749e3002c9eb4bee1894d4853bb362","size":5009},{"path":"addons/godot_ai/clients/claude_code.gd.uid","sha256":"18d1ebc89fe7cd71054545da54c04477465e69b8291d082f8c860abc77dd2c41","size":20},{"path":"addons/godot_ai/clients/claude_desktop.gd","sha256":"25cd87161a8985273b5ad3954b397a95af64ab205ecd8359c63da09a0efbc020","size":1634},{"path":"addons/godot_ai/clients/claude_desktop.gd.uid","sha256":"e430bb213b5005f3182aca544fdade6c8a7e89128705907c11228f699eb27fc0","size":20},{"path":"addons/godot_ai/clients/cline.gd","sha256":"f48504c220090cdb44914241e9ac760246e7e931678c9f4c9f119b651299f85c","size":2159},{"path":"addons/godot_ai/clients/cline.gd.uid","sha256":"c8d58f6b86221ef68289a090b836d31f4006d395c3f42ed3000fc027a020c06d","size":20},{"path":"addons/godot_ai/clients/codebuddy.gd","sha256":"7eca77746e458647de6c02befb3cfabc171e988af0dbe3cdfe12e6c055fa9a68","size":907},{"path":"addons/godot_ai/clients/codebuddy.gd.uid","sha256":"e3b12124e5b591f480aff9bc890bcc1169cd6f83907c1d7dbabd95e9f5964eec","size":20},{"path":"addons/godot_ai/clients/codex.gd","sha256":"1840261a6e6eafdde63a26e3a8d85334fde916f21079192e9b3957231232a103","size":1493},{"path":"addons/godot_ai/clients/codex.gd.uid","sha256":"4bdcaae2f5f0c2e850430a7cc275d64bb84b880df8076a1cd9710dfb9fdc17e2","size":19},{"path":"addons/godot_ai/clients/cursor.gd","sha256":"197be68bfa76b0ca142fdde802f14d706b26a85d78aa96f0476dafc1d44a5353","size":821},{"path":"addons/godot_ai/clients/cursor.gd.uid","sha256":"3625853ad9bb909b2668f94f22a592231612d216c384a9a19e59f295eece2314","size":20},{"path":"addons/godot_ai/clients/deepseek_harness.gd","sha256":"b45feb086a36a19f925bd8de8a619377b72df49ac911609cfd3cb8e914112f3e","size":2072},{"path":"addons/godot_ai/clients/deepseek_harness.gd.uid","sha256":"e9f7b2f0544a8c03f4e153af30e21a8c4e4a138814ea16e005416fdc127bc466","size":20},{"path":"addons/godot_ai/clients/gemini_cli.gd","sha256":"647e4627e3977de234aed98f762e7d9a624ed518ebfe189ffd6b4e14bb313d47","size":874},{"path":"addons/godot_ai/clients/gemini_cli.gd.uid","sha256":"7af07715d59b73f3e69173d39364eb4103f5d84eeee312e13ed04fae4c5279d6","size":20},{"path":"addons/godot_ai/clients/grok.gd","sha256":"b2ae578424587437e14a56c801ef2551e7652ffb01808e6b8285e4b2557d6502","size":1612},{"path":"addons/godot_ai/clients/grok.gd.uid","sha256":"e911f4916e044eea03744e3a3e9920d53d18db876a515e0f9415be6f76d3b89b","size":20},{"path":"addons/godot_ai/clients/hermes.gd","sha256":"28759488fa59ead7c094c59e31f8cf2080f644e17b806929d94a1f8645870782","size":1956},{"path":"addons/godot_ai/clients/hermes.gd.uid","sha256":"331b1b1d2699ddc02ed293d674dae6b2d5b69ed5e29a94ce7b6662de6ec24a1e","size":19},{"path":"addons/godot_ai/clients/kilo_code.gd","sha256":"28cca26f36ae48c0382efde216f7946fcc731cb5ef5a87242580df75b346d0a3","size":1937},{"path":"addons/godot_ai/clients/kilo_code.gd.uid","sha256":"73705b20213a8cdabde5c3207cbfd750acba3ecfdb12e5fc0ad2d32c861fdeee","size":20},{"path":"addons/godot_ai/clients/kimi_code.gd","sha256":"233c5719b1532a7d3d0ad533ca9584dc4eb0d21d3308e9d183ee64a789373e9a","size":1557},{"path":"addons/godot_ai/clients/kimi_code.gd.uid","sha256":"caeefab43e4177a581a0a527194db6580c308f640ff3755526b95602c7d284e1","size":20},{"path":"addons/godot_ai/clients/kiro.gd","sha256":"73a1e60d961f81508f6ab5263770978af3a9a0cb29adf5cf30267674c86ccbb3","size":829},{"path":"addons/godot_ai/clients/kiro.gd.uid","sha256":"e36281df5685847acc757533d5ab4beb44391db897e123ebb0b377ce554e0ce5","size":20},{"path":"addons/godot_ai/clients/opencode.gd","sha256":"fe0cdab3b2541f330367b97c718e3331be323a5d31d056e6502f399ce8711a5e","size":2475},{"path":"addons/godot_ai/clients/opencode.gd.uid","sha256":"cebbf0729ea920db3266906f32279189520176bd10225ebbeec69882d97b9c09","size":19},{"path":"addons/godot_ai/clients/pi.gd","sha256":"0afa45f304d7da41a624aafcb6d684c76fe6a16f3f54c6d5b573e93963be4471","size":2731},{"path":"addons/godot_ai/clients/pi.gd.uid","sha256":"6ca88777d2a2a46d7ae8cb27c96650c24821c2db31efdaf14ec1c672770b13a7","size":19},{"path":"addons/godot_ai/clients/qwen_code.gd","sha256":"af8c74797bf3b0617d0639052a86167cabff123fc110487a2f9a53033be5cef1","size":884},{"path":"addons/godot_ai/clients/qwen_code.gd.uid","sha256":"124a3efc1063037a79d1ef713b066808dff87f84b062779108e3df0dfe78c70a","size":19},{"path":"addons/godot_ai/clients/roo_code.gd","sha256":"7dbdbfa610abfea313d53497b35b3d9f482a6a8d67e37486d2d8e01c9fa48935","size":2149},{"path":"addons/godot_ai/clients/roo_code.gd.uid","sha256":"60128a673c0122f6cdc2562d2ad517805c4120b98ae24ebce02c5471a5a2b16b","size":20},{"path":"addons/godot_ai/clients/trae.gd","sha256":"3fabf591a0e7507b509116ec0539f7175cbed74346c82c3f56a2fab81d122746","size":873},{"path":"addons/godot_ai/clients/trae.gd.uid","sha256":"69409fe3a502dd271d2abddbd97ac31187034b3f8aa409226116e2f1bc7199b8","size":20},{"path":"addons/godot_ai/clients/vscode.gd","sha256":"e44f4c08cf67326ae312c83fa3bb1179840e5bb178352e50e6a432590e0d969a","size":1219},{"path":"addons/godot_ai/clients/vscode.gd.uid","sha256":"738e01d95a248402eeca40d764030a4a4063a246904475b49145c6c159d55ddf","size":20},{"path":"addons/godot_ai/clients/vscode_insiders.gd","sha256":"1b9da0fce3d453dd108b28b1e0b645feb12be4e56401f9d5b58aed95f3396a92","size":851},{"path":"addons/godot_ai/clients/vscode_insiders.gd.uid","sha256":"4fd8b6a9a5e330f7428dac1adb0fd0e5406343febd8bafad0a7fe3e5d214cf88","size":20},{"path":"addons/godot_ai/clients/windsurf.gd","sha256":"9a1a3ba1be1d27dd24684923a483fe8706a65343bf08e47d7625ff6ef2c0f453","size":1242},{"path":"addons/godot_ai/clients/windsurf.gd.uid","sha256":"6b348d4a775be6af159c77bbc7ff5df86a921928f76a1920df97060c5f3c5901","size":20},{"path":"addons/godot_ai/clients/zed.gd","sha256":"e087ab94dea5a41dd1046f83d120583b9258935668e81091ffa12161c4d7cb1b","size":1646},{"path":"addons/godot_ai/clients/zed.gd.uid","sha256":"737f5044ffdf88b5d9f00a52e69108378b4bf4dd404fe240b8865a1e13bd45cf","size":20},{"path":"addons/godot_ai/clients/zoo_code.gd","sha256":"260b57e7e7c7e32854f7e3690adb1e01164f855f8a828948b8f78fd3f9144637","size":1742},{"path":"addons/godot_ai/clients/zoo_code.gd.uid","sha256":"9000f73477a13428779c8e1db0631291275fde02913148ec0094ced66bc0080b","size":20},{"path":"addons/godot_ai/connection.gd","sha256":"802071f7d38251101d448c421ade7b09fb28c2f3438344455f2965dede857910","size":48377},{"path":"addons/godot_ai/connection.gd.uid","sha256":"5bd784acd459f5d89ce2290026030f9bac4586f2ab9fa2ce6f680acbba6cd7ec","size":20},{"path":"addons/godot_ai/custom_tools/custom_tool_wrapper.gd","sha256":"5a103ae1c5c26f01028b35e85b1dfdb264a0d1cf04ad9588210cd2dc11ebbe30","size":4070},{"path":"addons/godot_ai/custom_tools/custom_tool_wrapper.gd.uid","sha256":"ce5f3ecfd8794eac6ae799df6b73ca6f9ee74637820e99e31eec831ff00bdf70","size":20},{"path":"addons/godot_ai/custom_tools/mcp_call_context.gd","sha256":"229fa14a9257878dd3dd7ad2628195c09ac9068680498105551540864e55cbd6","size":1498},{"path":"addons/godot_ai/custom_tools/mcp_call_context.gd.uid","sha256":"197960618a07c1962b7ae9e2d6cffdfc7e38f66c3980be5f3922e5dbf8be55a2","size":20},{"path":"addons/godot_ai/custom_tools/mcp_custom_tool_spec.gd","sha256":"c9edada47c56e4aea1735b668d9212dae0420a75ffc66ca4e974962bc8351df2","size":4400},{"path":"addons/godot_ai/custom_tools/mcp_custom_tool_spec.gd.uid","sha256":"7472166e686079f1054ca3365154fa4d05db5a7fed8f137ce05d2f268b48f3b3","size":19},{"path":"addons/godot_ai/custom_tools/mcp_service_locator.gd","sha256":"f329eac25097a201c3d00f342ccdb5c9e4cd5bd758bc5504726abe2a6c5b3069","size":377},{"path":"addons/godot_ai/custom_tools/mcp_service_locator.gd.uid","sha256":"4b878baf63f39983325ac5435cb6fc456090a24610f5db7dfefe1d8aa344a9bf","size":20},{"path":"addons/godot_ai/custom_tools/mcp_tool_registry.gd","sha256":"d9b69c3464dc09e4e1fa224f4cb8b4c928a9190d6d44bd2216c48e3026ea70dc","size":10436},{"path":"addons/godot_ai/custom_tools/mcp_tool_registry.gd.uid","sha256":"1c6f5521d3757f347c4cd4171541328008245c9423500f379c4c9bd9ed0cf0fd","size":20},{"path":"addons/godot_ai/debugger/mcp_debugger_plugin.gd","sha256":"13766ef2d32d90bdb86df03010e507dff6251959b115d0840ead5695e9294b7b","size":86484},{"path":"addons/godot_ai/debugger/mcp_debugger_plugin.gd.uid","sha256":"be3050234c07a27e62be35eb521fb48d37037de4beefe88cbea9e2232051d75c","size":20},{"path":"addons/godot_ai/dispatcher.gd","sha256":"6a4a802a0d7b44837d9a25b9b95199150881e1acf7e7bd5484f7e51425db921f","size":22386},{"path":"addons/godot_ai/dispatcher.gd.uid","sha256":"66d99fb65718903951361405e5f161cab7aa44636aa080c0bb4c7eef774a10c6","size":20},{"path":"addons/godot_ai/dock_panels/log_viewer.gd","sha256":"3975f54e49a4259f6cba23d031426ab4cbd90efda6ade75f0d3263d91b202a73","size":3036},{"path":"addons/godot_ai/dock_panels/log_viewer.gd.uid","sha256":"0c49d5ca8c63cc2d29614dbc7cbc1dcdc046f4dd85ddfd46cda93c3b3246426c","size":20},{"path":"addons/godot_ai/dock_panels/port_picker_panel.gd","sha256":"718b8e592abbe2e5b1118a04c452735fdb7e247904ac971003692ddcec3bc225","size":4252},{"path":"addons/godot_ai/dock_panels/port_picker_panel.gd.uid","sha256":"c4c8bfff382983add25baf1000e1214a6c6d227cb9ae4dd9c94e078513b74cd1","size":19},{"path":"addons/godot_ai/export/mcp_export_plugin.gd","sha256":"e32fd58f473c4d9c974d866ada447058e1f71b933aadcf4b1ef6c6c4ad2a470a","size":3039},{"path":"addons/godot_ai/export/mcp_export_plugin.gd.uid","sha256":"35c878e5453ee13abf3955993de021f7cb71b3c50c30a2b3dc84236959eb7e6e","size":19},{"path":"addons/godot_ai/handlers/_node_validator.gd","sha256":"f24931cfddfec7e0b311e9f53778a5ea7e75026c5a776a6ee75ef1d82050bb7d","size":2864},{"path":"addons/godot_ai/handlers/_node_validator.gd.uid","sha256":"3d293beeb180ea1e5ed05a17655a9ba61498d49ddd3ade61608cd56613cc27bb","size":20},{"path":"addons/godot_ai/handlers/_param_validators.gd","sha256":"d9104c864b375304cf54d7d8825376bae3f606c7ab0956c302b8714968036a33","size":1397},{"path":"addons/godot_ai/handlers/_param_validators.gd.uid","sha256":"f77bead393cdf61b23131c5151bd75b08f6671a2457cff7ea3c7cfe38278eb66","size":20},{"path":"addons/godot_ai/handlers/_property_errors.gd","sha256":"92ddc1dfd433a4aae7d4088a53a0cff36b0b196aaca9d0488e43b7f2614bbd97","size":2837},{"path":"addons/godot_ai/handlers/_property_errors.gd.uid","sha256":"2b49d3d85542c5e0af947ab114fbb7f71885d0310f35a026411323df007c0a2c","size":20},{"path":"addons/godot_ai/handlers/animation_handler.gd","sha256":"7914575e6f8e8ee216a17b43eecde176ab42f61343a218fe88ca29a4feff4789","size":33812},{"path":"addons/godot_ai/handlers/animation_handler.gd.uid","sha256":"47b49982ea7e09cddf957ab596d401b28b8838190a434e07c2afd02ad5381567","size":20},{"path":"addons/godot_ai/handlers/animation_presets.gd","sha256":"39cdcfb12975d194b6cd58c75d5aa4eb08d31aaa36558cb68c9e45198625f47b","size":20151},{"path":"addons/godot_ai/handlers/animation_presets.gd.uid","sha256":"8678c72d4d8d9883c7f0282a80058f0d19c41aaf82d32e17215cf3378171c1bd","size":20},{"path":"addons/godot_ai/handlers/animation_values.gd","sha256":"d6d2d066eee01c312421775e8188aecc6fb235b11329c8b4f5bed36fbac59130","size":16811},{"path":"addons/godot_ai/handlers/animation_values.gd.uid","sha256":"d251c65c53fa71891d10e420ed8ca06ac1ca308f853b6a758badad17acc90aa9","size":20},{"path":"addons/godot_ai/handlers/api_handler.gd","sha256":"9d32daff53e0b78759ff20bca3661ecccd75bef91b9e8bb7feb7928774807fb5","size":3427},{"path":"addons/godot_ai/handlers/api_handler.gd.uid","sha256":"b22ddf4e5b697aab7099ce15d47c2086624997f8e86d2e93c92a585fe23eb5b8","size":19},{"path":"addons/godot_ai/handlers/audio_handler.gd","sha256":"4a1f4832b6cf1b90de41a126d1ccf762c95b18d9f01c6e86cb1b475c4d28c3b1","size":11600},{"path":"addons/godot_ai/handlers/audio_handler.gd.uid","sha256":"64b5a9af05623f153e788733749516412509423eb57b945146b0778cd867b146","size":20},{"path":"addons/godot_ai/handlers/autoload_handler.gd","sha256":"b35b70096de141afa97035127702d6e6dad7f1432218a22dee217a2bc9cfd8b7","size":3112},{"path":"addons/godot_ai/handlers/autoload_handler.gd.uid","sha256":"aa05d871c5800e244428010f2a19dc357f0f29b8a025b8c64614357c4db31956","size":20},{"path":"addons/godot_ai/handlers/batch_handler.gd","sha256":"d951813ac00f536e2f8d14d97d4949ced152ddb94e8a305852193e3bb94f794c","size":10760},{"path":"addons/godot_ai/handlers/batch_handler.gd.uid","sha256":"e506e0940373f6e8dbf2c775e1bb690cb7c75b1d877f6e1a0d2493b2d364ce05","size":20},{"path":"addons/godot_ai/handlers/camera_handler.gd","sha256":"e17a7e037ac887f5b7b78e462ffdc78c51d6f0a11cd846cf5b274507383d99ef","size":40171},{"path":"addons/godot_ai/handlers/camera_handler.gd.uid","sha256":"bd89c9cdb3c8834e74b6a8e2fcbf233b516f1c471309f28c21f0b020b9c31792","size":20},{"path":"addons/godot_ai/handlers/camera_presets.gd","sha256":"b9a3f6814f183ea15cfba625742496fdc6fc26f0952294cf89f526fab653085d","size":2208},{"path":"addons/godot_ai/handlers/camera_presets.gd.uid","sha256":"68ef1f3fe020a144155eabe99b2c2c17feb113c095a41b3af57442dc5d36c471","size":20},{"path":"addons/godot_ai/handlers/camera_values.gd","sha256":"8a0483e799d7e27bfb513670ece5fd0c8638b288fed8ec2c92971cd06a371cbd","size":4251},{"path":"addons/godot_ai/handlers/camera_values.gd.uid","sha256":"fa6f2ea8c1bb70167d06426cb01dd29ecd47d8b3cdfe9efa034cfdc16d95427a","size":20},{"path":"addons/godot_ai/handlers/client_handler.gd","sha256":"7cd2d0873d7b1cdff84abc310ca6b9c0de69167015a437bb93c219f19bcc5a34","size":1922},{"path":"addons/godot_ai/handlers/client_handler.gd.uid","sha256":"2599d215b73e6a60b767aba1083a68da6203674c13bb131d29179e98eef029cf","size":20},{"path":"addons/godot_ai/handlers/command_handler.gd","sha256":"2cd67d1e5482c1f1cb1768aaca18b80ea4902ece50ce2b2b67e23c721ca1ac02","size":555},{"path":"addons/godot_ai/handlers/command_handler.gd.uid","sha256":"47fb104a35055fa877816068db671b7dc8afde68734e64ba62f591f54eeda078","size":19},{"path":"addons/godot_ai/handlers/control_draw_recipe_handler.gd","sha256":"e0d50f72bdc61ee3c6ce0dade726cc7d9668c747af33290e17d74bb92861d0ea","size":9717},{"path":"addons/godot_ai/handlers/control_draw_recipe_handler.gd.uid","sha256":"ccb3400c75d59519bdcc4da1931831bae093ce327a9c5fd39938cb3710e37cf3","size":20},{"path":"addons/godot_ai/handlers/csg_handler.gd","sha256":"45216f2820921f2e94a1ae7c849368c9a738c51d7dda2525ec888b1bf6d5e3d8","size":4479},{"path":"addons/godot_ai/handlers/csg_handler.gd.uid","sha256":"a593088c8622b9ac81d8a4790eccb1630e79ce57568a87b2c2f87edb6ba25aba","size":20},{"path":"addons/godot_ai/handlers/curve_handler.gd","sha256":"b7791298b4362de8191d0b87605dd05d4bb5e408b803d837482cb49d15680050","size":9363},{"path":"addons/godot_ai/handlers/curve_handler.gd.uid","sha256":"7fec875de89cf7a634b5fa787d139c601face5e123df6134dc13eecd9e3f3285","size":20},{"path":"addons/godot_ai/handlers/editor_handler.gd","sha256":"6d88b551b5ec92d210c9ffc0956217229ab91f4959976d0ac81185ed8f11e6fc","size":45777},{"path":"addons/godot_ai/handlers/editor_handler.gd.uid","sha256":"d36224d3a7b803805c0e80ce017ea7dcdd395c360ccce81a102edf9b264cc499","size":20},{"path":"addons/godot_ai/handlers/environment_handler.gd","sha256":"51fa05f0f0c2f4c8ce98569380955815796f6ec33ab198f57be326bb1372d61c","size":6731},{"path":"addons/godot_ai/handlers/environment_handler.gd.uid","sha256":"88f573289f80073f1b7556c25c0c235762e9454c38973a71d57a3319e34da63e","size":20},{"path":"addons/godot_ai/handlers/filesystem_handler.gd","sha256":"9dddf357cafb9ff774f5c1167a71558c41c8956d071b65bf573bfaec22c66988","size":13807},{"path":"addons/godot_ai/handlers/filesystem_handler.gd.uid","sha256":"3156268e89d4c2df4d58027620252304ade3622dfffa63621c9cdfa9ea4d8676","size":20},{"path":"addons/godot_ai/handlers/gridmap_handler.gd","sha256":"308eca951d561a84c13430f5c71b2bb92c0a8bd86f175ea126d3cd47726bc3e1","size":7927},{"path":"addons/godot_ai/handlers/gridmap_handler.gd.uid","sha256":"2d7826639c1a23d5b6b2949b4349200b11d4ebd633e1f3fb22b1207cccfab4c0","size":20},{"path":"addons/godot_ai/handlers/input_handler.gd","sha256":"5c24996f5979ebacdf0321f0383917ae9a31db3aedae2d93087a6c6279f85659","size":16046},{"path":"addons/godot_ai/handlers/input_handler.gd.uid","sha256":"33ec35e4bfce00cf0af9bb186a89b6d489690418209d6b3fccdd2d3e5e5a1fce","size":20},{"path":"addons/godot_ai/handlers/material_handler.gd","sha256":"4d6ad02aaf8a2d4e405e62d5c89f0071d6de28dd46a2754821dd8c7a9c57fec4","size":28527},{"path":"addons/godot_ai/handlers/material_handler.gd.uid","sha256":"2ce5a5847745f8992c75e6634fa8292ad145bac78e97a75df6bc3ec5ff4f3c01","size":20},{"path":"addons/godot_ai/handlers/material_presets.gd","sha256":"cbcff9a52cd25d36e7f462f9b81e85df04d7570afc340f5f741d6a04d80c06b8","size":2217},{"path":"addons/godot_ai/handlers/material_presets.gd.uid","sha256":"25ef884fb537b484f3509bd84558598661ed2f48cd1121893b18203aea55f114","size":20},{"path":"addons/godot_ai/handlers/material_values.gd","sha256":"899d5f9fd614f21f5b98c57ce062d792ef1d6bca204d860e2476680d364e7971","size":6776},{"path":"addons/godot_ai/handlers/material_values.gd.uid","sha256":"c610639c2977ceea714b5c0f9dd97251fd2a4b5b3a500d8ca34cff0b65f502ec","size":20},{"path":"addons/godot_ai/handlers/node_handler.gd","sha256":"d706c30a6286ea1a568f94c40531cfdb2a4cab48058118a841417268d4185819","size":53928},{"path":"addons/godot_ai/handlers/node_handler.gd.uid","sha256":"8a6a204c9cc207814aac0a20f3c2837c174dd8b7d0ba452f2a8694bceb8370bc","size":19},{"path":"addons/godot_ai/handlers/particle_handler.gd","sha256":"f665324b19c2e2e12e57da229b51e0401d83294af12cd0e5934a861b220124ac","size":30982},{"path":"addons/godot_ai/handlers/particle_handler.gd.uid","sha256":"e01cc4640e7f68f31ce6a709f5da2b00e1e14fb93d970101bd4df72be351890e","size":20},{"path":"addons/godot_ai/handlers/particle_presets.gd","sha256":"6a9d93719af847462f057a8ab58dfef61965dbc360f60f045093f4c30187267c","size":8261},{"path":"addons/godot_ai/handlers/particle_presets.gd.uid","sha256":"67e6d4b02f715fdbc8abc9df4667176b7638658f46049d4c040d7ea5b6118b00","size":20},{"path":"addons/godot_ai/handlers/particle_values.gd","sha256":"dc5d00ec054b1aaa4c7c6db2de82f5f18047e2b13bbe5f93ba27c83427ca722e","size":8356},{"path":"addons/godot_ai/handlers/particle_values.gd.uid","sha256":"7843a953959bd04557fa586070f376ff1f9fb6ad6452d53fa6602dad80ff2059","size":20},{"path":"addons/godot_ai/handlers/physics_shape_handler.gd","sha256":"a40de61c3d574a8a4b7c01d9e59c69efc9f6a44982aff43771a956abf09ebee3","size":29747},{"path":"addons/godot_ai/handlers/physics_shape_handler.gd.uid","sha256":"c338b89967011e02e6f143c3115da1c25793dcd2394a2e286f5a9f787f8c0d19","size":20},{"path":"addons/godot_ai/handlers/project_handler.gd","sha256":"ca5d7b9085104824cc9a62802f1bab2e0f34067bf16413b1c857e8f57c4cb892","size":30927},{"path":"addons/godot_ai/handlers/project_handler.gd.uid","sha256":"d2de31aa9f55873f21af809e8933555f5975d208e4555f3e1b788e5d9aad53ca","size":20},{"path":"addons/godot_ai/handlers/resource_handler.gd","sha256":"7b43ae86b51630a9a605963c56ee10c5faf6dda0954fc8924d60eda5a354cf4f","size":24437},{"path":"addons/godot_ai/handlers/resource_handler.gd.uid","sha256":"e57815d98359fcc777f4b5c05c0561299da4aff4c1ccf14add572e068dc53daf","size":19},{"path":"addons/godot_ai/handlers/scene_handler.gd","sha256":"38ab2f20f669ed3641d1606db685993aef8f922a3ab25946417127a22bc04317","size":15888},{"path":"addons/godot_ai/handlers/scene_handler.gd.uid","sha256":"7905d18048c92df68d4ad37c176ec366ffaff0f108d8aca733c48142a8f8aae1","size":19},{"path":"addons/godot_ai/handlers/script_handler.gd","sha256":"2d492cc34949dda110791a87819faa233e3983818e016a977a75465dfcb4d09f","size":21903},{"path":"addons/godot_ai/handlers/script_handler.gd.uid","sha256":"4105d57bc4d5524fa01d0a38a227f03ce5ddf0944206c21f72169b030c33a56d","size":20},{"path":"addons/godot_ai/handlers/signal_handler.gd","sha256":"87183b18355079dbb8350bc81948028a7ac82090654939fea0fb843887733d1c","size":11116},{"path":"addons/godot_ai/handlers/signal_handler.gd.uid","sha256":"81475e35a53bccfb598d9679652169729ea2fa69f365f78005454bd1dd762210","size":20},{"path":"addons/godot_ai/handlers/test_handler.gd","sha256":"91fb4553f92233738d32e30ae1b0e1c6f98b49127ae6fd51fa05eb2aa8988ffe","size":14556},{"path":"addons/godot_ai/handlers/test_handler.gd.uid","sha256":"e92068b687252be24be2b95b3b93eabf906282214b583059c2ec6a45f69fa29c","size":20},{"path":"addons/godot_ai/handlers/texture_handler.gd","sha256":"8c36a856d9fe062075af5427655fab9d9ad9323d70acbd54147f873ac9f8f895","size":6466},{"path":"addons/godot_ai/handlers/texture_handler.gd.uid","sha256":"3bdeafe73069a8f04b9486d8b6697e63c4971bb01f7bab619eaedded39722bf7","size":20},{"path":"addons/godot_ai/handlers/theme_handler.gd","sha256":"cf717acb4c440f6697cb080131c61f35a0cb0a1deeb7da40d167736564a74159","size":18738},{"path":"addons/godot_ai/handlers/theme_handler.gd.uid","sha256":"e1c87aa079ad87e7624994e2539414bddf2caadd180458fd49dafd0109982004","size":19},{"path":"addons/godot_ai/handlers/tilemap_handler.gd","sha256":"13c334e9259965b0990bd7b64fcc249befb98e7cdb520f7ab26cc45558e2ceac","size":6052},{"path":"addons/godot_ai/handlers/tilemap_handler.gd.uid","sha256":"3c688fd5b5f2929b092a9d59c7a55c0878834287a78e18b333654c1f4c2421f2","size":20},{"path":"addons/godot_ai/handlers/tileset_handler.gd","sha256":"d6b11b0e75444aa3e8f9473c7b2ff2207b772f370a77978667541907cd7d56b0","size":5968},{"path":"addons/godot_ai/handlers/tileset_handler.gd.uid","sha256":"44eedb2ff0845c76c196b498a020e9aeb667cdb1b45e4da5813ea87a0b9b9ec7","size":20},{"path":"addons/godot_ai/handlers/ui_handler.gd","sha256":"c3aa8fea776e6bf441f940b90950e890d230b3088acff19381680f61aaddaed4","size":19080},{"path":"addons/godot_ai/handlers/ui_handler.gd.uid","sha256":"a0e5f401b82ea94d81b4ec3b7a2a8905d47258335f728932c01bab75476b84ea","size":20},{"path":"addons/godot_ai/mcp_dock.gd","sha256":"d9766235bd329ae3f63e0e16b9d906fd5b053a636dc613051cad4d4b200b8b03","size":118796},{"path":"addons/godot_ai/mcp_dock.gd.uid","sha256":"62248eebd93ecd7e65fae56ddb14ca23ec0b85989079a6e999bfd60c314712db","size":20},{"path":"addons/godot_ai/migration_bridge.gd","sha256":"851afbc299883d6dc3d091070d03a791804ab0f84a46cfacecb2a4b7f49c2e06","size":286},{"path":"addons/godot_ai/migration_bridge.gd.uid","sha256":"81cfb331c7aca2fde909e025312caf3dca2b52499e6aa7e95510c6bc01d24eec","size":19},{"path":"addons/godot_ai/migration_coordinator.gd","sha256":"3dc10fb657cb3485cb80115348281f2e666dcd8c6ee0caec3794824a15ccf100","size":626},{"path":"addons/godot_ai/migration_coordinator.gd.uid","sha256":"098e70726396f27526b26ec4f4ed115d7f81ae1a63a9eae736bdffd5c63988a8","size":19},{"path":"addons/godot_ai/migration_fallback.gd","sha256":"50a5f046be8f44169135d92fa2f945c3454c13b42b99398eaae67d0a54418e08","size":247},{"path":"addons/godot_ai/migration_fallback.gd.uid","sha256":"6c8781f55e5d61deae64832a79c30273ea73b95857d2b847d354bd72c46bf42e","size":20},{"path":"addons/godot_ai/plugin.cfg","sha256":"6447a6e9b4cf265bca7947e8cbca6b12192d2786ba0cc85ef4c0b5ce61535f0f","size":127},{"path":"addons/godot_ai/plugin.gd","sha256":"8bc48528c68bc16dae211816a93b7b8d2fca2983931e2c1b116ca785d71dc687","size":80914},{"path":"addons/godot_ai/plugin.gd.uid","sha256":"35a798ab044b50da51e1624b5aa951551b83aa45573ae2fa0ae378146cbb58c7","size":20},{"path":"addons/godot_ai/runtime/draw_recipe.gd","sha256":"51ca515c420abb816991c5a730e9ee166b5296086cfad98c89c7abce68a0edff","size":2131},{"path":"addons/godot_ai/runtime/draw_recipe.gd.uid","sha256":"7ae9a6fc4fe77482b2189b7421f024a35cb544e8b1368107e7f0f26598a778c8","size":20},{"path":"addons/godot_ai/runtime/editor_logger.gd","sha256":"d4093bea61f6a8275ff752e85b2265e6f0834f420bbf08c3a59b62b57e7a06af","size":5510},{"path":"addons/godot_ai/runtime/editor_logger.gd.uid","sha256":"587fd5ccabfe31b6a3c78742cb37fcfa696b6e6b9e27746f455a5e3547508542","size":20},{"path":"addons/godot_ai/runtime/game_helper.gd","sha256":"99a0391661d957ea5c0210ae51265ce1f14e68855edd6e73d8d048339897328a","size":55041},{"path":"addons/godot_ai/runtime/game_helper.gd.uid","sha256":"2bb2be95830555d56ff38ad2bcfd69ef29b4a55348f879a9cb61b97b727558ff","size":19},{"path":"addons/godot_ai/runtime/game_logger.gd","sha256":"54f4657c2b9ca26b0c8152f21c5d48a2a71a7cb82c062973218172d507e989ac","size":5606},{"path":"addons/godot_ai/runtime/game_logger.gd.uid","sha256":"e012598e90decb26fe0acee8a5c722e83ab52760470e74f13a91989c3a0f11b6","size":20},{"path":"addons/godot_ai/runtime/validation_logger.gd","sha256":"cc1254c56343d69bd531610b25f99b57f4437fb5b10b1c64312247005bcc2c1d","size":1065},{"path":"addons/godot_ai/runtime/validation_logger.gd.uid","sha256":"e1ec736f74292480f977c54421dcafb68c5def09ec2bf4b78569521b37bfd816","size":20},{"path":"addons/godot_ai/telemetry.gd","sha256":"92755278b554dc0a07b29430edc81abd416e21dc69fbf2c1a569868147b95299","size":8767},{"path":"addons/godot_ai/telemetry.gd.uid","sha256":"641f5ee47d5f74c0591652a500324a81b03b8bd152d874caabceab1489d78920","size":20},{"path":"addons/godot_ai/testing/script_error_capture.gd","sha256":"47363f95b07cff8d2dde46956a7dc46d1721ae5c4b5679633c0038ca0b0ea5cb","size":1102},{"path":"addons/godot_ai/testing/script_error_capture.gd.uid","sha256":"99acfa0494027479551559fb293ad0770cf463042bf4c6974703b873916990f5","size":20},{"path":"addons/godot_ai/testing/test_runner.gd","sha256":"73f1a3efd4024bf6214128c5229c4307f6ee04532a0980defedcf5b3b46301fc","size":14841},{"path":"addons/godot_ai/testing/test_runner.gd.uid","sha256":"af6daba585151379f5275a4dca4418521df77249d71f82050ba44135fd04650e","size":19},{"path":"addons/godot_ai/testing/test_suite.gd","sha256":"7cb7e68509660880d68f98d01e775354b767faff58e31a9f90d33e3857bb5f24","size":9027},{"path":"addons/godot_ai/testing/test_suite.gd.uid","sha256":"20d26aae67a6df5b2c40d0f3bd513d1257d4adc8320d9f559f8a87c85cd19744","size":20},{"path":"addons/godot_ai/tool_catalog.gd","sha256":"8629fb510f27b8b4f8c8d45d440eb19cedfafa754fa136ca572f9282d050ef87","size":5299},{"path":"addons/godot_ai/tool_catalog.gd.uid","sha256":"a5067a52ef97b89d8c9d587679d661d0c5ca7d550e0eef3c3b8a18bb56f31f68","size":20},{"path":"addons/godot_ai/utils/allow_hosts.gd","sha256":"2decc72bb5e3a880a84b4ee4536050bf458f4f4e4987f3672319fa037e64ada5","size":6343},{"path":"addons/godot_ai/utils/allow_hosts.gd.uid","sha256":"6c02022e576afca8d8487f87481b8618543df37030d9e0f19bfdf5d9d9c7b373","size":20},{"path":"addons/godot_ai/utils/class_introspection.gd","sha256":"f0708952ed838349630efe705f70dcc878c86073ddb1d8f37aacd7b5c5b5eca7","size":9452},{"path":"addons/godot_ai/utils/class_introspection.gd.uid","sha256":"f4cf97e1f9676bb5bfe0c3af123ef61678a7881b176cf4cd68114c5642ffe3a9","size":20},{"path":"addons/godot_ai/utils/client_job_owner.gd","sha256":"df1096c71a18842f2fac07227c7af05026eb010ed972bb001fce672d0dd7d86f","size":34489},{"path":"addons/godot_ai/utils/client_job_owner.gd.uid","sha256":"753fdba75d6a597d1e7fbbbe5657c35e80a96d009e36f4746157d3c07d05b082","size":19},{"path":"addons/godot_ai/utils/client_mutation_lock.gd","sha256":"add3b8894d56b66a43ece0406b28601c4d4572ba151e841025499a3ca240d8dc","size":4903},{"path":"addons/godot_ai/utils/client_mutation_lock.gd.uid","sha256":"0e0a9933268cb5eb04f14db2e70db024f069a911ffdf06aee572b238614a4719","size":21},{"path":"addons/godot_ai/utils/diagnostics_capture.gd","sha256":"0eaa9aa3e00274405091cb6d74dc1042341efe88048b6bf168955c5c1b873530","size":2386},{"path":"addons/godot_ai/utils/diagnostics_capture.gd.uid","sha256":"811bc51627303add74eec6dcc72f85dc61aef633c0dc3231442d8eb4b7130fe9","size":20},{"path":"addons/godot_ai/utils/editor_log_buffer.gd","sha256":"f21b0a767b05bd1763f03d1b81e63ad36212071529c1e415b88d874deda0ad43","size":3395},{"path":"addons/godot_ai/utils/editor_log_buffer.gd.uid","sha256":"acfbe64271596c496835b86f4b4c2642cd8675e85d456d4ea5e6a8422da5cd29","size":20},{"path":"addons/godot_ai/utils/error_codes.gd","sha256":"8895a773d74e3b8e7602a4109c8aee8077abd63d3223392f9a87884d27787437","size":9149},{"path":"addons/godot_ai/utils/error_codes.gd.uid","sha256":"df7b436601e68a12ecc38acbad7543bfe0fe6cb152c716b8f9dc7f70a417fe80","size":20},{"path":"addons/godot_ai/utils/fuzzy_suggestions.gd","sha256":"bc2306f4b27d5ae3d8b9a76a00a773382a943ec3df4ce412f92d10bdac336308","size":1072},{"path":"addons/godot_ai/utils/fuzzy_suggestions.gd.uid","sha256":"07f5f545e8c14bd19b4fad4bc5d683fcf4268bb6adfc1eda5d30a1603bc36459","size":20},{"path":"addons/godot_ai/utils/game_log_buffer.gd","sha256":"c3f9e9247ec8b70032bca8ce309c90c938238e859b82f11a7679ce736da55a52","size":3080},{"path":"addons/godot_ai/utils/game_log_buffer.gd.uid","sha256":"2d288c2000132251bc1b969c654ca3b9b7593ffb8e32aabe5faa81c0a7d8303b","size":20},{"path":"addons/godot_ai/utils/json_values.gd","sha256":"cf8b8ca0b9becc9fc7adeb7200972337ef2ee351f77b7cd11cebac90bf7321f5","size":3642},{"path":"addons/godot_ai/utils/json_values.gd.uid","sha256":"c52c80ff8b4fc6120879a598831139a5971fc98551aaac1aaf542f74ec8fed55","size":19},{"path":"addons/godot_ai/utils/log_backtrace.gd","sha256":"d7df786a4437991f403715e6bbdd20bbc396009dc5baf48f7dfeacf75ab2b27b","size":3887},{"path":"addons/godot_ai/utils/log_backtrace.gd.uid","sha256":"450dc80371aac8d4d974f042ecdedc15e0766a2442e8765fa298cb3dd18b6a20","size":20},{"path":"addons/godot_ai/utils/log_buffer.gd","sha256":"81d8a9e24c73b745b3dc90b6b7990f4babada9687189bcffa4379d84f94fb919","size":2569},{"path":"addons/godot_ai/utils/log_buffer.gd.uid","sha256":"cdbbcffb78756e9d9b68d19412110fd6e8fb7a8b2dcfe02d9490b2dd1151d1e1","size":20},{"path":"addons/godot_ai/utils/mcp_adoption_label.gd","sha256":"1c6461816767dbc1d3b0ffefffe4147ea06fe5d8137a7760964b131aa73d0112","size":267},{"path":"addons/godot_ai/utils/mcp_adoption_label.gd.uid","sha256":"6720efd543a8f55ed448f5e2a8216a9950adca1942714958932b1149920509cd","size":19},{"path":"addons/godot_ai/utils/mcp_client_refresh_state.gd","sha256":"589cd2fe35e19a1484ddc10afee2b0070b01b90bda877548aa3f4ff34342f46f","size":4010},{"path":"addons/godot_ai/utils/mcp_client_refresh_state.gd.uid","sha256":"509aec8279aced0e854c8e766e9719cd8a436d945e96ee2e1d223d9f16ea4c3d","size":20},{"path":"addons/godot_ai/utils/mcp_server_state.gd","sha256":"9d44e6d320396375c639bd13f52efd34481cee97edad9b3f244de62f2c7b493f","size":1825},{"path":"addons/godot_ai/utils/mcp_server_state.gd.uid","sha256":"0454bc89b123487b8a90927318fa4ec91315971ff0e798395191bc5d225e6f8a","size":20},{"path":"addons/godot_ai/utils/mcp_startup_path.gd","sha256":"ec7ba9d388b6a565598d433eb43ea40eeb9a8006df1cf05fbcc823742f7fec84","size":455},{"path":"addons/godot_ai/utils/mcp_startup_path.gd.uid","sha256":"570bec0886f6787fd3a74de10f76df90840b284a02f2edf4f4dfff956d7b2933","size":20},{"path":"addons/godot_ai/utils/path_validator.gd","sha256":"9399ba742fc8af571b6ceaa12504efef1a4fafbe6388f74c4e48a20e06ce43fe","size":11101},{"path":"addons/godot_ai/utils/path_validator.gd.uid","sha256":"dbbce00244442b32cc776f39d815a4c09c5ce840f3da001d5aa5db37dd1fa0ef","size":20},{"path":"addons/godot_ai/utils/plugin_reload.gd","sha256":"6fac4ed4e5b3ed6ba54f75d7f9ef6708cfacee8bd532dbad3370a519fedca87c","size":5092},{"path":"addons/godot_ai/utils/plugin_reload.gd.uid","sha256":"56054cb7d397405f0a1137b14cc46bc412207594fc28de393856d13bc9e29131","size":20},{"path":"addons/godot_ai/utils/port_resolver.gd","sha256":"c9d6c9b97501277d86b49ebe84c669b2b612c2e6a4a8674ae505d3bd4840d5a8","size":30181},{"path":"addons/godot_ai/utils/port_resolver.gd.uid","sha256":"4f386419a069705d93a56b64395add29e23e9f74745934a515813c119eb96c70","size":19},{"path":"addons/godot_ai/utils/release_verifier.gd","sha256":"23ed32925d8d6d73874b78b2fa5dbda1f20e57ee3cc433b4bcf25aab96cd1528","size":32268},{"path":"addons/godot_ai/utils/release_verifier.gd.uid","sha256":"dc0b8903879c85733a651a06cd702bd3d3c10922c8e3ee7436d49cccafd8964a","size":20},{"path":"addons/godot_ai/utils/resource_io.gd","sha256":"9afe123cb81f2113f28e7fdbf88daf6a464be6cd327082b813a811b3a7e712c1","size":13172},{"path":"addons/godot_ai/utils/resource_io.gd.uid","sha256":"1759b54639494e1fd14b97a87cae668500555e0141148a9e2aea04d3d1d2b48e","size":20},{"path":"addons/godot_ai/utils/scene_path.gd","sha256":"ab457ce39833897d7448a01e4fe397e44cab022eb407c0b3384bf5bc2bbec7fd","size":7545},{"path":"addons/godot_ai/utils/scene_path.gd.uid","sha256":"1d74b073f8cbc0778b3facbba2a7039553a657710f93899c2514104b221a6560","size":20},{"path":"addons/godot_ai/utils/screenshot_encode.gd","sha256":"6655c7a62d8458656303dc97744f0122aa32b46506d5b6c8b921e599241b085b","size":1487},{"path":"addons/godot_ai/utils/screenshot_encode.gd.uid","sha256":"010f223143d099028ccafba428f9cdb40c05d59b0a949dae49eab339bfb8b006","size":20},{"path":"addons/godot_ai/utils/script_work.gd","sha256":"8a0bef00aafda06debe57cd3562e299919b116548ae07acdbfa86328a989bde7","size":836},{"path":"addons/godot_ai/utils/script_work.gd.uid","sha256":"5caa9666d97006f759ecfa3ac3d9f74688563daaab595347fd0a962d40f0db47","size":19},{"path":"addons/godot_ai/utils/server_authority.gd","sha256":"bbca05a304a369a6ab77a2af34608c0f738e801b13715344b22ccd4f735e9e13","size":3745},{"path":"addons/godot_ai/utils/server_authority.gd.uid","sha256":"093b575d10ad2700e48f454a18e3d00c7df64ae9414e6f77a89e14c0c5eed845","size":19},{"path":"addons/godot_ai/utils/server_lifecycle.gd","sha256":"7570c099314e67d777b67606ccb708f490c7cb35a93ec815911bc8d1ba81b0ad","size":67067},{"path":"addons/godot_ai/utils/server_lifecycle.gd.uid","sha256":"546899b3a5d4024aac3b001ca634e8e74d74343faba27e491ac03af2c3fd6bb1","size":20},{"path":"addons/godot_ai/utils/server_version_check.gd","sha256":"534ca6fd105415d2e9562308a23f62e8e0af6fb0563caf05b9108d1007bfa8d3","size":2597},{"path":"addons/godot_ai/utils/server_version_check.gd.uid","sha256":"b71827a9383a60c02e26d59c19530cfc65a2df0d523220780e5283a9a8573f82","size":20},{"path":"addons/godot_ai/utils/settings.gd","sha256":"8e7409077186eeb15a4a88eeaceb79bacb01d9cfb72bbee4bae745e5c8bcce2f","size":6074},{"path":"addons/godot_ai/utils/settings.gd.uid","sha256":"99e66f1dfe502b81097d3d83819bc211e42ef3b3a97bff4ffd3d672bf21b26dc","size":19},{"path":"addons/godot_ai/utils/structured_log_ring.gd","sha256":"7bb5b56f3b5ec3ba4427b44ac5867005c01ac8a3d84735afd38479993e1f4d7f","size":5282},{"path":"addons/godot_ai/utils/structured_log_ring.gd.uid","sha256":"5ff3dab0db39995b662e5aea73ae0223ba92f6a219a283141d29ef9bce6667a1","size":20},{"path":"addons/godot_ai/utils/surfaced_error_tracker.gd","sha256":"a7e6fc7f4525aa64ceec15a7ab3eab6188f32f744fdd714ad1f4a819e9ede236","size":23645},{"path":"addons/godot_ai/utils/surfaced_error_tracker.gd.uid","sha256":"09b485e56fdd3a69a42d9b74400816bd36943fe15d0781da3aefe4b51be0dc6b","size":19},{"path":"addons/godot_ai/utils/transport_capability.gd","sha256":"9b4d7e41620b1b73aa3e14ba46f57851aa1e38c8cbbeaf365c231ecbc07bebad","size":10590},{"path":"addons/godot_ai/utils/transport_capability.gd.uid","sha256":"b3fdc46837d80983269f7c5cd1b3de88577f9a4daa3cf0312bf72cdb87703fff","size":20},{"path":"addons/godot_ai/utils/update_activation_runner.gd","sha256":"432aa506051e1ffcba51211bb91e9a2a7d98e3c84c8937c7382e70eee95c4601","size":16706},{"path":"addons/godot_ai/utils/update_activation_runner.gd.uid","sha256":"6cbd1c1da3fe5addb3144e517eeee7d8ba775f2806bf822f60d87dacfca03cd4","size":19},{"path":"addons/godot_ai/utils/update_installer.gd","sha256":"d0abd539eda13b8d3b6a00f3b4db8534cf242872a0772853115d5e16f6786d0a","size":22372},{"path":"addons/godot_ai/utils/update_installer.gd.uid","sha256":"100a3707894816165ab33e7cadbb07ec9d7346dcde22ff89eebbced7a82f018e","size":20},{"path":"addons/godot_ai/utils/update_manager.gd","sha256":"615bcde9ea6aa30219b319c0aadc4e17a5bf4abf9482262bebe650f0aa54de12","size":17826},{"path":"addons/godot_ai/utils/update_manager.gd.uid","sha256":"2a2e8422a89307dc7c57711daa1aab0629b5a54ec7f108097a36c2273a0735d0","size":20},{"path":"addons/godot_ai/utils/update_mixed_state.gd","sha256":"97195f0fb8cc005d6a91707dcfd1f14c91c6111f5d675d325de110763b87cd8d","size":6295},{"path":"addons/godot_ai/utils/update_mixed_state.gd.uid","sha256":"ed8d5d6740f1bcc183897ec4407487bea769160573353887d7811a8decf8ca3f","size":20},{"path":"addons/godot_ai/utils/uv_cache_cleanup.gd","sha256":"2f75771088514910b48ff26269e64064a37984f50ba91284185cf3955985269b","size":6444},{"path":"addons/godot_ai/utils/uv_cache_cleanup.gd.uid","sha256":"d209b2b5de32eb6cfeb7b18c8e785f26011d44326b43794478967da7705eace4","size":20},{"path":"addons/godot_ai/utils/uv_resolution_policy.gd","sha256":"9c3da07655e2a0378de430a4612accd1df5709af8555fa9a9df90d0b5abe264f","size":3652},{"path":"addons/godot_ai/utils/uv_resolution_policy.gd.uid","sha256":"cde159a474d570d804fb1efba4b289629b4a830b93f89992a8dd1224c115fe60","size":20},{"path":"addons/godot_ai/utils/variant_serializer.gd","sha256":"25d6e56e62372ea4f1dd7b5be7a1538931177d1dc93ba36687cdb1f2949a4598","size":3150},{"path":"addons/godot_ai/utils/variant_serializer.gd.uid","sha256":"3954570c851e2a1c908b24dfe1e542c622feb5efb1ee5137bdf1a201cadafbbc","size":20},{"path":"addons/godot_ai/utils/windows_port_reservation.gd","sha256":"fbf0060529259bb97dbeb8a0a0f4dce8bbf0ab6a140b9e62170b67908763d14d","size":5248},{"path":"addons/godot_ai/utils/windows_port_reservation.gd.uid","sha256":"f369812cbcfa0201ed23ca05081ad8981a0200f53a8ef7e4ba690d722bf13abb","size":20},{"path":"addons/godot_ai/vision_routing.gd","sha256":"9eab92fda631edf36297712937c8cef1eff5cfbe804e3634e8422b053f622c1a","size":45078},{"path":"addons/godot_ai/vision_routing.gd.uid","sha256":"47ddab7fc353d2a49c3dcf4bbaa5e0e9f05116f7c7fc6bc5ece12246ad0a1494","size":20}],"repository":"hi-godot/godot-ai","schema_version":1,"source_commit":"7968ada03044473372a8ab4beadd975c60b9d94a","tag":"v4.1.0","version":"4.1.0"}
diff --git a/addons/godot_ai/migration_payload/godot-ai-v4-plugin.manifest.sig b/addons/godot_ai/migration_payload/godot-ai-v4-plugin.manifest.sig
deleted file mode 100644
index d7a34b8..0000000
Binary files a/addons/godot_ai/migration_payload/godot-ai-v4-plugin.manifest.sig and /dev/null differ
diff --git a/addons/godot_ai/migration_payload/godot-ai-v4-plugin.zip b/addons/godot_ai/migration_payload/godot-ai-v4-plugin.zip
deleted file mode 100644
index bf2ee8f..0000000
Binary files a/addons/godot_ai/migration_payload/godot-ai-v4-plugin.zip and /dev/null differ
diff --git a/addons/godot_ai/plugin.cfg b/addons/godot_ai/plugin.cfg
index 75c2188..5657942 100644
--- a/addons/godot_ai/plugin.cfg
+++ b/addons/godot_ai/plugin.cfg
@@ -1,7 +1,7 @@
[plugin]
-name="Godot AI v4 Migration"
-description="One-click signed migration from Godot AI v3 to v4"
+name="Godot AI"
+description="MCP server and AI tools for Godot"
author="Godot AI"
version="4.1.0"
script="plugin.gd"
diff --git a/addons/godot_ai/plugin.gd b/addons/godot_ai/plugin.gd
index dfe0fe4..77f617c 100644
--- a/addons/godot_ai/plugin.gd
+++ b/addons/godot_ai/plugin.gd
@@ -1,177 +1,1209 @@
@tool
extends EditorPlugin
-## Temporary v3-compatible entry point delivered through the signed legacy
-## updater asset. It owns presentation only. Verification and staging run in
-## the detached bridge node; the value-only coordinator survives this plugin
-## handing off to the shared in-editor activation runner.
+const GAME_HELPER_AUTOLOAD_NAME := "_mcp_game_helper"
+const GAME_HELPER_AUTOLOAD_PATH := "res://addons/godot_ai/runtime/game_helper.gd"
-const MigrationBridge := preload("res://addons/godot_ai/migration_bridge.gd")
-const MigrationCoordinator := preload("res://addons/godot_ai/migration_coordinator.gd")
-const MigrationFallback := preload("res://addons/godot_ai/migration_fallback.gd")
-const FLOOR_REFUSAL := "Godot AI v4 migration requires Godot 4.7 or newer; bridge remains inactive."
-const FLOOR_MESSAGE := "Godot AI v4 requires Godot 4.7 or newer. Upgrade Godot, reopen this project, then click Retry."
-const FLOOR_RESTORING := (
- "Godot AI v4 requires Godot 4.7 or newer. Restoring the previous Godot AI version; "
- + "upgrade Godot to update."
-)
-const EDITOR_REQUIRED := (
- "Godot AI v4 migration needs the interactive Godot editor. "
- + "Reopen the project in the editor (not headless or exported), then click Retry migration."
-)
+## Editor-process Logger subclass — captures parse errors, @tool runtime
+## errors, and push_error/push_warning so the LLM can read them via
+## `logs_read(source="editor")`.
+const EditorLogger := preload("res://addons/godot_ai/runtime/editor_logger.gd")
-var _dock: VBoxContainer
-var _label: Label
-var _retry: Button
-var _bridge
-var _coordinator
+const PluginReload := preload("res://addons/godot_ai/utils/plugin_reload.gd")
+const ReleaseVerifier := preload("res://addons/godot_ai/utils/release_verifier.gd")
+const UpdateInstaller := preload("res://addons/godot_ai/utils/update_installer.gd")
+const LIVE_ADDON_ROOT := "res://addons/godot_ai"
+const PLUGIN_CFG := "res://addons/godot_ai/plugin.cfg"
+const MIN_GODOT_MAJOR := 4
+const MIN_GODOT_MINOR := 7
+const UNSUPPORTED_GODOT_MESSAGE := \
+ "Godot AI v4 requires Godot 4.7 or newer in the 4.x line; plugin remains inactive."
+
+## The lifecycle manager owns the serialized server episode and process
+## authority. This root only captures its immutable launch plan and routes
+## copied snapshots/results to the connection and Dock.
+const ServerLifecycleManager := preload("res://addons/godot_ai/utils/server_lifecycle.gd")
+const PortResolver := preload("res://addons/godot_ai/utils/port_resolver.gd")
+const ServerStateScript := preload("res://addons/godot_ai/utils/mcp_server_state.gd")
+const TransportCapability := preload("res://addons/godot_ai/utils/transport_capability.gd")
+
+## Plugin-class scripts used by this file. The script-local preload aliases
+## are ordinary dependency shorthand and keep construction sites compact.
+## They are not the self-update safety boundary. Whole-tree namespace swaps
+## happen inside the editor; the live tree is renamed, never overlaid, and the
+## only disable/scan/enable; no script mutates the tree that contains itself.
+const Connection := preload("res://addons/godot_ai/connection.gd")
+const Dispatcher := preload("res://addons/godot_ai/dispatcher.gd")
+const Telemetry := preload("res://addons/godot_ai/telemetry.gd")
+const LogBuffer := preload("res://addons/godot_ai/utils/log_buffer.gd")
+const GameLogBuffer := preload("res://addons/godot_ai/utils/game_log_buffer.gd")
+const EditorLogBuffer := preload("res://addons/godot_ai/utils/editor_log_buffer.gd")
+const SurfacedErrorTracker := preload("res://addons/godot_ai/utils/surfaced_error_tracker.gd")
+const Dock := preload("res://addons/godot_ai/mcp_dock.gd")
+const DebuggerPlugin := preload("res://addons/godot_ai/debugger/mcp_debugger_plugin.gd")
+const VisionRoutingScript := preload("res://addons/godot_ai/vision_routing.gd")
+const ExportPlugin := preload("res://addons/godot_ai/export/mcp_export_plugin.gd")
+const ClientConfigurator := preload("res://addons/godot_ai/client_configurator.gd")
+const CliExec := preload("res://addons/godot_ai/clients/_cli_exec.gd")
+const ClientJobOwner := preload("res://addons/godot_ai/utils/client_job_owner.gd")
+const UpdateManager := preload("res://addons/godot_ai/utils/update_manager.gd")
+const WindowsPortReservation := preload("res://addons/godot_ai/utils/windows_port_reservation.gd")
+const McpToolRegistry := preload("res://addons/godot_ai/custom_tools/mcp_tool_registry.gd")
+const McpServiceLocator := preload("res://addons/godot_ai/custom_tools/mcp_service_locator.gd")
+
+## Handlers are intentionally NOT preloaded here (#736). The old
+## `const X := preload("res://addons/godot_ai/handlers/...")` block pulled
+## every handler — and everything handlers preload — into plugin.gd's
+## compile closure, so Godot parsed/compiled ~119 addon scripts before the
+## first instruction of _enter_tree ran. GDScript has no cross-restart
+## compile cache, so that stalled "Initializing plugins" on every editor
+## boot and every plugin re-enable. Handlers are now registered by script
+## path via McpDispatcher.register_lazy_handler / register_lazy and are
+## load()ed at the first dispatch of one of their commands.
+##
+## Handlers remain preload-style scripts with no `class_name` so they don't
+## pollute the project-wide global scope (#253): a user project that happens
+## to define its own `InputHandler`, `SceneHandler`, etc. would otherwise
+## hard-error on plugin enable.
+const HANDLERS_DIR := "res://addons/godot_ai/handlers/"
+
+const STARTUP_TRACE_COUNTER_NAMES := [
+ "powershell",
+ "netstat",
+ "netsh",
+ "lsof",
+ "http_status_probe",
+ "server_command_discovery",
+]
+
+## Untyped on purpose — see policy below. Type fences move to handler `_init`
+## sites that take typed parameters.
+##
+## Self-update field and load-surface policy: plugin entry-load fields that
+## survive reload stay untyped. Typed fields against plugin-defined classes
+## were the #242 / #244 crash class: Godot can reparse a long-lived script
+## while its old field storage and the new type shape disagree. Static-var
+## initializers are the most dangerous form because they execute at
+## script-load; a top-level typed Dictionary/Array storage change can fail
+## before `_enter_tree` runs.
+##
+## The mitigation is two-part:
+## (1) Field declarations are untyped (this block).
+## (2) Construction and static access use local names declared at the top
+## of the file (e.g. `Connection`, `Dispatcher`, `LogBuffer`,
+## `ClientConfigurator`, `WindowsPortReservation`, ...), which keeps
+## this entry script's load surface explicit and reviewable.
+##
+## Constructors, constants, and static methods on `Mcp*` classes are not the
+## self-update safety metric. The installer swaps one exact, re-hashed tree
+## before the resource scan, so the root never observes an extracted-over-live
+## mixture. In short: preload aliases are not the self-update safety metric.
+##
+var _connection
+var _dispatcher
+var _telemetry
+var _log_buffer
+var _game_log_buffer
+var _editor_log_buffer
+var _surfaced_error_tracker
+var _editor_logger: Logger
+var _dock
+var _debugger_plugin
+var _vision_routing
+var _export_plugin
+var _custom_tool_registry
+var _custom_tool_service_locator
+## Plugin-lifetime owners. The Dock only emits intents and receives copied
+## snapshots/outcomes, so replacing the view cannot abandon a live Thread or
+## a self-update.
+var _client_jobs
+var _update_manager
+## Process identity continuity and the immutable terminal outcome cross only
+## as values. The coordinator retains neither this plugin nor its objects.
+var _post_update_outcome: Dictionary = {}
+## The version an update just replaced. An AI client attached through the
+## update may spawn a backend of that version into the restart window; the
+## restarted editor replaces it once instead of asking the user to.
+var _post_update_replaced_version := ""
+## Clients the post-update migration left unchanged (`{id, reason}`), named
+## in the dock's completion banner so the user knows to click Configure.
+## Untyped on purpose: a typed collection field is the hot-reload crash
+## class (#245) the self-update smoke injects to prove the swap survives it.
+var _post_update_deferred := []
+var _last_logged_block := ""
+## Bounded re-probes while that backend is still binding its port: a port
+## that is bound but not yet answering status reads as merely occupied.
+const POST_UPDATE_REPROBE_LIMIT := 10
+var _post_update_reprobes_left := POST_UPDATE_REPROBE_LIMIT
+var _post_update_retry_episode := 0
+## A pre-v4 server left on the port by a still-running v3 attach bridge
+## outlives the fast budget above: its lease lasts 30 s after that client
+## quits and its idle backstop another 120 s. Poll slowly across that
+## window so the editor comes up green once the user has relaunched the
+## client, without any replacement authority over a server we cannot
+## authenticate.
+const POST_UPDATE_STALE_REPROBE_SECONDS := 10.0
+const POST_UPDATE_STALE_REPROBE_LIMIT := 21
+var _post_update_stale_reprobes_left := POST_UPDATE_STALE_REPROBE_LIMIT
+## An old bridge spawns again as soon as the port frees, so one replacement
+## may not be the last; a few are allowed before the dock takes over.
+const POST_UPDATE_REPLACEMENT_LIMIT := 3
+var _post_update_replacements_left := POST_UPDATE_REPLACEMENT_LIMIT
+## Set once the live tree has been renamed; the lock then belongs to the restart.
+var _update_swapped := false
+var _post_update_action := ""
+var _normal_start_released := false
+var _update_barrier_blocked := false
+## Serialized lifecycle episode owner. Construction is inert; `_enter_tree`
+## supplies a copied plan and starts it only after composition completes.
+var _lifecycle
+static var _resolved_ws_port := ClientConfigurator.DEFAULT_WS_PORT
+var _endpoint_policy: Dictionary = {}
+var _headless_disabled := false
+var _startup_trace_enabled := false
+var _startup_trace_start_ms := 0
+var _startup_trace_last_ms := 0
+var _startup_trace_counters: Dictionary = {}
+## Startup-path probes can now run on a worker thread (#678); the trace
+## counters they bump are shared with the main thread, so serialize.
+var _startup_trace_mutex := Mutex.new()
+var _startup_trace_netsh_start_count := 0
+var _unsupported_engine := false
+var _loaded_plugin_version := ""
+
+
+func _init() -> void:
+ _unsupported_engine = not _supports_godot_version(Engine.get_version_info())
+ if _unsupported_engine:
+ return
+ _lifecycle = ServerLifecycleManager.new()
func _enter_tree() -> void:
- _build_dock()
- var prior := _bridge_status()
- if not prior.is_empty():
- _present_error(str(prior.get("error", "The previous migration attempt did not finish.")))
+ if _unsupported_engine:
+ push_error(UNSUPPORTED_GODOT_MESSAGE + _pending_swap_hint())
return
- if not _editor_available():
- ## Headless imports and exports must never mutate the tree, not even
- ## to restore final v3; the next interactive start handles it.
- _present_state(EDITOR_REQUIRED, true, false)
+ _startup_trace_begin()
+
+ ## Keep main-thread result polling off until the vision worker owner exists.
+ ## Unsupported/headless/barrier-blocked startup therefore has no process
+ ## callback and constructs no worker-capable object.
+ set_process(false)
+
+ _loaded_plugin_version = get_plugin_version()
+ ## An installed tree that was just swapped in by the updater (or by the
+ ## v3 migration capsule) proves itself before anything else is built: the
+ ## live tree must hash to exactly what the signed manifest promised. A
+ ## mismatch restores the retained backup; a missing backup keeps the plugin
+ ## inactive with the exact paths. Dev checkouts never self-update.
+ if not ClientConfigurator.is_dev_checkout():
+ var was_swapped := str(UpdateInstaller.read_pending().get("status", "")) == "swapped"
+ var outcome: Dictionary = UpdateInstaller.verify_after_restart(LIVE_ADDON_ROOT)
+ if was_swapped and not outcome.is_empty():
+ _warn_if_verified_elsewhere(outcome)
+ if not outcome.is_empty():
+ _post_update_outcome = outcome.duplicate(true)
+ _post_update_outcome["outcome"] = str(outcome.get("status", ""))
+ var status := str(outcome.get("status", ""))
+ if status == "repair_required":
+ _block_update_startup(str(outcome.get("error", "")))
+ return
+ if status == "rolled_back" and was_swapped:
+ ## The backup was just restored on disk while this process still
+ ## runs the rejected tree. Restart into the restored tree; the next
+ ## start presents the failure from the marker.
+ push_error("MCP | update to %s failed and the previous version was restored: %s" % [
+ str(outcome.get("to_version", "")), str(outcome.get("error", ""))
+ ])
+ UpdateInstaller.persist_next_start_enabled(PLUGIN_CFG)
+ UpdateInstaller.request_restart.call_deferred()
+ return
+
+ _continue_enter_tree_after_update_barrier()
+
+
+## An update swapped in under one Godot and restarted into another (seen on
+## macOS, where the relaunch goes through LaunchServices by bundle). Say which
+## editor is waiting to verify it instead of refusing blindly.
+static func _pending_swap_hint() -> String:
+ var pending := UpdateInstaller.read_pending()
+ if str(pending.get("status", "")) != "swapped":
+ return ""
+ return (
+ " An update to %s was swapped in under Godot %s and is waiting to be verified;"
+ + " reopen the project in that Godot."
+ ) % [str(pending.get("to_version", "")), str(pending.get("godot_version", "unknown"))]
+
+
+static func _warn_if_verified_elsewhere(outcome: Dictionary) -> void:
+ var swapped_in := str(outcome.get("godot_version", ""))
+ var running := str(Engine.get_version_info().get("string", ""))
+ if swapped_in.is_empty() or swapped_in == running:
return
- if not _meets_floor():
- print(FLOOR_REFUSAL)
- ## The user's updater already overlaid the capsule over their v3 tree.
- ## Put the final v3 back so nothing is lost; without the fallback (an
- ## older capsule) the bridge can only wait for a newer Godot.
- if MigrationFallback.available():
- _present_state(FLOOR_RESTORING, false, false)
- var fallback: Node = MigrationFallback.new()
- _parent_node().add_child(fallback)
- fallback.start()
+ push_warning(
+ "MCP | update to %s was swapped in under Godot %s; this editor is Godot %s"
+ % [str(outcome.get("to_version", "")), swapped_in, running]
+ )
+
+
+func _continue_enter_tree_after_update_barrier() -> void:
+
+ ## Register only after the update barrier, but before the headless guard:
+ ## `godot --headless --export-*` still needs to strip the game-helper
+ ## autoload from exported packs. The export plugin is otherwise inert.
+ _export_plugin = ExportPlugin.new()
+ add_export_plugin(_export_plugin)
+
+ if _mcp_disabled_for_headless_launch():
+ _headless_disabled = true
+ print("MCP | plugin disabled in headless mode")
+ return
+
+ ## Register port overrides before spawn so `http_port()` / `ws_port()`
+ ## return the user's configured values (if any) when `_start_server`
+ ## builds the CLI args.
+ ClientConfigurator.ensure_settings_registered()
+ _startup_trace_phase("settings_registered")
+
+ _log_buffer = LogBuffer.new()
+ ## Apply the persisted dock "Log" toggle before anything logs through the
+ ## buffer. Without this the choice only took effect after a manual toggle
+ ## and reset to noisy on every editor restart (#626).
+ _log_buffer.enabled = McpSettings.mcp_logging_enabled()
+
+ ## Capture desired endpoint settings without probing the OS. WS reservation
+ ## is an activation effect on Windows (`netsh`), so it runs only after every
+ ## owner and the Dock have been constructed and wired below.
+ _endpoint_policy = ClientConfigurator.capture_endpoint_policy()
+ _resolved_ws_port = int(_endpoint_policy.ws_port)
+
+ ## Construct plugin-lifetime work owners before attaching the replaceable
+ ## Dock. Constructors are inert; activate() is the single normal-start seam
+ ## that Phase 6's durable activation barrier will guard.
+ _client_jobs = ClientJobOwner.new()
+ _client_jobs.snapshot_changed.connect(_on_client_work_snapshot_changed)
+ _client_jobs.status_refresh_completed.connect(_on_client_status_refresh_completed)
+ _client_jobs.mcp_status_completed.connect(_on_mcp_client_status_completed)
+ _client_jobs.mcp_action_completed.connect(_on_mcp_client_action_completed)
+ _client_jobs.post_update_repin_completed.connect(_on_post_update_repin_completed)
+ _client_jobs.action_completed.connect(_on_client_action_completed)
+ _client_jobs.action_timed_out.connect(_on_client_action_timed_out)
+ add_child(_client_jobs)
+ _update_manager = UpdateManager.new()
+ _update_manager.update_check_completed.connect(_on_update_check_completed)
+ _update_manager.install_state_changed.connect(_on_update_install_state_changed)
+ _update_manager.activation_requested.connect(_on_update_activation_requested)
+ add_child(_update_manager)
+
+ _lifecycle.snapshot_changed.connect(_on_lifecycle_snapshot_changed)
+ _lifecycle.transport_ready.connect(_on_lifecycle_transport_ready)
+ _lifecycle.transport_cleared.connect(_on_lifecycle_transport_cleared)
+ _lifecycle.startup_finished.connect(_startup_trace_finish)
+
+ _game_log_buffer = GameLogBuffer.new()
+ _editor_log_buffer = EditorLogBuffer.new()
+ _surfaced_error_tracker = SurfacedErrorTracker.new(_editor_log_buffer, _game_log_buffer)
+ _attach_editor_logger()
+ _dispatcher = Dispatcher.new(_log_buffer, _surfaced_error_tracker)
+ _dispatcher.mcp_logging = _log_buffer.enabled
+ _startup_trace_phase("core_objects")
+
+ _connection = Connection.new()
+ _connection.log_buffer = _log_buffer
+ _connection.surfaced_error_tracker = _surfaced_error_tracker
+ _connection.ws_port = _resolved_ws_port
+ _connection.auth_token = ""
+ ## Pause-depth restore boundary (#712): the dispatcher rebalances any
+ ## pause_processing level a crashed handler leaked.
+ _dispatcher.pause_target = _connection
+ _connection.connect_blocked = _lifecycle.is_connection_blocked()
+ _connection.connect_block_reason = _lifecycle.get_status_dict().get("message", "")
+ ## Replay the custom-tool catalog on (re)connect so tools registered
+ ## before the initial connection or during a disconnect window reach
+ ## the server — send_event silently drops while _connected is false.
+ _connection.connection_state_changed.connect(_on_connection_state_changed)
+
+ _telemetry = Telemetry.new(_connection)
+
+ _debugger_plugin = DebuggerPlugin.new(_log_buffer, _game_log_buffer, _editor_log_buffer, _surfaced_error_tracker)
+ _vision_routing = VisionRoutingScript.new()
+ _vision_routing.log_buffer = _log_buffer
+ set_process(true)
+ _debugger_plugin.vision_routing = _vision_routing
+ add_debugger_plugin(_debugger_plugin)
+ _connection.debugger_plugin = _debugger_plugin
+ _ensure_game_helper_autoload()
+
+ ## Lazy handler registration (#736): declare each handler's script path
+ ## and constructor args, then map every command to (handler_key, method).
+ ## The dispatcher load()s + constructs a handler at the first dispatch of
+ ## one of its commands and caches the instance, so this block is the
+ ## authoritative command list without pulling any handler script into the
+ ## boot-time compile closure. Constructor args are captured now (they are
+ ## all plugin-lifetime objects) and released by _dispatcher.clear() in
+ ## _exit_tree.
+ var undo := get_undo_redo()
+ _dispatcher.register_lazy_handler("editor", HANDLERS_DIR + "editor_handler.gd", [_log_buffer, _connection, _debugger_plugin, _game_log_buffer, _editor_log_buffer, null, _surfaced_error_tracker, _vision_routing])
+ _dispatcher.register_lazy_handler("scene", HANDLERS_DIR + "scene_handler.gd", [_connection])
+ _dispatcher.register_lazy_handler("node", HANDLERS_DIR + "node_handler.gd", [undo])
+ _dispatcher.register_lazy_handler("project", HANDLERS_DIR + "project_handler.gd", [_connection, _debugger_plugin, _editor_log_buffer])
+ _dispatcher.register_lazy_handler(
+ "client",
+ HANDLERS_DIR + "client_handler.gd",
+ [_client_jobs],
+ )
+ _dispatcher.register_lazy_handler("script", HANDLERS_DIR + "script_handler.gd", [undo, _connection])
+ _dispatcher.register_lazy_handler("resource", HANDLERS_DIR + "resource_handler.gd", [undo, _connection])
+ _dispatcher.register_lazy_handler("api", HANDLERS_DIR + "api_handler.gd", [])
+ _dispatcher.register_lazy_handler("filesystem", HANDLERS_DIR + "filesystem_handler.gd", [_connection])
+ _dispatcher.register_lazy_handler("signal", HANDLERS_DIR + "signal_handler.gd", [undo])
+ _dispatcher.register_lazy_handler("autoload", HANDLERS_DIR + "autoload_handler.gd", [])
+ _dispatcher.register_lazy_handler("input", HANDLERS_DIR + "input_handler.gd", [])
+ _dispatcher.register_lazy_handler("test", HANDLERS_DIR + "test_handler.gd", [undo, _log_buffer, _dispatcher, _connection])
+ _dispatcher.register_lazy_handler("batch", HANDLERS_DIR + "batch_handler.gd", [_dispatcher, undo])
+ _dispatcher.register_lazy_handler("ui", HANDLERS_DIR + "ui_handler.gd", [undo])
+ _dispatcher.register_lazy_handler("theme", HANDLERS_DIR + "theme_handler.gd", [undo, _connection])
+ _dispatcher.register_lazy_handler("animation", HANDLERS_DIR + "animation_handler.gd", [undo])
+ _dispatcher.register_lazy_handler("material", HANDLERS_DIR + "material_handler.gd", [undo, _connection])
+ _dispatcher.register_lazy_handler("particle", HANDLERS_DIR + "particle_handler.gd", [undo])
+ _dispatcher.register_lazy_handler("camera", HANDLERS_DIR + "camera_handler.gd", [undo])
+ _dispatcher.register_lazy_handler("audio", HANDLERS_DIR + "audio_handler.gd", [undo])
+ _dispatcher.register_lazy_handler(
+ "physics_shape", HANDLERS_DIR + "physics_shape_handler.gd", [undo, _connection]
+ )
+ _dispatcher.register_lazy_handler("environment", HANDLERS_DIR + "environment_handler.gd", [undo, _connection])
+ _dispatcher.register_lazy_handler("texture", HANDLERS_DIR + "texture_handler.gd", [undo, _connection])
+ _dispatcher.register_lazy_handler("curve", HANDLERS_DIR + "curve_handler.gd", [undo, _connection])
+ _dispatcher.register_lazy_handler("control_draw_recipe", HANDLERS_DIR + "control_draw_recipe_handler.gd", [undo])
+ _dispatcher.register_lazy_handler("tilemap", HANDLERS_DIR + "tilemap_handler.gd", [undo])
+ _dispatcher.register_lazy_handler("tileset", HANDLERS_DIR + "tileset_handler.gd", [])
+ _dispatcher.register_lazy_handler("gridmap", HANDLERS_DIR + "gridmap_handler.gd", [undo])
+ _dispatcher.register_lazy_handler("csg", HANDLERS_DIR + "csg_handler.gd", [undo])
+
+ _dispatcher.register_lazy("get_editor_state", "editor", &"get_editor_state")
+ _dispatcher.register_lazy("get_scene_tree", "scene", &"get_scene_tree")
+ _dispatcher.register_lazy("get_open_scenes", "scene", &"get_open_scenes")
+ _dispatcher.register_lazy("find_nodes", "scene", &"find_nodes")
+ _dispatcher.register_lazy("create_scene", "scene", &"create_scene")
+ _dispatcher.register_lazy("open_scene", "scene", &"open_scene")
+ _dispatcher.register_lazy("save_scene", "scene", &"save_scene")
+ _dispatcher.register_lazy("save_scene_as", "scene", &"save_scene_as")
+ _dispatcher.register_lazy("get_selection", "editor", &"get_selection")
+ _dispatcher.register_lazy("create_node", "node", &"create_node")
+ _dispatcher.register_lazy("delete_node", "node", &"delete_node")
+ _dispatcher.register_lazy("reparent_node", "node", &"reparent_node")
+ _dispatcher.register_lazy("set_property", "node", &"set_property")
+ _dispatcher.register_lazy("rename_node", "node", &"rename_node")
+ _dispatcher.register_lazy("duplicate_node", "node", &"duplicate_node")
+ _dispatcher.register_lazy("move_node", "node", &"move_node")
+ _dispatcher.register_lazy("add_to_group", "node", &"add_to_group")
+ _dispatcher.register_lazy("remove_from_group", "node", &"remove_from_group")
+ _dispatcher.register_lazy("set_selection", "node", &"set_selection")
+ _dispatcher.register_lazy("get_node_properties", "node", &"get_node_properties")
+ _dispatcher.register_lazy("get_children", "node", &"get_children")
+ _dispatcher.register_lazy("get_groups", "node", &"get_groups")
+ _dispatcher.register_lazy("get_logs", "editor", &"get_logs")
+ _dispatcher.register_lazy("clear_logs", "editor", &"clear_logs")
+ _dispatcher.register_lazy("take_screenshot", "editor", &"take_screenshot")
+ _dispatcher.register_lazy("get_performance_monitors", "editor", &"get_performance_monitors")
+ _dispatcher.register_lazy("reload_plugin", "editor", &"reload_plugin")
+ _dispatcher.register_lazy("quit_editor", "editor", &"quit_editor")
+ _dispatcher.register_lazy("game_eval", "editor", &"game_eval")
+ _dispatcher.register_lazy("game_command", "editor", &"game_command")
+ _dispatcher.register_lazy("game_debug_control", "editor", &"game_debug_control")
+ _dispatcher.register_lazy("get_project_setting", "project", &"get_project_setting")
+ _dispatcher.register_lazy("set_project_setting", "project", &"set_project_setting")
+ _dispatcher.register_lazy("set_main_scene", "project", &"set_main_scene")
+ _dispatcher.register_lazy("run_project", "project", &"run_project")
+ _dispatcher.register_lazy("stop_project", "project", &"stop_project")
+ _dispatcher.register_lazy("search_filesystem", "project", &"search_filesystem")
+ _dispatcher.register_lazy("configure_client", "client", &"configure_client")
+ _dispatcher.register_lazy("remove_client", "client", &"remove_client")
+ _dispatcher.register_lazy("check_client_status", "client", &"check_client_status")
+ _dispatcher.register_lazy("create_script", "script", &"create_script")
+ _dispatcher.register_lazy("patch_script", "script", &"patch_script")
+ _dispatcher.register_lazy("read_script", "script", &"read_script")
+ _dispatcher.register_lazy("attach_script", "script", &"attach_script")
+ _dispatcher.register_lazy("detach_script", "script", &"detach_script")
+ _dispatcher.register_lazy("find_symbols", "script", &"find_symbols")
+ _dispatcher.register_lazy("search_resources", "resource", &"search_resources")
+ _dispatcher.register_lazy("load_resource", "resource", &"load_resource")
+ _dispatcher.register_lazy("assign_resource", "resource", &"assign_resource")
+ _dispatcher.register_lazy("create_resource", "resource", &"create_resource")
+ _dispatcher.register_lazy("get_resource_info", "resource", &"get_resource_info")
+ _dispatcher.register_lazy("get_class_info", "api", &"get_class_info")
+ _dispatcher.register_lazy("read_file", "filesystem", &"read_file")
+ _dispatcher.register_lazy("write_file", "filesystem", &"write_file")
+ _dispatcher.register_lazy("reimport", "filesystem", &"reimport")
+ _dispatcher.register_lazy("scan_filesystem", "filesystem", &"scan_filesystem")
+ _dispatcher.register_lazy("list_signals", "signal", &"list_signals")
+ _dispatcher.register_lazy("connect_signal", "signal", &"connect_signal")
+ _dispatcher.register_lazy("disconnect_signal", "signal", &"disconnect_signal")
+ _dispatcher.register_lazy("list_autoloads", "autoload", &"list_autoloads")
+ _dispatcher.register_lazy("add_autoload", "autoload", &"add_autoload")
+ _dispatcher.register_lazy("remove_autoload", "autoload", &"remove_autoload")
+ _dispatcher.register_lazy("list_actions", "input", &"list_actions")
+ _dispatcher.register_lazy("add_action", "input", &"add_action")
+ _dispatcher.register_lazy("ensure_action", "input", &"ensure_action")
+ _dispatcher.register_lazy("remove_action", "input", &"remove_action")
+ _dispatcher.register_lazy("bind_event", "input", &"bind_event")
+ _dispatcher.register_lazy("ensure_binding", "input", &"ensure_binding")
+ _dispatcher.register_lazy("run_tests", "test", &"run_tests")
+ _dispatcher.register_lazy("get_test_results", "test", &"get_test_results")
+ _dispatcher.register_lazy("batch_execute", "batch", &"batch_execute")
+ _dispatcher.register_lazy("set_anchor_preset", "ui", &"set_anchor_preset")
+ _dispatcher.register_lazy("set_text", "ui", &"set_text")
+ _dispatcher.register_lazy("build_layout", "ui", &"build_layout")
+ _dispatcher.register_lazy("create_theme", "theme", &"create_theme")
+ _dispatcher.register_lazy("theme_set_color", "theme", &"set_color")
+ _dispatcher.register_lazy("theme_set_constant", "theme", &"set_constant")
+ _dispatcher.register_lazy("theme_set_font_size", "theme", &"set_font_size")
+ _dispatcher.register_lazy("theme_set_stylebox_flat", "theme", &"set_stylebox_flat")
+ _dispatcher.register_lazy("apply_theme", "theme", &"apply_theme")
+ _dispatcher.register_lazy("animation_player_create", "animation", &"create_player")
+ _dispatcher.register_lazy("animation_create", "animation", &"create_animation")
+ _dispatcher.register_lazy("animation_add_property_track", "animation", &"add_property_track")
+ _dispatcher.register_lazy("animation_add_method_track", "animation", &"add_method_track")
+ _dispatcher.register_lazy("animation_set_autoplay", "animation", &"set_autoplay")
+ _dispatcher.register_lazy("animation_play", "animation", &"play")
+ _dispatcher.register_lazy("animation_stop", "animation", &"stop")
+ _dispatcher.register_lazy("animation_list", "animation", &"list_animations")
+ _dispatcher.register_lazy("animation_get", "animation", &"get_animation")
+ _dispatcher.register_lazy("animation_create_simple", "animation", &"create_simple")
+ _dispatcher.register_lazy("animation_delete", "animation", &"delete_animation")
+ _dispatcher.register_lazy("animation_validate", "animation", &"validate_animation")
+ _dispatcher.register_lazy("animation_preset_fade", "animation", &"preset_fade")
+ _dispatcher.register_lazy("animation_preset_slide", "animation", &"preset_slide")
+ _dispatcher.register_lazy("animation_preset_shake", "animation", &"preset_shake")
+ _dispatcher.register_lazy("animation_preset_pulse", "animation", &"preset_pulse")
+ _dispatcher.register_lazy("material_create", "material", &"create_material")
+ _dispatcher.register_lazy("material_set_param", "material", &"set_param")
+ _dispatcher.register_lazy("material_set_shader_param", "material", &"set_shader_param")
+ _dispatcher.register_lazy("material_get", "material", &"get_material")
+ _dispatcher.register_lazy("material_list", "material", &"list_materials")
+ _dispatcher.register_lazy("material_assign", "material", &"assign_material")
+ _dispatcher.register_lazy("material_apply_to_node", "material", &"apply_to_node")
+ _dispatcher.register_lazy("material_apply_preset", "material", &"apply_preset")
+ _dispatcher.register_lazy("particle_create", "particle", &"create_particle")
+ _dispatcher.register_lazy("particle_set_main", "particle", &"set_main")
+ _dispatcher.register_lazy("particle_set_process", "particle", &"set_process")
+ _dispatcher.register_lazy("particle_set_draw_pass", "particle", &"set_draw_pass")
+ _dispatcher.register_lazy("particle_restart", "particle", &"restart_particle")
+ _dispatcher.register_lazy("particle_get", "particle", &"get_particle")
+ _dispatcher.register_lazy("particle_apply_preset", "particle", &"apply_preset")
+ _dispatcher.register_lazy("camera_create", "camera", &"create_camera")
+ _dispatcher.register_lazy("camera_configure", "camera", &"configure")
+ _dispatcher.register_lazy("camera_set_limits_2d", "camera", &"set_limits_2d")
+ _dispatcher.register_lazy("camera_set_damping_2d", "camera", &"set_damping_2d")
+ _dispatcher.register_lazy("camera_follow_2d", "camera", &"follow_2d")
+ _dispatcher.register_lazy("camera_get", "camera", &"get_camera")
+ _dispatcher.register_lazy("camera_list", "camera", &"list_cameras")
+ _dispatcher.register_lazy("camera_apply_preset", "camera", &"apply_preset")
+ _dispatcher.register_lazy("audio_player_create", "audio", &"create_player")
+ _dispatcher.register_lazy("audio_player_set_stream", "audio", &"set_stream")
+ _dispatcher.register_lazy("audio_player_set_playback", "audio", &"set_playback")
+ _dispatcher.register_lazy("audio_play", "audio", &"play")
+ _dispatcher.register_lazy("audio_stop", "audio", &"stop")
+ _dispatcher.register_lazy("audio_list", "audio", &"list_streams")
+ _dispatcher.register_lazy("physics_shape_autofit", "physics_shape", &"autofit")
+ _dispatcher.register_lazy("physics_shape_generate", "physics_shape", &"generate")
+ _dispatcher.register_lazy("environment_create", "environment", &"create_environment")
+ _dispatcher.register_lazy("gradient_texture_create", "texture", &"create_gradient_texture")
+ _dispatcher.register_lazy("noise_texture_create", "texture", &"create_noise_texture")
+ _dispatcher.register_lazy("curve_set_points", "curve", &"set_points")
+ _dispatcher.register_lazy("control_draw_recipe", "control_draw_recipe", &"control_draw_recipe")
+ _dispatcher.register_lazy("tilemap_set_cell", "tilemap", &"set_cell")
+ _dispatcher.register_lazy("tilemap_set_cells_rect", "tilemap", &"set_cells_rect")
+ _dispatcher.register_lazy("tilemap_clear", "tilemap", &"clear_layer")
+ _dispatcher.register_lazy("tilemap_get_cells", "tilemap", &"get_used_cells")
+ _dispatcher.register_lazy("tileset_get_atlas_tiles", "tileset", &"get_atlas_tiles")
+ _dispatcher.register_lazy("tileset_get_atlas_image", "tileset", &"get_atlas_image")
+ _dispatcher.register_lazy("gridmap_set_item", "gridmap", &"set_item")
+ _dispatcher.register_lazy("gridmap_fill", "gridmap", &"fill")
+ _dispatcher.register_lazy("gridmap_clear", "gridmap", &"clear_layer")
+ _dispatcher.register_lazy("gridmap_get_used_cells", "gridmap", &"get_used_cells")
+ _dispatcher.register_lazy("gridmap_list_library_items", "gridmap", &"list_library_items")
+ _dispatcher.register_lazy("csg_create", "csg", &"create")
+ _dispatcher.register_lazy("csg_set_operation", "csg", &"set_operation")
+
+ _connection.dispatcher = _dispatcher
+ add_child(_connection)
+ _startup_trace_phase("handlers_registered")
+
+ # Custom tool registry
+ _custom_tool_service_locator = McpServiceLocator.new()
+ _custom_tool_service_locator.setup(_connection, _log_buffer)
+ _custom_tool_registry = McpToolRegistry.new()
+ _custom_tool_registry.setup(_dispatcher, _custom_tool_service_locator)
+ _custom_tool_registry.tools_changed.connect(_on_custom_tools_changed)
+ _custom_tool_registry.mark_ready()
+ _startup_trace_phase("custom_tools_ready")
+
+ # Dock panel
+ _dock = Dock.new()
+ _dock.vision_routing = _vision_routing
+ _dock.name = "Godot AI"
+ _dock.update_requested.connect(_on_dock_update_requested)
+ _dock.client_action_requested.connect(_on_dock_client_action_requested)
+ _dock.client_status_refresh_requested.connect(_on_dock_client_status_refresh_requested)
+ _dock.status_snapshot_requested.connect(_on_dock_status_snapshot_requested)
+ _dock.live_server_probe_requested.connect(_on_dock_live_server_probe_requested)
+ _dock.lifecycle_action_requested.connect(_on_dock_lifecycle_action_requested)
+ _dock.dev_server_action_requested.connect(_on_dock_dev_server_action_requested)
+ _dock.mcp_logging_changed.connect(_on_dock_mcp_logging_changed)
+ _dock.log_snapshot_requested.connect(_on_dock_log_snapshot_requested)
+ _dock.plugin_reload_requested.connect(_on_dock_plugin_reload_requested)
+ _dock.settings_apply_requested.connect(_on_dock_settings_apply_requested)
+ _dock.post_update_action_requested.connect(_on_dock_post_update_action_requested)
+ _client_jobs.set_client_health_blocked(_client_health_is_blocked())
+ _publish_dock_status_snapshots()
+ add_control_to_dock(DOCK_SLOT_RIGHT_BL, _dock)
+ _dock.present_client_work_snapshot(_client_jobs.snapshot())
+ _startup_trace_phase("dock_attached")
+ ## Activation barrier: no process, socket, or probe effect begins until all
+ ## owners and the replaceable Dock have been constructed and wired.
+ _activate_startup_endpoints()
+
+
+## Endpoint selection is retryable before the immutable launch policy or any
+## client migration worker exists. An old bridge keeps its own ports.
+func _activate_startup_endpoints() -> void:
+ if str(_post_update_outcome.get("outcome", "")) == "success":
+ var prepared := ClientConfigurator.prepare_major_upgrade_endpoints(
+ str(_post_update_outcome.get("from_version", "")),
+ str(_post_update_outcome.get("to_version", "")),
+ )
+ if not bool(prepared.get("ok", false)):
+ _present_endpoint_setup_failure(str(prepared.get("error", "Endpoint selection failed.")))
return
- _present_state(FLOOR_MESSAGE, true, false)
+ var override := ClientConfigurator.v4_endpoint_ports_status()
+ if not bool(override.get("ok", false)):
+ _present_endpoint_setup_failure(str(override.get("error", "Invalid endpoint override.")))
return
- _start_migration.call_deferred()
+ var resolved_policy := ClientConfigurator.capture_endpoint_policy()
+ var http_port := int(resolved_policy.http_port)
+ var configured_ws := int(resolved_policy.ws_port)
+ if (
+ http_port < ClientConfigurator.MIN_PORT or http_port > ClientConfigurator.MAX_PORT
+ or configured_ws < ClientConfigurator.MIN_PORT or configured_ws > ClientConfigurator.MAX_PORT
+ or http_port == configured_ws
+ ):
+ _present_endpoint_setup_failure("Choose distinct HTTP and WebSocket ports between %d and %d in Godot AI settings." % [ClientConfigurator.MIN_PORT, ClientConfigurator.MAX_PORT])
+ return
+ var resolved_ws := _resolve_ws_port(configured_ws)
+ if (
+ resolved_ws < ClientConfigurator.MIN_PORT or resolved_ws > ClientConfigurator.MAX_PORT
+ or resolved_ws == http_port
+ or (bool(override.present) and resolved_ws != configured_ws)
+ ):
+ _present_endpoint_setup_failure("The configured WebSocket port is unavailable. Choose another endpoint pair in Godot AI settings, then retry.")
+ return
+ resolved_policy["ws_port"] = resolved_ws
+ resolved_policy["capability_path"] = TransportCapability.path_for_http_port(http_port)
+ _set_endpoint_policy(resolved_policy)
+ if _connection != null:
+ _connection.ws_port = resolved_ws
+ if _post_update_action == "retry_endpoints":
+ _post_update_action = ""
+ if _dock != null:
+ _dock.present_update_state({"post_update_action": "", "status_text": "", "label_text": "", "banner_visible": false})
+ ## #691: publish every environment/setting value before the first worker.
+ ClientConfigurator.warm_env_snapshot(_endpoint_policy)
+ _lifecycle.configure(_capture_lifecycle_plan())
+ _begin_startup_release()
+
+
+func _present_endpoint_setup_failure(error: String) -> void:
+ _post_update_action = "retry_endpoints"
+ _lifecycle._block_without_effect("endpoint_setup_failed", error)
+ if _dock != null:
+ _dock.present_update_state({
+ "install_in_flight": false,
+ "button_text": "Retry endpoint setup",
+ "status_text": "Client endpoint setup failed",
+ "button_disabled": false,
+ "label_text": error,
+ "banner_visible": true,
+ "post_update_action": "retry_endpoints",
+ })
+
+
+func _client_health_is_blocked() -> bool:
+ return ServerStateScript.blocks_client_health(
+ int(_lifecycle.get_status_dict().get("state", ServerStateScript.UNINITIALIZED))
+ )
+
+
+func _on_dock_client_action_requested(client_id: String, action: String) -> void:
+ if _client_jobs == null:
+ return
+ if not _client_jobs.request_action(client_id, action) and _dock != null:
+ _dock.present_client_work_snapshot(_client_jobs.snapshot())
+
+
+func _on_dock_client_status_refresh_requested(client_ids: Array[String], force: bool) -> void:
+ if _client_jobs == null:
+ return
+ _client_jobs.set_client_health_blocked(_client_health_is_blocked())
+ _client_jobs.request_status_refresh(client_ids, force)
+
+
+func _on_client_work_snapshot_changed(snapshot: Dictionary) -> void:
+ if _dock != null:
+ _dock.present_client_work_snapshot(snapshot)
+
+
+func _on_client_status_refresh_completed(results: Dictionary) -> void:
+ if _dock != null:
+ _dock.present_client_status_refresh_results(results)
+
+
+func _on_mcp_client_status_completed(
+ request_ids: Array[String], payload: Dictionary
+) -> void:
+ if _connection == null:
+ return
+ for request_id in request_ids:
+ _connection.send_deferred_response(request_id, payload)
+
+
+func _on_mcp_client_action_completed(request_id: String, payload: Dictionary) -> void:
+ if _connection != null:
+ _connection.send_deferred_response(request_id, payload)
+
+
+func _on_client_action_completed(
+ client_id: String, action: String, result: Dictionary, prewarm: Dictionary
+) -> void:
+ if _dock != null:
+ _dock.present_client_action_result(client_id, action, result, prewarm)
+
+
+func _on_client_action_timed_out(client_id: String, action: String, detail: String) -> void:
+ if _dock != null:
+ _dock.present_client_action_timeout(client_id, action, detail)
+
+
+func _transport_snapshot_for_dock() -> Dictionary:
+ if _connection == null:
+ return {"connected": false, "server_version": "", "status": {}}
+ return {
+ "connected": _connection.is_connected,
+ "server_version": _connection.server_version,
+ "status": _connection.get_transport_status(),
+ }
+
+
+func _lifecycle_snapshot_for_dock() -> Dictionary:
+ if _lifecycle == null:
+ return {
+ "state": ServerStateScript.UNINITIALIZED,
+ "server_pid": -1,
+ "resolved_ws_port": _resolved_ws_port,
+ "can_restart_managed": false,
+ "can_recover_incompatible": false,
+ "normal_start_released": _normal_start_released,
+ }
+ var snapshot: Dictionary = _lifecycle.get_status_dict().duplicate(true)
+ snapshot["server_pid"] = _lifecycle.get_server_pid()
+ snapshot["resolved_ws_port"] = _resolved_ws_port
+ snapshot["can_restart_managed"] = (
+ _normal_start_released and _lifecycle.can_restart_managed_server()
+ )
+ snapshot["can_recover_incompatible"] = (
+ _normal_start_released and bool(snapshot.get("can_recover_incompatible", false))
+ )
+ snapshot["normal_start_released"] = _normal_start_released
+ if _post_update_retry_episode > 0 and int(snapshot.get("episode_id", 0)) == _post_update_retry_episode:
+ var episode: Dictionary = _lifecycle.episode_snapshot()
+ if str(episode.get("state", "")) == "BLOCKED" and str(episode.get("reason", "")) == "launch_gone" and str(episode.get("proof_pending_reason", "")) == "capability_pair":
+ ## Only the presentation changes; transport remains blocked until proof.
+ snapshot["state"] = ServerStateScript.SPAWNING
+ snapshot["handoff_retry_pending"] = true
+ return snapshot
+
+
+func _publish_dock_status_snapshots() -> void:
+ if _dock == null:
+ return
+ _dock.present_transport_snapshot(_transport_snapshot_for_dock())
+ _dock.present_lifecycle_snapshot(_lifecycle_snapshot_for_dock())
+
+
+func _on_dock_status_snapshot_requested() -> void:
+ _publish_dock_status_snapshots()
+
+
+func _on_dock_live_server_probe_requested(port: int) -> void:
+ if _dock != null:
+ _dock.present_live_server_probe_result(
+ ServerLifecycleManager.probe_live_server_status(
+ port, ServerLifecycleManager.DEFAULT_PROBE_TIMEOUT_MS,
+ str(_endpoint_policy.get("capability_path", ""))
+ )
+ )
+
+
+func _on_dock_lifecycle_action_requested(action: int) -> void:
+ if not _normal_start_released:
+ if _dock != null:
+ _dock.present_lifecycle_action_result(false)
+ _publish_dock_status_snapshots()
+ return
+ var accepted := false
+ match action:
+ Dock.LifecycleAction.RECOVER_INCOMPATIBLE:
+ accepted = bool(await recover_incompatible_server())
+ Dock.LifecycleAction.RESTART_SERVER:
+ accepted = force_restart_server()
+ if _dock != null:
+ _dock.present_lifecycle_action_result(accepted)
+ _publish_dock_status_snapshots()
+
+
+func _on_dock_dev_server_action_requested(action: int) -> void:
+ if action != Dock.DevServerAction.STOP and not _normal_start_released:
+ _publish_dock_status_snapshots()
+ return
+ match action:
+ Dock.DevServerAction.START_OR_RESTART:
+ if _telemetry != null:
+ _telemetry.record_dev_server_toggle("start")
+ restart_or_start_managed_server()
+ Dock.DevServerAction.STOP:
+ if _telemetry != null:
+ _telemetry.record_dev_server_toggle("stop")
+ stop_managed_server()
+ _publish_dock_status_snapshots()
+
+
+func _on_dock_mcp_logging_changed(enabled: bool) -> void:
+ if _dispatcher != null:
+ _dispatcher.mcp_logging = enabled
+ if _log_buffer != null:
+ _log_buffer.enabled = enabled
+
+
+func _on_dock_log_snapshot_requested(after_sequence: int) -> void:
+ if _dock == null or _log_buffer == null:
+ return
+ var sequence: int = _log_buffer.total_logged()
+ var reset: bool = sequence < after_sequence
+ var count: int = sequence if reset else maxi(0, sequence - after_sequence)
+ _dock.present_log_snapshot({
+ "sequence": sequence,
+ "reset": reset,
+ "lines": _log_buffer.get_recent(count),
+ })
+
+
+func _on_dock_plugin_reload_requested(reason: String) -> void:
+ Telemetry.record_pending_plugin_reload(reason)
+ ## The reload frees this plugin, so it must not run on one of this
+ ## instance's own frames: defer the static call itself, never a method of
+ ## this instance. A deferred method that reloads synchronously returns into
+ ## a freed script and takes the editor down (SIGBUS/SIGABRT after
+ ## "Bad address index").
+ PluginReload.reload_enabled_plugin.call_deferred()
+
+
+func _on_dock_settings_apply_requested(changes: Dictionary, reload: bool) -> void:
+ var applied := ClientConfigurator.apply_endpoint_settings(changes.duplicate(true))
+ if not bool(applied.get("ok", false)):
+ push_error("MCP | refused settings change: %s" % str(applied.get("error", "invalid settings")))
+ return
+ ## #913: the setting is persisted, so push any opt-out to the live server —
+ ## the reload below replaces this plugin, not a server it merely adopted.
+ ## Best-effort: the reload may cut the socket first, and the reconnect
+ ## re-asserts it from `Telemetry._on_connection_state_changed` anyway.
+ if _telemetry != null:
+ _telemetry.assert_opt_out()
+ if reload:
+ _on_dock_plugin_reload_requested("endpoint_settings")
+
+
+func _on_dock_update_requested() -> void:
+ if _update_manager != null and _update_manager.has_install_candidate():
+ _update_manager.start_install(_preflight_update())
+
+
+func _on_update_check_completed(result: Dictionary) -> void:
+ if _dock != null:
+ _dock.present_update_check(result)
+
+
+func _on_update_install_state_changed(state: Dictionary) -> void:
+ if not bool(state.get("install_in_flight", true)) and _client_jobs != null:
+ _client_jobs.resume_after_quiesce()
+ if not bool(state.get("install_in_flight", true)) and not _update_swapped:
+ UpdateInstaller.release_lock()
+ if _dock != null:
+ _dock.present_update_state(state)
+
+
+func _on_update_activation_requested(package: Dictionary) -> void:
+ install_downloaded_update(package)
+
+
+## Hold successful activation behind the client-migration barrier. Rollback
+## resumes normally after recording its terminal outcome.
+func _begin_startup_release() -> void:
+ if str(_post_update_outcome.get("outcome", "")) != "success":
+ if not _post_update_outcome.is_empty():
+ _present_post_update_failure()
+ UpdateInstaller.clear_pending()
+ _fan_post_update_outcome()
+ _release_normal_startup()
+ return
+ var started: Dictionary = _client_jobs.begin_post_update_repin(
+ str(_post_update_outcome.get("from_version", "")),
+ str(_post_update_outcome.get("to_version", "")),
+ bool(_post_update_outcome.get(
+ "replace_owned_mismatches",
+ _post_update_outcome.get("manual_migration", false),
+ )),
+ )
+ if not bool(started.get("ok", false)):
+ _present_post_update_barrier_failure(str(started.get("error", "Client migration could not start.")))
+ return
+ _post_update_action = ""
+ if _dock != null:
+ _dock.present_update_state({
+ "install_in_flight": true,
+ "status_text": "Migrating client configuration…",
+ "button_disabled": true,
+ "label_text": "The server remains stopped until configured clients are repinned.",
+ "banner_visible": true,
+ "post_update_action": "",
+ })
+
+
+func _on_post_update_repin_completed(result: Dictionary) -> void:
+ if str(_post_update_outcome.get("outcome", "")) != "success":
+ return
+ if not bool(result.get("ok", false)):
+ _present_post_update_barrier_failure(str(result.get("error", "Client migration failed.")))
+ return
+ for client_id in result.get("foreign_ids", []):
+ push_warning(
+ "MCP | the %s entry named godot-ai launches something else; it was left unchanged. Use Configure in the dock to replace it."
+ % str(client_id)
+ )
+ _post_update_deferred = []
+ for entry in result.get("deferred", []):
+ if entry is Dictionary:
+ _post_update_deferred.append((entry as Dictionary).duplicate(true))
+ push_warning(
+ "MCP | %s was not migrated automatically because %s; it was left unchanged. Use Configure in the dock."
+ % [str(entry.get("id", "")), str(entry.get("reason", ""))]
+ )
+ ## A click cannot prove that an external client restarted. The enforceable
+ ## boundary is the one we own: repin its configuration, mark the update
+ ## complete, then start and authenticate that server. A major upgrade can
+ ## select independent ports; old clients must reload the migrated config,
+ ## but cannot hold the new editor endpoint behind their existing leases.
+ _finish_post_update()
+
+
+func _present_post_update_barrier_failure(error: String) -> void:
+ _post_update_action = "retry"
+ push_error("MCP | post-update client migration blocked startup: %s" % error)
+ if _dock != null:
+ _dock.present_update_state({
+ "install_in_flight": false,
+ "button_text": "Retry client migration",
+ "status_text": "Installed — client migration failed",
+ "button_disabled": false,
+ "label_text": "Server startup is blocked: %s" % error,
+ "banner_visible": true,
+ "post_update_action": "retry",
+ })
+
+
+func _on_dock_post_update_action_requested(action: String) -> void:
+ if action != _post_update_action:
+ return
+ if action == "retry_endpoints":
+ _activate_startup_endpoints()
+ elif action == "retry":
+ _begin_startup_release()
+
+
+func _finish_post_update() -> void:
+ print("MCP | client migration completed")
+ ## The success marker stays as the durable record of the last update; the
+ ## next swap overwrites it and preflight only refuses swapped/repair states.
+ ## Recording the migration keeps later starts from repeating it.
+ var recorded := UpdateInstaller.record_clients_migrated()
+ if recorded != OK:
+ push_warning("MCP | could not record client migration in the update marker: %s" % error_string(recorded))
+ ## In-editor updates retain old script graphs for undo. Keep their backing
+ ## files until a fresh editor process can safely prune older generations.
+ if not get_tree().root.has_meta("godot_ai_retained_update_scripts"):
+ UpdateInstaller.prune_backups(str(_post_update_outcome.get("from_version", "")))
+ _post_update_replaced_version = str(_post_update_outcome.get("from_version", ""))
+ _post_update_reprobes_left = POST_UPDATE_REPROBE_LIMIT
+ _post_update_stale_reprobes_left = POST_UPDATE_STALE_REPROBE_LIMIT
+ _post_update_replacements_left = POST_UPDATE_REPLACEMENT_LIMIT
+ var to_version := str(_post_update_outcome.get("to_version", ""))
+ if McpServerVersionCheck.attached_bridges_follow(_post_update_replaced_version, to_version):
+ print("MCP | AI clients using v%s can reconnect to v%s; relaunch clients still using older versions" % [_post_update_replaced_version, to_version])
+ else:
+ print(
+ "MCP | Refresh the Godot AI MCP connection and reload its configuration once to use v%s; relaunch the AI app if it cannot reload the configuration"
+ % to_version
+ )
+ _present_post_update_complete()
+ _fan_post_update_outcome()
+ _release_normal_startup()
+
+
+func _present_post_update_failure() -> void:
+ var error := str(_post_update_outcome.get("error", ""))
+ var to_version := str(_post_update_outcome.get("to_version", ""))
+ push_error("MCP | update to %s failed; the previous version is live: %s" % [to_version, error])
+ if _dock != null:
+ _dock.present_update_state({
+ "install_in_flight": false,
+ "status_text": "Update failed — previous version restored",
+ "button_disabled": false,
+ "label_text": error,
+ "banner_visible": true,
+ "post_update_action": "",
+ })
+
+
+func _present_post_update_complete() -> void:
+ _post_update_action = ""
+ if _dock != null:
+ _dock.present_update_state({
+ "install_in_flight": false,
+ "status_text": "Godot AI installed" if _post_update_deferred.is_empty() else "Installed — client setup needed",
+ "button_disabled": true,
+ "label_text": _post_update_complete_label(),
+ "banner_visible": true,
+ "post_update_action": "",
+ "outcome": "success",
+ })
+
+
+func _post_update_complete_label() -> String:
+ var to_version := str(_post_update_outcome.get("to_version", ""))
+ var from_version := str(_post_update_outcome.get("from_version", ""))
+ var text := (
+ "AI clients already using v%s can reconnect to v%s without restarting. Refresh older MCP connections and reload their configuration; relaunch the AI app if needed." % [from_version, to_version]
+ if McpServerVersionCheck.attached_bridges_follow(from_version, to_version)
+ else "Refresh the Godot AI MCP connection and reload its configuration once to use v%s. If the AI app cannot reload its configuration, quit and relaunch it."
+ % to_version
+ )
+ if _post_update_deferred.is_empty():
+ return text
+ var named: Array[String] = []
+ for entry in _post_update_deferred:
+ var client_id := str(entry.get("id", ""))
+ var client := McpClientRegistry.get_by_id(client_id)
+ var name: String = client.display_name if client != null else client_id
+ named.append("%s (%s)" % [name, str(entry.get("reason", "not migrated"))])
+ return text + " Not migrated: %s. Use Configure to replace them." % ", ".join(named)
+
+
+## Sole release point for ordinary work and the server lifecycle. Keeping
+## these effects together makes the post-update legal ordering reviewable.
+func _release_normal_startup() -> void:
+ if _normal_start_released:
+ return
+ _normal_start_released = true
+ _client_jobs.set_client_health_blocked(_client_health_is_blocked())
+ _client_jobs.activate()
+ _update_manager.check_for_updates.call_deferred()
+ _start_server()
+ _startup_trace_phase("server_start")
+ _log_buffer.log("plugin loaded")
+ if _telemetry != null:
+ _telemetry.record_dock_startup()
+ _telemetry.flush_pending_plugin_reload()
+
+
+## Fan one immutable terminal update outcome. It is held only in this
+## instance's memory, never EditorSettings, and is cleared exactly once.
+func _fan_post_update_outcome() -> void:
+ if _post_update_outcome.is_empty():
+ return
+ var outcome := str(_post_update_outcome.get("outcome", ""))
+ var status := "unknown"
+ match outcome:
+ "success":
+ status = "success"
+ "rolled_back":
+ status = "failed_clean"
+ "repair_required", "quarantined":
+ status = "failed_mixed"
+ var error := "" if outcome == "success" else str(_post_update_outcome.get("error", outcome.replace("_", " ")))
+ var from_version := str(_post_update_outcome.get("from_version", ""))
+ var to_version := str(_post_update_outcome.get("to_version", ""))
+ if _telemetry != null and not bool(_post_update_outcome.get("manual_migration", false)):
+ _telemetry.record_self_update(status, from_version, to_version, error)
+ _post_update_outcome.clear()
+
+
+func _block_update_startup(reason: String) -> void:
+ _update_barrier_blocked = true
+ push_error("MCP | update recovery blocked plugin startup: %s" % reason)
+ _disable_after_update_barrier.call_deferred()
+
+
+func _disable_after_update_barrier() -> void:
+ ## A live tree that neither matches its manifest nor has a backup to
+ ## restore must not run. Disable the shell so nothing is built from it;
+ ## the marker keeps the exact paths for manual recovery.
+ if _update_barrier_blocked:
+ print("MCP | disabling plugin after update barrier refusal")
+ EditorInterface.set_plugin_enabled("res://addons/godot_ai/plugin.cfg", false)
func _exit_tree() -> void:
- _free_bridge()
- if _coordinator != null and is_instance_valid(_coordinator):
- ## The coordinator outlives this plugin on purpose; it disables the
- ## capsule and keeps running. Only stop listening to it.
- if _coordinator.state_changed.is_connected(_present_state):
- _coordinator.state_changed.disconnect(_present_state)
- _coordinator = null
- if _dock != null and is_instance_valid(_dock):
+ if _unsupported_engine:
+ return
+ set_process(false)
+ ## Registered before the headless guard in _enter_tree, so it must be
+ ## removed before the headless early-return here too.
+ if _export_plugin != null:
+ remove_export_plugin(_export_plugin)
+ _export_plugin = null
+
+ if _headless_disabled:
+ _headless_disabled = false
+ ## Ported from upstream PR #936 at
+ ## 537a490c865837bedb96042d10ee0fc74673cd99: `_lifecycle` is built in
+ ## _init(), before the headless guard in _enter_tree() runs, so it exists
+ ## even on this path — null it here too (full teardown below is skipped).
+ _lifecycle = null
+ return
+
+ if _update_barrier_blocked:
+ _update_barrier_blocked = false
+ _lifecycle = null
+ return
+
+ ## Client work has plugin lifetime, independent of the Dock. Realize every
+ ## thread before either owner script can be reloaded or freed.
+ if _client_jobs != null:
+ _client_jobs.quiesce()
+
+ if _custom_tool_registry != null:
+ _custom_tool_registry.clear()
+ _custom_tool_registry = null
+
+ _custom_tool_service_locator = null
+
+ ## Outer-to-inner teardown. Dispatcher Callables hold RefCounted handlers
+ ## alive past the point where Godot reloads their class_name scripts — the
+ ## first post-reload call into a typed-array-holding handler (e.g.
+ ## McpGameLogBuffer._storage) then SIGSEGVs against a stale class descriptor.
+ ## See issue #46.
+
+ # Stop inbound work first so _process can't enqueue new commands or
+ # null-deref log_buffer on the next tick mid-teardown.
+ if _connection:
+ _connection.teardown()
+
+ if _vision_routing:
+ _vision_routing.shutdown()
+ _vision_routing = null
+ # Transport is stopped and both worker owners have joined. Release the
+ # ordinary plugin-lifetime graph; this is not the stronger hot script-swap
+ # authorization (prepare_for_update_reload still requires clear()).
+ if _dispatcher:
+ _dispatcher.release_after_teardown()
+
+ if _dock:
+ _dock.release_editor_progress_dialog()
remove_control_from_docks(_dock)
_dock.queue_free()
- _dock = null
+ _dock = null
+ if _update_manager:
+ _update_manager.cancel_install()
+ _update_manager.queue_free()
+ _update_manager = null
+ if _client_jobs:
+ _client_jobs.queue_free()
+ _client_jobs = null
+ if _connection:
+ _connection.queue_free()
+ _connection = null
+ if _debugger_plugin:
+ remove_debugger_plugin(_debugger_plugin)
+ _debugger_plugin = null
+
+ ## Detach the editor logger BEFORE nulling the buffer. After remove_logger
+ ## returns, Godot guarantees no further virtual calls — so the logger's
+ ## next access to `_buffer` (if any in flight) lands on a still-live
+ ## ref-counted buffer, not a freed one.
+ _detach_editor_logger()
+
+ _dispatcher = null
+ _log_buffer = null
+ _game_log_buffer = null
+ _editor_log_buffer = null
+ _surfaced_error_tracker = null
+
+ ## Teardown follows the immutable launch plan: keep-on-exit or an active
+ ## lease detaches; otherwise the exact owned-process grant is stopped.
+ _lifecycle.teardown_for_editor_exit()
+ _lifecycle = null
+ print("MCP | plugin unloaded")
-func _build_dock() -> void:
- _dock = VBoxContainer.new()
- _dock.name = "Godot AI"
- _label = Label.new()
- _label.text = "Preparing the signed Godot AI v4 migration…"
- _label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
- _label.add_theme_font_size_override("font_size", 15)
- _dock.add_child(_label)
- _retry = Button.new()
- _retry.text = "Retry migration"
- _retry.visible = false
- _retry.pressed.connect(_on_retry)
- _dock.add_child(_retry)
- add_control_to_dock(DOCK_SLOT_RIGHT_UL, _dock)
+func _process(_delta: float) -> void:
+ if _vision_routing != null:
+ _vision_routing.poll_completed()
-func _start_migration() -> void:
- if _bridge != null or _coordinator != null:
+## Attach editor_logger.gd as a Godot logger so editor-process script
+## errors (parse errors, @tool runtime errors, EditorPlugin errors,
+## push_error/push_warning) flow into _editor_log_buffer for
+## logs_read(source="editor").
+##
+## Limitation called out in the issue: parse errors fired *before* the
+## plugin's _enter_tree (e.g. during the editor's initial filesystem
+## scan, or for scripts that fail on first project open) happen before
+## add_logger is called and are not captured. There's no public API to
+## drain the editor's already-emitted error history; rescanning the
+## file would re-emit them but at the cost of disrupting the user's
+## editing state, so we accept the gap.
+func _attach_editor_logger() -> void:
+ _editor_logger = EditorLogger.new(_editor_log_buffer)
+ OS.add_logger(_editor_logger)
+
+
+func _detach_editor_logger() -> void:
+ if _editor_logger != null:
+ OS.remove_logger(_editor_logger)
+ _editor_logger = null
+
+
+## Register the game-side autoload on plugin enable. Runs the helper inside
+## the game process so the editor-side debugger plugin can request
+## framebuffer captures over EngineDebugger messages. Removed on
+## _disable_plugin so disabling the plugin leaves project.godot clean.
+func _enable_plugin() -> void:
+ if _mcp_disabled_for_headless_launch():
return
- _clear_bridge_status()
- _retry.visible = false
- _label.text = "Preparing the signed Godot AI v4 migration…"
- print("MCP | v3 bridge verifying and staging the signed v4 tree")
- _bridge = MigrationBridge.new()
- _bridge.state_changed.connect(_present_state)
- _bridge.prepared.connect(_activate_prepared)
- _parent_node().add_child(_bridge)
- _bridge.start()
+ _ensure_game_helper_autoload()
-func _present_state(message: String, failed: bool = false, record: bool = true) -> void:
- if failed:
- print("MCP | v3 bridge: %s" % message)
- if record:
- MigrationCoordinator.record_status(message)
- _free_bridge()
- _coordinator = null
- if _label != null:
- _label.text = message
- if _retry != null:
- _retry.visible = failed
+static func _mcp_disabled_for_headless_launch() -> bool:
+ return _mcp_disabled_for_headless(
+ OS.get_cmdline_args(),
+ DisplayServer.get_name(),
+ OS.get_environment("GODOT_AI_ALLOW_HEADLESS")
+ )
-func _present_error(message: String) -> void:
- _present_state(message, true)
-
-
-func _activate_prepared(package: Dictionary) -> void:
- _free_bridge()
- print("MCP | v3 bridge activating canonical v4 tree")
- _coordinator = MigrationCoordinator.new()
- _coordinator.state_changed.connect(_present_state)
- _parent_node().add_child(_coordinator)
- _coordinator.start(package)
-
-
-func _on_retry() -> void:
- if not _meets_floor():
- _present_state(FLOOR_MESSAGE, true, false)
- return
- if not _editor_available():
- _present_state(EDITOR_REQUIRED, true, false)
- return
- _start_migration()
-
-
-func _free_bridge() -> void:
- if _bridge != null and is_instance_valid(_bridge):
- _bridge.queue_free()
- _bridge = null
-
-
-func _parent_node() -> Node:
- var parent: Node = EditorInterface.get_base_control()
- return parent if parent != null else get_tree().root
-
-
-static func _meets_floor() -> bool:
- ## Test-only: lets a scenario exercise the refusal path on a current Godot.
- if OS.get_environment("GODOT_AI_TEST_GODOT_FLOOR") == "unmet":
+static func _mcp_disabled_for_headless(args: PackedStringArray, display_name: String, allow_value: String) -> bool:
+ if McpSettings.truthy(allow_value):
return false
- var version := Engine.get_version_info()
- return int(version.get("major", 0)) == 4 and int(version.get("minor", 0)) >= 7
-
-
-## Updates are interactive-only: a headless import or an export run loads
-## enabled plugins too, and must never swap the add-on tree or restart.
-static func _editor_available() -> bool:
- if not Engine.is_editor_hint():
- return false
- if _truthy(OS.get_environment("GODOT_AI_ALLOW_HEADLESS")):
- ## The override the v4 plugin honours too, so CI can drive the
- ## crossing in a headless editor. Never set it for a real install.
- return EditorInterface.get_base_control() != null
- if DisplayServer.get_name().to_lower() == "headless":
- return false
- if _args_request_headless(OS.get_cmdline_args()):
- return false
- return EditorInterface.get_base_control() != null
-
-
-static func _truthy(value: String) -> bool:
- var lowered := value.strip_edges().to_lower()
- return lowered == "1" or lowered == "true" or lowered == "yes" or lowered == "on"
+ return _args_request_headless(args) or display_name.to_lower() == "headless"
static func _args_request_headless(args: PackedStringArray) -> bool:
@@ -186,17 +1218,590 @@ static func _args_request_headless(args: PackedStringArray) -> bool:
return false
-static func _bridge_status() -> Dictionary:
- var settings := EditorInterface.get_editor_settings()
- var key := MigrationCoordinator.status_setting()
- if settings == null or not settings.has_setting(key):
- return {}
- var parsed: Variant = JSON.parse_string(str(settings.get_setting(key)))
- return parsed if parsed is Dictionary else {}
-static func _clear_bridge_status() -> void:
- var settings := EditorInterface.get_editor_settings()
- var key := MigrationCoordinator.status_setting()
- if settings != null and settings.has_setting(key):
- settings.erase(key)
+func _disable_plugin() -> void:
+ var key := "autoload/" + GAME_HELPER_AUTOLOAD_NAME
+ if not ProjectSettings.has_setting(key):
+ return
+ ProjectSettings.clear(key)
+ ProjectSettings.save()
+
+
+func _ensure_game_helper_autoload() -> void:
+ ## Write the autoload directly to ProjectSettings and save immediately.
+ ## EditorPlugin.add_autoload_singleton only mutates in-memory settings —
+ ## the on-disk project.godot is only persisted when the editor saves
+ ## (e.g. on quit). CI spawns the game subprocess before any save fires,
+ ## so the child process never sees the autoload and the capture times
+ ## out. Mirror AutoloadHandler's pattern: set_setting + save().
+ var key := "autoload/" + GAME_HELPER_AUTOLOAD_NAME
+ var value := "*" + GAME_HELPER_AUTOLOAD_PATH # "*" prefix = singleton
+ if ProjectSettings.get_setting(key, "") == value:
+ return ## already registered with the right target
+ ProjectSettings.set_setting(key, value)
+ ProjectSettings.set_initial_value(key, "")
+ ProjectSettings.set_as_basic(key, true)
+ var err := ProjectSettings.save()
+ if err != OK:
+ push_warning("MCP: failed to save project.godot after registering %s autoload (error %d)"
+ % [GAME_HELPER_AUTOLOAD_NAME, err])
+
+
+func _startup_trace_begin() -> void:
+ _startup_trace_enabled = ClientConfigurator.startup_trace_enabled()
+ if not _startup_trace_enabled:
+ return
+ _startup_trace_start_ms = Time.get_ticks_msec()
+ _startup_trace_last_ms = _startup_trace_start_ms
+ _startup_trace_netsh_start_count = WindowsPortReservation.netsh_query_count()
+ _startup_trace_counters.clear()
+ for counter in STARTUP_TRACE_COUNTER_NAMES:
+ _startup_trace_counters[counter] = 0
+ print(
+ "MCP startup trace | begin platform=%s http_port=%d ws_port=%d"
+ % [
+ OS.get_name(),
+ ClientConfigurator.http_port(),
+ ClientConfigurator.ws_port(),
+ ]
+ )
+
+
+func _startup_trace_count(counter: String, amount: int = 1) -> void:
+ if not _startup_trace_enabled:
+ return
+ _startup_trace_mutex.lock()
+ _startup_trace_counters[counter] = int(_startup_trace_counters.get(counter, 0)) + amount
+ _startup_trace_mutex.unlock()
+
+
+func _startup_trace_phase(name: String) -> void:
+ if not _startup_trace_enabled:
+ return
+ var now := Time.get_ticks_msec()
+ print(
+ "MCP startup trace | phase=%s delta_ms=%d total_ms=%d"
+ % [name, now - _startup_trace_last_ms, now - _startup_trace_start_ms]
+ )
+ _startup_trace_last_ms = now
+
+
+func _startup_trace_finish(path: String) -> void:
+ if not _startup_trace_enabled:
+ return
+ var now := Time.get_ticks_msec()
+ ## Same lock as _startup_trace_count — a worker probe may still be
+ ## bumping counters while this reads/writes the shared dictionary.
+ _startup_trace_mutex.lock()
+ _startup_trace_counters["netsh"] = (
+ WindowsPortReservation.netsh_query_count() - _startup_trace_netsh_start_count
+ )
+ var counters_snapshot: Dictionary = _startup_trace_counters.duplicate()
+ _startup_trace_mutex.unlock()
+ print(
+ "MCP startup trace | done path=%s total_ms=%d counters=%s"
+ % [path, now - _startup_trace_start_ms, str(counters_snapshot)]
+ )
+
+
+func _start_server() -> void:
+ if not _normal_start_released:
+ push_error("MCP | server start refused before normal startup release")
+ return
+ _lifecycle.start_server()
+
+
+func _capture_lifecycle_plan() -> Dictionary:
+ var policy := _endpoint_policy.duplicate(true)
+ var http_port := int(policy.get("http_port", ClientConfigurator.DEFAULT_HTTP_PORT))
+ var worktree_src := ""
+ if ClientConfigurator.is_dev_checkout():
+ worktree_src = ClientConfigurator.find_worktree_src_dir(
+ ProjectSettings.globalize_path("res://")
+ )
+ return {
+ "http_port": http_port,
+ "capability_path": str(policy.get("capability_path", "")),
+ "ws_port": int(policy.get("ws_port", ClientConfigurator.DEFAULT_WS_PORT)),
+ "expected_version": ClientConfigurator.get_plugin_version(),
+ "server_command": ClientConfigurator.get_server_command(),
+ "pid_file": ProjectSettings.globalize_path(PortResolver.SERVER_PID_FILE),
+ "startup_report": ProjectSettings.globalize_path(PortResolver.SERVER_STARTUP_REPORT),
+ "http_port_reserved": WindowsPortReservation.is_port_excluded(http_port),
+ "excluded_domains": str(policy.get("excluded_domains", "")),
+ "allow_hosts": str(policy.get("allow_hosts", "")),
+ "keep_alive": bool(policy.get("keep_alive", false)),
+ "worktree_src": worktree_src,
+ "ambient_pythonpath": OS.get_environment("PYTHONPATH"),
+ "disable_telemetry": not bool(policy.get("telemetry_enabled", true)),
+ "automatic_effects": true,
+ "defer_effects": true,
+ }
+
+
+static func _supports_godot_version(version_info: Dictionary) -> bool:
+ var major := int(version_info.get("major", 0))
+ var minor := int(version_info.get("minor", 0))
+ return major == MIN_GODOT_MAJOR and minor >= MIN_GODOT_MINOR
+
+
+func _on_lifecycle_snapshot_changed(snapshot: Dictionary) -> void:
+ if int(snapshot.get("episode_id", 0)) != _post_update_retry_episode or str(snapshot.get("episode_state", "")) != "BLOCKED":
+ _post_update_retry_episode = 0
+ if _connection != null and bool(snapshot.get("connection_blocked", true)):
+ _connection.connect_blocked = true
+ _connection.connect_block_reason = str(snapshot.get("message", ""))
+ _replace_server_left_by_update(snapshot)
+ _log_lifecycle_block(snapshot)
+ if _client_jobs != null:
+ _client_jobs.set_client_health_blocked(
+ ServerStateScript.blocks_client_health(
+ int(snapshot.get("state", ServerStateScript.UNINITIALIZED))
+ )
+ )
+ _publish_dock_status_snapshots()
+
+
+## The dock shows why a server start blocked; the editor log should too, once
+## per distinct reason, so a report from the field carries it.
+func _log_lifecycle_block(snapshot: Dictionary) -> void:
+ if str(snapshot.get("episode_state", "")) != "BLOCKED":
+ _last_logged_block = ""
+ return
+ var message := str(snapshot.get("message", ""))
+ if message.is_empty() or message == _last_logged_block:
+ return
+ _last_logged_block = message
+ var retry_pending := bool(_lifecycle_snapshot_for_dock().get("handoff_retry_pending", false))
+ print("MCP | %s: %s" % ["server handoff retry pending" if retry_pending else "server start blocked", message])
+
+
+## Once, right after an update: a godot-ai server at the version we just
+## replaced is on our port (typically spawned by an attach bridge that lost
+## server A during the restart). Replace it instead of asking the user to.
+## Any other conflict keeps the dock's explicit Restart Server authority.
+func _replace_server_left_by_update(snapshot: Dictionary) -> void:
+ if _post_update_replaced_version.is_empty():
+ return
+ if not bool(snapshot.get("connection_blocked", true)):
+ _post_update_replaced_version = ""
+ return
+ if not bool(snapshot.get("can_recover_incompatible", false)):
+ ## Any other block right after an update is the bridge's backend in
+ ## flight: bound but not answering yet, or bound between our probe and
+ ## our launch so our server exited on bind. Probe again shortly, a
+ ## bounded number of times; the re-probe finds that backend answering
+ ## and takes the replacement path. Then the dock's Restart Server is
+ ## the remaining path.
+ if str(snapshot.get("episode_state", "")) != "BLOCKED":
+ return
+ var episode_id := int(snapshot.get("episode_id", 0))
+ if episode_id <= 0 or episode_id == _post_update_retry_episode:
+ return
+ if str(snapshot.get("blocked_hint", "")) == ServerLifecycleManager.STALE_PRE_V4_HINT:
+ if _post_update_stale_reprobes_left <= 0:
+ return
+ if _post_update_stale_reprobes_left == POST_UPDATE_STALE_REPROBE_LIMIT:
+ print(
+ "MCP | a pre-v4 godot-ai server holds port %d; waiting for it to exit once its AI client is relaunched"
+ % int(snapshot.get("conflict_port", 0))
+ )
+ _post_update_stale_reprobes_left -= 1
+ _post_update_retry_episode = episode_id
+ get_tree().create_timer(POST_UPDATE_STALE_REPROBE_SECONDS).timeout.connect(
+ _reprobe_after_update.bind(episode_id), CONNECT_ONE_SHOT
+ )
+ return
+ if _post_update_reprobes_left > 0:
+ _post_update_reprobes_left -= 1
+ _post_update_retry_episode = episode_id
+ get_tree().create_timer(1.0).timeout.connect(_reprobe_after_update.bind(episode_id), CONNECT_ONE_SHOT)
+ return
+ var version := str(snapshot.get("conflict_version", ""))
+ if not _update_may_replace(version):
+ return
+ if _post_update_replacements_left <= 0:
+ return
+ _post_update_replacements_left -= 1
+ print(
+ "MCP | replacing the v%s server left on port %d by the update"
+ % [version, int(snapshot.get("conflict_port", 0))]
+ )
+ if not _lifecycle.request_replacement():
+ push_warning(
+ "MCP | could not replace the v%s server automatically; use Restart Server in the dock"
+ % version
+ )
+
+
+## The server the update superseded, or any older server of our major
+## version an attach bridge left on the port (a client pinned further back).
+## Never a newer one: that is another editor's server, and adoption or the
+## dock's explicit Restart Server decides there.
+func _update_may_replace(conflict_version: String) -> bool:
+ if conflict_version.is_empty():
+ return false
+ if conflict_version == _post_update_replaced_version:
+ return true
+ return McpServerVersionCheck.is_older_same_major(
+ conflict_version, ClientConfigurator.get_plugin_version()
+ )
+
+
+func _reprobe_after_update(episode_id: int) -> void:
+ if episode_id != _post_update_retry_episode:
+ return
+ _post_update_retry_episode = 0
+ if _post_update_replaced_version.is_empty() or _lifecycle == null or not _normal_start_released:
+ _publish_dock_status_snapshots()
+ return
+ var snapshot: Dictionary = _lifecycle.get_status_dict()
+ if int(snapshot.get("episode_id", 0)) != episode_id or str(snapshot.get("episode_state", "")) != "BLOCKED":
+ _publish_dock_status_snapshots()
+ return
+ _lifecycle.start_server()
+
+
+func _on_lifecycle_transport_ready(ws_port: int, ws_capability: String) -> void:
+ _set_resolved_ws_port(ws_port)
+ if _client_jobs != null:
+ _client_jobs.set_client_health_blocked(false)
+ _client_jobs.request_status_refresh(ClientConfigurator.client_ids(), true)
+ if _connection != null:
+ _connection.authorize_transport(ws_port, ws_capability)
+ _publish_dock_status_snapshots()
+
+
+func _on_lifecycle_transport_cleared(reason: String) -> void:
+ if _connection != null:
+ _connection.revoke_transport(reason)
+ _publish_dock_status_snapshots()
+
+
+## Snapshot of the server-spawn outcome for the dock.
+##
+## `state` is one of the `McpServerState.*` int constants; the dock owns
+## the UI copy per state via its own `_crash_body_for_state`. `exit_ms`
+## is only meaningful for `CRASHED`.
+func get_server_status() -> Dictionary:
+ return _lifecycle.get_status_dict()
+
+
+## Diagnostic accessor for the Dock. Positive means this lifecycle owns an
+## exact process grant; adoption always reports -1 and cannot authorize stop.
+func get_server_pid() -> int:
+ return _lifecycle.get_server_pid()
+
+
+func get_resolved_ws_port() -> int:
+ return _resolved_ws_port
+
+
+func _set_resolved_ws_port(port: int) -> void:
+ var policy := _endpoint_policy.duplicate(true)
+ if policy.is_empty():
+ policy = ClientConfigurator.capture_endpoint_policy(port)
+ policy["ws_port"] = port
+ _set_endpoint_policy(policy)
+ if _connection != null:
+ _connection.ws_port = port
+
+
+func _set_endpoint_policy(policy: Dictionary) -> void:
+ _endpoint_policy = policy.duplicate(true)
+ _resolved_ws_port = int(_endpoint_policy.get(
+ "ws_port", ClientConfigurator.DEFAULT_WS_PORT
+ ))
+ ClientConfigurator.capture_launch_context(_endpoint_policy)
+
+
+func _resolve_ws_port(configured_port: int) -> int:
+ return PortResolver.resolve_ws_port(
+ configured_port,
+ ClientConfigurator.MAX_PORT,
+ _log_buffer,
+ )
+
+
+func prepare_for_update_reload() -> Dictionary:
+ ## Refuse while frame-yielding work is still live, before stopping any
+ ## owner. These probes are non-mutating; retry after the work completes.
+ if _dispatcher != null:
+ var handlers_ready: Dictionary = _dispatcher.quiesce_for_script_swap()
+ if not bool(handlers_ready.get("ok", false)):
+ return handlers_ready
+ if _debugger_plugin != null:
+ var debugger_ready: Dictionary = _debugger_plugin.quiesce_for_script_swap()
+ if not bool(debugger_ready.get("ok", false)):
+ return debugger_ready
+ ## Stop the exact managed process only after the non-mutating probes.
+ var lifecycle_quiesced: Dictionary = _lifecycle.prepare_for_update_reload()
+ if not bool(lifecycle_quiesced.get("ok", false)):
+ return lifecycle_quiesced
+ if _vision_routing != null:
+ _vision_routing.shutdown()
+ if _dispatcher != null:
+ var quiesced: Dictionary = _dispatcher.quiesce_for_script_swap()
+ if not bool(quiesced.get("ok", false)):
+ quiesced["reload_required"] = true
+ return quiesced
+ var cleared: Dictionary = _dispatcher.clear()
+ if not bool(cleared.get("ok", false)):
+ cleared["reload_required"] = true
+ return cleared
+ return {"ok": true}
+
+
+## Refuse to start a download while an earlier update is unresolved or another
+## live editor holds this project's update lock. The lock is taken here, before
+## the download, and released by every failure path before the swap.
+func _preflight_update() -> Dictionary:
+ var pending: Dictionary = UpdateInstaller.read_pending()
+ var status := str(pending.get("status", ""))
+ if status == "swapped" or status == "repair_required":
+ return {"ok": false, "error": "an earlier update is unresolved (%s)" % status, "download_root": ""}
+ var pid := OS.get_process_id()
+ var lock: Dictionary = UpdateInstaller.acquire_lock(pid, McpPortResolver.process_fingerprint(pid))
+ if not bool(lock.get("ok", false)):
+ return {"ok": false, "error": str(lock.get("error", "")), "download_root": ""}
+ var download_root := ProjectSettings.globalize_path("user://godot_ai_update/download").trim_suffix("/")
+ if DirAccess.dir_exists_absolute(download_root):
+ _remove_tree(download_root)
+ if DirAccess.make_dir_recursive_absolute(download_root) != OK:
+ UpdateInstaller.release_lock()
+ return {"ok": false, "error": "could not create the download directory", "download_root": ""}
+ return {"ok": true, "error": "", "download_root": download_root}
+
+
+static func _remove_tree(path: String) -> void:
+ var dir := DirAccess.open(path)
+ if dir == null:
+ return
+ dir.include_hidden = true
+ for name in dir.get_directories():
+ _remove_tree(path.path_join(name))
+ for name in dir.get_files():
+ DirAccess.remove_absolute(path.path_join(name))
+ DirAccess.remove_absolute(path)
+
+
+## Verify, stage, quiesce, then hand activation to an independent runner. Every check runs in this editor
+## against the downloaded bytes; nothing outside the editor is executed. The
+## live tree is touched only by the two renames inside `swap`, and only after
+## the staged tree has been re-hashed against the signed manifest.
+## Activation runs on the main thread: verification hashes the archive,
+## staging extracts it, and the worker quiescence waits for threads. Each
+## phase names itself in the dock and yields one frame first so the label
+## repaints; without that the dock sat on "Downloading…" for seconds after
+## the download had finished, looking frozen.
+func install_downloaded_update(package: Dictionary) -> void:
+ await _present_install_phase("Verifying signed update…")
+ var manifest_bytes := FileAccess.get_file_as_bytes(str(package.get("manifest", "")))
+ var signature := FileAccess.get_file_as_bytes(str(package.get("signature", "")))
+ var verified: Dictionary = ReleaseVerifier.verify_manifest(
+ manifest_bytes,
+ signature,
+ {
+ "repository": str(package.get("repository", "")),
+ "channel": str(package.get("channel", "")),
+ "version": str(package.get("version", "")),
+ "current_version": ClientConfigurator.get_plugin_version(),
+ },
+ UpdateManager.RELEASE_SIGNING_PUBLIC_KEY_PEM,
+ )
+ var manifest: Dictionary = verified.get("manifest", {})
+ var checked: Dictionary = verified
+ if bool(verified.get("ok", false)):
+ checked = ReleaseVerifier.verify_archive(str(package.get("archive", "")), manifest)
+ if not bool(checked.get("ok", false)):
+ _fail_update("Update verification failed", "signed update refused: %s" % str(checked.get("error", "")))
+ return
+ await _present_install_phase("Staging the verified tree…")
+ var staged: Dictionary = UpdateInstaller.stage(str(package.get("archive", "")), manifest)
+ if not bool(staged.get("ok", false)):
+ _fail_update("Update staging failed", "update staging refused: %s" % str(staged.get("error", "")))
+ return
+ if _update_manager != null:
+ _update_manager.discard_downloads()
+ await _present_install_phase("Waiting for client workers…")
+ if _client_jobs != null:
+ var jobs_quiesced: Dictionary = _client_jobs.quiesce(
+ Time.get_ticks_msec() + ClientConfigurator.PREWARM_TIMEOUT_MS
+ )
+ if not bool(jobs_quiesced.get("ok", false)):
+ UpdateInstaller.discard_stage()
+ _fail_update("Update cancelled safely", "client workers refused update quiescence")
+ return
+ var script_quiesced := prepare_for_update_reload()
+ if not bool(script_quiesced.get("ok", false)):
+ UpdateInstaller.discard_stage()
+ _fail_update("Update cancelled safely", "command workers refused update quiescence")
+ if bool(script_quiesced.get("reload_required", false)):
+ _reload_plugin_after_failed_update()
+ return
+ _on_update_install_state_changed({
+ "install_in_flight": true,
+ "status_text": "Activating verified update…",
+ "button_disabled": true,
+ })
+ var to_version := str(manifest.get("version", ""))
+ var record := {
+ "from_version": ClientConfigurator.get_plugin_version(),
+ "to_version": to_version,
+ "manifest_sha256": ReleaseVerifier.sha256_bytes(manifest_bytes),
+ "expected_tree_sha256": str(staged.get("tree_sha256", "")),
+ "editor_nonce": Crypto.new().generate_random_bytes(16).hex_encode(),
+ "replace_owned_mismatches": false,
+ }
+ ## Compile an independent script with no resource path. Loading this as a
+ ## normal Script would let the filesystem scan replace our live runner.
+ var runner_script := GDScript.new()
+ runner_script.source_code = FileAccess.get_file_as_string(
+ "res://addons/godot_ai/utils/update_activation_runner.gd"
+ )
+ if runner_script.source_code.is_empty() or runner_script.reload() != OK:
+ UpdateInstaller.discard_stage()
+ _fail_update("Update cancelled safely", "could not compile the independent activation runner")
+ _reload_plugin_after_failed_update()
+ return
+ var runner = runner_script.new()
+ get_tree().root.add_child(runner)
+ if not runner.start({"stage_root": str(staged.get("stage_root", "")), "record": record}):
+ var refusal_reason := str(runner.refusal_reason)
+ runner.queue_free()
+ UpdateInstaller.discard_stage()
+ _fail_update("Update cancelled safely", refusal_reason)
+ _reload_plugin_after_failed_update()
+ return
+ ## The runner now owns the lock. Teardown's cancellation signal must not
+ ## release it before the deferred disable/drain/swap sequence completes.
+ _update_swapped = true
+ ## Return: no frame of this plugin may be suspended across source replacement.
+
+
+
+## Name the activation phase in the dock and let it repaint before the
+## phase's main-thread work begins.
+func _present_install_phase(status_text: String) -> void:
+ _on_update_install_state_changed({
+ "install_in_flight": true,
+ "status_text": status_text,
+ "button_disabled": true,
+ })
+ var tree := get_tree()
+ if tree != null:
+ await tree.process_frame
+
+
+func _fail_update(status_text: String, error: String) -> void:
+ if _update_manager != null:
+ _update_manager.discard_downloads()
+ push_error("MCP | %s" % error)
+ _on_update_install_state_changed({
+ "install_in_flight": false,
+ "status_text": status_text,
+ "button_disabled": false,
+ })
+
+
+func _reload_plugin_after_failed_update() -> void:
+ ## The signed prepared tree was aborted before mutation. Reconstruct the
+ ## unchanged old composition if any later quiescence step had already
+ ## stopped vision or released handler references. The reload frees this
+ ## plugin, so it must not run on one of this plugin's own frames: defer
+ ## the static call itself, not a method of this instance.
+ PluginReload.reload_enabled_plugin.call_deferred()
+
+
+func can_recover_incompatible_server() -> bool:
+ return _normal_start_released and _lifecycle.can_recover_incompatible_server()
+
+
+func recover_incompatible_server(_user_initiated: bool = true, _stale_version: String = "") -> bool:
+ ## The Dock click is the sole source of replacement authority. The manager
+ ## binds, spends, and discards one authorization for this exact target.
+ if not _normal_start_released:
+ return false
+ return _lifecycle.request_replacement()
+
+
+## Managed restart uses the exact owned grant. An unowned incompatible server
+## can only reach replacement through the separate explicit Dock intent.
+func force_restart_server() -> bool:
+ if not _normal_start_released:
+ return false
+ return _lifecycle.force_restart_server()
+
+
+## Developer controls use the same lifecycle owner as ordinary startup. The
+## plugin may restart only its exact process grant; a compatible external
+## server is adopted for transport but remains the launcher's responsibility.
+func restart_or_start_managed_server() -> bool:
+ if not _normal_start_released:
+ return false
+ if has_managed_server():
+ _lifecycle.force_restart_server()
+ return true
+ var port := ClientConfigurator.http_port()
+ if PortResolver.is_port_in_use(port):
+ push_warning(
+ "MCP | refusing to restart the unowned server on port %d; stop it from its launcher"
+ % port
+ )
+ return false
+ _lifecycle.start_server()
+ return true
+
+
+func stop_managed_server() -> void:
+ if has_managed_server():
+ _lifecycle.stop_server()
+
+
+func has_managed_server() -> bool:
+ ## Returns true if the plugin is currently managing a server process it spawned.
+ return _lifecycle.has_managed_server()
+
+
+func can_restart_managed_server() -> bool:
+ ## Restart needs an owned grant or a currently replaceable blocked target.
+ return _normal_start_released and _lifecycle.can_restart_managed_server()
+
+
+func _on_custom_tools_changed() -> void:
+ if _connection == null:
+ push_warning("MCP | connection isn't established")
+ return
+ var tool_list: Array[Dictionary] = []
+ ## Send all definitions plus their state: the server hides disabled tools
+ ## from fresh tools/list responses but retains a callable tombstone so a
+ ## client using a cached promoted name receives CUSTOM_TOOL_DISABLED.
+ for spec in _custom_tool_registry.all():
+ tool_list.append({
+ "name": spec.name,
+ "description": spec.description,
+ "params_schema": spec.params_schema,
+ "source": spec.source,
+ "deferred": spec.deferred,
+ "timeout_ms": spec.timeout_ms,
+ "requires_writable": spec.requires_writable,
+ "undoable": spec.undoable,
+ "promoted": spec.promoted,
+ "enabled": _custom_tool_registry.is_tool_enabled(spec.name)
+ })
+ _connection.send_event("custom_tools_changed", {"tools": tool_list})
+
+
+## On (re)connect, replay the current custom-tool catalog so tools
+## registered before the initial connection or during a disconnect
+## window reach the server. send_event silently drops while
+## _connected is false (connection.gd::_send_json), so without this
+## replay those tools only surface on the next registry mutation.
+func _on_connection_state_changed(is_open: bool) -> void:
+ if is_open:
+ _lifecycle.transport_authenticated(_connection.server_version)
+ if _custom_tool_registry != null:
+ _on_custom_tools_changed()
+ else:
+ _lifecycle.transport_lost()
diff --git a/addons/godot_ai/plugin.gd.uid b/addons/godot_ai/plugin.gd.uid
index 33cf809..4c550dd 100644
--- a/addons/godot_ai/plugin.gd.uid
+++ b/addons/godot_ai/plugin.gd.uid
@@ -1 +1 @@
-uid://b5w7nw8pfnjww
+uid://d3ui3yx6vdigl
diff --git a/addons/godot_ai/runtime/draw_recipe.gd b/addons/godot_ai/runtime/draw_recipe.gd
new file mode 100644
index 0000000..8204bd7
--- /dev/null
+++ b/addons/godot_ai/runtime/draw_recipe.gd
@@ -0,0 +1,86 @@
+@tool
+extends Control
+
+## Runtime helper attached by control_draw_recipe.
+## Reads an array of op dicts from node metadata under key "_ops" and dispatches
+## each to a CanvasItem draw call in _draw(). The ops list is set by the handler
+## via set_meta; this script is deterministic — re-setting meta + queue_redraw
+## is enough to update the visuals.
+
+const META_KEY := "_ops"
+
+
+func _ready() -> void:
+ queue_redraw()
+
+
+func _draw() -> void:
+ if not has_meta(META_KEY):
+ return
+ var ops: Variant = get_meta(META_KEY)
+ if typeof(ops) != TYPE_ARRAY:
+ return
+ for op in ops:
+ if typeof(op) != TYPE_DICTIONARY:
+ continue
+ match op.get("draw", ""):
+ "line":
+ draw_line(
+ op.from,
+ op.to,
+ op.color,
+ float(op.get("width", 1.0)),
+ bool(op.get("antialiased", false))
+ )
+ "rect":
+ # Godot warns if `width` is passed when `filled` is true —
+ # width has no effect on filled rects. Split the call so we
+ # only pass width when stroking an outline.
+ var filled := bool(op.get("filled", true))
+ if filled:
+ draw_rect(op.rect, op.color, true)
+ else:
+ draw_rect(
+ op.rect,
+ op.color,
+ false,
+ float(op.get("width", 1.0))
+ )
+ "arc":
+ draw_arc(
+ op.center,
+ float(op.radius),
+ float(op.start_angle),
+ float(op.end_angle),
+ int(op.get("point_count", 32)),
+ op.color,
+ float(op.get("width", 1.0)),
+ bool(op.get("antialiased", false))
+ )
+ "circle":
+ draw_circle(op.center, float(op.radius), op.color)
+ "polyline":
+ draw_polyline(
+ op.points,
+ op.color,
+ float(op.get("width", 1.0)),
+ bool(op.get("antialiased", false))
+ )
+ "polygon":
+ var colors: PackedColorArray = (
+ op.colors if op.has("colors") else PackedColorArray([op.color])
+ )
+ draw_polygon(op.points, colors)
+ "string":
+ var font: Font = get_theme_default_font()
+ if font == null:
+ continue
+ draw_string(
+ font,
+ op.position,
+ str(op.text),
+ int(op.get("align", HORIZONTAL_ALIGNMENT_LEFT)),
+ float(op.get("max_width", -1.0)),
+ int(op.get("font_size", 16)),
+ op.color
+ )
diff --git a/addons/godot_ai/runtime/draw_recipe.gd.uid b/addons/godot_ai/runtime/draw_recipe.gd.uid
new file mode 100644
index 0000000..5de2df2
--- /dev/null
+++ b/addons/godot_ai/runtime/draw_recipe.gd.uid
@@ -0,0 +1 @@
+uid://da3fqfqv6gtgm
diff --git a/addons/godot_ai/runtime/editor_logger.gd b/addons/godot_ai/runtime/editor_logger.gd
new file mode 100644
index 0000000..2d19daa
--- /dev/null
+++ b/addons/godot_ai/runtime/editor_logger.gd
@@ -0,0 +1,139 @@
+@tool
+extends Logger
+
+## Editor-process Logger subclass.
+##
+## NOTE: deliberately no `class_name`. Registered from plugin.gd::_enter_tree
+## so we can intercept editor-process script errors — parse errors, @tool
+## runtime errors, EditorPlugin errors, push_error/push_warning — and surface
+## them via `logs_read(source="editor")`. Without this, the LLM sees nothing
+## in `logs_read` while the same errors show in red lines in Godot's Output
+## panel.
+##
+## Why only `_log_error` and not `_log_message`:
+## `_log_message(msg, error)` covers print() and printerr(), which is the
+## firehose path — running editors print thousands of internal info lines
+## a session. The issue (#231) explicitly asks to filter so the buffer
+## isn't drowned. Errors and warnings flow through `_log_error` (parse
+## errors, push_error/push_warning, runtime errors), which is what
+## debugging callers actually need. If we discover @tool printerr() is a
+## valuable source later, _log_message can be added behind the same filter.
+##
+## Logger virtuals can be called from any thread (e.g. async script
+## loaders push parse errors off the main thread). McpEditorLogBuffer is
+## mutex-protected so we can append directly without an intermediate queue.
+
+const ADDON_PATH_MARKER := "/addons/godot_ai/"
+
+## Resolve McpLogBacktrace by path, not by the `McpLogBacktrace` class_name.
+## A bare class_name reference depends on the global class-name table being populated
+## at compile time, which isn't guaranteed on a cold editor enable mid-scan.
+## `const preload` resolves at compile time independent of the registry —
+## matches game_logger.gd's deliberate choice for the same reason.
+const _LogBacktrace := preload("res://addons/godot_ai/utils/log_backtrace.gd")
+
+## Constructor-injected so the hot path doesn't need a per-call null check.
+var _buffer
+
+
+func _init(buffer = null) -> void:
+ _buffer = buffer
+
+
+func _log_error(
+ function: String,
+ file: String,
+ line: int,
+ code: String,
+ rationale: String,
+ _editor_notify: bool,
+ error_type: int,
+ script_backtraces: Array,
+) -> void:
+ if _buffer == null:
+ return
+ ## Cheap reject for the firehose: when `file` is already non-user (the
+ ## bulk of editor-internal C++ chatter), there's no backtrace to remap
+ ## from, and the message doesn't name a project resource, the resolved
+ ## path can only stay non-user — drop without paying for resolve_error's
+ ## call frame + dict allocation.
+ var message := rationale if not rationale.is_empty() else code
+ var message_res_path := _extract_user_res_path(message)
+ if not _is_user_script(file) and script_backtraces.is_empty() and message_res_path.is_empty():
+ return
+ var resolved := _LogBacktrace.resolve_error(
+ function, file, line, code, rationale, error_type, script_backtraces,
+ )
+ if not _is_user_script(resolved.path):
+ if message_res_path.is_empty():
+ return
+ resolved.path = message_res_path
+ resolved.line = 0
+ resolved.function = function
+ _update_resolved_details(resolved)
+ if _is_in_godot_ai_addon(resolved.path):
+ return
+ if not message_res_path.is_empty() and _is_in_godot_ai_addon(message_res_path):
+ return
+ var details: Dictionary = resolved.get("details", {})
+ _buffer.append(resolved.level, resolved.message, resolved.path, resolved.line, resolved.function, details)
+
+
+static func _update_resolved_details(resolved: Dictionary) -> void:
+ var details: Dictionary = resolved.get("details", {})
+ if details.is_empty():
+ return
+ details["resolved"] = {
+ "path": resolved.get("path", ""),
+ "line": resolved.get("line", 0),
+ "function": resolved.get("function", ""),
+ }
+ resolved["details"] = details
+
+
+## Predicate broken out so tests can drive the path-filter logic without
+## constructing real Logger calls.
+static func _is_user_script(path: String) -> bool:
+ if path.is_empty():
+ return false
+ ## Match .gd / .cs (case-insensitively to handle .GD on case-insensitive
+ ## filesystems). C# scripts compile elsewhere but the parser path can
+ ## still surface .cs files for assembly load failures.
+ var lower := path.to_lower()
+ return lower.ends_with(".gd") or lower.ends_with(".cs")
+
+
+## Path-substring check works for both `res://addons/godot_ai/foo.gd` and
+## globalized absolute paths (`/Users/.../addons/godot_ai/foo.gd`) that
+## Godot can also report depending on where the error originated.
+static func _is_in_godot_ai_addon(path: String) -> bool:
+ if path.begins_with("res://addons/godot_ai/"):
+ return true
+ return path.find(ADDON_PATH_MARKER) >= 0
+
+
+## Some engine-origin errors have no ScriptBacktrace even though they are
+## project-relevant, notably ResourceLoader failures:
+## `Failed loading resource: res://does/not/exist.tres.`. Capture these by
+## extracting a named `res://` path from the message while keeping editor
+## internals and this addon's own resources filtered.
+static func _extract_user_res_path(message: String) -> String:
+ var start := message.find("res://")
+ if start < 0:
+ return ""
+ var end := message.length()
+ var quote_end := message.find("'", start)
+ if quote_end >= 0:
+ end = mini(end, quote_end)
+ quote_end = message.find("\"", start)
+ if quote_end >= 0:
+ end = mini(end, quote_end)
+ quote_end = message.find("`", start)
+ if quote_end >= 0:
+ end = mini(end, quote_end)
+ var path := message.substr(start, end - start).strip_edges()
+ while not path.is_empty() and path.substr(path.length() - 1, 1) in [".", ",", ";", ":", ")"]:
+ path = path.substr(0, path.length() - 1)
+ if path.is_empty() or _is_in_godot_ai_addon(path):
+ return ""
+ return path
diff --git a/addons/godot_ai/runtime/editor_logger.gd.uid b/addons/godot_ai/runtime/editor_logger.gd.uid
new file mode 100644
index 0000000..9db6035
--- /dev/null
+++ b/addons/godot_ai/runtime/editor_logger.gd.uid
@@ -0,0 +1 @@
+uid://cxfregddqj5b8
diff --git a/addons/godot_ai/runtime/game_helper.gd b/addons/godot_ai/runtime/game_helper.gd
new file mode 100644
index 0000000..85d9c23
--- /dev/null
+++ b/addons/godot_ai/runtime/game_helper.gd
@@ -0,0 +1,1352 @@
+extends Node
+
+## Godot AI MCP — game-process helper.
+##
+## Registered as an autoload by plugin.gd when the Godot AI plugin is enabled.
+## Runs in the running game process (separate from the editor) so the plugin
+## can request the game's framebuffer over the editor-debugger channel.
+##
+## The editor never has direct access to the game's pixels: even when "Embed
+## Game Mode" is on, the game is still a separate OS child process whose
+## window is reparented into the editor via Win32 SetParent / X11
+## XReparentWindow / macOS remote layer (Godot PR godotengine/godot#99010).
+## So viewport-texture capture on the editor side never contains game pixels.
+## This autoload solves that by replying to "mcp:take_screenshot" debug
+## messages with a PNG of Viewport.get_texture() from inside the game.
+##
+## No-ops in the editor (Engine.is_editor_hint) and silently sits idle
+## when the debugger channel is inactive (e.g. exported release builds)
+## — register_message_capture is safe to call either way, it's
+## send_message that requires an active channel.
+
+const CAPTURE_PREFIX := "mcp"
+## Cap per-frame flush so a runaway print loop can't blow the debugger's
+## packet budget in a single send. Surplus stays queued for the next frame.
+const FLUSH_BATCH_LIMIT := 200
+## How long take_screenshot waits for the game's first real presentation
+## before reading the viewport texture back. The "mcp" capture registers in
+## this autoload's _ready(), which runs BEFORE the main scene enters the tree
+## and before the renderer has presented anything — so a request arriving
+## right after mcp:hello would otherwise read back the clear-color
+## framebuffer (observed as a uniform RGB(77,77,77) PNG on GitHub's
+## GPU-less paravirtualized macOS runners, where the first present lags
+## seconds behind boot). MUST stay below the editor-side reply timer
+## (DEFAULT_TIMEOUT_SEC = 8.0 in debugger/mcp_debugger_plugin.gd) so a
+## game that genuinely can't render falls through to the existing
+## texture/image error replies before the editor gives up with its
+## generic timeout.
+const FIRST_FRAME_WAIT_SEC := 6.0
+## #777: how long the main loop can go without ticking _process before
+## _handle_take_screenshot treats it as frozen and commits a synchronous
+## stale-frame capture instead of awaiting frames that will never come.
+## A backgrounded/minimized play-in-editor game stops iterating its main
+## loop entirely, so any real threshold works; 1s keeps a merely-slow game
+## (heavy frame, low FPS) on the fresh-frame await path.
+const MAIN_LOOP_STALL_MSEC := 1000
+## How long frames_drawn can stay flat before _handle_take_screenshot treats
+## rendering as suppressed and commits the synchronous stale-frame capture.
+## On Windows, minimizing the game window freezes frame presentation but NOT
+## the main loop — _process keeps ticking, so the MAIN_LOOP_STALL_MSEC beacon
+## never trips and every capture used to burn the full FIRST_FRAME_WAIT_SEC
+## await before replying stale (issue #794 smoke, item 1b). Larger than the
+## loop threshold so a heavy-but-rendering game (~1 FPS frame gaps) stays on
+## the fresh-frame await path; a sub-0.7 FPS game that trips this still gets
+## an honestly stale-flagged image immediately instead of a 6s wait.
+const RENDER_STALL_MSEC := 1500
+
+const GameLogger := preload("res://addons/godot_ai/runtime/game_logger.gd")
+const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
+## Shared with the editor-side copy in editor_handler.gd (#716). Preload by
+## path, not class_name: this autoload runs in the game process and must not
+## depend on the editor's global-class cache being warm.
+const ScreenshotEncode := preload("res://addons/godot_ai/utils/screenshot_encode.gd")
+
+var _registered := false
+## Captures game-process print, warning, and error output for the editor.
+var _logger: Logger
+var _logger_attached := false
+## Entries drained from the logger but not yet sent over the debugger
+## channel. Holds the tail of one drain() so we can bleed it out across
+## frames at FLUSH_BATCH_LIMIT per frame rather than blasting the whole
+## queue in a single _process tick.
+var _pending_outbound: Array = []
+## #490: in-flight evals, keyed by request_id (multiple deferred game_evals
+## can run at once). Each entry: {node:Node, token:String, baseline:int}.
+## `token` names this eval's unique wrapper function so a runtime error is
+## attributed only to the eval that actually raised it — not an unrelated
+## background game error, and not a sibling overlapping eval. `baseline` is the
+## logger's script-error seq just before this eval ran. The editor's eval_check
+## probe (and #488's in-flight poll loop, when the game is focused) consult
+## these to report a runtime error that aborted execute() before the reply.
+var _inflight_evals: Dictionary = {}
+var _eval_token_counter: int = 0
+## #777: last time _process ran, in ticks msec. The debugger message capture
+## stays live while a backgrounded game's main loop is frozen, so this is how
+## _handle_take_screenshot (running inside that capture) detects the freeze
+## synchronously. -1 until the first tick.
+var _last_loop_tick_msec: int = -1
+var _mcp_runtime_process_ticks: int = 0
+var _last_debug_status_reply: Dictionary = {}
+## Rendering-freeze beacon for the Windows-minimize state (#794 smoke, 1b):
+## the frames_drawn value last observed in _process, and when it last
+## advanced. -1 until the first observed advance, so a booting or
+## render-less game (frames_drawn stuck at 0) can never read as
+## render-stalled and keeps the fresh-frame await path's error replies.
+var _last_frames_drawn_seen: int = -1
+var _last_frames_advance_msec: int = -1
+## #859: test/diagnostic seam for the synchronous eval liveness reply. The
+## debugger capture remains callable while the game loop is parked, so the
+## reply reports whether the _process beacon is actually advancing rather
+## than merely proving that the capture was registered once.
+var _last_eval_liveness_reply: Dictionary = {}
+
+
+func _ready() -> void:
+ ## Only run in the game process, not in the editor. Use is_editor_hint
+ ## — NOT OS.has_feature("editor"), which is a BUILD-config check
+ ## (TOOLS_ENABLED) and returns true in the game subprocess too because
+ ## the game is spawned with the same editor binary. is_editor_hint is
+ ## the runtime-context check: true only inside the editor GUI, false
+ ## in play-from-editor. The earlier has_feature check was causing us
+ ## to skip registration in the game and time out every capture.
+ if Engine.is_editor_hint():
+ return
+ ## Keep ticking while the tree is paused: _process both ferries game logs
+ ## and timestamps main-loop liveness for the stalled-loop screenshot
+ ## fallback (#777). A paused game still iterates its loop and renders, and
+ ## must not be misread as frozen.
+ process_mode = Node.PROCESS_MODE_ALWAYS
+ ## register_message_capture is safe to call before the debugger
+ ## handshake completes; the capture sits until a message arrives.
+ EngineDebugger.register_message_capture(CAPTURE_PREFIX, _on_debug_message)
+ _registered = true
+ ## Capture print() / printerr() / push_error() / push_warning() and
+ ## ferry them to the editor in mcp:log_batch messages flushed from
+ ## _process.
+ _logger = GameLogger.new()
+ OS.add_logger(_logger)
+ _logger_attached = true
+ ## Routed to the editor's Output panel via Godot's remote-stdout
+ ## forwarder — handy when diagnosing why capture timed out.
+ print("[godot_ai game_helper] registered mcp capture (debugger active=%s, logger=%s)"
+ % [EngineDebugger.is_active(), _logger_attached])
+ ## Boot beacon so the editor side can confirm the autoload ran even
+ ## if no screenshot was ever requested.
+ if EngineDebugger.is_active():
+ EngineDebugger.send_message("mcp:hello", [])
+
+
+func _process(_delta: float) -> void:
+ _mcp_runtime_process_ticks += 1
+ ## #777: liveness beacon for _handle_take_screenshot's stalled-loop check.
+ ## Recorded before the early returns below so the signal stays truthful
+ ## even when the logger or debugger channel is unavailable.
+ _last_loop_tick_msec = Time.get_ticks_msec()
+ ## Rendering beacon: on Windows a minimized game keeps ticking _process
+ ## while presentation stops, so frames_drawn stagnation — not loop
+ ## silence — is the observable freeze signal there (#794 smoke, 1b).
+ var frames_now := Engine.get_frames_drawn()
+ if frames_now != _last_frames_drawn_seen:
+ _last_frames_drawn_seen = frames_now
+ _last_frames_advance_msec = _last_loop_tick_msec
+ ## Drain the logger queue on the main thread (Logger virtuals can fire
+ ## from any thread; EngineDebugger.send_message is only safe from main).
+ ## Send at most one FLUSH_BATCH_LIMIT-sized batch per frame so a runaway
+ ## print loop can't stall the game by shoving thousands of entries
+ ## through the debugger packet path in a single tick. Surplus stays in
+ ## `_pending_outbound` and bleeds out across subsequent frames.
+ if not _logger_attached or _logger == null:
+ return
+ if not EngineDebugger.is_active():
+ return
+ if _pending_outbound.is_empty():
+ if not _logger.has_pending():
+ return
+ _pending_outbound = _logger.drain()
+ var batch := _pending_outbound.slice(0, FLUSH_BATCH_LIMIT)
+ _pending_outbound = _pending_outbound.slice(FLUSH_BATCH_LIMIT)
+ EngineDebugger.send_message("mcp:log_batch", [batch])
+
+
+func _exit_tree() -> void:
+ if _registered:
+ EngineDebugger.unregister_message_capture(CAPTURE_PREFIX)
+ _registered = false
+ if _logger_attached and _logger != null:
+ OS.remove_logger(_logger)
+ _logger_attached = false
+ _logger = null
+
+
+## Dispatched for messages prefixed "mcp:" on the debugger channel.
+## Godot passes the full message ("mcp:take_screenshot") to the capture
+## callable; trim defensively so tests can still call the helper with either
+## form.
+func _on_debug_message(message: String, data: Array) -> bool:
+ var action := message.trim_prefix("mcp:")
+ match action:
+ "take_screenshot":
+ _handle_take_screenshot(data)
+ return true
+ "eval_liveness":
+ _reply_eval_liveness(data)
+ return true
+ "debug_status":
+ _reply_debug_status(data)
+ return true
+ "eval":
+ _handle_eval(data)
+ return true
+ "eval_check":
+ _handle_eval_check(data)
+ return true
+ "game_command":
+ _handle_game_command(data)
+ return true
+ return false
+
+
+## #859: answer from the debugger capture without awaiting a game frame. The
+## capture can still run while a backgrounded/frozen game's main loop cannot,
+## so the _process beacon is the liveness signal that matters for game_eval.
+func _reply_eval_liveness(data: Array) -> void:
+ var request_id: String = data[0] if data.size() > 0 else ""
+ var loop_live := not _main_loop_appears_stalled()
+ _last_eval_liveness_reply = {"request_id": request_id, "loop_live": loop_live}
+ if EngineDebugger.is_active():
+ EngineDebugger.send_message("mcp:eval_liveness_response", [request_id, loop_live])
+
+
+func _debug_status_snapshot() -> Dictionary:
+ var inside_tree := is_inside_tree()
+ var tree := get_tree() if inside_tree else null
+ return {
+ "probe_version": 1,
+ "helper_found": true,
+ ## GameHelper is PROCESS_MODE_ALWAYS, so pause does not stop it; native
+ ## SceneTree suspension does. This exposes Godot's debugger-owned suspend
+ ## bit through Node::can_process() without conflating it with pause/time scale.
+ "suspended": inside_tree and not can_process(),
+ "loop_live": not _main_loop_appears_stalled(),
+ "loop_tick_msec": _last_loop_tick_msec,
+ "process_ticks": _mcp_runtime_process_ticks,
+ "tree_paused": tree.paused if tree != null else false,
+ "frames_drawn": Engine.get_frames_drawn(),
+ "process_frames": Engine.get_process_frames(),
+ "physics_frames": Engine.get_physics_frames(),
+ "time_scale": Engine.time_scale,
+ }
+
+
+func _reply_debug_status(data: Array) -> void:
+ var request_id: String = data[0] if data.size() > 0 else ""
+ var state := _debug_status_snapshot()
+ _last_debug_status_reply = {"request_id": request_id, "state": state.duplicate(true)}
+ if EngineDebugger.is_active():
+ EngineDebugger.send_message("mcp:debug_status_response", [request_id, state])
+
+
+func _handle_take_screenshot(data: Array) -> void:
+ var request_id: String = data[0] if data.size() > 0 else ""
+ var max_resolution: int = int(data[1]) if data.size() > 1 else 0
+
+ var tree := get_tree()
+ var viewport := tree.root if tree != null else null
+ if viewport == null:
+ _reply_error(request_id, "No game root viewport available")
+ return
+
+ ## #777: this function runs inside the debugger message capture, which
+ ## stays live even when a backgrounded/minimized play-in-editor game has
+ ## frozen its main loop. In that state awaiting `process_frame` parks
+ ## this coroutine forever — no reply is ever sent, and the game side
+ ## cannot self-timeout because timers need the same frozen loop. Commit a
+ ## synchronous capture of the last rendered frame instead: stale, but a
+ ## real image, flagged as such in the reply. Only fall through to the
+ ## fresh-frame awaits when the loop is demonstrably alive.
+ if _should_capture_stale_sync(
+ _main_loop_appears_stalled(),
+ _rendering_appears_stalled(),
+ tree.current_scene != null,
+ Engine.get_frames_drawn()
+ ):
+ _capture_and_reply(request_id, viewport, max_resolution, Engine.get_frames_drawn())
+ return
+
+ ## Wait (bounded — see FIRST_FRAME_WAIT_SEC) until the main scene is in
+ ## the tree and at least one frame has been drawn after this request, so
+ ## the readback never precedes the first real present. Past the deadline,
+ ## fall through anyway: current_scene stays null under a custom main
+ ## loop, and frames_drawn never advances in a render-less game — both
+ ## are handled by the texture/image error replies below.
+ var deadline := Time.get_ticks_msec() + int(FIRST_FRAME_WAIT_SEC * 1000.0)
+ while tree.current_scene == null and Time.get_ticks_msec() < deadline:
+ await tree.process_frame
+ var frames_at_request := Engine.get_frames_drawn()
+ while Engine.get_frames_drawn() <= frames_at_request and Time.get_ticks_msec() < deadline:
+ await tree.process_frame
+
+ _capture_and_reply(request_id, viewport, max_resolution, frames_at_request)
+
+
+## #777: pure decision for the synchronous stale-frame path. Sync capture is
+## only worth committing when awaiting can't produce a fresh frame — the main
+## loop is frozen (macOS/suspend), or the loop still ticks but presentation
+## is suppressed (Windows minimize, #794 smoke 1b) — AND the viewport
+## plausibly holds a real frame: the main scene is in the tree and at least
+## one frame was presented. Without those, the stale readback would be the
+## boot clear-color framebuffer — worse than the honest timeout.
+static func _should_capture_stale_sync(
+ loop_stalled: bool, render_stalled: bool, has_current_scene: bool, frames_drawn: int
+) -> bool:
+ return (loop_stalled or render_stalled) and has_current_scene and frames_drawn > 0
+
+
+## #777: true when _process hasn't ticked within MAIN_LOOP_STALL_MSEC —
+## i.e. the main loop is frozen (backgrounded window) or has never run.
+func _main_loop_appears_stalled() -> bool:
+ ## The hello→probe round trip normally follows at least one _process tick.
+ ## Treat the never-ticked sentinel as stalled defensively rather than
+ ## dispatching an eval into a game whose main loop has not started.
+ if _last_loop_tick_msec < 0:
+ return true
+ return Time.get_ticks_msec() - _last_loop_tick_msec > MAIN_LOOP_STALL_MSEC
+
+
+## True when frames_drawn has sat flat past RENDER_STALL_MSEC while _process
+## kept ticking — Windows minimize suppresses presentation without freezing
+## the loop, so the loop beacon alone misses it (#794 smoke, 1b). False until
+## the first observed frame advance: a game that has never presented has no
+## trustworthy frame to return, and must fall through to the await path's
+## texture/image error replies instead.
+func _rendering_appears_stalled() -> bool:
+ if _last_frames_advance_msec < 0:
+ return false
+ return Time.get_ticks_msec() - _last_frames_advance_msec > RENDER_STALL_MSEC
+
+
+## Read back the viewport texture and reply — fully synchronous, so it is
+## safe to call from the debugger capture while the main loop is frozen.
+## `frames_at_request` is Engine.get_frames_drawn() at request receipt: if no
+## further frame was drawn by capture time, the image predates the request
+## and the reply is flagged stale.
+func _capture_and_reply(
+ request_id: String, viewport: Viewport, max_resolution: int, frames_at_request: int
+) -> void:
+ var texture := viewport.get_texture()
+ if texture == null:
+ _reply_error(request_id, "Root viewport has no texture (headless?)")
+ return
+
+ var image := texture.get_image()
+ if image == null or image.is_empty():
+ _reply_error(request_id, "Captured an empty image from game viewport")
+ return
+
+ var encoded: Dictionary = ScreenshotEncode.downscale_and_encode(image, max_resolution)
+ var frames_drawn := Engine.get_frames_drawn()
+ var stale := frames_drawn <= frames_at_request
+
+ _last_screenshot_reply = {
+ "kind": "response",
+ "request_id": request_id,
+ "frames_drawn": frames_drawn,
+ "stale": stale,
+ "width": encoded.width,
+ "height": encoded.height,
+ }
+ if EngineDebugger.is_active():
+ ## Fields 7+8 are new in #777; older editors read the first six and
+ ## ignore the rest.
+ EngineDebugger.send_message("mcp:screenshot_response", [
+ request_id,
+ encoded.base64,
+ encoded.width,
+ encoded.height,
+ encoded.original_width,
+ encoded.original_height,
+ frames_drawn,
+ stale,
+ ])
+
+
+## Testing seam: the last screenshot reply (response or error), recorded
+## before hitting the EngineDebugger channel (inactive in the editor-side
+## test harness). Mirrors _last_eval_reply.
+var _last_screenshot_reply: Dictionary = {}
+
+
+func _reply_error(request_id: String, message: String) -> void:
+ _last_screenshot_reply = {"kind": "error", "request_id": request_id, "message": message}
+ if EngineDebugger.is_active():
+ EngineDebugger.send_message("mcp:screenshot_error", [request_id, message])
+
+
+## --- game_command: curated runtime inspection and input ---
+
+func _handle_game_command(data: Array) -> void:
+ var request_id: String = data[0] if data.size() > 0 else ""
+ var op: String = data[1] if data.size() > 1 else ""
+ var params_json: String = data[2] if data.size() > 2 else "{}"
+
+ if request_id.is_empty():
+ return
+ if op.is_empty():
+ _reply_game_command_error(request_id, "No op provided")
+ return
+
+ var json := JSON.new()
+ var parse_err := json.parse(params_json)
+ if parse_err != OK or not (json.data is Dictionary):
+ _reply_game_command_error(request_id, "Invalid params JSON")
+ return
+
+ var result: Dictionary
+ match op:
+ "get_scene_tree":
+ result = _game_get_scene_tree(json.data)
+ "get_node_info":
+ result = _game_get_node_info(json.data)
+ "get_ui_elements":
+ result = _game_get_ui_elements(json.data)
+ "input_key":
+ result = _game_input_key(json.data)
+ "input_mouse":
+ result = _game_input_mouse(json.data)
+ "input_gamepad":
+ result = _game_input_gamepad(json.data)
+ "input_action":
+ result = _game_input_action(json.data)
+ "input_state":
+ result = _game_input_state(json.data)
+ "input_sequence":
+ ## Async: steps frames and replies itself (deferred), so bail out
+ ## before the synchronous send below — same shape as the eval and
+ ## screenshot capture paths.
+ _run_input_sequence(request_id, json.data)
+ return
+ _:
+ _reply_game_command_error(request_id, "Unknown game op: %s" % op)
+ return
+
+ result["source"] = "game"
+ result["op"] = op
+ EngineDebugger.send_message("mcp:game_command_response",
+ [request_id, JSON.stringify(_variant_to_json(result))])
+
+
+func _reply_game_command_error(request_id: String, message: String) -> void:
+ EngineDebugger.send_message("mcp:game_command_error", [request_id, message])
+
+
+func _game_get_scene_tree(params: Dictionary) -> Dictionary:
+ var depth := maxi(0, int(params.get("depth", 10)))
+ var root := _resolve_runtime_node(str(params.get("root_path", "")))
+ if root == null:
+ return {"root": "", "nodes": [], "total_count": 0, "not_found": params.get("root_path", "")}
+
+ var nodes: Array[Dictionary] = []
+ _collect_runtime_nodes(root, 0, depth, nodes)
+ return {
+ "root": _runtime_path(root),
+ "nodes": nodes,
+ "total_count": nodes.size(),
+ }
+
+
+func _collect_runtime_nodes(node: Node, current_depth: int, max_depth: int, out: Array[Dictionary]) -> void:
+ out.append({
+ "name": node.name,
+ "type": node.get_class(),
+ "path": _runtime_path(node),
+ "children_count": node.get_child_count(),
+ })
+ if current_depth >= max_depth:
+ return
+ for child in node.get_children():
+ if child is Node:
+ _collect_runtime_nodes(child, current_depth + 1, max_depth, out)
+
+
+func _game_get_node_info(params: Dictionary) -> Dictionary:
+ var path := str(params.get("path", ""))
+ var node := _resolve_runtime_node(path)
+ if node == null:
+ return {"path": path, "found": false}
+
+ var info := {
+ "path": _runtime_path(node),
+ "name": node.name,
+ "type": node.get_class(),
+ "children_count": node.get_child_count(),
+ "groups": node.get_groups(),
+ "found": true,
+ }
+ if bool(params.get("include_properties", true)):
+ info["properties"] = _runtime_node_properties(node)
+ return info
+
+
+func _game_get_ui_elements(params: Dictionary) -> Dictionary:
+ var max_depth := maxi(0, int(params.get("max_depth", 10)))
+ var include_hidden := bool(params.get("include_hidden", false))
+ var include_disabled := bool(params.get("include_disabled", true))
+ var root_path := str(params.get("root_path", ""))
+ var root := _resolve_runtime_node(root_path)
+ if root == null:
+ return {"root": "", "elements": [], "total_count": 0, "not_found": root_path}
+
+ var elements: Array[Dictionary] = []
+ _collect_ui_elements(root, 0, max_depth, include_hidden, include_disabled, elements)
+ return {
+ "root": _runtime_path(root),
+ "elements": elements,
+ "total_count": elements.size(),
+ }
+
+
+func _collect_ui_elements(
+ node: Node,
+ current_depth: int,
+ max_depth: int,
+ include_hidden: bool,
+ include_disabled: bool,
+ out: Array[Dictionary]
+) -> void:
+ if node is Control:
+ var control := node as Control
+ var visible := _control_visible_in_tree(control)
+ var disabled := _control_disabled(control)
+ if (include_hidden or visible) and (include_disabled or not disabled):
+ out.append(_ui_element_info(control, visible, disabled))
+
+ if current_depth >= max_depth:
+ return
+ for child in node.get_children():
+ if child is Node:
+ _collect_ui_elements(
+ child,
+ current_depth + 1,
+ max_depth,
+ include_hidden,
+ include_disabled,
+ out
+ )
+
+
+func _ui_element_info(control: Control, visible: bool, disabled: bool) -> Dictionary:
+ var info := {
+ "path": _runtime_path(control),
+ "name": control.name,
+ "type": control.get_class(),
+ "visible": visible,
+ "disabled": disabled,
+ "rect": _variant_to_json(control.get_rect()),
+ "global_rect": _variant_to_json(control.get_global_rect()),
+ }
+ if _object_has_property(control, "text"):
+ info["text"] = str(control.get("text"))
+ return info
+
+
+func _control_disabled(control: Control) -> bool:
+ if _object_has_property(control, "disabled"):
+ return bool(control.get("disabled"))
+ return false
+
+
+func _control_visible_in_tree(control: Control) -> bool:
+ if not control.visible:
+ return false
+ var parent := control.get_parent()
+ while parent != null:
+ if parent is CanvasItem and not (parent as CanvasItem).visible:
+ return false
+ parent = parent.get_parent()
+ if Engine.is_editor_hint():
+ return true
+ return control.is_visible_in_tree()
+
+
+static var _property_name_cache: Dictionary = {}
+
+
+func _object_has_property(obj: Object, property_name: String) -> bool:
+ var key := _property_cache_key(obj)
+ if not _property_name_cache.has(key):
+ var names := {}
+ for prop in obj.get_property_list():
+ names[str(prop.get("name", ""))] = true
+ _property_name_cache[key] = names
+ return (_property_name_cache[key] as Dictionary).has(property_name)
+
+
+func _property_cache_key(obj: Object) -> String:
+ var script = obj.get_script()
+ if script == null:
+ return obj.get_class()
+ var script_id := str(script.get_instance_id())
+ if not script.resource_path.is_empty():
+ script_id = script.resource_path
+ return "%s:%s" % [obj.get_class(), script_id]
+
+
+func _runtime_node_properties(node: Node) -> Dictionary:
+ var props := {}
+ for p in node.get_property_list():
+ var name := str(p.get("name", ""))
+ var usage := int(p.get("usage", 0))
+ if name.is_empty() or (usage & PROPERTY_USAGE_EDITOR) == 0:
+ continue
+ props[name] = _variant_to_json(node.get(name))
+ return props
+
+
+func _resolve_runtime_node(path: String) -> Node:
+ var scene_root := _current_scene_root()
+ if scene_root == null:
+ return null
+ if path.is_empty() or path == "/":
+ return scene_root
+
+ if path.begins_with("/root/"):
+ return get_tree().root.get_node_or_null(path.trim_prefix("/root/"))
+
+ var scene_path := path.trim_prefix("/")
+ if scene_path == str(scene_root.name):
+ return scene_root
+ var prefix := str(scene_root.name) + "/"
+ if scene_path.begins_with(prefix):
+ scene_path = scene_path.substr(prefix.length())
+ return scene_root.get_node_or_null(scene_path)
+
+
+func _runtime_path(node: Node) -> String:
+ var scene_root := _current_scene_root()
+ if scene_root == null:
+ return str(node.get_path())
+ if node == scene_root:
+ return "/" + str(scene_root.name)
+ return "/" + str(scene_root.name) + "/" + str(scene_root.get_path_to(node))
+
+
+func _current_scene_root() -> Node:
+ var tree := get_tree()
+ if tree == null:
+ return null
+ var scene_root := tree.current_scene
+ if scene_root == null and Engine.is_editor_hint():
+ # Look the editor singleton up by name rather than referencing the bare
+ # `EditorInterface` identifier: that identifier is compiled out of export
+ # templates, so the GDScript parser rejects it ("Identifier
+ # "EditorInterface" not declared in the current scope") in an exported
+ # build even though `Engine.is_editor_hint()` would never run it there.
+ # That parse failure stops this autoload from loading in every export.
+ var editor := Engine.get_singleton(&"EditorInterface")
+ if editor:
+ scene_root = editor.get_edited_scene_root()
+ return scene_root
+
+
+func _game_input_key(params: Dictionary) -> Dictionary:
+ var key_name := str(params.get("key", ""))
+ var keycode := OS.find_keycode_from_string(key_name)
+ if keycode == KEY_NONE:
+ return {"sent": false, "error": "Unknown key: %s" % key_name}
+ var ev := InputEventKey.new()
+ ev.keycode = keycode
+ ev.physical_keycode = keycode
+ ev.pressed = bool(params.get("pressed", true))
+ ev.echo = bool(params.get("echo", false))
+ Input.parse_input_event(ev)
+ return {"sent": true, "key": key_name, "pressed": ev.pressed}
+
+
+func _game_input_mouse(params: Dictionary) -> Dictionary:
+ var event := str(params.get("event", "button"))
+ var pos_result := _resolve_mouse_position(params.get("position"))
+ if pos_result.has("error"):
+ return {"sent": false, "event": event, "error": pos_result.error}
+ var pos: Vector2 = pos_result.position
+ match event:
+ "motion":
+ var motion := InputEventMouseMotion.new()
+ motion.position = pos
+ motion.global_position = pos
+ Input.parse_input_event(motion)
+ return {"sent": true, "event": "motion", "position": _variant_to_json(pos)}
+ "button":
+ var button_event := InputEventMouseButton.new()
+ button_event.position = pos
+ button_event.global_position = pos
+ button_event.button_index = _mouse_button_index(str(params.get("button", "left")))
+ button_event.pressed = bool(params.get("pressed", true))
+ Input.parse_input_event(button_event)
+ return {
+ "sent": true,
+ "event": "button",
+ "button": params.get("button", "left"),
+ "pressed": button_event.pressed,
+ "position": _variant_to_json(pos),
+ }
+ return {"sent": false, "error": "Invalid mouse event: %s" % event}
+
+
+func _game_input_gamepad(params: Dictionary) -> Dictionary:
+ var device := int(params.get("device", 0))
+ var control := str(params.get("control", "button"))
+ match control:
+ "button":
+ var button := InputEventJoypadButton.new()
+ button.device = device
+ button.button_index = int(params.get("index", 0))
+ button.pressed = bool(params.get("pressed", true))
+ Input.parse_input_event(button)
+ return {"sent": true, "control": "button", "device": device, "index": button.button_index, "pressed": button.pressed}
+ "axis":
+ var axis := InputEventJoypadMotion.new()
+ axis.device = device
+ axis.axis = int(params.get("index", 0))
+ axis.axis_value = float(params.get("value", 0.0))
+ Input.parse_input_event(axis)
+ return {"sent": true, "control": "axis", "device": device, "index": axis.axis, "value": axis.axis_value}
+ return {"sent": false, "error": "Invalid gamepad control: %s" % control}
+
+
+func _game_input_action(params: Dictionary) -> Dictionary:
+ var action := str(params.get("action", ""))
+ if action.is_empty():
+ return {"sent": false, "error": "Missing action"}
+ if not InputMap.has_action(action):
+ return {"sent": false, "action": action, "error": "Unknown action: %s" % action}
+ var pressed := bool(params.get("pressed", true))
+ var strength := clampf(float(params.get("strength", 1.0)), 0.0, 1.0)
+ if pressed:
+ Input.action_press(action, strength)
+ else:
+ Input.action_release(action)
+ return {
+ "sent": true,
+ "action": action,
+ "pressed": pressed,
+ "strength": strength,
+ "delivery": "action_state",
+ }
+
+
+func _game_input_state(params: Dictionary) -> Dictionary:
+ var actions: Array = params.get("actions", [])
+ if actions.is_empty():
+ actions = InputMap.get_actions()
+ var states := {}
+ for action in actions:
+ var name := str(action)
+ states[name] = Input.is_action_pressed(name)
+ return {"actions": states}
+
+
+## --- input_sequence: frame-timed action timeline (deferred) ---
+##
+## Per-step round-trips can't hit a target frame — network jitter lands each
+## input on whatever frame its reply happens to arrive on, so a jump arc or a
+## timed combo is unreproducible (#814). input_sequence takes the whole
+## timeline in one call and drives it game-side, applying each step's action on
+## its scheduled frame, then replies once (deferred). Frame count (not ms) is
+## the timing basis: it's what reproduces identically across runs.
+
+## Hard caps mirrored by the server-side schema (see game handlers). The game
+## side re-checks them so a malformed direct message can't park the coroutine
+## on an unbounded await; the server rejects the same cases up front with a
+## clearer error.
+##
+## The frame cap bounds the sequence in *frames*, which is a wall-clock time
+## only at a given FPS: 600 frames is ~10s at 60fps but longer under load or on
+## a throttled runner. It is not sized to the ~30s deferred budget
+## (editor_handler.INPUT_SEQUENCE_TIMEOUT_SEC) — the two are independent
+## safeguards. If a genuinely slow run exceeds the budget, the dispatcher
+## returns a clean DEFERRED_TIMEOUT rather than hanging, so the cap can stay a
+## simple frame count.
+const MAX_SEQUENCE_STEPS := 256
+const MAX_SEQUENCE_FRAMES := 600
+
+## Testing seam: the last input_sequence reply (response or error), recorded
+## before the EngineDebugger channel (inactive in the editor-side test
+## harness). Mirrors _last_screenshot_reply / _last_eval_reply.
+var _last_game_command_reply: Dictionary = {}
+
+## Testing seam: overrides the per-frame wait in _run_input_sequence. Left
+## invalid in production (real `process_frame` awaits). A test sets it to a
+## synchronously-returning Callable so the multi-frame loop runs to completion
+## in one call — the editor test runner invokes tests synchronously and never
+## pumps `process_frame`, so a real frame-await would suspend and record zero
+## assertions. Timing itself (one frame per step) is engine-guaranteed; this
+## seam covers the scheduling/application/reply logic layered on top.
+var _frame_waiter: Callable = Callable()
+
+
+## Validate + normalize an input_sequence request. Pure (no engine state), so
+## the ordering/cap/shape rules are unit-testable without a running game.
+## Returns {"error": String} or {"steps": Array, "end_frame": int}.
+func _plan_input_sequence(params: Dictionary) -> Dictionary:
+ var raw_steps: Variant = params.get("steps", null)
+ if not (raw_steps is Array):
+ return {"error": "steps must be an array"}
+ var steps_arr: Array = raw_steps
+ if steps_arr.is_empty():
+ return {"error": "steps must not be empty"}
+ if steps_arr.size() > MAX_SEQUENCE_STEPS:
+ return {"error": "steps exceeds cap of %d (got %d)" % [MAX_SEQUENCE_STEPS, steps_arr.size()]}
+
+ ## Validate field *kinds* rather than coercing them: the server already
+ ## rejects bad shapes, but this planner is also the backstop for a
+ ## malformed direct debugger message, so it must not silently turn
+ ## pressed="false" into true or at_frame="oops" into 0. _is_number accepts
+ ## int or float (JSON round-trips whole numbers as either) but not bool or
+ ## string, so this stays consistent with the server without tripping on
+ ## JSON's number typing.
+ var settle_raw: Variant = params.get("settle_frames", 0)
+ if not _is_number(settle_raw):
+ return {"error": "settle_frames must be a number"}
+ var settle_frames := int(settle_raw)
+ if settle_frames < 0:
+ return {"error": "settle_frames must be >= 0"}
+
+ var normalized: Array = []
+ var prev_frame := -1
+ for i in steps_arr.size():
+ var raw: Variant = steps_arr[i]
+ if not (raw is Dictionary):
+ return {"error": "steps[%d] must be an object" % i}
+ var step: Dictionary = raw
+ if not (step.get("action", "") is String) or str(step.get("action", "")).is_empty():
+ return {"error": "steps[%d].action is required" % i}
+ var action: String = step["action"]
+ var at_frame_raw: Variant = step.get("at_frame", 0)
+ if not _is_number(at_frame_raw):
+ return {"error": "steps[%d].at_frame must be a number" % i}
+ var at_frame := int(at_frame_raw)
+ if at_frame < 0:
+ return {"error": "steps[%d].at_frame must be >= 0" % i}
+ if at_frame < prev_frame:
+ return {"error": "steps must be ordered by at_frame (steps[%d]=%d < previous %d)" % [i, at_frame, prev_frame]}
+ prev_frame = at_frame
+ var pressed_raw: Variant = step.get("pressed", true)
+ if not (pressed_raw is bool):
+ return {"error": "steps[%d].pressed must be a boolean" % i}
+ var strength_raw: Variant = step.get("strength", 1.0)
+ if not _is_number(strength_raw):
+ return {"error": "steps[%d].strength must be a number" % i}
+ normalized.append({
+ "at_frame": at_frame,
+ "action": action,
+ "pressed": pressed_raw,
+ "strength": clampf(float(strength_raw), 0.0, 1.0),
+ })
+
+ var end_frame: int = int(normalized[-1]["at_frame"]) + settle_frames
+ if end_frame > MAX_SEQUENCE_FRAMES:
+ return {"error": "sequence spans %d frames, exceeds cap of %d" % [end_frame, MAX_SEQUENCE_FRAMES]}
+ return {"steps": normalized, "end_frame": end_frame}
+
+
+## Async: apply each step's action on its scheduled frame, awaiting one
+## process_frame per frame, then reply (deferred). Bails out before applying
+## anything if the plan is invalid or any action is unknown to the running
+## game's InputMap — a half-applied timeline leaves inputs in an undefined
+## state, so it's all-or-nothing on the pre-checks.
+func _run_input_sequence(request_id: String, params: Dictionary) -> void:
+ var plan := _plan_input_sequence(params)
+ if plan.has("error"):
+ _reply_input_sequence_error(request_id, plan["error"])
+ return
+
+ var steps: Array = plan["steps"]
+ var end_frame: int = plan["end_frame"]
+
+ ## Resolve action names against the *game's* InputMap up front — the server
+ ## can't see it, so this is the first place unknown actions surface.
+ for step in steps:
+ if not InputMap.has_action(step["action"]):
+ _reply_input_sequence_error(request_id, "Unknown action: %s" % step["action"])
+ return
+
+ var tree := get_tree()
+ if tree == null:
+ _reply_input_sequence_error(request_id, "No SceneTree available for input sequence")
+ return
+
+ var applied: Array = []
+ var step_i := 0
+ for f in range(0, end_frame + 1):
+ while step_i < steps.size() and int(steps[step_i]["at_frame"]) == f:
+ var step: Dictionary = steps[step_i]
+ _game_input_action(step)
+ applied.append({"at_frame": f, "action": step["action"], "pressed": step["pressed"]})
+ step_i += 1
+ if f < end_frame:
+ if _frame_waiter.is_valid():
+ await _frame_waiter.call()
+ else:
+ await tree.process_frame
+
+ _reply_input_sequence_ok(request_id, {
+ "completed": true,
+ "steps_applied": applied.size(),
+ "frames_elapsed": end_frame,
+ "applied": applied,
+ "actions_pressed_at_end": _actions_pressed_at_end(steps),
+ })
+
+
+## Distinct actions the sequence touched that are still held at the end, so the
+## caller knows what it must release (a press with no matching release leaves
+## the action stuck on across the next frames).
+func _actions_pressed_at_end(steps: Array) -> Array:
+ var seen := {}
+ var pressed: Array = []
+ for step in steps:
+ var action: String = step["action"]
+ if seen.has(action):
+ continue
+ seen[action] = true
+ if Input.is_action_pressed(action):
+ pressed.append(action)
+ return pressed
+
+
+func _reply_input_sequence_ok(request_id: String, result: Dictionary) -> void:
+ result["source"] = "game"
+ result["op"] = "input_sequence"
+ _last_game_command_reply = {"kind": "response", "op": "input_sequence", "result": result}
+ if EngineDebugger.is_active():
+ EngineDebugger.send_message("mcp:game_command_response",
+ [request_id, JSON.stringify(_variant_to_json(result))])
+
+
+func _reply_input_sequence_error(request_id: String, message: String) -> void:
+ _last_game_command_reply = {"kind": "error", "op": "input_sequence", "message": message}
+ if EngineDebugger.is_active():
+ EngineDebugger.send_message("mcp:game_command_error", [request_id, message])
+
+
+## Resolve a mouse-position param. Absent (null, or an empty {}) falls back to
+## the live cursor position — a deliberate default. A present but wrong-shaped
+## value is rejected instead of silently substituting the cursor, which
+## previously hid caller bugs (#635). Accepts a {x, y} dict or an [x, y] array;
+## returns {position: Vector2} or {error: String}.
+func _resolve_mouse_position(value: Variant) -> Dictionary:
+ var viewport := get_viewport()
+ var fallback := viewport.get_mouse_position() if viewport != null else Vector2.ZERO
+ if value == null:
+ return {"position": fallback}
+ if value is Dictionary:
+ var dict: Dictionary = value
+ if dict.is_empty():
+ return {"position": fallback}
+ # A non-empty dict that carries neither coordinate is a caller mistake,
+ # not "use the default" — reject rather than silently substitute.
+ if not dict.has("x") and not dict.has("y"):
+ return {"error": "position object must have an 'x' and/or 'y' key (got keys %s)" % str(dict.keys())}
+ var x_val: Variant = dict.get("x", fallback.x)
+ var y_val: Variant = dict.get("y", fallback.y)
+ if not _is_number(x_val) or not _is_number(y_val):
+ return {"error": "position x/y must be numbers (got x=%s, y=%s)" % [type_string(typeof(x_val)), type_string(typeof(y_val))]}
+ return {"position": Vector2(float(x_val), float(y_val))}
+ if value is Array:
+ var arr: Array = value
+ if arr.size() != 2:
+ return {"error": "position array must be [x, y] (got %d elements)" % arr.size()}
+ if not _is_number(arr[0]) or not _is_number(arr[1]):
+ return {"error": "position array elements must be numbers (got [%s, %s])" % [type_string(typeof(arr[0])), type_string(typeof(arr[1]))]}
+ return {"position": Vector2(float(arr[0]), float(arr[1]))}
+ return {"error": "position must be a {x, y} object or [x, y] array (got %s)" % type_string(typeof(value))}
+
+
+func _is_number(v: Variant) -> bool:
+ return typeof(v) == TYPE_INT or typeof(v) == TYPE_FLOAT
+
+
+func _mouse_button_index(name: String) -> int:
+ match name:
+ "right":
+ return MOUSE_BUTTON_RIGHT
+ "middle":
+ return MOUSE_BUTTON_MIDDLE
+ "wheel_up":
+ return MOUSE_BUTTON_WHEEL_UP
+ "wheel_down":
+ return MOUSE_BUTTON_WHEEL_DOWN
+ return MOUSE_BUTTON_LEFT
+
+
+## --- game_eval: execute arbitrary GDScript in the running game ---
+
+## Wall-clock ceiling for a single game_eval. Evaluated code that awaits
+## something which never completes (a signal that never fires, a timer on a
+## paused tree) would otherwise pin the request open until the dispatcher's
+## 15s deferred budget / the server's 15s command timeout fires it as an
+## opaque INTERNAL_ERROR — with the temp eval Node leaked into the tree.
+## Bounding it here lets us free the node and reply with an actionable
+## message instead. See hi-godot/godot-ai#487.
+##
+## TIMEOUT ORDERING — load-bearing across three files: this value MUST stay
+## below the editor-side fallback timer in
+## `debugger/mcp_debugger_plugin.gd::request_game_eval` (`timeout_sec`,
+## default 10.0), which in turn stays below the dispatcher's `game_eval`
+## budget in `dispatcher.gd` (15000 ms). So: game 8s < editor 10s <
+## dispatcher 15s. Only this game-side guard emits the specific
+## "Eval exceeded 8s" message (both it and the editor backstop now carry the
+## EVAL_HUNG code, #518, but the editor's message can't name the cause).
+## Raise this at/above the editor timer (or drop that timer below this) and
+## the less specific editor message wins the race, silently losing the
+## diagnostic this fix exists to provide. The cross-file contract is enforced
+## by tests/unit/test_game_eval_timeout_ordering.py.
+##
+## NOTE: this catches a hung `await`, not a CPU-bound loop with no `await` —
+## a tight `while true:` with no yield blocks the main thread, so nothing
+## (including this poll) runs until it yields. That case is out of scope.
+const EVAL_TIMEOUT_SEC := 8.0
+
+
+func _handle_eval(data: Array) -> void:
+ var request_id: String = data[0] if data.size() > 0 else ""
+ var code: String = data[1] if data.size() > 1 else ""
+
+ if code.is_empty():
+ _reply_eval_error(request_id, "No code provided")
+ return
+
+ ## Wrap user code in an execute() coroutine (so it can `await` internally)
+ ## whose inner function is uniquely named per eval. A runtime error's
+ ## backtrace then carries `_mcp_run_`, letting us attribute it to
+ ## THIS eval — not an unrelated background game error, and not a sibling
+ ## overlapping eval. (#490)
+ _eval_token_counter += 1
+ var token := str(_eval_token_counter)
+ var run_fn := "_mcp_run_%s" % token
+ var script_source := (
+ "extends Node\n"
+ + "func execute():\n"
+ + "\treturn await %s()\n\n" % run_fn
+ + "func %s():\n" % run_fn
+ + _indent_eval_code(code)
+ )
+
+ ## Snapshot the logger's script-error seq BEFORE running so we only attribute
+ ## errors raised by this eval. In a debug build a parse error aborts reload()
+ ## and a runtime error aborts execute() — either way this function may never
+ ## reach its reply: the editor infers a compile error from the missing
+ ## mcp:eval_compiled beacon, and a runtime error is reported (via the
+ ## eval_check probe / the in-flight poll loop) once a logged error past this
+ ## baseline carries this eval's token.
+ var baseline: int = _logger.script_error_seq() if _logger != null else 0
+
+ var script: GDScript = GDScript.new()
+ script.source_code = script_source
+ ## #490: ack BEFORE reload(). A parse error aborts this function at reload()
+ ## without a return code in a debug build, so this is our only chance to tell
+ ## the editor "received + about to compile." The editor uses that to tell a
+ ## real parse error (acked, never compiled) apart from a message it simply
+ ## hasn't serviced yet (never acked); see mcp_debugger_plugin._on_eval_grace.
+ EngineDebugger.send_message("mcp:eval_ack", [request_id])
+ ## reload() ABORTS this function on a parse error in a debug build (it does
+ ## not return a non-OK code there), so the lines below only run when the
+ ## source compiled. Keep reload() INLINE — moving it behind a timer/await
+ ## poisons subsequent evals (#490). The err branch still matters for the
+ ## editor process (handler unit tests), where reload() does return.
+ var err: int = script.reload()
+ if err != OK:
+ _reply_eval_error(request_id,
+ "Failed to compile GDScript (error %d). Check syntax." % err)
+ return
+
+ ## Compiled OK — tell the editor so its grace timer doesn't flag a compile
+ ## error and so it begins probing for a runtime error.
+ EngineDebugger.send_message("mcp:eval_compiled", [request_id])
+
+ var temp_node := Node.new()
+ temp_node.set_script(script)
+ temp_node.process_mode = Node.PROCESS_MODE_ALWAYS
+ add_child(temp_node)
+
+ if not temp_node.has_method("execute"):
+ temp_node.queue_free()
+ _reply_eval_error(request_id, "Internal error: eval wrapper is missing execute().")
+ return
+
+ ## Register in-flight BEFORE running: a runtime error aborts execute() (and
+ ## may unwind this function) before we could record it afterward, and the
+ ## editor probe / poll loop need the entry to attribute and report the error.
+ _inflight_evals[request_id] = {"node": temp_node, "token": token, "baseline": baseline}
+
+ ## Drive execute() as a fire-and-forget coroutine that records its outcome
+ ## into `holder`, then poll frames until it finishes or the deadline passes
+ ## (#488's hung-await guard). A plain `await temp_node.execute()` has no
+ ## escape hatch: if user code never returns, we never reach the reply/cleanup
+ ## below and the request hangs with the node leaked.
+ var holder := {"done": false, "value": null, "abandoned": false}
+ _drive_eval(temp_node, holder)
+
+ var tree := get_tree()
+ var deadline_ms := int(EVAL_TIMEOUT_SEC * 1000.0)
+ var start_ms := Time.get_ticks_msec()
+ while not holder["done"] and (Time.get_ticks_msec() - start_ms) < deadline_ms:
+ ## #490 focused fast path: a runtime error aborts _drive_eval (holder
+ ## never completes), so check each frame whether THIS eval's token now
+ ## appears in a logged error and report it immediately. (Backgrounded,
+ ## this loop is frozen and the editor probe does the same job.)
+ if _try_report_eval_runtime_error(request_id):
+ holder["abandoned"] = true
+ return
+ await tree.process_frame
+
+ if not holder["done"]:
+ ## Past the 8s deadline. Disambiguate a runtime error (its token is in a
+ ## logged error) from a genuine hung await before the generic timeout.
+ holder["abandoned"] = true
+ if _try_report_eval_runtime_error(request_id):
+ return
+ _inflight_evals.erase(request_id)
+ if is_instance_valid(temp_node):
+ remove_child(temp_node)
+ _reply_eval_error(request_id,
+ ("Eval exceeded %ds and was aborted — the code likely awaits "
+ + "something that never completes (a signal that never fires, a timer on "
+ + "a paused tree) or loops forever. Check logs_read(source='game').")
+ % int(EVAL_TIMEOUT_SEC),
+ ErrorCodes.EVAL_HUNG)
+ return
+
+ ## Clean finish.
+ _inflight_evals.erase(request_id)
+ temp_node.queue_free()
+ _reply_eval_response(request_id, holder["value"])
+
+
+## Run the compiled eval node's execute() and stash the result. Kept
+## separate from _handle_eval so the latter can race it against a deadline
+## via frame polling. If the eval was abandoned (timed out) before this
+## resumes, drop the result and free the now-detached node — _handle_eval
+## has already replied.
+##
+## RESIDUAL LEAK (accepted): if the awaited thing *never* fires, this
+## coroutine never resumes, so the `node` it holds is detached (via
+## _handle_eval's remove_child) but never freed — one orphaned Node per such
+## timeout, for the game-process lifetime. GDScript has no way to cancel a
+## suspended coroutine, so this is the best achievable in-process. It is still
+## strictly better than the pre-#487 behavior, where the node leaked *into*
+## the live tree and the request hung to the 15s ceiling.
+func _drive_eval(node: Node, holder: Dictionary) -> void:
+ var value = await node.execute()
+ if holder.get("abandoned", false):
+ if is_instance_valid(node):
+ node.queue_free()
+ return
+ holder["value"] = value
+ holder["done"] = true
+
+
+## #518: cap on the serialized eval result. Godot's remote-debugger TCP peer
+## silently discards any single message over ~8 MiB, so a bigger reply never
+## reaches the editor and the request rides to the 10s backstop as a phantom
+## "hang". (Results over the editor↔server WebSocket buffer cap of 4 MiB fail
+## there with their own explicit error; this game-side cap only needs to stay
+## under the debugger peer's drop threshold to keep the failure visible.)
+const EVAL_RESULT_MAX_BYTES := 6 * 1024 * 1024
+
+## Testing seam: the last eval reply, recorded before hitting the
+## EngineDebugger channel (inactive in the editor-side test harness).
+var _last_eval_reply: Dictionary = {}
+
+
+## `code` (optional) rides as a third payload element so the editor can map
+## the reply to a specific error code instead of the generic INTERNAL_ERROR;
+## the editor allowlists the value (see mcp_debugger_plugin._on_eval_error).
+func _reply_eval_error(request_id: String, message: String, code: String = "") -> void:
+ _last_eval_reply = {"kind": "error", "request_id": request_id,
+ "message": message, "code": code}
+ var payload := [request_id, message]
+ if not code.is_empty():
+ payload.append(code)
+ if EngineDebugger.is_active():
+ EngineDebugger.send_message("mcp:eval_error", payload)
+
+
+func _reply_eval_response(request_id: String, value: Variant) -> void:
+ var serialized := JSON.stringify(_variant_to_json(value))
+ var serialized_bytes := serialized.to_utf8_buffer().size()
+ if serialized_bytes > EVAL_RESULT_MAX_BYTES:
+ _reply_eval_error(request_id,
+ ("Eval result too large to return (%d bytes serialized, limit %d). "
+ + "Return a smaller slice instead — e.g. counts, node paths, or a "
+ + "truncated substring.") % [serialized_bytes, EVAL_RESULT_MAX_BYTES],
+ ErrorCodes.EVAL_RESULT_TOO_LARGE)
+ return
+ _last_eval_reply = {"kind": "response", "request_id": request_id}
+ if EngineDebugger.is_active():
+ EngineDebugger.send_message("mcp:eval_response", [request_id, serialized])
+
+
+## #490: if a logged script error past THIS eval's baseline carries its unique
+## wrapper-function token, a runtime error aborted it before it could reply —
+## report it with the real text + line. Returns true if it reported. Called
+## from the editor's eval_check probe (the reliable path when a backgrounded
+## game's idle loop is frozen — the debugger capture callback still runs) and
+## from _handle_eval's poll loop (the focused fast path). Token + baseline
+## matching means an unrelated background error, or a sibling overlapping
+## eval's error, can never fail this request.
+func _try_report_eval_runtime_error(request_id: String) -> bool:
+ if _logger == null:
+ return false
+ var entry = _inflight_evals.get(request_id)
+ if entry == null:
+ return false
+ var text: String = _logger.find_script_error_since(
+ int(entry["baseline"]), "_mcp_run_%s" % str(entry["token"]))
+ if text.is_empty():
+ return false
+ _inflight_evals.erase(request_id)
+ var node: Node = entry["node"]
+ if node != null and is_instance_valid(node):
+ node.queue_free()
+ if EngineDebugger.is_active():
+ EngineDebugger.send_message("mcp:eval_runtime_error", [request_id, text])
+ return true
+
+
+## #490: answer an editor eval_check probe. The editor polls this once the
+## eval has compiled but not yet replied. This runs in the debugger capture
+## callback, which stays live even when the backgrounded game's _process is
+## frozen — so it's the reliable channel for reporting a runtime error that
+## aborted the eval. Report if one is detected for this request, else stay
+## silent (the editor keeps polling until the real reply or the hang timeout).
+func _handle_eval_check(data: Array) -> void:
+ var request_id: String = data[0] if data.size() > 0 else ""
+ if request_id.is_empty():
+ return
+ _try_report_eval_runtime_error(request_id)
+
+
+func _indent_eval_code(code: String) -> String:
+ var lines: PackedStringArray = code.split("\n")
+ var out := ""
+ for line in lines:
+ out += "\t" + line + "\n"
+ return out
+
+
+## Serialize any Godot Variant to a JSON-safe dictionary/array/primitive.
+## Ported from godot-mcp's mcp_interaction_server.gd.
+func _variant_to_json(value: Variant) -> Variant:
+ if value == null:
+ return null
+ if value is bool or value is int or value is float or value is String:
+ return value
+ if value is Vector2:
+ return {"x": value.x, "y": value.y}
+ if value is Vector3:
+ return {"x": value.x, "y": value.y, "z": value.z}
+ if value is Vector4:
+ return {"x": value.x, "y": value.y, "z": value.z, "w": value.w}
+ if value is Vector2i:
+ return {"x": value.x, "y": value.y}
+ if value is Vector3i:
+ return {"x": value.x, "y": value.y, "z": value.z}
+ if value is Vector4i:
+ return {"x": value.x, "y": value.y, "z": value.z, "w": value.w}
+ if value is Color:
+ return {"r": value.r, "g": value.g, "b": value.b, "a": value.a}
+ if value is Quaternion:
+ return {"x": value.x, "y": value.y, "z": value.z, "w": value.w}
+ if value is Basis:
+ return {
+ "x": _variant_to_json(value.x),
+ "y": _variant_to_json(value.y),
+ "z": _variant_to_json(value.z),
+ }
+ if value is Transform3D:
+ return {
+ "basis": _variant_to_json(value.basis),
+ "origin": _variant_to_json(value.origin),
+ }
+ if value is Transform2D:
+ return {
+ "x": _variant_to_json(value.x),
+ "y": _variant_to_json(value.y),
+ "origin": _variant_to_json(value.origin),
+ }
+ if value is Rect2:
+ return {
+ "position": _variant_to_json(value.position),
+ "size": _variant_to_json(value.size),
+ }
+ if value is Rect2i:
+ return {
+ "position": _variant_to_json(value.position),
+ "size": _variant_to_json(value.size),
+ }
+ if value is AABB:
+ return {
+ "position": _variant_to_json(value.position),
+ "size": _variant_to_json(value.size),
+ }
+ if value is NodePath or value is StringName:
+ return str(value)
+ if value is Plane:
+ return {
+ "normal": _variant_to_json(value.normal),
+ "d": value.d,
+ }
+ if value is Projection:
+ return {
+ "x": _variant_to_json(value.x),
+ "y": _variant_to_json(value.y),
+ "z": _variant_to_json(value.z),
+ "w": _variant_to_json(value.w),
+ }
+ ## Packed arrays
+ if value is PackedByteArray:
+ var arr: Array = []
+ for item in value: arr.append(item)
+ return arr
+ if value is PackedInt32Array or value is PackedInt64Array:
+ var arr: Array = []
+ for item in value: arr.append(item)
+ return arr
+ if value is PackedFloat32Array or value is PackedFloat64Array:
+ var arr: Array = []
+ for item in value: arr.append(item)
+ return arr
+ if value is PackedStringArray:
+ var arr: Array = []
+ for item in value: arr.append(item)
+ return arr
+ if value is PackedVector2Array:
+ var arr: Array = []
+ for item in value: arr.append({"x": item.x, "y": item.y})
+ return arr
+ if value is PackedVector3Array:
+ var arr: Array = []
+ for item in value: arr.append({"x": item.x, "y": item.y, "z": item.z})
+ return arr
+ if value is PackedVector4Array:
+ var arr: Array = []
+ for item in value: arr.append({"x": item.x, "y": item.y, "z": item.z, "w": item.w})
+ return arr
+ if value is PackedColorArray:
+ var arr: Array = []
+ for item in value: arr.append({"r": item.r, "g": item.g, "b": item.b, "a": item.a})
+ return arr
+ ## Generic arrays and dictionaries — recurse
+ if value is Array:
+ var arr: Array = []
+ for item in value:
+ arr.append(_variant_to_json(item))
+ return arr
+ if value is Dictionary:
+ var dict: Dictionary = {}
+ for key in value.keys():
+ dict[str(key)] = _variant_to_json(value[key])
+ return dict
+ ## Fallback: string representation
+ return str(value)
diff --git a/addons/godot_ai/runtime/game_helper.gd.uid b/addons/godot_ai/runtime/game_helper.gd.uid
new file mode 100644
index 0000000..7224a50
--- /dev/null
+++ b/addons/godot_ai/runtime/game_helper.gd.uid
@@ -0,0 +1 @@
+uid://gfybkdtsclti
diff --git a/addons/godot_ai/runtime/game_logger.gd b/addons/godot_ai/runtime/game_logger.gd
new file mode 100644
index 0000000..6384960
--- /dev/null
+++ b/addons/godot_ai/runtime/game_logger.gd
@@ -0,0 +1,142 @@
+@tool
+extends Logger
+
+## Game-process Logger subclass.
+##
+## NOTE: deliberately no `class_name`. Registered from inside the running
+## game so we can intercept print(), printerr(), push_error(), and
+## push_warning() and ferry them back to the editor over the EngineDebugger
+## channel — the same bridge PR #76 uses for screenshots.
+##
+## Logger virtuals can be called from any thread (e.g. async loaders push
+## errors off the main thread). We accumulate into _pending under a Mutex
+## and the host (game_helper.gd) flushes once per frame from the main
+## thread, where EngineDebugger.send_message is safe to call.
+
+## `McpLogBacktrace` is published as a `class_name` on log_backtrace.gd, but a
+## freshly-launched game subprocess (no prior editor scan; e.g. CI launching
+## `--headless --path`) hits this autoload before the global class_name table
+## is populated, and parsing this script fails with
+## "Identifier 'McpLogBacktrace' not declared in the current scope". Using
+## `const preload` resolves the path at parse time and is independent of the
+## class_name registry — matches the project convention in CLAUDE.md
+## ("Internals … skip class_name entirely and load via const preload").
+const _LogBacktrace := preload("res://addons/godot_ai/utils/log_backtrace.gd")
+
+var _pending: Array = []
+var _mutex := Mutex.new()
+## #490: a monotonic sequence + a small ring of recent GDScript runtime
+## (script-type) errors, each with its text AND the function names in its
+## backtrace. game_helper uses this to attribute a runtime error to the
+## *specific* eval that raised it: each eval's wrapper has a uniquely named
+## inner function, and game_helper asks find_script_error_since() whether any
+## error past its pre-eval baseline carries that function in its stack. This
+## avoids failing an eval on an unrelated background game error that merely
+## advanced a global counter, and keeps overlapping evals from cross-
+## attributing. Gated on ERROR_TYPE_SCRIPT (2) so push_error()/push_warning()
+## (types 0/1) never count. Mutex-guarded: _log_error can fire from any thread.
+const _ERROR_TYPE_SCRIPT := 2
+const _MAX_RECENT_SCRIPT_ERRORS := 64
+var _script_error_seq: int = 0
+var _recent_script_errors: Array = []
+
+
+func _log_message(message: String, error: bool) -> void:
+ ## `error` is true for printerr(), false for print().
+ var level := "error" if error else "info"
+ _append(level, message)
+
+
+func _log_error(
+ function: String,
+ file: String,
+ line: int,
+ code: String,
+ rationale: String,
+ _editor_notify: bool,
+ error_type: int,
+ script_backtraces: Array,
+) -> void:
+ ## EngineDebugger's payload shape is `[level, text]` — the source
+ ## location has nowhere structured to land for the game side, so we
+ ## inline it into `text`. editor_logger keeps the resolved fields
+ ## as structured columns instead.
+ var resolved := _LogBacktrace.resolve_error(
+ function, file, line, code, rationale, error_type, script_backtraces,
+ )
+ var loc := ""
+ if not resolved.path.is_empty():
+ loc = "%s:%d @ %s" % [resolved.path, resolved.line, resolved.function] if not resolved.function.is_empty() else "%s:%d" % [resolved.path, resolved.line]
+ var text: String = "%s (%s)" % [resolved.message, loc] if not loc.is_empty() else resolved.message
+ var details: Dictionary = resolved.get("details", {})
+ _append(resolved.level, text, details)
+ if error_type == _ERROR_TYPE_SCRIPT:
+ ## Collect every function name in the first non-empty backtrace so
+ ## game_helper can match its eval's uniquely named wrapper function.
+ var funcs := PackedStringArray()
+ for bt: RefCounted in script_backtraces:
+ if bt != null and bt.get_frame_count() > 0:
+ for i: int in bt.get_frame_count():
+ funcs.append(bt.get_frame_function(i))
+ break
+ _mutex.lock()
+ _script_error_seq += 1
+ _recent_script_errors.append({"seq": _script_error_seq, "text": text, "funcs": funcs})
+ if _recent_script_errors.size() > _MAX_RECENT_SCRIPT_ERRORS:
+ _recent_script_errors.remove_at(0)
+ _mutex.unlock()
+
+
+func _append(level: String, text: String, details: Dictionary = {}) -> void:
+ _mutex.lock()
+ if details.is_empty():
+ _pending.append([level, text])
+ else:
+ _pending.append([level, text, details.duplicate(true)])
+ _mutex.unlock()
+
+
+## Drain the pending queue and return entries as [[level, text], ...].
+## Called from the main thread by game_helper each frame.
+func drain() -> Array:
+ _mutex.lock()
+ var out := _pending
+ _pending = []
+ _mutex.unlock()
+ return out
+
+
+func has_pending() -> bool:
+ _mutex.lock()
+ var any := not _pending.is_empty()
+ _mutex.unlock()
+ return any
+
+
+## #490: monotonic count of script-type runtime errors seen this run.
+## game_helper snapshots this before an eval to use as the `since_seq`
+## baseline for find_script_error_since(). Mutex-guarded.
+func script_error_seq() -> int:
+ _mutex.lock()
+ var v := _script_error_seq
+ _mutex.unlock()
+ return v
+
+
+## #490: text of the most recent script error with seq > since_seq whose
+## backtrace includes `function_name`, or "" if none. Lets game_helper
+## attribute a runtime error to the exact eval whose uniquely named wrapper
+## function appears in the stack — ignoring unrelated game errors and errors
+## from before the eval started. Mutex-guarded.
+func find_script_error_since(since_seq: int, function_name: String) -> String:
+ _mutex.lock()
+ var found := ""
+ for i in range(_recent_script_errors.size() - 1, -1, -1):
+ var rec: Dictionary = _recent_script_errors[i]
+ if int(rec["seq"]) <= since_seq:
+ break
+ if (rec["funcs"] as PackedStringArray).has(function_name):
+ found = rec["text"]
+ break
+ _mutex.unlock()
+ return found
diff --git a/addons/godot_ai/runtime/game_logger.gd.uid b/addons/godot_ai/runtime/game_logger.gd.uid
new file mode 100644
index 0000000..d4e263e
--- /dev/null
+++ b/addons/godot_ai/runtime/game_logger.gd.uid
@@ -0,0 +1 @@
+uid://dwcs6d1y7vhqi
diff --git a/addons/godot_ai/runtime/validation_logger.gd b/addons/godot_ai/runtime/validation_logger.gd
new file mode 100644
index 0000000..672b11f
--- /dev/null
+++ b/addons/godot_ai/runtime/validation_logger.gd
@@ -0,0 +1,43 @@
+@tool
+extends Logger
+
+## Short-lived Logger used only for per-write validation loads.
+##
+## Unlike editor_logger.gd this deliberately has no addon feedback-loop filter:
+## the caller attaches it around one ResourceLoader.load() call, reads its
+## private buffer, and immediately removes it. The shared editor logger should
+## still drop these validation-load errors so logs_read(source="editor") stays
+## clean.
+
+const _LogBacktrace := preload("res://addons/godot_ai/utils/log_backtrace.gd")
+
+var _buffer
+
+
+func _init(buffer = null) -> void:
+ _buffer = buffer
+
+
+func _log_error(
+ function: String,
+ file: String,
+ line: int,
+ code: String,
+ rationale: String,
+ _editor_notify: bool,
+ error_type: int,
+ script_backtraces: Array,
+) -> void:
+ if _buffer == null:
+ return
+ var resolved := _LogBacktrace.resolve_error(
+ function,
+ file,
+ line,
+ code,
+ rationale,
+ error_type,
+ script_backtraces,
+ )
+ var details: Dictionary = resolved.get("details", {})
+ _buffer.append(resolved.level, resolved.message, resolved.path, resolved.line, resolved.function, details)
diff --git a/addons/godot_ai/runtime/validation_logger.gd.uid b/addons/godot_ai/runtime/validation_logger.gd.uid
new file mode 100644
index 0000000..4e693b4
--- /dev/null
+++ b/addons/godot_ai/runtime/validation_logger.gd.uid
@@ -0,0 +1 @@
+uid://b2ff3aot6t2l4
diff --git a/addons/godot_ai/telemetry.gd b/addons/godot_ai/telemetry.gd
new file mode 100644
index 0000000..0dfe414
--- /dev/null
+++ b/addons/godot_ai/telemetry.gd
@@ -0,0 +1,224 @@
+## Plugin-side telemetry helper.
+##
+## Relays plugin-only events (dock startup, self-update outcome, plugin
+## reload, dev-server toggle) to the Python MCP server via the existing
+## `send_event("plugin_event", {...})` channel. The server's
+## `transport/websocket.py` allowlists event names and forwards into the
+## central telemetry pipeline — meaning opt-out, endpoint, customer_uuid
+## and the bounded-queue worker stay in one place (Python), not
+## duplicated in GDScript.
+##
+## Opt-out options priority:
+## 1. `GODOT_AI_DISABLE_TELEMETRY` / `DISABLE_TELEMETRY` env vars —
+## checked first so CI / operators can force-disable without touching
+## EditorSettings.
+## 2. The `godot_ai/telemetry_enabled` EditorSetting — set through the
+## MCP dock and persisted between sessions.
+##
+## When telemetry is disabled, events are never buffered or sent. Only a
+## *truthy* env var force-disables; a falsey or absent env var falls through
+## to the EditorSetting (which defaults to enabled). See McpSettings.telemetry_enabled.
+##
+## Buffering: events recorded before the WebSocket is connected go into
+## a small bounded buffer and flush on the next `record_event` call once
+## connected. The buffer is intentionally small (`_MAX_BUFFER`); plugin
+## events are sparse, and a flood means something is misconfigured.
+
+extends RefCounted
+
+## Allowlist mirrored on the Python side in
+## `src/godot_ai/transport/websocket.py::_PLUGIN_EVENT_NAMES`. Update
+## both together.
+const _ALLOWED_EVENTS := [
+ "dock_startup",
+ "plugin_reload",
+ "self_update",
+ "dev_server_toggle",
+]
+
+## Runtime opt-out sent over the authenticated WebSocket (#913). Twinned
+## with `transport/websocket.py::TELEMETRY_OPT_OUT_EVENT`. Outside
+## `_ALLOWED_EVENTS` on purpose: that list gates events the server records,
+## and this one tells it to stop.
+const OPT_OUT_EVENT := "telemetry_opt_out"
+
+const _MAX_BUFFER := 32
+
+## EditorSetting key used to defer a ``plugin_reload`` event across the
+## disable -> enable boundary. Callers that trigger plugin reload (the
+## dock reload button, ``editor_reload_plugin`` MCP-tool path) write
+## here *before* the disable kills the live WebSocket; the new
+## plugin's ``_enter_tree`` flushes via ``flush_pending_plugin_reload``.
+const PENDING_PLUGIN_RELOAD_KEY := "godot_ai/pending_plugin_reload_event"
+
+
+## Persist a ``plugin_reload`` event so the re-enabled plugin instance
+## can emit it once its new WebSocket is up. Static so callers without
+## a telemetry instance handle (e.g. ``editor_handler.reload_plugin``)
+## can use it via the preloaded const alias.
+static func record_pending_plugin_reload(source: String) -> void:
+ var settings := EditorInterface.get_editor_settings()
+ if settings == null:
+ return
+ settings.set_setting(
+ PENDING_PLUGIN_RELOAD_KEY,
+ JSON.stringify({"source": source, "success": true}),
+ )
+
+
+## Read + clear an EditorSetting JSON-encoded event payload. Returns
+## the parsed dict, or ``null`` if the key is absent / empty /
+## malformed. Used by ``flush_pending_plugin_reload`` (below) and by
+## ``plugin.gd::_flush_pending_self_update_telemetry``. Centralising
+## the read-and-clear dance keeps both flush sites symmetric with the
+## ``record_pending_*`` writers and prevents the "key gets stuck"
+## class of bug if a future flush helper forgets the clear step.
+static func _drain_editor_setting_dict(key: String):
+ var settings := EditorInterface.get_editor_settings()
+ if settings == null:
+ return null
+ if not settings.has_setting(key):
+ return null
+ var raw := str(settings.get_setting(key))
+ settings.set_setting(key, "")
+ if raw == "":
+ return null
+ var parsed = JSON.parse_string(raw)
+ if typeof(parsed) != TYPE_DICTIONARY:
+ return null
+ return parsed
+
+var _connection
+var _disabled: bool = false
+var _pending: Array = [] # of {name: String, data: Dictionary}
+
+func _init(connection) -> void:
+ _connection = connection
+ _disabled = not McpSettings.telemetry_enabled()
+ ## Subscribe to ``connection_state_changed`` so events buffered before
+ ## the WebSocket handshake (e.g. ``record_dock_startup`` from
+ ## ``plugin._enter_tree``) actually leave the editor. Without this,
+ ## the buffer only drained on the next ``record_event`` call — when
+ ## that call never came (the common single-session case), the very
+ ## events we cared about most sat in the queue forever.
+ if _connection != null and _connection.has_signal("connection_state_changed"):
+ _connection.connection_state_changed.connect(_on_connection_state_changed)
+
+
+func record_event(name: String, data: Dictionary = {}) -> void:
+ if _disabled:
+ return
+ if not _ALLOWED_EVENTS.has(name):
+ ## Drop silently — matches the server's behavior for unknown
+ ## names, and avoids editor yellow-bar noise from third-party
+ ## callers or stale event names mid-rollout.
+ return
+ if _connection != null and _connection.is_connected:
+ _flush()
+ _send_one(name, data)
+ return
+ ## Pre-handshake: stash in a small bounded buffer. Drained on the
+ ## first ``connection_state_changed(true)`` after this point (see
+ ## ``_on_connection_state_changed``). Falling back to "drain on the
+ ## next record_event" is a footgun: the most useful plugin events
+ ## (``dock_startup``, pending ``self_update``) fire from
+ ## ``plugin._enter_tree`` before the handshake, and a single-session
+ ## editor may never emit a second event — so without the signal-
+ ## driven flush they sat buffered forever.
+ if _pending.size() >= _MAX_BUFFER:
+ _pending.pop_front()
+ _pending.append({"name": name, "data": data})
+
+
+func _on_connection_state_changed(is_open: bool) -> void:
+ if is_open:
+ ## Before the flush: a server we adopted must hear our preference
+ ## before it records anything else on this session.
+ assert_opt_out()
+ _flush()
+
+
+## Tell the connected server to stop sending telemetry, if this editor is
+## opted out (#913). Spawn-time env injection only reaches a server the
+## plugin started; this reaches one it adopted, over the same authenticated
+## WebSocket every command uses. The server latches it — off until that
+## process is replaced, never back on.
+##
+## Reads the setting live rather than the `_disabled` snapshot so the dock
+## can call this the moment the user applies a change, before the plugin
+## reload swaps this instance out. Returns whether anything was sent.
+func assert_opt_out() -> bool:
+ if McpSettings.telemetry_enabled():
+ return false
+ if _connection == null or not _connection.is_connected:
+ return false
+ return bool(_connection.send_event(OPT_OUT_EVENT, {}))
+
+func _flush() -> void:
+ if _pending.is_empty():
+ return
+ var to_send := _pending.duplicate()
+ _pending.clear()
+ for entry in to_send:
+ _send_one(entry["name"], entry["data"])
+
+func _send_one(name: String, data: Dictionary) -> void:
+ if _connection == null:
+ return
+ _connection.send_event("plugin_event", {"name": name, "data": data})
+
+# --- convenience emitters --------------------------------------------------
+
+func record_dock_startup(extra: Dictionary = {}) -> void:
+ record_event("dock_startup", extra)
+
+func record_self_update(
+ status: String,
+ from_version: String = "",
+ to_version: String = "",
+ error: String = "",
+) -> void:
+ var data := {"status": status}
+ if from_version != "":
+ data["from_version"] = from_version
+ if to_version != "":
+ data["to_version"] = to_version
+ if error != "":
+ data["error"] = error.substr(0, 200)
+ record_event("self_update", data)
+
+func record_dev_server_toggle(action: String) -> void:
+ record_event("dev_server_toggle", {"action": action})
+
+
+## Drain a pending ``plugin_reload`` event written by the previous
+## instance before it disabled itself. Pending events are currently
+## always success=true — ``record_pending_plugin_reload`` above is the
+## only writer and hardcodes it (a reload that fails never reaches the
+## flush anyway; there is no new instance to drain the key). The
+## error/success parsing below stays tolerant for forward compat with
+## a writer that records failures.
+func flush_pending_plugin_reload() -> void:
+ var parsed = _drain_editor_setting_dict(PENDING_PLUGIN_RELOAD_KEY)
+ if parsed == null:
+ return
+ var data := {
+ "success": bool(parsed.get("success", true)),
+ "source": str(parsed.get("source", "unknown")),
+ }
+ var error := str(parsed.get("error", ""))
+ if error != "":
+ data["error"] = error.substr(0, 200)
+ record_event("plugin_reload", data)
+
+# --- test seam -------------------------------------------------------------
+
+## Inject a fake connection or force the disabled flag for unit tests
+## that don't have a live WebSocket. Production code does not call this.
+func _test_set_state(connection, disabled: bool) -> void:
+ _connection = connection
+ _disabled = disabled
+ _pending.clear()
+
+func _test_pending_count() -> int:
+ return _pending.size()
diff --git a/addons/godot_ai/telemetry.gd.uid b/addons/godot_ai/telemetry.gd.uid
new file mode 100644
index 0000000..6f46be6
--- /dev/null
+++ b/addons/godot_ai/telemetry.gd.uid
@@ -0,0 +1 @@
+uid://dlul2gculiy1p
diff --git a/addons/godot_ai/testing/script_error_capture.gd b/addons/godot_ai/testing/script_error_capture.gd
new file mode 100644
index 0000000..92c53ed
--- /dev/null
+++ b/addons/godot_ai/testing/script_error_capture.gd
@@ -0,0 +1,49 @@
+@tool
+extends Logger
+
+## Captures GDScript runtime errors emitted while a test is running.
+##
+## Deliberately no class_name: this is an internal test helper.
+##
+## Only ERROR_TYPE_SCRIPT is captured. push_error(), push_warning(), and
+## engine-internal ERR_FAIL_* checks are often valid negative-path assertions and
+## should not abort the test.
+
+var _mutex := Mutex.new()
+var _capturing := false
+var _errors := PackedStringArray()
+
+
+func begin_capture() -> void:
+ _mutex.lock()
+ _capturing = true
+ _errors.clear()
+ _mutex.unlock()
+
+
+func end_capture() -> PackedStringArray:
+ _mutex.lock()
+ var captured := _errors.duplicate()
+ _capturing = false
+ _errors.clear()
+ _mutex.unlock()
+ return captured
+
+
+func _log_error(
+ function: String,
+ file: String,
+ line: int,
+ code: String,
+ rationale: String,
+ _editor_notify: bool,
+ error_type: int,
+ _script_backtraces: Array,
+) -> void:
+ if error_type != ERROR_TYPE_SCRIPT:
+ return
+ _mutex.lock()
+ if _capturing:
+ var text := rationale if not rationale.is_empty() else code
+ _errors.append("%s (%s:%d in %s)" % [text, file, line, function])
+ _mutex.unlock()
diff --git a/addons/godot_ai/testing/script_error_capture.gd.uid b/addons/godot_ai/testing/script_error_capture.gd.uid
new file mode 100644
index 0000000..6b2d712
--- /dev/null
+++ b/addons/godot_ai/testing/script_error_capture.gd.uid
@@ -0,0 +1 @@
+uid://crk8w2nei087v
diff --git a/addons/godot_ai/testing/test_runner.gd b/addons/godot_ai/testing/test_runner.gd
new file mode 100644
index 0000000..1aa0041
--- /dev/null
+++ b/addons/godot_ai/testing/test_runner.gd
@@ -0,0 +1,441 @@
+@tool
+class_name McpTestRunner
+extends RefCounted
+
+## Lightweight test runner for MCP plugin tests. Discovers test_* methods
+## on McpTestSuite instances, runs them, and collects structured results.
+
+const ScriptErrorCapture := preload("res://addons/godot_ai/testing/script_error_capture.gd")
+
+var _results: Array[Dictionary] = []
+var _last_run_ms: int = 0
+var _script_error_capture: ScriptErrorCapture = null
+var _capture_registered := false
+
+
+func _notification(what: int) -> void:
+ if what == NOTIFICATION_PREDELETE and _capture_registered and _script_error_capture != null:
+ OS.remove_logger(_script_error_capture)
+ _capture_registered = false
+
+
+func run_suite(suite: McpTestSuite, test_filter: String = "", exclude_test_filter: String = "") -> void:
+ var owns_capture := not _capture_registered
+ if owns_capture:
+ _register_capture()
+
+ _run_suite_tests(suite, test_filter, exclude_test_filter, Callable(), 0, {})
+
+ if owns_capture:
+ _unregister_capture()
+
+
+## Shared per-test loop for both the legacy synchronous path and the
+## serviced driver. Returns "" when the loop completed, or a terminal
+## outcome ("timeout" / "transport_lost" / "paused") when a checkpoint
+## aborted it. Checkpoints run BETWEEN tests — an atomic test body is
+## never preempted (see docs/test-run-transport-starvation-plan.md).
+func _run_suite_tests(
+ suite: McpTestSuite,
+ test_filter: String,
+ exclude_test_filter: String,
+ service_cb: Callable,
+ deadline_ticks_ms: int,
+ run_state: Dictionary,
+) -> String:
+ var name := suite.suite_name()
+ var methods := _get_test_methods(suite)
+ var exclusions := _parse_exclusions(exclude_test_filter)
+
+ for method_name in methods:
+ if not test_filter.is_empty() and method_name.find(test_filter) == -1:
+ continue
+ _selected_processed += 1
+ if _matches_any_exclusion(method_name, exclusions):
+ _results.append({
+ "suite": name,
+ "test": method_name,
+ "passed": true,
+ "skipped": true,
+ "message": "Excluded by exclude_test_name filter",
+ "assertion_count": 0,
+ "duration_ms": 0,
+ })
+ continue
+
+ var test_start := Time.get_ticks_msec()
+ var entry := _run_one_test(suite, name, method_name)
+ entry["duration_ms"] = Time.get_ticks_msec() - test_start
+ _results.append(entry)
+
+ var stop := _checkpoint(service_cb, deadline_ticks_ms, run_state)
+ if not stop.is_empty():
+ return stop
+ return ""
+
+
+## Execute one test method and return its result entry (not yet appended;
+## the caller stamps `duration_ms`). Extracted from run_suite so the legacy
+## and serviced drivers share one execution core — behavior must stay
+## byte-identical to the pre-refactor loop body.
+func _run_one_test(suite: McpTestSuite, name: String, method_name: String) -> Dictionary:
+ suite._reset()
+ _begin_script_error_capture()
+ suite.setup()
+ suite.call(method_name)
+ suite.teardown()
+ var script_errors := suite._unexpected_script_errors(_end_script_error_capture())
+ suite._free_tracked()
+
+ ## Issue #19 defence: free any `_McpTest*` nodes the test created, even
+ ## nested ones. If the scene gets auto-saved mid-test while one of these
+ ## exists, the reference bakes into main.tscn and breaks the next open
+ ## with a "missing dependency" error. Runs after every test, not just at
+ ## suite boundaries, so a test that fails mid-flow can't leave a trap
+ ## for the next test or for scene autosave.
+ var scene_root_for_cleanup := _edited_scene_root()
+ if scene_root_for_cleanup != null and scene_root_for_cleanup.is_inside_tree():
+ _free_mcp_test_nodes_recursive(scene_root_for_cleanup)
+
+ if not script_errors.is_empty():
+ var abort_message := "Aborted by SCRIPT ERROR: %s" % "; ".join(script_errors)
+ if suite._failed:
+ abort_message += " (after assertion failure: %s)" % suite._message
+ return {
+ "suite": name,
+ "test": method_name,
+ "passed": false,
+ "message": abort_message,
+ "assertion_count": suite._assertion_count,
+ }
+
+ ## A failed assertion always wins over a later skip(): a test that
+ ## fails and then hits a skip-guard must report the failure, not
+ ## park itself as green-skipped.
+ if suite._skipped and not suite._failed:
+ return {
+ "suite": name,
+ "test": method_name,
+ "passed": true,
+ "skipped": true,
+ "message": suite._skip_reason,
+ "assertion_count": 0,
+ }
+
+ var passed := not suite._failed
+ var msg := suite._message
+
+ ## Warn about zero-assertion tests (likely silently skipped logic).
+ if passed and suite._assertion_count == 0:
+ passed = false
+ msg = "Test completed with 0 assertions (likely skipped its logic)"
+
+ return {
+ "suite": name,
+ "test": method_name,
+ "passed": passed,
+ "message": msg,
+ "assertion_count": suite._assertion_count,
+ }
+
+
+func run_suites(suites: Array, suite_filter: String = "", test_filter: String = "", ctx: Dictionary = {}, verbose: bool = false, exclude_test_filter: String = "") -> Dictionary:
+ ## Legacy synchronous API — unchanged signature and semantics for direct
+ ## callers, unit-test fixtures, and any batch context. No servicing, no
+ ## ceiling: with an invalid Callable and deadline 0 every checkpoint is a
+ ## no-op and the outcome is always "completed".
+ var run := run_suites_serviced(suites, suite_filter, test_filter, ctx, verbose, exclude_test_filter)
+ return run["results"]
+
+
+## Serviced driver for live MCP runs. Between tests and at suite
+## boundaries it (a) aborts once `deadline_ticks_ms` passes and (b) calls
+## `service_cb` (McpConnection.service_transport_during_exclusive_run) so
+## the WebSocket heartbeat stays alive while the suite monopolizes the
+## main thread. Returns:
+## {
+## "outcome": "completed" | "timeout" | "transport_lost" | "paused",
+## "results": ,
+## "tests_not_run": ,
+## }
+## Every outcome path — including aborts — runs the current suite's
+## teardown + leak cleanup, restores console echo, and unregisters the
+## capture logger. `run_state` is caller-owned and threaded through to the
+## service callback (cumulative packet counter lives there).
+func run_suites_serviced(
+ suites: Array,
+ suite_filter: String = "",
+ test_filter: String = "",
+ ctx: Dictionary = {},
+ verbose: bool = false,
+ exclude_test_filter: String = "",
+ service_cb: Callable = Callable(),
+ deadline_ticks_ms: int = 0,
+ run_state: Dictionary = {},
+) -> Dictionary:
+ _results.clear()
+ _selected_processed = 0
+ var selected_total := _count_selected_tests(suites, suite_filter, test_filter)
+ var start := Time.get_ticks_msec()
+ var outcome := ""
+
+ ## Silence the plugin's ring-buffer console echo while tests run. Negative-
+ ## path suites deliberately fill the ring with 500 lines and log malformed-
+ ## result errors; echoing all of that buries an all-green run in scary
+ ## console output. The ring contents tests assert on are untouched, and
+ ## the flag is restored after the run so live logging resumes.
+ var _prev_console_echo := McpLogBuffer.console_echo
+ McpLogBuffer.console_echo = false
+
+ ## If a prior run was interrupted after registering the logger but before
+ ## normal teardown, remove that stale registration before starting fresh.
+ _unregister_capture()
+ _register_capture()
+
+ for suite: McpTestSuite in suites:
+ if not suite_filter.is_empty() and suite.suite_name() != suite_filter:
+ continue
+
+ ## Snapshot scene children before the suite so we can clean up leaks.
+ var scene_root := _edited_scene_root()
+ var before_children: Array[Node] = []
+ if scene_root != null:
+ before_children = _get_children_snapshot(scene_root)
+
+ suite._reset_suite_state()
+ suite.suite_setup(ctx.duplicate(true))
+
+ ## fail_setup() / skip_suite() gives suites a clean way to bail out of
+ ## suite_setup without leaving N tests to fail with "0 assertions". We
+ ## emit ONE suite-level result and skip individual tests entirely.
+ if suite._suite_failed:
+ _results.append({
+ "suite": suite.suite_name(),
+ "test": "",
+ "passed": false,
+ "message": "suite_setup() failed: %s (subsequent tests not run)" % suite._suite_failed_message,
+ "assertion_count": 0,
+ })
+ ## The bailed suite's tests are accounted for, not "not run".
+ _selected_processed += _count_selected_tests([suite], "", test_filter)
+ elif suite._suite_skipped:
+ _results.append({
+ "suite": suite.suite_name(),
+ "test": "",
+ "passed": true,
+ "skipped": true,
+ "message": "suite_setup() skipped: %s" % suite._suite_skipped_reason,
+ "assertion_count": 0,
+ })
+ _selected_processed += _count_selected_tests([suite], "", test_filter)
+ else:
+ outcome = _checkpoint(service_cb, deadline_ticks_ms, run_state)
+ if outcome.is_empty():
+ outcome = _run_suite_tests(
+ suite, test_filter, exclude_test_filter,
+ service_cb, deadline_ticks_ms, run_state
+ )
+ ## Suite epilogue runs on EVERY path, including aborts: the suite has
+ ## begun, so its teardown and leak cleanup must not be skipped.
+ suite.suite_teardown()
+ suite._free_tracked()
+
+ ## Remove any nodes the suite left behind (failed undo, missing cleanup).
+ if scene_root != null and scene_root.is_inside_tree():
+ _cleanup_leaked_nodes(scene_root, before_children)
+
+ if not outcome.is_empty():
+ break
+
+ outcome = _checkpoint(service_cb, deadline_ticks_ms, run_state)
+ if not outcome.is_empty():
+ break
+
+ _last_run_ms = Time.get_ticks_msec() - start
+ McpLogBuffer.console_echo = _prev_console_echo
+ _unregister_capture()
+ if outcome.is_empty():
+ outcome = "completed"
+ return {
+ "outcome": outcome,
+ "results": get_results(verbose),
+ "tests_not_run": maxi(0, selected_total - _selected_processed),
+ }
+
+
+## Count of selected (filter-surviving) tests already looped over this run,
+## including exclusion-skips and bailed-suite accounting. Drives the
+## `tests_not_run` estimate on aborted runs.
+var _selected_processed := 0
+
+
+func _count_selected_tests(suites: Array, suite_filter: String, test_filter: String) -> int:
+ var count := 0
+ for suite: McpTestSuite in suites:
+ if not suite_filter.is_empty() and suite.suite_name() != suite_filter:
+ continue
+ for method_name in _get_test_methods(suite):
+ if not test_filter.is_empty() and method_name.find(test_filter) == -1:
+ continue
+ count += 1
+ return count
+
+
+## Between-phase checkpoint: "" to continue, or a terminal outcome.
+## Delegates to the shared mapping so the discovery checkpoints in
+## test_handler.gd can never drift from the between-test ones (plan §9 Q2).
+func _checkpoint(service_cb: Callable, deadline_ticks_ms: int, run_state: Dictionary) -> String:
+ return McpConnection.exclusive_run_checkpoint(service_cb, deadline_ticks_ms, run_state)
+
+
+func _register_capture() -> void:
+ if _capture_registered:
+ return
+ if _script_error_capture == null:
+ _script_error_capture = ScriptErrorCapture.new()
+ if _script_error_capture == null:
+ return
+ OS.add_logger(_script_error_capture)
+ _capture_registered = true
+
+
+func _unregister_capture() -> void:
+ if not _capture_registered:
+ return
+ if _script_error_capture == null:
+ _capture_registered = false
+ return
+ OS.remove_logger(_script_error_capture)
+ _capture_registered = false
+
+
+func _begin_script_error_capture() -> void:
+ if _script_error_capture != null and _capture_registered:
+ _script_error_capture.begin_capture()
+
+
+func _end_script_error_capture() -> PackedStringArray:
+ if _script_error_capture == null or not _capture_registered:
+ return PackedStringArray()
+ return _script_error_capture.end_capture()
+
+
+static func _edited_scene_root() -> Node:
+ if not Engine.is_editor_hint():
+ return null
+ return EditorInterface.get_edited_scene_root()
+
+
+func get_results(verbose: bool = false) -> Dictionary:
+ var passed := 0
+ var failed := 0
+ var skipped := 0
+ var failures: Array[Dictionary] = []
+ var suites_seen := {}
+ for r in _results:
+ suites_seen[r.suite] = true
+ if r.get("skipped", false):
+ skipped += 1
+ elif r.passed:
+ passed += 1
+ else:
+ failed += 1
+ failures.append(r)
+
+ var result := {
+ "passed": passed,
+ "failed": failed,
+ "skipped": skipped,
+ "total": _results.size(),
+ "duration_ms": _last_run_ms,
+ "suites_run": suites_seen.keys(),
+ "suite_count": suites_seen.size(),
+ }
+
+ if not failures.is_empty():
+ result["failures"] = failures
+
+ if verbose:
+ result["results"] = _results
+
+ return result
+
+
+func clear() -> void:
+ _results.clear()
+ _last_run_ms = 0
+
+
+func _get_test_methods(obj: Object) -> Array[String]:
+ var methods: Array[String] = []
+ for m in obj.get_method_list():
+ var name: String = m.get("name", "")
+ if name.begins_with("test_"):
+ methods.append(name)
+ methods.sort()
+ return methods
+
+
+func _get_children_snapshot(node: Node) -> Array[Node]:
+ var children: Array[Node] = []
+ for child in node.get_children():
+ children.append(child)
+ return children
+
+
+## Remove any nodes in scene_root that weren't present before the suite ran,
+## plus any _McpTest* named nodes anywhere in the tree (catches nested leaks).
+## NOTE: this bypasses EditorUndoRedoManager by design — the test runner
+## owns these leaks and needs to clear them unconditionally. Don't Ctrl-Z in
+## the editor immediately after a test run that triggered cleanup; the undo
+## stack may reference freed nodes.
+func _cleanup_leaked_nodes(scene_root: Node, before: Array[Node]) -> void:
+ var before_set := {}
+ for n in before:
+ before_set[n] = true
+ for child in scene_root.get_children():
+ if not before_set.has(child):
+ scene_root.remove_child(child)
+ child.queue_free()
+
+
+## Recursively free every node whose name starts with `_McpTest`, anywhere in
+## the scene. Intentionally bypasses undo — these are test leaks, not user
+## work. Walk breadth-first so we can collect victims before mutating the tree.
+func _free_mcp_test_nodes_recursive(root: Node) -> void:
+ var victims: Array[Node] = []
+ var queue: Array[Node] = [root]
+ while not queue.is_empty():
+ var node: Node = queue.pop_back()
+ for child in node.get_children():
+ if str(child.name).begins_with("_McpTest"):
+ victims.append(child)
+ else:
+ queue.append(child)
+ for v in victims:
+ if v.get_parent() != null:
+ v.get_parent().remove_child(v)
+ v.queue_free()
+
+
+## Split the `exclude_test_name` filter into individual substring matchers.
+## Comma-separated so the CI smoke harness can list multiple flaky tests
+## without shipping a richer schema (single names still work — same string,
+## no comma, same one-element list). Whitespace around each name is stripped
+## so `"a, b"` and `"a,b"` behave identically.
+static func _parse_exclusions(filter: String) -> Array[String]:
+ var out: Array[String] = []
+ if filter.is_empty():
+ return out
+ for part in filter.split(","):
+ var trimmed := part.strip_edges()
+ if not trimmed.is_empty():
+ out.append(trimmed)
+ return out
+
+
+static func _matches_any_exclusion(method_name: String, exclusions: Array[String]) -> bool:
+ for ex in exclusions:
+ if method_name.find(ex) != -1:
+ return true
+ return false
diff --git a/addons/godot_ai/testing/test_runner.gd.uid b/addons/godot_ai/testing/test_runner.gd.uid
new file mode 100644
index 0000000..c0befb0
--- /dev/null
+++ b/addons/godot_ai/testing/test_runner.gd.uid
@@ -0,0 +1 @@
+uid://367b77qh5grt
diff --git a/addons/godot_ai/testing/test_suite.gd b/addons/godot_ai/testing/test_suite.gd
new file mode 100644
index 0000000..e5e7adb
--- /dev/null
+++ b/addons/godot_ai/testing/test_suite.gd
@@ -0,0 +1,302 @@
+@tool
+class_name McpTestSuite
+extends RefCounted
+
+## Base class for MCP test suites. Provides assertion methods and
+## lifecycle hooks. Subclass this, add test_* methods, and drop the
+## script in res://tests/.
+
+## Override to return a short name for this suite (e.g. "scene", "node").
+func suite_name() -> String:
+ return "unnamed"
+
+
+## Called once before the suite runs. Override to create handlers.
+func suite_setup(_ctx: Dictionary) -> void:
+ pass
+
+
+## Called before each test method.
+func setup() -> void:
+ pass
+
+
+## Called after each test method.
+func teardown() -> void:
+ pass
+
+
+## Called once after the suite finishes.
+func suite_teardown() -> void:
+ pass
+
+
+# ----- tracked allocations (freed by the runner after each test) -----
+
+var _tracked_objects: Array[Object] = []
+
+
+## Register a manually-managed Object (plain Object or out-of-tree Node) so
+## the runner frees it after the current test. RefCounted instances are
+## accepted but ignored because they manage their own lifetime.
+func track(obj: Object) -> Object:
+ if obj != null and not obj is RefCounted:
+ _tracked_objects.append(obj)
+ return obj
+
+
+## Free everything registered via track(). Called by the runner after each
+## test's teardown() and again after suite_teardown().
+func _free_tracked() -> void:
+ for obj in _tracked_objects:
+ if not is_instance_valid(obj) or (obj is Node and obj.is_queued_for_deletion()):
+ continue
+ if obj is Node:
+ var parent := (obj as Node).get_parent()
+ if parent != null:
+ parent.remove_child(obj)
+ obj.free()
+ _tracked_objects.clear()
+
+
+# ----- assertion state (managed by McpTestRunner) -----
+
+var _failed: bool = false
+var _message: String = ""
+var _assertion_count: int = 0
+var _skipped: bool = false
+var _skip_reason: String = ""
+var _expected_script_error_substrings: Array[String] = []
+
+# ----- suite-level state (managed by McpTestRunner) -----
+
+var _suite_failed: bool = false
+var _suite_failed_message: String = ""
+var _suite_skipped: bool = false
+var _suite_skipped_reason: String = ""
+
+
+func _reset() -> void:
+ _failed = false
+ _message = ""
+ _assertion_count = 0
+ _skipped = false
+ _skip_reason = ""
+ _expected_script_error_substrings.clear()
+
+
+func _reset_suite_state() -> void:
+ _suite_failed = false
+ _suite_failed_message = ""
+ _suite_skipped = false
+ _suite_skipped_reason = ""
+
+
+## Mark the current test as skipped. Use when a precondition isn't met
+## (e.g. no scene open, no Node3D in scene) and the test can't run.
+## Skipped tests count separately from passed/failed.
+func skip(reason: String = "") -> void:
+ _skipped = true
+ _skip_reason = reason
+
+
+## Bail out of suite_setup() with a failure. Subsequent tests in this suite
+## are not run; the runner reports a single suite-level failure with the
+## given reason instead of N zero-assertion noise lines per test.
+##
+## Example:
+## func suite_setup(ctx):
+## var arena = preload("res://game/arena.gd").new()
+## if arena == null:
+## fail_setup("arena.gd failed to instantiate in @tool scope")
+## return
+func fail_setup(reason: String) -> void:
+ _suite_failed = true
+ _suite_failed_message = reason
+
+
+## Bail out of suite_setup() because a precondition isn't met (no scene open,
+## no game running, etc.). Subsequent tests are not run and the runner emits
+## a single suite-level skip rather than per-test skip noise.
+func skip_suite(reason: String) -> void:
+ _suite_skipped = true
+ _suite_skipped_reason = reason
+
+
+## Allow one captured SCRIPT ERROR whose text contains `substring`.
+## Use only for negative-path tests that intentionally compile or execute
+## invalid GDScript and assert on the resulting diagnostics.
+func expect_script_error_containing(substring: String) -> void:
+ _expected_script_error_substrings.append(substring)
+
+
+func _unexpected_script_errors(captured: PackedStringArray) -> PackedStringArray:
+ var unexpected := PackedStringArray()
+ var remaining := _expected_script_error_substrings.duplicate()
+ for error in captured:
+ var matched_index := -1
+ for i in range(remaining.size()):
+ if error.find(remaining[i]) != -1:
+ matched_index = i
+ break
+ if matched_index == -1:
+ unexpected.append(error)
+ else:
+ remaining.remove_at(matched_index)
+ return unexpected
+
+
+## Trigger an undo against whichever history (scene or global) holds the most
+## recent action. `EditorUndoRedoManager` in Godot 4.x doesn't expose `.undo()`
+## directly — you resolve the history's underlying UndoRedo and call it there.
+## Actions registered via `add_do_method(self, …)` with a non-scene target land
+## in GLOBAL_HISTORY, while actions on scene nodes land in the scene's history,
+## so we try both (matches the pattern in batch_handler.gd).
+func editor_undo(undo_redo: EditorUndoRedoManager) -> bool:
+ for ur in _collect_histories(undo_redo):
+ if ur.undo():
+ return true
+ return false
+
+
+## Mirror of `editor_undo` for redo.
+func editor_redo(undo_redo: EditorUndoRedoManager) -> bool:
+ for ur in _collect_histories(undo_redo):
+ if ur.redo():
+ return true
+ return false
+
+
+func _collect_histories(undo_redo: EditorUndoRedoManager) -> Array:
+ var out: Array = []
+ if undo_redo == null:
+ return out
+ var scene_root := EditorInterface.get_edited_scene_root()
+ if scene_root != null:
+ var scene_id := undo_redo.get_object_history_id(scene_root)
+ var scene_ur := undo_redo.get_history_undo_redo(scene_id)
+ if scene_ur != null:
+ out.append(scene_ur)
+ var global_ur := undo_redo.get_history_undo_redo(EditorUndoRedoManager.GLOBAL_HISTORY)
+ if global_ur != null and not global_ur in out:
+ out.append(global_ur)
+ return out
+
+
+# ----- assertions -----
+
+func assert_true(condition: bool, msg: String = "") -> void:
+ _assertion_count += 1
+ if _failed:
+ return
+ if not condition:
+ _failed = true
+ _message = msg if msg else "Expected true"
+
+
+func assert_false(condition: bool, msg: String = "") -> void:
+ _assertion_count += 1
+ if _failed:
+ return
+ if condition:
+ _failed = true
+ _message = msg if msg else "Expected false"
+
+
+func assert_eq(actual: Variant, expected: Variant, msg: String = "") -> void:
+ _assertion_count += 1
+ if _failed:
+ return
+ if actual != expected:
+ _failed = true
+ _message = msg if msg else "Expected %s, got %s" % [str(expected), str(actual)]
+
+
+func assert_ne(actual: Variant, not_expected: Variant, msg: String = "") -> void:
+ _assertion_count += 1
+ if _failed:
+ return
+ if actual == not_expected:
+ _failed = true
+ _message = msg if msg else "Expected value != %s" % str(not_expected)
+
+
+func assert_gt(actual: Variant, threshold: Variant, msg: String = "") -> void:
+ _assertion_count += 1
+ if _failed:
+ return
+ if not (actual > threshold):
+ _failed = true
+ _message = msg if msg else "Expected %s > %s" % [str(actual), str(threshold)]
+
+
+func assert_has_key(dict: Variant, key: String, msg: String = "") -> void:
+ _assertion_count += 1
+ if _failed:
+ return
+ if not dict is Dictionary:
+ _failed = true
+ _message = msg if msg else "Expected Dictionary, got %s" % type_string(typeof(dict))
+ return
+ if not dict.has(key):
+ _failed = true
+ _message = msg if msg else "Missing key: %s (keys: %s)" % [key, str(dict.keys())]
+
+
+func assert_contains(haystack: Variant, needle: Variant, msg: String = "") -> void:
+ _assertion_count += 1
+ if _failed:
+ return
+ if haystack is String:
+ if haystack.find(str(needle)) == -1:
+ _failed = true
+ _message = msg if msg else "'%s' not found in '%s'" % [str(needle), haystack]
+ elif haystack is Array:
+ if not haystack.has(needle):
+ _failed = true
+ _message = msg if msg else "%s not found in array" % str(needle)
+ else:
+ _failed = true
+ _message = msg if msg else "assert_contains requires String or Array"
+
+
+func assert_is_error(result: Dictionary, expected_code: String = "", msg: String = "") -> void:
+ _assertion_count += 1
+ if _failed:
+ return
+ if not result.has("error"):
+ _failed = true
+ _message = msg if msg else "Expected error response, got: %s" % str(result.keys())
+ return
+ if expected_code and result.error.get("code", "") != expected_code:
+ _failed = true
+ _message = msg if msg else "Expected error code %s, got %s" % [expected_code, result.error.get("code", "")]
+
+
+# ----- scene helpers (shared across suites that create/remove Controls) -----
+
+## Add a Control under the scene root. Creates a Panel if ctl is null.
+## Returns the scene path, or "" when no scene is open — in which case a
+## caller-supplied ctl is freed to prevent leaks.
+func _add_control(ctl_name: String, ctl: Control = null) -> String:
+ var scene_root := EditorInterface.get_edited_scene_root()
+ if scene_root == null:
+ if ctl != null:
+ ctl.queue_free()
+ return ""
+ if ctl == null:
+ ctl = Panel.new()
+ ctl.name = ctl_name
+ scene_root.add_child(ctl)
+ ctl.owner = scene_root
+ return "/" + scene_root.name + "/" + ctl_name
+
+
+func _remove_control(path: String) -> void:
+ var scene_root := EditorInterface.get_edited_scene_root()
+ if scene_root == null:
+ return
+ var node := McpScenePath.resolve(path, scene_root)
+ if node != null:
+ node.get_parent().remove_child(node)
+ node.queue_free()
diff --git a/addons/godot_ai/testing/test_suite.gd.uid b/addons/godot_ai/testing/test_suite.gd.uid
new file mode 100644
index 0000000..b75e726
--- /dev/null
+++ b/addons/godot_ai/testing/test_suite.gd.uid
@@ -0,0 +1 @@
+uid://dlrq2s7jhp71s
diff --git a/addons/godot_ai/tool_catalog.gd b/addons/godot_ai/tool_catalog.gd
new file mode 100644
index 0000000..d1e8b4a
--- /dev/null
+++ b/addons/godot_ai/tool_catalog.gd
@@ -0,0 +1,110 @@
+@tool
+class_name McpToolCatalog
+extends RefCounted
+
+## Mirror of src/godot_ai/tools/domains.py — drives the dock's Tools tab
+## so the UI can render checkboxes, tool counts, and tooltips without
+## round-tripping to a running server.
+##
+## DO NOT EDIT by hand. tests/unit/test_tool_domains.py verifies this file
+## against actual tool registration and fails CI when they drift; the
+## failure message prints the up-to-date catalog body for paste-over.
+##
+## The four core tools are always registered and cannot be excluded — they
+## render as a single grayed-out "Core" row in the UI. Each non-core domain
+## now exposes one or two named verbs plus a single rolled-up
+## `_manage` tool.
+
+const CORE_TOOLS := [
+ "editor_state",
+ "node_get_properties",
+ "scene_get_hierarchy",
+ "session_activate",
+]
+
+## Non-core tools that live in a NON-excludable domain (only `session`
+## today), so they appear in no DOMAINS row yet are always registered.
+## Counted alongside CORE_TOOLS so the dock's totals match the real
+## server surface.
+const ALWAYS_ON_TOOLS := [
+ "session_manage",
+]
+
+## Ordered list of user-toggleable domains. Each entry:
+## id: matches the name passed to `--exclude-domains`
+## label: human-friendly display (same as id for now, kept separate so
+## a future renaming doesn't break the setting)
+## count: number of NON-CORE tools in this domain
+## tools: flat list of tool names registered by this domain (non-core only)
+const DOMAINS := [
+ {"id": "animation", "label": "animation", "count": 2, "tools": ["animation_create", "animation_manage"]},
+ {"id": "api", "label": "api", "count": 1, "tools": ["api_manage"]},
+ {"id": "audio", "label": "audio", "count": 1, "tools": ["audio_manage"]},
+ {"id": "autoload", "label": "autoload", "count": 1, "tools": ["autoload_manage"]},
+ {"id": "batch", "label": "batch", "count": 1, "tools": ["batch_execute"]},
+ {"id": "camera", "label": "camera", "count": 1, "tools": ["camera_manage"]},
+ {"id": "client", "label": "client", "count": 1, "tools": ["client_manage"]},
+ {"id": "custom", "label": "custom", "count": 1, "tools": ["custom_manage"]},
+ {"id": "editor", "label": "editor", "count": 4, "tools": ["editor_manage", "editor_reload_plugin", "editor_screenshot", "logs_read"]},
+ {"id": "filesystem", "label": "filesystem", "count": 1, "tools": ["filesystem_manage"]},
+ {"id": "game", "label": "game", "count": 1, "tools": ["game_manage"]},
+ {"id": "input_map", "label": "input_map", "count": 1, "tools": ["input_map_manage"]},
+ {"id": "material", "label": "material", "count": 1, "tools": ["material_manage"]},
+ {"id": "node", "label": "node", "count": 4, "tools": ["node_create", "node_find", "node_manage", "node_set_property"]},
+ {"id": "particle", "label": "particle", "count": 1, "tools": ["particle_manage"]},
+ {"id": "project", "label": "project", "count": 2, "tools": ["project_manage", "project_run"]},
+ {"id": "resource", "label": "resource", "count": 1, "tools": ["resource_manage"]},
+ {"id": "scene", "label": "scene", "count": 3, "tools": ["scene_manage", "scene_open", "scene_save"]},
+ {"id": "script", "label": "script", "count": 4, "tools": ["script_attach", "script_create", "script_manage", "script_patch"]},
+ {"id": "signal", "label": "signal", "count": 1, "tools": ["signal_manage"]},
+ {"id": "testing", "label": "testing", "count": 2, "tools": ["test_manage", "test_run"]},
+ {"id": "theme", "label": "theme", "count": 1, "tools": ["theme_manage"]},
+ {"id": "tilemap", "label": "tilemap", "count": 1, "tools": ["tilemap_manage"]},
+ {"id": "tileset", "label": "tileset", "count": 1, "tools": ["tileset_manage"]},
+ {"id": "gridmap", "label": "gridmap", "count": 1, "tools": ["gridmap_manage"]},
+ {"id": "csg", "label": "csg", "count": 1, "tools": ["csg_manage"]},
+ {"id": "ui", "label": "ui", "count": 1, "tools": ["ui_manage"]},
+]
+
+
+## Whether `id` is a real, excludable domain in this plugin version. Used to
+## drop stale names (e.g. a domain removed since the setting was written) so
+## they never reach the server's `--exclude-domains`, whose `parse_exclude_list`
+## hard-fails on unknown names.
+static func is_excludable_domain(id: String) -> bool:
+ for d in DOMAINS:
+ if d["id"] == id:
+ return true
+ return false
+
+
+## Total tool count when no domains are excluded. Used for the "Enabled: N / M"
+## readout in the Tools tab without looping the catalog on every repaint.
+static func total_tool_count() -> int:
+ var n := CORE_TOOLS.size() + ALWAYS_ON_TOOLS.size()
+ for d in DOMAINS:
+ n += int(d["count"])
+ return n
+
+
+## Tool count remaining after excluding the given set of domain ids.
+static func enabled_tool_count(excluded: PackedStringArray) -> int:
+ var n := CORE_TOOLS.size() + ALWAYS_ON_TOOLS.size()
+ for d in DOMAINS:
+ if excluded.find(d["id"]) == -1:
+ n += int(d["count"])
+ return n
+
+
+## Canonical comma-separated string for a set of domain ids — sorted and
+## deduplicated so two equivalent settings (entered in different orders)
+## hash to the same EditorSetting value. Matches `excluded_domains()` in
+## client_configurator.gd.
+static func canonical(excluded: PackedStringArray) -> String:
+ var seen := PackedStringArray()
+ for e in excluded:
+ var t := e.strip_edges()
+ if not t.is_empty() and seen.find(t) == -1:
+ seen.append(t)
+ seen.sort()
+ return ",".join(seen)
diff --git a/addons/godot_ai/tool_catalog.gd.uid b/addons/godot_ai/tool_catalog.gd.uid
new file mode 100644
index 0000000..c0c179b
--- /dev/null
+++ b/addons/godot_ai/tool_catalog.gd.uid
@@ -0,0 +1 @@
+uid://d1vqyt4uyo378
diff --git a/addons/godot_ai/utils/allow_hosts.gd b/addons/godot_ai/utils/allow_hosts.gd
new file mode 100644
index 0000000..a34c964
--- /dev/null
+++ b/addons/godot_ai/utils/allow_hosts.gd
@@ -0,0 +1,170 @@
+@tool
+class_name McpAllowHosts
+extends RefCounted
+
+## Client-side helpers for the `--allow-host` LAN opt-in (#507, server core
+## in #421). Pure static functions only — no EditorSettings, no sockets —
+## so the settings-value → launch-args plumbing and the manual-command LAN
+## URL builder are deterministically testable without a live editor.
+##
+## Accepted syntax mirrors the server's `parse_allow_hosts`
+## (src/godot_ai/transport/origin_guard.py): each token is a bare IP
+## (IPv4 or IPv6) or a CIDR, comma-separated. Host bits set on a CIDR are
+## tolerated server-side (`strict=False`), so we only validate the IP part
+## and the prefix length here — anything else fails loudly at server
+## startup, which the dock-side validation exists to pre-empt.
+
+
+## Canonicalize a comma-separated allow-host value: whitespace-stripped,
+## deduplicated, sorted. Returns "" for a value with no usable tokens so
+## callers can skip appending `--allow-host` entirely (keeps spawns
+## compatible with pre-#421 servers — same contract as
+## `ClientConfigurator.excluded_domains()`).
+static func normalize(raw: String) -> String:
+ var parts := PackedStringArray()
+ for p in raw.split(","):
+ var t := p.strip_edges()
+ if not t.is_empty() and parts.find(t) == -1:
+ parts.append(t)
+ parts.sort()
+ return ",".join(parts)
+
+
+## Whether a single token is a bare IP or a CIDR the server will accept.
+static func token_is_valid(token: String) -> bool:
+ var t := token.strip_edges()
+ if t.is_empty():
+ return false
+ var ip := t
+ var prefix := 0
+ ## Track slash presence separately from the prefix value: reusing -1 as
+ ## the "no slash" sentinel let an explicit negative prefix like
+ ## "10.0.0.0/-1" validate (is_valid_int accepts "-1"). CodeRabbit review.
+ var has_prefix := false
+ var slash := t.find("/")
+ if slash != -1:
+ ip = t.substr(0, slash)
+ var prefix_text := t.substr(slash + 1)
+ ## Explicit signs are rejected by the server's parse_allow_hosts
+ ## (ipaddress refuses "10.0.0.0/+8"), but is_valid_int accepts
+ ## them — keep the mirror honest.
+ if prefix_text.is_empty() or prefix_text.begins_with("+") or prefix_text.begins_with("-"):
+ return false
+ if not prefix_text.is_valid_int():
+ return false
+ prefix = int(prefix_text)
+ has_prefix = true
+ if not ip.is_valid_ip_address():
+ return false
+ var max_prefix := 128 if ip.contains(":") else 32
+ return not has_prefix or (prefix >= 0 and prefix <= max_prefix)
+
+
+## Every token in `raw` that fails `token_is_valid` — the dock surfaces
+## these inline so a typo is caught before it aborts the server spawn.
+static func invalid_tokens(raw: String) -> PackedStringArray:
+ var bad := PackedStringArray()
+ for p in raw.split(","):
+ var t := p.strip_edges()
+ if t.is_empty():
+ continue
+ if not token_is_valid(t) and bad.find(t) == -1:
+ bad.append(t)
+ return bad
+
+
+## True when the allowlist names at least one non-loopback range — i.e.
+## the server is actually reachable off this machine and the manual
+## command should surface a LAN URL.
+static func is_lan_allowlist_active(value: String) -> bool:
+ for p in value.split(","):
+ var t := p.strip_edges()
+ if t.is_empty():
+ continue
+ var ip := t.substr(0, t.find("/")) if t.contains("/") else t
+ if not _is_loopback(ip):
+ return true
+ return false
+
+
+## Choose the LAN address to show in the manual command from the
+## machine's local addresses (caller passes `IP.get_local_addresses()`
+## so this stays pure). Loopback and link-local addresses are dropped;
+## the first private-range IPv4 wins, then any remaining IPv4, then
+## anything left (IPv6). Returns `{"address": String, "ambiguous": bool}`
+## — `ambiguous` flags multiple viable candidates so the note can tell
+## the user to pick the interface on their trusted network.
+static func pick_lan_address(addresses: PackedStringArray) -> Dictionary:
+ var candidates := PackedStringArray()
+ for a in addresses:
+ var addr := String(a).strip_edges()
+ if addr.is_empty() or _is_loopback(addr) or _is_link_local(addr):
+ continue
+ candidates.append(addr)
+ if candidates.is_empty():
+ return {"address": "", "ambiguous": false}
+ var chosen := ""
+ for addr in candidates:
+ if _is_private_ipv4(addr):
+ chosen = addr
+ break
+ if chosen.is_empty():
+ for addr in candidates:
+ if addr.contains("."):
+ chosen = addr
+ break
+ if chosen.is_empty():
+ chosen = candidates[0]
+ return {"address": chosen, "ambiguous": candidates.size() > 1}
+
+
+## Informational LAN-URL note appended to the manual command when the
+## allowlist is active (#507). Never changes what gets WRITTEN to client
+## configs — loopback stays the write target; this is copy-paste help for
+## pointing a remote agent at the right address.
+static func lan_url_note(allow_hosts_value: String, addresses: PackedStringArray, http_port: int) -> String:
+ if not is_lan_allowlist_active(allow_hosts_value):
+ return ""
+ var pick := pick_lan_address(addresses)
+ var addr := String(pick.get("address", ""))
+ if addr.is_empty():
+ return (
+ "LAN access is enabled (--allow-host %s), but no LAN address was detected on this machine."
+ % allow_hosts_value
+ )
+ var host := "[%s]" % addr if addr.contains(":") else addr
+ var note := (
+ "LAN access is enabled (--allow-host %s). Remote agents on the allowed network can use: http://%s:%d/mcp"
+ % [allow_hosts_value, host, http_port]
+ )
+ if bool(pick.get("ambiguous", false)):
+ note += "\n(multiple network interfaces detected — pick the address on the network you allowed)"
+ return note
+
+
+static func _is_loopback(addr: String) -> bool:
+ var a := addr.to_lower()
+ return a.begins_with("127.") or a == "::1" or a == "localhost"
+
+
+static func _is_link_local(addr: String) -> bool:
+ var a := addr.to_lower()
+ if a.begins_with("169.254."):
+ return true
+ ## IPv6 link-local is fe80::/10 — the whole fe80-febf first hextet, not
+ ## just literal "fe80" (Copilot review on #507's PR: fea0::... etc. must
+ ## also be excluded from LAN-URL candidates).
+ if a.length() >= 4 and a.begins_with("fe") and a[2] in "89ab":
+ return true
+ return false
+
+
+static func _is_private_ipv4(addr: String) -> bool:
+ if not addr.contains("."):
+ return false
+ if addr.begins_with("10.") or addr.begins_with("192.168."):
+ return true
+ if addr.begins_with("172."):
+ var second := int(addr.get_slice(".", 1))
+ return second >= 16 and second <= 31
+ return false
diff --git a/addons/godot_ai/utils/allow_hosts.gd.uid b/addons/godot_ai/utils/allow_hosts.gd.uid
new file mode 100644
index 0000000..02fecd1
--- /dev/null
+++ b/addons/godot_ai/utils/allow_hosts.gd.uid
@@ -0,0 +1 @@
+uid://b7qk3vw2nxr4d
diff --git a/addons/godot_ai/utils/class_introspection.gd b/addons/godot_ai/utils/class_introspection.gd
new file mode 100644
index 0000000..3076740
--- /dev/null
+++ b/addons/godot_ai/utils/class_introspection.gd
@@ -0,0 +1,259 @@
+@tool
+extends RefCounted
+
+## Builds stable, JSON-safe metadata for any class registered in ClassDB.
+
+const VariantSerializer := preload("res://addons/godot_ai/utils/variant_serializer.gd")
+
+## Sections returned when the caller does not name any. Deliberately narrow:
+## a bare `get_class` is almost always "what properties does X have", and the
+## full five-section dump for a large class (e.g. Node, Control) costs an agent
+## thousands of tokens it rarely wanted. Callers opt into the rest by name, or
+## request the lot with the "all" keyword (see `_sections`).
+const DEFAULT_SECTIONS: Array[String] = ["properties"]
+## The full documentation-shaped section set (excludes the heavier, separately
+## gated "inheritors"). Expanded from the "all" keyword.
+const ALL_SECTIONS: Array[String] = ["properties", "methods", "signals", "enums", "constants"]
+const KNOWN_SECTIONS: Array[String] = ["properties", "methods", "signals", "enums", "constants", "inheritors"]
+## Tokens a caller may legitimately pass in `sections` — the known sections plus
+## the "all" meta-keyword. Used for error suggestions so a typo like "al" can
+## resolve to "all"; "all" is NOT a section (it expands in `_sections`), so it
+## stays out of KNOWN_SECTIONS which gates validity.
+const SUGGESTABLE_SECTION_TOKENS: Array[String] = [
+ "properties", "methods", "signals", "enums", "constants", "inheritors", "all"
+]
+const MAX_DEFAULT_ITEMS := 100
+
+
+static func build(type_name: String, options: Dictionary = {}) -> Dictionary:
+ var sections := _sections(options.get("sections", DEFAULT_SECTIONS))
+ var include_inherited := bool(options.get("include_inherited", false))
+ var include_inheritors := bool(options.get("include_inheritors", false))
+ var offset := max(0, int(options.get("offset", 0)))
+ var limit := int(options.get("limit", MAX_DEFAULT_ITEMS))
+ if limit < 0:
+ limit = MAX_DEFAULT_ITEMS
+ var can_instantiate := ClassDB.can_instantiate(type_name)
+
+ var data := {
+ "class_name": type_name,
+ "engine_version": Engine.get_version_info().get("string", ""),
+ "parent_class": str(ClassDB.get_parent_class(type_name)),
+ "inheritance_chain": _inheritance_chain(type_name),
+ "can_instantiate": can_instantiate,
+ "is_singleton": Engine.has_singleton(type_name),
+ "include_inherited": include_inherited,
+ "offset": offset,
+ "limit": limit,
+ }
+ if include_inheritors or sections.has("inheritors"):
+ _add_paged(data, "inheritor", "inheritors", _inheritors(type_name, false), offset, limit)
+ _add_paged(
+ data,
+ "concrete_inheritor",
+ "concrete_inheritors",
+ _inheritors(type_name, true),
+ offset,
+ limit
+ )
+ if sections.has("properties"):
+ _add_paged(data, "property", "properties", _properties(type_name, include_inherited), offset, limit)
+ if sections.has("methods"):
+ _add_paged(data, "method", "methods", _methods(type_name, include_inherited), offset, limit)
+ if sections.has("signals"):
+ _add_paged(data, "signal", "signals", _signals(type_name, include_inherited), offset, limit)
+ if sections.has("enums"):
+ _add_paged(data, "enum", "enums", _enums(type_name, include_inherited), offset, limit)
+ if sections.has("constants"):
+ _add_paged(
+ data,
+ "constant",
+ "constants",
+ _unscoped_constants(type_name, include_inherited),
+ offset,
+ limit
+ )
+ return data
+
+
+static func validate_sections(raw_sections: Variant) -> Dictionary:
+ var sections := _sections(raw_sections)
+ var invalid: Array[String] = []
+ for section in sections:
+ if not KNOWN_SECTIONS.has(section):
+ invalid.append(section)
+ return {"sections": sections, "invalid": invalid}
+
+
+static func _inheritance_chain(type_name: String) -> Array[String]:
+ var chain: Array[String] = []
+ var current := type_name
+ while not current.is_empty():
+ chain.append(current)
+ current = str(ClassDB.get_parent_class(current))
+ return chain
+
+
+static func _sections(raw_sections: Variant) -> Array[String]:
+ var result: Array[String] = []
+ var values: Array = []
+ if raw_sections is String:
+ values = raw_sections.split(",", false)
+ elif raw_sections is Array:
+ values = raw_sections
+ else:
+ values = DEFAULT_SECTIONS
+ for raw_section in values:
+ var section := str(raw_section).strip_edges().to_lower()
+ if section == "all":
+ for expanded in ALL_SECTIONS:
+ if not result.has(expanded):
+ result.append(expanded)
+ continue
+ if not section.is_empty() and not result.has(section):
+ result.append(section)
+ if result.is_empty():
+ result.assign(DEFAULT_SECTIONS)
+ return result
+
+
+static func _add_paged(
+ data: Dictionary,
+ singular: String,
+ key: String,
+ items: Array,
+ offset: int,
+ limit: int
+) -> void:
+ var end := items.size() if limit == 0 else min(items.size(), offset + limit)
+ var page: Array = []
+ if offset < items.size():
+ page = items.slice(offset, end)
+ data[key] = page
+ data["%s_count" % singular] = items.size()
+ data["%s_returned_count" % singular] = page.size()
+
+
+static func _inheritors(type_name: String, concrete_only: bool) -> Array[String]:
+ var result: Array[String] = []
+ for inheritor in ClassDB.get_inheriters_from_class(type_name):
+ var inheritor_name := str(inheritor)
+ if concrete_only and not ClassDB.can_instantiate(inheritor_name):
+ continue
+ result.append(inheritor_name)
+ result.sort()
+ return result
+
+
+static func _properties(type_name: String, include_inherited: bool) -> Array[Dictionary]:
+ var result: Array[Dictionary] = []
+ for raw_prop in ClassDB.class_get_property_list(type_name, not include_inherited):
+ var prop: Dictionary = raw_prop
+ var usage := int(prop.get("usage", 0))
+ if not (usage & PROPERTY_USAGE_EDITOR):
+ continue
+ var prop_name := str(prop.get("name", ""))
+ result.append({
+ "name": prop_name,
+ "type": type_string(int(prop.get("type", TYPE_NIL))),
+ "class_name": str(prop.get("class_name", "")),
+ "hint": int(prop.get("hint", PROPERTY_HINT_NONE)),
+ "hint_string": str(prop.get("hint_string", "")),
+ "usage": usage,
+ "default": VariantSerializer.serialize(
+ ClassDB.class_get_property_default_value(type_name, prop_name)
+ ),
+ })
+ result.sort_custom(func(a, b): return a.name < b.name)
+ return result
+
+
+static func _methods(type_name: String, include_inherited: bool) -> Array[Dictionary]:
+ var result: Array[Dictionary] = []
+ for raw_method in ClassDB.class_get_method_list(type_name, not include_inherited):
+ var method: Dictionary = raw_method
+ var args: Array[Dictionary] = []
+ for raw_arg in method.get("args", []):
+ args.append(_argument_info(raw_arg))
+ var defaults: Array = []
+ for value in method.get("default_args", []):
+ defaults.append(VariantSerializer.serialize(value))
+ result.append({
+ "name": str(method.get("name", "")),
+ "arguments": args,
+ "default_arguments": defaults,
+ "return": _argument_info(method.get("return", {})),
+ "flags": int(method.get("flags", 0)),
+ })
+ result.sort_custom(func(a, b): return a.name < b.name)
+ return result
+
+
+static func _signals(type_name: String, include_inherited: bool) -> Array[Dictionary]:
+ var result: Array[Dictionary] = []
+ for raw_signal in ClassDB.class_get_signal_list(type_name, not include_inherited):
+ var signal_info: Dictionary = raw_signal
+ var args: Array[Dictionary] = []
+ for raw_arg in signal_info.get("args", []):
+ args.append(_argument_info(raw_arg))
+ var defaults: Array = []
+ for value in signal_info.get("default_args", []):
+ defaults.append(VariantSerializer.serialize(value))
+ result.append({
+ "name": str(signal_info.get("name", "")),
+ "arguments": args,
+ "default_arguments": defaults,
+ "flags": int(signal_info.get("flags", 0)),
+ })
+ result.sort_custom(func(a, b): return a.name < b.name)
+ return result
+
+
+static func _argument_info(raw_info: Variant) -> Dictionary:
+ var info: Dictionary = raw_info if raw_info is Dictionary else {}
+ return {
+ "name": str(info.get("name", "")),
+ "type": type_string(int(info.get("type", TYPE_NIL))),
+ "class_name": str(info.get("class_name", "")),
+ "hint": int(info.get("hint", PROPERTY_HINT_NONE)),
+ "hint_string": str(info.get("hint_string", "")),
+ "usage": int(info.get("usage", 0)),
+ }
+
+
+static func _enums(type_name: String, include_inherited: bool) -> Array[Dictionary]:
+ var result: Array[Dictionary] = []
+ var enum_names: Array[String] = []
+ for enum_name in ClassDB.class_get_enum_list(type_name, not include_inherited):
+ enum_names.append(str(enum_name))
+ enum_names.sort()
+ for enum_name in enum_names:
+ var values: Array[Dictionary] = []
+ for constant_name in ClassDB.class_get_enum_constants(type_name, enum_name, not include_inherited):
+ values.append({
+ "name": str(constant_name),
+ "value": ClassDB.class_get_integer_constant(type_name, constant_name),
+ })
+ values.sort_custom(func(a, b): return a.name < b.name)
+ result.append({
+ "name": enum_name,
+ "is_bitfield": ClassDB.is_class_enum_bitfield(type_name, enum_name, not include_inherited),
+ "values": values,
+ })
+ return result
+
+
+static func _unscoped_constants(type_name: String, include_inherited: bool) -> Array[Dictionary]:
+ var result: Array[Dictionary] = []
+ for constant_name in ClassDB.class_get_integer_constant_list(type_name, not include_inherited):
+ var enum_name := str(
+ ClassDB.class_get_integer_constant_enum(type_name, constant_name, not include_inherited)
+ )
+ if not enum_name.is_empty():
+ continue
+ result.append({
+ "name": str(constant_name),
+ "value": ClassDB.class_get_integer_constant(type_name, constant_name),
+ })
+ result.sort_custom(func(a, b): return a.name < b.name)
+ return result
diff --git a/addons/godot_ai/utils/class_introspection.gd.uid b/addons/godot_ai/utils/class_introspection.gd.uid
new file mode 100644
index 0000000..3eda5ca
--- /dev/null
+++ b/addons/godot_ai/utils/class_introspection.gd.uid
@@ -0,0 +1 @@
+uid://caedbsmsl6fk4
diff --git a/addons/godot_ai/utils/client_job_owner.gd b/addons/godot_ai/utils/client_job_owner.gd
new file mode 100644
index 0000000..58ef803
--- /dev/null
+++ b/addons/godot_ai/utils/client_job_owner.gd
@@ -0,0 +1,966 @@
+@tool
+extends Node
+
+## Plugin-lifetime owner for every background client-configuration job.
+##
+## The Dock is replaceable UI. Threads cannot share that lifetime: a timed-out
+## probe or package pre-warm can outlive the panel that requested it, and
+## destroying a live Godot Thread corrupts the GDScript VM. This node therefore
+## owns both worker pools, their cancellation, phases, and one action slot per
+## client until its Thread has been joined. It publishes copied value snapshots
+## and outcomes; it never retains the Dock, plugin, lifecycle, or update manager.
+
+const Client := preload("res://addons/godot_ai/clients/_base.gd")
+const ClientConfigurator := preload("res://addons/godot_ai/client_configurator.gd")
+const ClientRegistry := preload("res://addons/godot_ai/clients/_registry.gd")
+const JsonStrategy := preload("res://addons/godot_ai/clients/_json_strategy.gd")
+const TomlStrategy := preload("res://addons/godot_ai/clients/_toml_strategy.gd")
+const DshStrategy := preload("res://addons/godot_ai/clients/_dsh_strategy.gd")
+const CliStrategy := preload("res://addons/godot_ai/clients/_cli_strategy.gd")
+const RefreshState := preload("res://addons/godot_ai/utils/mcp_client_refresh_state.gd")
+const MutationLock := preload("res://addons/godot_ai/utils/client_mutation_lock.gd")
+
+const STATUS_COOLDOWN_MSEC := 15 * 1000
+const STATUS_TIMEOUT_MSEC := 30 * 1000
+## One scoped CLI configure may run three bounded cleanup commands, one write,
+## discovery, and read-back verification before it reaches the optional package
+## prewarm. Keep the owner watchdog above that real mutation budget.
+const ACTION_TIMEOUT_MSEC := 75 * 1000
+const MCP_ACTION_DEFERRED_GRACE_MSEC := 5 * 1000
+const MAX_MCP_STATUS_WAITERS := 64
+const PHASE_PREWARM := "prewarm"
+const MUTATION_UNPROVEN_META := &"_godot_ai_client_mutation_termination_unproven"
+const POST_UPDATE_UNPROVEN_META := &"_godot_ai_post_update_termination_unproven"
+
+signal snapshot_changed(snapshot: Dictionary)
+signal status_refresh_completed(results: Dictionary)
+signal mcp_status_completed(request_ids: Array[String], payload: Dictionary)
+signal mcp_action_completed(request_id: String, payload: Dictionary)
+signal post_update_repin_completed(result: Dictionary)
+signal action_completed(client_id: String, action: String, result: Dictionary, prewarm: Dictionary)
+signal action_timed_out(client_id: String, action: String, detail: String)
+
+var _accepting_work := false
+var _client_health_blocked := false
+var _client_ids: Array[String] = []
+
+var _refresh_thread: Thread
+var _refresh_state := RefreshState.IDLE
+var _refresh_pending := false
+var _refresh_pending_force := false
+var _refresh_completed_msec := 0
+var _refresh_started_msec := 0
+var _refresh_generation := 0
+## request_id -> first refresh generation allowed to answer it. An MCP request
+## arriving behind a partial Dock refresh waits for the coalesced all-client
+## retry instead of accepting the partial result.
+var _mcp_status_waiters: Dictionary = {}
+
+var _action_threads: Dictionary = {}
+var _action_started_msec: Dictionary = {}
+var _action_names: Dictionary = {}
+var _action_request_ids: Dictionary = {}
+var _action_timeout_reported: Dictionary = {}
+var _action_phase_mutex := Mutex.new()
+var _action_phases: Dictionary = {}
+var _action_cancel_mutex := Mutex.new()
+var _action_cancelled: Dictionary = {}
+## client_id -> action whose CLI termination could not be proven. Mirrored into
+## process-lifetime Engine metadata for immediate reload safety; the CLI
+## strategy's global lock directory is the durable cross-restart authority.
+var _mutation_termination_unproven: Dictionary = {}
+## A successful update enters one pre-activation migration job. It is owned and
+## joined here like every other client worker, while ordinary Dock/MCP work
+## remains closed and the server lifecycle stays dormant.
+var _post_update_thread: Thread
+var _post_update_generation := 0
+var _post_update_cancel_mutex := Mutex.new()
+var _post_update_cancelled := false
+var _post_update_termination_unproven := false
+var _last_snapshot: Dictionary = {}
+
+
+func _init() -> void:
+ var mutations: Variant = Engine.get_meta(MUTATION_UNPROVEN_META, {})
+ if mutations is Dictionary:
+ _mutation_termination_unproven = (mutations as Dictionary).duplicate(true)
+ _post_update_termination_unproven = bool(
+ Engine.get_meta(POST_UPDATE_UNPROVEN_META, false)
+ )
+
+
+func _ready() -> void:
+ ## Work can start — and the owner can be activated — before this node is
+ ## ready. The composition root adds this child, wires it, and calls
+ ## `activate()` inside the plugin's `_enter_tree`; Godot defers a child's
+ ## `_ready` until that returns, so this callback runs LAST, after both the
+ ## post-update migration thread and the ordinary activation seam.
+ ##
+ ## Scripts that define `_process` are processing by default, so an inert
+ ## owner still has to turn it off here. What it must never do is turn it
+ ## off again once work has been admitted: `_process` is the only thing that
+ ## joins finished workers, so disabling it after `activate()` strands every
+ ## refresh and client action for the rest of the session — the sweep never
+ ## leaves RUNNING, Configure never leaves its last published phase.
+ set_process(_accepting_work or _has_work_in_flight())
+
+
+func _has_work_in_flight() -> bool:
+ return _post_update_thread != null or _refresh_thread != null or not _action_threads.is_empty()
+
+
+## Construction is inert. The composition root calls activate only after its
+## startup barrier has allowed normal client work.
+func activate() -> void:
+ if _refresh_state == RefreshState.SHUTTING_DOWN or _post_update_thread != null:
+ return
+ _accepting_work = true
+ set_process(true)
+ _publish_snapshot()
+ _retry_deferred_refresh()
+
+
+func set_client_health_blocked(blocked: bool) -> void:
+ _client_health_blocked = blocked
+ if not blocked:
+ _retry_deferred_refresh()
+
+
+func snapshot() -> Dictionary:
+ var busy: Array[String] = []
+ for client_id in _action_threads:
+ busy.append(String(client_id))
+ busy.sort()
+ return {
+ "accepting_work": _accepting_work,
+ "refresh_state": _refresh_state,
+ "refresh_completed": _refresh_completed_msec > 0,
+ "post_update_repin_running": _post_update_thread != null,
+ "busy_actions": busy,
+ "action_names": _action_names.duplicate(true),
+ "action_phases": _read_action_phases(),
+ }
+
+
+func _publish_snapshot() -> void:
+ var value := snapshot()
+ if value == _last_snapshot:
+ return
+ _last_snapshot = value.duplicate(true)
+ snapshot_changed.emit(value)
+
+
+func _process(_delta: float) -> void:
+ _poll_post_update_repin()
+ _poll_refresh()
+ _poll_actions()
+ _check_refresh_timeout()
+ _check_action_timeouts()
+ _retry_deferred_refresh()
+
+
+## Stop accepting work, cooperatively cancel action subprocesses, then realize
+## every Thread before scripts can be overwritten. Godot exposes no timed join;
+## deadline_msec is reported honestly rather than pretending the join can be
+## interrupted without risking VM corruption.
+func quiesce(deadline_msec: int = 0) -> Dictionary:
+ _accepting_work = false
+ _refresh_state = RefreshState.SHUTTING_DOWN
+ _publish_snapshot()
+ for client_id in _action_threads:
+ _set_action_cancelled(String(client_id), true)
+ _set_post_update_cancelled(true)
+ _refresh_generation += 1
+ if _refresh_thread != null:
+ _refresh_thread.wait_to_finish()
+ _refresh_thread = null
+ if _post_update_thread != null:
+ _record_unproven_post_update_result(_post_update_thread.wait_to_finish())
+ _post_update_thread = null
+ for thread in _action_threads.values():
+ if thread != null:
+ _record_unproven_action_payload((thread as Thread).wait_to_finish())
+ _action_threads.clear()
+ _action_started_msec.clear()
+ _action_names.clear()
+ _action_request_ids.clear()
+ _action_timeout_reported.clear()
+ _action_phase_mutex.lock()
+ _action_phases.clear()
+ _action_phase_mutex.unlock()
+ _action_cancel_mutex.lock()
+ _action_cancelled.clear()
+ _action_cancel_mutex.unlock()
+ _refresh_pending = false
+ _refresh_pending_force = false
+ _mcp_status_waiters.clear()
+ set_process(false)
+ _publish_snapshot()
+ var termination_unproven := (
+ _post_update_termination_unproven
+ or not _mutation_termination_unproven.is_empty()
+ or MutationLock.is_locked()
+ )
+ return {
+ "ok": not termination_unproven,
+ "deadline_exceeded": deadline_msec > 0 and Time.get_ticks_msec() > deadline_msec,
+ "termination_unproven": termination_unproven,
+ }
+
+
+## Re-open the same owner after a pre-activation update failure. Successful
+## activation tears down the plugin instead, so this is only a bail-out path.
+func resume_after_quiesce() -> void:
+ if _refresh_state != RefreshState.SHUTTING_DOWN:
+ return
+ _refresh_state = RefreshState.IDLE
+ _set_post_update_cancelled(false)
+ activate()
+
+
+## Start the hard post-update client barrier. The signed transaction outcome
+## supplies both versions; a mismatch with the code actually loaded fails
+## closed before touching client config or starting the server.
+func begin_post_update_repin(
+ from_version: String, to_version: String, replace_owned_mismatches: bool = false
+) -> Dictionary:
+ ## `from_version` is empty when the closed-editor installer put v4 into a
+ ## project that had no add-on: nothing was replaced, and owned client
+ ## entries still repin to the installed version on this first start.
+ var replaced := from_version.strip_edges()
+ var installed := to_version.strip_edges()
+ if installed.is_empty():
+ return {"ok": false, "error": "Post-update target version is missing."}
+ if installed != ClientConfigurator.get_plugin_version():
+ return {"ok": false, "error": "Post-update target does not match the loaded plugin."}
+ if MutationLock.is_locked():
+ return {"ok": false, "error": MutationLock.recovery_message()}
+ if _post_update_termination_unproven or not _mutation_termination_unproven.is_empty():
+ return {"ok": false, "error": MutationLock.recovery_message()}
+ if (
+ _accepting_work
+ or _post_update_thread != null
+ or _refresh_thread != null
+ or not _action_threads.is_empty()
+ or _refresh_state == RefreshState.SHUTTING_DOWN
+ ):
+ return {"ok": false, "error": "Client work is not idle for post-update migration."}
+ ClientConfigurator.warm_env_snapshot()
+ _warm_worker_bytecode()
+ var probes: Array[Dictionary] = []
+ for client_id in ClientConfigurator.client_ids():
+ var probe := ClientConfigurator.client_status_probe_snapshot(client_id)
+ ## Manual-only descriptors remain visible in ordinary status UI, but M6
+ ## cannot safely parse or rewrite them. In particular Zed owns JSONC and
+ ## may contain comments that the strict shared JSON parser must reject.
+ ## Keep manual remediation outside the hard startup barrier.
+ if not probe.is_empty() and bool(probe.get("automatic_config_edits", true)):
+ probes.append(probe)
+ var context := ClientConfigurator.capture_launch_context()
+ if context.is_empty():
+ return {"ok": false, "error": "Post-update launch context is unavailable."}
+ _set_post_update_cancelled(false)
+ _post_update_generation += 1
+ _post_update_thread = Thread.new()
+ var start_error := _post_update_thread.start(
+ Callable(self, "_run_post_update_repin").bind(
+ probes,
+ context,
+ replaced,
+ installed,
+ replace_owned_mismatches,
+ _post_update_generation,
+ )
+ )
+ if start_error != OK:
+ _post_update_thread = null
+ return {"ok": false, "error": "Could not start post-update client migration."}
+ set_process(true)
+ _publish_snapshot()
+ return {"ok": true}
+
+
+func _run_post_update_repin(
+ probes: Array[Dictionary],
+ context: Dictionary,
+ from_version: String,
+ to_version: String,
+ replace_owned_mismatches: bool,
+ generation: int,
+) -> Dictionary:
+ var prewarm := ClientConfigurator.prewarm_server_package_blocking(
+ to_version,
+ ClientConfigurator.PREWARM_TIMEOUT_MS,
+ Callable(self, "_is_post_update_cancelled"),
+ )
+ var configured_ids: Array[String] = []
+ var repinned_ids: Array[String] = []
+ var foreign_ids: Array[String] = []
+ var deferred: Array[Dictionary] = []
+ if bool(prewarm.get("termination_failed", false)):
+ return _post_update_result(
+ false,
+ generation,
+ configured_ids,
+ repinned_ids,
+ "Post-update package pre-warm could not be proven stopped; stop relevant uvx processes or reboot before retrying.",
+ prewarm,
+ true,
+ )
+ var server_url := ClientConfigurator.server_url_from(context)
+ var resolved_launch := ClientConfigurator.resolve_attach_launch(context)
+ for probe in probes:
+ if _is_post_update_cancelled():
+ return _post_update_result(false, generation, configured_ids, repinned_ids,
+ "Post-update client migration was cancelled.", prewarm)
+ var client_id := String(probe.get("id", ""))
+ var details := ClientConfigurator.check_status_details_for_url_with_cli_path(
+ client_id,
+ server_url,
+ String(probe.get("cli_path", "")),
+ context,
+ resolved_launch,
+ )
+ var status := int(details.get("status", Client.Status.ERROR))
+ if status == Client.Status.NOT_CONFIGURED:
+ continue
+ if status == Client.Status.ERROR:
+ ## One client whose configuration cannot even be read must not
+ ## hold every other client and the server itself hostage: leave
+ ## it for the dock's Configure and say so.
+ deferred.append({
+ "id": client_id,
+ "reason": "its configuration could not be read: %s"
+ % str(details.get("error_msg", "unknown error")),
+ })
+ continue
+ if status == Client.Status.CONFIGURED:
+ configured_ids.append(client_id)
+ continue
+ if replace_owned_mismatches:
+ ## A major migration rewrites only entries that launch Godot AI.
+ ## An entry under our name that starts something else is the
+ ## user's own server: leave it, name it, let Configure replace it.
+ if not bool(details.get("owned", false)):
+ foreign_ids.append(client_id)
+ continue
+ elif not ClientConfigurator.entry_drift_is_version_pin_only(
+ client_id, from_version, context
+ ):
+ ## #890's write restriction stands: an entry that is not provably
+ ## what Configure wrote before the update is never rewritten here.
+ ## But refusing the write is not a reason to refuse the server
+ ## (#999): the entry is left untouched, named, and deferred to
+ ## the dock's Configure, and startup continues.
+ deferred.append({
+ "id": client_id,
+ "reason": "its godot-ai entry differs from what Configure wrote before the update",
+ })
+ continue
+ var configured := ClientConfigurator.configure(client_id, server_url, context)
+ if str(configured.get("status", "error")) != "ok":
+ return _post_update_result(false, generation, configured_ids, repinned_ids,
+ "%s repin failed: %s" % [client_id, str(configured.get("message", "unknown error"))],
+ prewarm,
+ bool(configured.get("termination_failed", false)),
+ client_id)
+ var verified := ClientConfigurator.check_status_details_for_url_with_cli_path(
+ client_id,
+ server_url,
+ String(probe.get("cli_path", "")),
+ context,
+ resolved_launch,
+ )
+ if int(verified.get("status", Client.Status.ERROR)) != Client.Status.CONFIGURED:
+ return _post_update_result(false, generation, configured_ids, repinned_ids,
+ "%s repin could not be verified." % client_id, prewarm)
+ configured_ids.append(client_id)
+ repinned_ids.append(client_id)
+ configured_ids.sort()
+ repinned_ids.sort()
+ foreign_ids.sort()
+ deferred.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return str(a.id) < str(b.id))
+ return _post_update_result(
+ true, generation, configured_ids, repinned_ids, "", prewarm, false, "", foreign_ids, deferred
+ )
+
+
+static func _post_update_result(
+ ok: bool,
+ generation: int,
+ configured_ids: Array[String],
+ repinned_ids: Array[String],
+ error: String,
+ prewarm: Dictionary,
+ termination_failed: bool = false,
+ unsafe_client_id: String = "",
+ foreign_ids: Array[String] = [],
+ deferred: Array[Dictionary] = [],
+) -> Dictionary:
+ return {
+ "ok": ok,
+ "generation": generation,
+ "configured_ids": configured_ids.duplicate(),
+ "repinned_ids": repinned_ids.duplicate(),
+ "error": error,
+ "prewarm": prewarm.duplicate(true),
+ "termination_failed": termination_failed,
+ "unsafe_client_id": unsafe_client_id,
+ ## Entries under our name that launch something else; left unchanged.
+ "foreign_ids": foreign_ids.duplicate(),
+ ## `{id, reason}` for entries the migration left unchanged because
+ ## they were unreadable or drifted beyond the version pin (#999).
+ ## They are the dock's Configure job, not a startup barrier.
+ "deferred": deferred.duplicate(true),
+ }
+
+
+func _poll_post_update_repin() -> void:
+ if _post_update_thread == null or _post_update_thread.is_alive():
+ return
+ var payload: Variant = _post_update_thread.wait_to_finish()
+ _post_update_thread = null
+ if _refresh_state == RefreshState.SHUTTING_DOWN:
+ return
+ var result: Dictionary = (
+ (payload as Dictionary).duplicate(true)
+ if payload is Dictionary
+ else {"ok": false, "error": "Post-update client migration returned no result."}
+ )
+ _record_unproven_post_update_result(result)
+ if int(result.get("generation", -1)) != _post_update_generation:
+ return
+ post_update_repin_completed.emit(result)
+ _publish_snapshot()
+
+
+func request_action(client_id: String, action: String) -> bool:
+ var started := _start_action(client_id, action, "")
+ if not bool(started.get("ok", false)) and bool(started.get("termination_failed", false)):
+ action_completed.emit(
+ client_id,
+ action,
+ {
+ "status": "error",
+ "message": str(started.get("error", "Client action is unavailable.")),
+ "termination_failed": true,
+ },
+ {},
+ )
+ return bool(started.get("ok", false))
+
+
+## MCP mutations use the same one-slot owner as Dock actions. They preserve the
+## tool's previous mutation-only behavior: package prewarm remains a Dock-only
+## convenience. The returned timeout is the bounded out-of-band reply budget.
+func request_mcp_action(request_id: String, client_id: String, action: String) -> Dictionary:
+ if request_id.is_empty():
+ return {"ok": false, "error": "Client mutation requires a deferred request context."}
+ return _start_action(client_id, action, request_id)
+
+
+func _start_action(client_id: String, action: String, request_id: String) -> Dictionary:
+ if not _accepting_work:
+ return {"ok": false, "error": "Client worker is unavailable."}
+ if action not in ["configure", "remove"]:
+ return {"ok": false, "error": "Unknown client action: %s" % action}
+ if _mutation_termination_unproven.has(client_id):
+ return {
+ "ok": false,
+ "error": MutationLock.recovery_message(),
+ "termination_failed": true,
+ }
+ if _action_threads.has(client_id):
+ return {"ok": false, "error": "A client action is already running for %s." % client_id}
+ if not ClientConfigurator.has_client(client_id):
+ return {"ok": false, "error": "Unknown client: %s" % client_id}
+ ClientConfigurator.warm_env_snapshot()
+ _set_action_cancelled(client_id, false)
+ _clear_action_phase(client_id)
+ var context := ClientConfigurator.capture_launch_context()
+ _action_names[client_id] = action
+ _action_request_ids[client_id] = request_id
+ _action_timeout_reported.erase(client_id)
+ _action_started_msec[client_id] = Time.get_ticks_msec()
+ var thread := Thread.new()
+ _action_threads[client_id] = thread
+ var error := thread.start(
+ Callable(self, "_run_action").bind(
+ client_id,
+ action,
+ ClientConfigurator.server_url_from(context),
+ context,
+ request_id.is_empty(),
+ )
+ )
+ if error != OK:
+ _action_threads.erase(client_id)
+ _action_started_msec.erase(client_id)
+ _action_names.erase(client_id)
+ _action_request_ids.erase(client_id)
+ return {"ok": false, "error": "Could not start client worker thread."}
+ ## Same invariant `begin_post_update_repin` states: a live Thread is only
+ ## ever joined from `_process`, so spawning one must guarantee the poll.
+ set_process(true)
+ _publish_snapshot()
+ return {
+ "ok": true,
+ "deferred_timeout_ms": _mcp_action_deferred_timeout_msec(action),
+ }
+
+
+func _run_action(
+ client_id: String,
+ action: String,
+ server_url: String,
+ context: Dictionary,
+ prewarm_after_configure: bool,
+) -> Dictionary:
+ var result: Dictionary
+ var prewarm: Dictionary = {}
+ if action == "remove":
+ result = ClientConfigurator.remove(client_id, server_url, context)
+ else:
+ result = ClientConfigurator.configure(client_id, server_url, context)
+ if result.get("status") == "ok" and prewarm_after_configure:
+ _set_action_phase(client_id, PHASE_PREWARM)
+ prewarm = ClientConfigurator.prewarm_attach_launch(
+ context,
+ ClientConfigurator.PREWARM_TIMEOUT_MS,
+ {},
+ Callable(self, "_is_action_cancelled").bind(client_id),
+ )
+ return {
+ "client_id": client_id,
+ "action": action,
+ "result": result,
+ "prewarm": prewarm,
+ }
+
+
+func _poll_actions() -> void:
+ var phase_changed := false
+ for client_id in _action_threads.keys():
+ var thread: Thread = _action_threads[client_id]
+ if thread == null:
+ continue
+ if thread.is_alive():
+ phase_changed = phase_changed or not _read_action_phase(String(client_id)).is_empty()
+ continue
+ var payload: Variant = thread.wait_to_finish()
+ _action_threads[client_id] = null
+ var action := String(_action_names.get(client_id, "configure"))
+ var result: Dictionary = {"status": "error", "message": "worker returned no result"}
+ var prewarm: Dictionary = {}
+ if payload is Dictionary:
+ var data := payload as Dictionary
+ action = String(data.get("action", action))
+ result = (data.get("result", {}) as Dictionary).duplicate(true)
+ prewarm = (data.get("prewarm", {}) as Dictionary).duplicate(true)
+ _finalize_action(String(client_id), action, result, prewarm)
+ if phase_changed:
+ _publish_snapshot()
+
+
+func _finalize_action(
+ client_id: String,
+ action: String,
+ result: Dictionary,
+ prewarm: Dictionary,
+) -> void:
+ ## Safety evidence is consumed before the sole slot is released. A completed
+ ## timed-out worker therefore cannot reopen a race window between is_alive()
+ ## flipping false and its termination_failed payload being persisted.
+ _record_unproven_action_result(client_id, action, result, prewarm)
+ var request_id := String(_action_request_ids.get(client_id, ""))
+ _action_threads.erase(client_id)
+ _action_started_msec.erase(client_id)
+ _action_names.erase(client_id)
+ _action_request_ids.erase(client_id)
+ _action_timeout_reported.erase(client_id)
+ _set_action_cancelled(client_id, false)
+ _clear_action_phase(client_id)
+ if _refresh_state != RefreshState.SHUTTING_DOWN:
+ action_completed.emit(client_id, action, result.duplicate(true), prewarm.duplicate(true))
+ if not request_id.is_empty():
+ mcp_action_completed.emit(
+ request_id,
+ _mcp_action_payload(client_id, result),
+ )
+ _publish_snapshot()
+
+
+static func _mcp_action_payload(client_id: String, result: Dictionary) -> Dictionary:
+ if str(result.get("status", "error")) == "error":
+ return McpErrorCodes.make(
+ McpErrorCodes.INTERNAL_ERROR,
+ str(result.get("message", "Configuration failed for '%s'" % client_id)),
+ )
+ return {"data": result.duplicate(true)}
+
+
+static func _mcp_action_deferred_timeout_msec(_action: String) -> int:
+ return ACTION_TIMEOUT_MSEC + MCP_ACTION_DEFERRED_GRACE_MSEC
+
+
+func _record_unproven_action_payload(payload: Variant) -> void:
+ if not payload is Dictionary:
+ return
+ var data := payload as Dictionary
+ var result: Dictionary = {}
+ var prewarm: Dictionary = {}
+ if data.get("result", null) is Dictionary:
+ result = data.get("result", {}) as Dictionary
+ if data.get("prewarm", null) is Dictionary:
+ prewarm = data.get("prewarm", {}) as Dictionary
+ _record_unproven_action_result(
+ str(data.get("client_id", "")),
+ str(data.get("action", "configure")),
+ result,
+ prewarm,
+ )
+
+
+func _record_unproven_action_result(
+ client_id: String,
+ action: String,
+ result: Dictionary,
+ prewarm: Dictionary,
+) -> void:
+ if client_id.is_empty():
+ return
+ if (
+ bool(result.get("termination_failed", false))
+ or bool(prewarm.get("termination_failed", false))
+ ):
+ _mark_mutation_termination_unproven(client_id, action)
+
+
+func _record_unproven_post_update_result(payload: Variant) -> void:
+ if not payload is Dictionary:
+ return
+ var result := payload as Dictionary
+ if not bool(result.get("termination_failed", false)):
+ return
+ var client_id := str(result.get("unsafe_client_id", ""))
+ if client_id.is_empty():
+ _post_update_termination_unproven = true
+ Engine.set_meta(POST_UPDATE_UNPROVEN_META, true)
+ else:
+ _mark_mutation_termination_unproven(client_id, "post_update_repin")
+
+
+func _mark_mutation_termination_unproven(client_id: String, action: String) -> void:
+ _mutation_termination_unproven[client_id] = action
+ Engine.set_meta(
+ MUTATION_UNPROVEN_META,
+ _mutation_termination_unproven.duplicate(true),
+ )
+
+
+func _check_action_timeouts(now_msec: int = -1) -> void:
+ var now := Time.get_ticks_msec() if now_msec < 0 else now_msec
+ for client_id in _action_threads.keys():
+ if bool(_action_timeout_reported.get(client_id, false)):
+ continue
+ var started := int(_action_started_msec.get(client_id, 0))
+ if started > 0 and now - started >= _action_budget(String(client_id)):
+ _report_action_timeout(String(client_id), now - started)
+
+
+func _action_budget(client_id: String) -> int:
+ if _read_action_phase(client_id) == PHASE_PREWARM:
+ return ACTION_TIMEOUT_MSEC + ClientConfigurator.PREWARM_TIMEOUT_MS
+ return ACTION_TIMEOUT_MSEC
+
+
+func _report_action_timeout(client_id: String, elapsed_msec: int) -> void:
+ if not _action_threads.has(client_id):
+ return
+ var thread: Thread = _action_threads[client_id]
+ var alive := thread != null and thread.is_alive()
+ if alive:
+ _set_action_cancelled(client_id, true)
+ _action_timeout_reported[client_id] = true
+ var action := String(_action_names.get(client_id, "configure"))
+ var label := "Remove" if action == "remove" else "Configure"
+ var detail := (
+ "%s is taking longer than expected and is being stopped; this row remains locked until its worker is joined." % label
+ if alive
+ else "%s did not report completion in time; waiting to inspect its result." % label
+ )
+ print(
+ "MCP | client action timed out: client=%s action=%s elapsed_ms=%d worker_alive=%s"
+ % [client_id, action, elapsed_msec, str(alive)]
+ )
+ action_timed_out.emit(client_id, action, detail)
+ _publish_snapshot()
+
+
+func request_status_refresh(ids: Array[String], force := false) -> bool:
+ _client_ids = ids.duplicate()
+ if not _accepting_work or _client_health_blocked:
+ return false
+ if RefreshState.has_worker_alive(_refresh_state):
+ ## One refresh worker total. A timed-out probe remains visible and owns
+ ## the slot until its bounded subprocess calls return; repeated force
+ ## requests collapse into one retry instead of growing live Threads.
+ _refresh_pending = true
+ _refresh_pending_force = _refresh_pending_force or force
+ _publish_snapshot()
+ return false
+ if RefreshState.is_blocked_for_spawn(_refresh_state):
+ return false
+ if not force and _refresh_completed_msec > 0:
+ if Time.get_ticks_msec() - _refresh_completed_msec < STATUS_COOLDOWN_MSEC:
+ return false
+ if ids.is_empty():
+ return false
+ if _filesystem_busy():
+ _refresh_state = RefreshState.DEFERRED_FOR_FILESYSTEM
+ _refresh_pending_force = _refresh_pending_force or force
+ _publish_snapshot()
+ return false
+ if force:
+ ClientConfigurator.invalidate_cli_cache()
+ _warm_worker_bytecode()
+ var probes: Array[Dictionary] = []
+ for client_id in ids:
+ var probe := ClientConfigurator.client_status_probe_snapshot(client_id)
+ if not probe.is_empty():
+ probes.append(probe)
+ if probes.is_empty():
+ _finish_refresh({})
+ return false
+ var context := ClientConfigurator.capture_launch_context()
+ _refresh_state = RefreshState.RUNNING
+ _refresh_pending = false
+ _refresh_pending_force = false
+ _refresh_started_msec = Time.get_ticks_msec()
+ _refresh_generation += 1
+ _refresh_thread = Thread.new()
+ var error := _refresh_thread.start(
+ Callable(self, "_run_status_refresh").bind(
+ probes,
+ ClientConfigurator.server_url_from(context),
+ context,
+ _refresh_generation,
+ )
+ )
+ if error != OK:
+ _refresh_thread = null
+ _refresh_state = RefreshState.IDLE
+ _publish_snapshot()
+ return false
+ ## See `_start_action`: the sweep leaves RUNNING only through `_poll_refresh`.
+ set_process(true)
+ _publish_snapshot()
+ return true
+
+
+## Queue one deferred MCP response onto the same aggregate refresh worker the
+## Dock uses. The dispatcher bounds its pending table; this local cap also
+## protects direct/future callers. No connection or handler is retained.
+func request_mcp_status(request_id: String) -> bool:
+ if (
+ request_id.is_empty()
+ or not _accepting_work
+ or _client_health_blocked
+ or _refresh_state == RefreshState.SHUTTING_DOWN
+ or _mcp_status_waiters.has(request_id)
+ or _mcp_status_waiters.size() >= MAX_MCP_STATUS_WAITERS
+ ):
+ return false
+ var target_generation := _refresh_generation + 1
+ _mcp_status_waiters[request_id] = target_generation
+ request_status_refresh(ClientConfigurator.client_ids(), true)
+ if (
+ _refresh_generation >= target_generation
+ or _refresh_pending
+ or _refresh_state == RefreshState.DEFERRED_FOR_FILESYSTEM
+ ):
+ return true
+ _mcp_status_waiters.erase(request_id)
+ return false
+
+
+func _run_status_refresh(
+ probes: Array[Dictionary],
+ server_url: String,
+ context: Dictionary,
+ generation: int,
+) -> Dictionary:
+ var results: Dictionary = {}
+ var resolved_launch := ClientConfigurator.resolve_attach_launch(context)
+ for probe in probes:
+ var client_id := String(probe.get("id", ""))
+ if client_id.is_empty():
+ continue
+ var details := ClientConfigurator.check_status_details_for_url_with_cli_path(
+ client_id,
+ server_url,
+ String(probe.get("cli_path", "")),
+ context,
+ resolved_launch,
+ )
+ results[client_id] = {
+ "status": details.get("status", Client.Status.NOT_CONFIGURED),
+ "installed": bool(probe.get("installed", false)),
+ "error_msg": details.get("error_msg", ""),
+ }
+ return {"results": results, "generation": generation}
+
+
+func _poll_refresh() -> void:
+ if _refresh_thread == null or _refresh_thread.is_alive():
+ return
+ var payload: Variant = _refresh_thread.wait_to_finish()
+ _refresh_thread = null
+ if not (payload is Dictionary):
+ _finish_refresh({}, _refresh_generation, "Client status worker returned an invalid response.")
+ return
+ var data := payload as Dictionary
+ if int(data.get("generation", -1)) != _refresh_generation:
+ return
+ var results := (data.get("results", {}) as Dictionary).duplicate(true)
+ var timed_out := _refresh_state == RefreshState.RUNNING_TIMED_OUT
+ _finish_refresh(
+ {} if timed_out else results,
+ int(data.get("generation", _refresh_generation)),
+ "Client status probe timed out." if timed_out else "",
+ )
+
+
+func _finish_refresh(
+ results: Dictionary, completed_generation: int = -1, mcp_error: String = ""
+) -> void:
+ _refresh_completed_msec = Time.get_ticks_msec()
+ if _refresh_state != RefreshState.SHUTTING_DOWN:
+ _refresh_state = RefreshState.IDLE
+ if not _client_health_blocked and _refresh_state != RefreshState.SHUTTING_DOWN:
+ status_refresh_completed.emit(results.duplicate(true))
+ _complete_mcp_status_waiters(results, completed_generation, mcp_error)
+ _publish_snapshot()
+ if _refresh_pending:
+ var force := _refresh_pending_force
+ _refresh_pending = false
+ _refresh_pending_force = false
+ request_status_refresh(_client_ids, force)
+
+
+func _complete_mcp_status_waiters(
+ results: Dictionary, completed_generation: int, error: String
+) -> void:
+ if completed_generation < 0 or _mcp_status_waiters.is_empty():
+ return
+ var completed: Array[String] = []
+ for request_id in _mcp_status_waiters:
+ if int(_mcp_status_waiters[request_id]) <= completed_generation:
+ completed.append(String(request_id))
+ for request_id in completed:
+ _mcp_status_waiters.erase(request_id)
+ if completed.is_empty():
+ return
+ var payload := (
+ McpErrorCodes.make(McpErrorCodes.INTERNAL_ERROR, error)
+ if not error.is_empty()
+ else ClientConfigurator.client_status_response(results)
+ )
+ mcp_status_completed.emit(completed, payload.duplicate(true))
+
+func _refresh_timed_out() -> bool:
+ return (
+ RefreshState.has_worker_alive(_refresh_state)
+ and _refresh_started_msec > 0
+ and Time.get_ticks_msec() - _refresh_started_msec >= STATUS_TIMEOUT_MSEC
+ )
+
+
+func _check_refresh_timeout() -> void:
+ if _refresh_state == RefreshState.RUNNING and _refresh_timed_out():
+ _refresh_state = RefreshState.RUNNING_TIMED_OUT
+ _publish_snapshot()
+
+
+func _retry_deferred_refresh() -> void:
+ if _refresh_state != RefreshState.DEFERRED_FOR_FILESYSTEM:
+ return
+ if not _accepting_work or _client_health_blocked or _filesystem_busy():
+ return
+ var force := _refresh_pending_force
+ _refresh_state = RefreshState.IDLE
+ _refresh_pending_force = false
+ request_status_refresh(_client_ids, force)
+
+
+func _filesystem_busy() -> bool:
+ var filesystem := EditorInterface.get_resource_filesystem()
+ return filesystem != null and filesystem.is_scanning()
+
+
+func _warm_worker_bytecode() -> void:
+ var ids := ClientConfigurator.client_ids()
+ if ids.is_empty():
+ return
+ var client := ClientRegistry.get_by_id(String(ids[0]))
+ if client != null:
+ JsonStrategy.verify_entry(client, {}, "")
+ DshStrategy.command_launch_error(client, {})
+ TomlStrategy.format_body(PackedStringArray(), "")
+ CliStrategy.format_args(PackedStringArray(), "", "")
+ ClientConfigurator.warm_env_snapshot()
+
+
+func _set_action_cancelled(client_id: String, cancelled: bool) -> void:
+ _action_cancel_mutex.lock()
+ if cancelled:
+ _action_cancelled[client_id] = true
+ else:
+ _action_cancelled.erase(client_id)
+ _action_cancel_mutex.unlock()
+
+
+func _is_action_cancelled(client_id: String) -> bool:
+ _action_cancel_mutex.lock()
+ var cancelled := bool(_action_cancelled.get(client_id, false))
+ _action_cancel_mutex.unlock()
+ return cancelled
+
+
+func _set_post_update_cancelled(cancelled: bool) -> void:
+ _post_update_cancel_mutex.lock()
+ _post_update_cancelled = cancelled
+ _post_update_cancel_mutex.unlock()
+
+
+func _is_post_update_cancelled() -> bool:
+ _post_update_cancel_mutex.lock()
+ var cancelled := _post_update_cancelled
+ _post_update_cancel_mutex.unlock()
+ return cancelled
+
+
+func _set_action_phase(client_id: String, phase: String) -> void:
+ _action_phase_mutex.lock()
+ _action_phases[client_id] = phase
+ _action_phase_mutex.unlock()
+
+
+func _read_action_phase(client_id: String) -> String:
+ _action_phase_mutex.lock()
+ var phase := String(_action_phases.get(client_id, ""))
+ _action_phase_mutex.unlock()
+ return phase
+
+
+func _read_action_phases() -> Dictionary:
+ _action_phase_mutex.lock()
+ var phases := _action_phases.duplicate(true)
+ _action_phase_mutex.unlock()
+ return phases
+
+
+func _clear_action_phase(client_id: String) -> void:
+ _action_phase_mutex.lock()
+ _action_phases.erase(client_id)
+ _action_phase_mutex.unlock()
diff --git a/addons/godot_ai/utils/client_job_owner.gd.uid b/addons/godot_ai/utils/client_job_owner.gd.uid
new file mode 100644
index 0000000..43da106
--- /dev/null
+++ b/addons/godot_ai/utils/client_job_owner.gd.uid
@@ -0,0 +1 @@
+uid://jo18d41wbdpx
diff --git a/addons/godot_ai/utils/client_mutation_lock.gd b/addons/godot_ai/utils/client_mutation_lock.gd
new file mode 100644
index 0000000..864f8cf
--- /dev/null
+++ b/addons/godot_ai/utils/client_mutation_lock.gd
@@ -0,0 +1,131 @@
+@tool
+class_name McpClientMutationLock
+extends RefCounted
+
+## Durable, atomic authority for all automatic client mutations.
+##
+## A POSIX exact-PID kill cannot prove that a timed-out CLI left no descendant
+## behind. The lock directory therefore survives plugin reload, editor restart,
+## and crashes. Only a sequence whose every direct child terminated without an
+## ambiguous kill removes it. An existing or malformed lock always fails closed.
+
+const ROOT_DIR_NAME := "godot-ai"
+const LOCK_DIR_NAME := "client_mutation.lock"
+const RECORD_NAME := "owner.json"
+const SCHEMA := 1
+const _POSIX_PERMISSION_MASK := 0x1ff ## 0777
+const _OWNER_DIRECTORY_MODE := (
+ FileAccess.UNIX_READ_OWNER
+ | FileAccess.UNIX_WRITE_OWNER
+ | FileAccess.UNIX_EXECUTE_OWNER
+)
+
+
+static func lock_path() -> String:
+ return _root_path().path_join(LOCK_DIR_NAME)
+
+
+static func recovery_message() -> String:
+ return (
+ "Automatic MCP client mutations are safety-locked. Stop relevant client processes "
+ + "or reboot, then explicitly remove '%s' before retrying; restarting Godot alone "
+ + "does not prove an old descendant stopped."
+ ) % lock_path()
+
+
+static func is_locked() -> bool:
+ var path := lock_path()
+ return DirAccess.dir_exists_absolute(path) or FileAccess.file_exists(path)
+
+
+## Atomically claim the one global mutation slot. Creation wins ownership;
+## failure for any reason is indistinguishable from an existing unsafe owner.
+static func acquire(client_id: String, operation: String) -> Dictionary:
+ var path := lock_path()
+ if not _prepare_private_root():
+ return {"ok": false, "error": recovery_message(), "path": path}
+ if DirAccess.make_dir_absolute(path) != OK:
+ return {"ok": false, "error": recovery_message(), "path": path}
+ if not _has_private_mode(path):
+ ## The directory itself remains as the durable deny marker. Never
+ ## write ownership data into a location whose privacy is unproven.
+ return {"ok": false, "error": recovery_message(), "path": path}
+ var token := Crypto.new().generate_random_bytes(16).hex_encode()
+ var record := {
+ "schema": SCHEMA,
+ "token": token,
+ "pid": OS.get_process_id(),
+ "client_id": client_id,
+ "operation": operation,
+ "started_unix": int(Time.get_unix_time_from_system()),
+ }
+ var record_path := path.path_join(RECORD_NAME)
+ var file := FileAccess.open(record_path, FileAccess.WRITE)
+ if file == null:
+ return {"ok": false, "error": recovery_message(), "path": path}
+ file.store_string(JSON.stringify(record))
+ file.flush()
+ var write_error := file.get_error()
+ file.close()
+ if write_error != OK:
+ return {"ok": false, "error": recovery_message(), "path": path}
+ return {"ok": true, "path": path, "record_path": record_path, "token": token}
+
+
+## Release only the exact claim this process wrote. Every failure leaves either
+## the record or its parent directory in place, which keeps later mutations
+## denied rather than guessing that cleanup succeeded.
+static func release(claim: Dictionary) -> bool:
+ var path := str(claim.get("path", ""))
+ var record_path := str(claim.get("record_path", ""))
+ var token := str(claim.get("token", ""))
+ if (
+ path != lock_path()
+ or record_path != path.path_join(RECORD_NAME)
+ or token.is_empty()
+ ):
+ return false
+ var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string(record_path))
+ if (
+ not parsed is Dictionary
+ or int(parsed.get("schema", 0)) != SCHEMA
+ or str(parsed.get("token", "")) != token
+ or int(parsed.get("pid", 0)) != OS.get_process_id()
+ ):
+ return false
+ if DirAccess.remove_absolute(record_path) != OK:
+ return false
+ return DirAccess.remove_absolute(path) == OK
+
+
+static func _root_path() -> String:
+ ## Unlike user://, the OS config directory is shared by every Godot
+ ## project owned by this account. User-scope client config is shared too,
+ ## so a project-specific lock would leave the real cross-editor race open.
+ return OS.get_config_dir().path_join(ROOT_DIR_NAME)
+
+
+static func _prepare_private_root() -> bool:
+ var root := _root_path()
+ if FileAccess.file_exists(root) or _is_link(root):
+ return false
+ if not DirAccess.dir_exists_absolute(root):
+ var created := DirAccess.make_dir_absolute(root)
+ ## Another editor may have won the benign root-creation race. Accept
+ ## that only after independently proving the resulting directory.
+ if created != OK and not DirAccess.dir_exists_absolute(root):
+ return false
+ return not _is_link(root) and _has_private_mode(root)
+
+
+static func _has_private_mode(path: String) -> bool:
+ if OS.get_name() == "Windows":
+ return true
+ var permission_error := FileAccess.set_unix_permissions(path, _OWNER_DIRECTORY_MODE)
+ var actual_mode := FileAccess.get_unix_permissions(path) & _POSIX_PERMISSION_MASK
+ return permission_error == OK and actual_mode == _OWNER_DIRECTORY_MODE
+
+
+static func _is_link(path: String) -> bool:
+ var parent := DirAccess.open(path.get_base_dir())
+ return parent == null or parent.is_link(path)
diff --git a/addons/godot_ai/utils/client_mutation_lock.gd.uid b/addons/godot_ai/utils/client_mutation_lock.gd.uid
new file mode 100644
index 0000000..c6ef2a2
--- /dev/null
+++ b/addons/godot_ai/utils/client_mutation_lock.gd.uid
@@ -0,0 +1 @@
+uid://climutlockv4x1
diff --git a/addons/godot_ai/utils/diagnostics_capture.gd b/addons/godot_ai/utils/diagnostics_capture.gd
new file mode 100644
index 0000000..22441ad
--- /dev/null
+++ b/addons/godot_ai/utils/diagnostics_capture.gd
@@ -0,0 +1,66 @@
+@tool
+class_name McpDiagnosticsCapture
+extends RefCounted
+
+## Small helper for scoped validation-log capture windows. Callers snapshot a
+## private log cursor, perform a deliberate validation action, then only report
+## new diagnostics whose original source location is the target file.
+
+
+static func capture_this_file(log_buffer: McpEditorLogBuffer, target_path: String, action: Callable) -> Dictionary:
+ var cursor := 0
+ if log_buffer != null:
+ cursor = log_buffer.appended_total()
+
+ var action_result = action.call()
+ var diagnostics: Array[Dictionary] = []
+ var truncated := false
+
+ if log_buffer != null:
+ var captured: Dictionary = log_buffer.get_since(cursor)
+ truncated = captured.get("truncated", false)
+ diagnostics = _diagnostics_for_target(captured.get("entries", []), target_path)
+
+ return {
+ "action": action_result if action_result is Dictionary else {},
+ "diagnostics": diagnostics,
+ "diagnostics_detail": "log_capture" if not diagnostics.is_empty() else "none",
+ "diagnostics_scope": "this_file",
+ "diagnostics_status": "partial" if truncated else "checked",
+ }
+
+
+static func _diagnostics_for_target(entries: Array, target_path: String) -> Array[Dictionary]:
+ var out: Array[Dictionary] = []
+ for raw_entry in entries:
+ if not raw_entry is Dictionary:
+ continue
+ var entry: Dictionary = raw_entry
+ if not _entry_matches_target(entry, target_path):
+ continue
+ out.append(_normalize_entry(entry, target_path))
+ return out
+
+
+static func _entry_matches_target(entry: Dictionary, target_path: String) -> bool:
+ var source := _source_location(entry)
+ return str(source.get("path", "")) == target_path
+
+
+static func _normalize_entry(entry: Dictionary, target_path: String) -> Dictionary:
+ var normalized := entry.duplicate(true)
+ var source := _source_location(entry)
+ normalized["path"] = str(source.get("path", target_path))
+ normalized["line"] = int(source.get("line", normalized.get("line", 0)))
+ normalized["function"] = str(source.get("function", normalized.get("function", "")))
+ if normalized.has("details") and normalized.details is Dictionary:
+ normalized["details"] = normalized.details.duplicate(true)
+ return normalized
+
+
+static func _source_location(entry: Dictionary) -> Dictionary:
+ if entry.get("details") is Dictionary:
+ var details: Dictionary = entry.details
+ if details.get("source") is Dictionary:
+ return details.source
+ return {}
diff --git a/addons/godot_ai/utils/diagnostics_capture.gd.uid b/addons/godot_ai/utils/diagnostics_capture.gd.uid
new file mode 100644
index 0000000..95973c9
--- /dev/null
+++ b/addons/godot_ai/utils/diagnostics_capture.gd.uid
@@ -0,0 +1 @@
+uid://b3npxxpuobbc2
diff --git a/addons/godot_ai/utils/editor_log_buffer.gd b/addons/godot_ai/utils/editor_log_buffer.gd
new file mode 100644
index 0000000..0ea44e7
--- /dev/null
+++ b/addons/godot_ai/utils/editor_log_buffer.gd
@@ -0,0 +1,127 @@
+@tool
+class_name McpEditorLogBuffer
+extends McpStructuredLogRing
+
+## Ring buffer for editor-process script errors and warnings (parse errors,
+## @tool runtime errors, EditorPlugin errors, push_error/push_warning) captured
+## by editor_logger.gd's Logger subclass.
+##
+## Smaller cap than McpGameLogBuffer (500 vs 2000) — the editor only emits errors,
+## not the full println firehose a game can produce. No run_id rotation: editor
+## errors persist across project_run cycles (they're about *editing* state, not
+## about the playing game).
+##
+## Mutex-protected because Logger virtuals can fire from any thread (e.g.
+## async script-loader threads emitting parse errors), and the buffer is
+## read on the main thread by EditorHandler.get_logs. Each public method
+## wraps the base ring's lockless helpers in `_mutex.lock()/unlock()` —
+## the base stays lockless so McpGameLogBuffer's hot path doesn't pay an
+## unused mutex cost.
+##
+## Entry shape: {source: "editor", level: "info"|"warn"|"error",
+## text, path, line, function} — `path/line/function` may be empty/zero
+## when the source location wasn't recoverable (e.g. printerr from a
+## thread without a script context).
+
+const MAX_LINES := 500
+
+var _mutex := Mutex.new()
+var _error_appended_total := 0
+var _warn_appended_total := 0
+
+
+func _init() -> void:
+ super._init(MAX_LINES)
+
+
+func append(level: String, text: String, path: String = "", line: int = 0, function: String = "", details: Dictionary = {}) -> void:
+ var coerced_level := _coerce_level(level)
+ var entry := {
+ "source": "editor",
+ "level": coerced_level,
+ "text": text,
+ "path": path,
+ "line": line,
+ "function": function,
+ }
+ if not details.is_empty():
+ entry["details"] = details.duplicate(true)
+ _mutex.lock()
+ _append_entry(entry)
+ if coerced_level == "error":
+ _error_appended_total += 1
+ elif coerced_level == "warn":
+ _warn_appended_total += 1
+ _mutex.unlock()
+
+
+func get_range(offset: int, count: int) -> Array[Dictionary]:
+ _mutex.lock()
+ var out := _get_range_unlocked(offset, count)
+ _mutex.unlock()
+ return out
+
+
+func get_recent(count: int) -> Array[Dictionary]:
+ ## Single-lock so the size we compute `start` from can't race against
+ ## a concurrent append between the size read and the slice copy.
+ _mutex.lock()
+ var size := _total_count_unlocked()
+ var start := maxi(0, size - count)
+ var out := _get_range_unlocked(start, size - start)
+ _mutex.unlock()
+ return out
+
+
+func get_since(since_seq: int, limit: int = -1) -> Dictionary:
+ ## Single-lock so the cursor snapshot and slice copy can't race against a
+ ## Logger-thread append.
+ _mutex.lock()
+ var out := _get_since_unlocked(since_seq, limit)
+ _mutex.unlock()
+ return out
+
+
+func total_count() -> int:
+ _mutex.lock()
+ var n := _total_count_unlocked()
+ _mutex.unlock()
+ return n
+
+
+func dropped_count() -> int:
+ _mutex.lock()
+ var n := _dropped_count_unlocked()
+ _mutex.unlock()
+ return n
+
+
+func appended_total() -> int:
+ _mutex.lock()
+ var n := _appended_total_unlocked()
+ _mutex.unlock()
+ return n
+
+
+func error_appended_total() -> int:
+ _mutex.lock()
+ var n := _error_appended_total
+ _mutex.unlock()
+ return n
+
+
+func warn_appended_total() -> int:
+ _mutex.lock()
+ var n := _warn_appended_total
+ _mutex.unlock()
+ return n
+
+
+func clear() -> int:
+ _mutex.lock()
+ var n := _total_count_unlocked()
+ _clear_storage()
+ _error_appended_total = 0
+ _warn_appended_total = 0
+ _mutex.unlock()
+ return n
diff --git a/addons/godot_ai/utils/editor_log_buffer.gd.uid b/addons/godot_ai/utils/editor_log_buffer.gd.uid
new file mode 100644
index 0000000..8c8d823
--- /dev/null
+++ b/addons/godot_ai/utils/editor_log_buffer.gd.uid
@@ -0,0 +1 @@
+uid://b6ynms0856hhq
diff --git a/addons/godot_ai/utils/error_codes.gd b/addons/godot_ai/utils/error_codes.gd
new file mode 100644
index 0000000..9136144
--- /dev/null
+++ b/addons/godot_ai/utils/error_codes.gd
@@ -0,0 +1,164 @@
+@tool
+class_name McpErrorCodes
+extends RefCounted
+
+## Error code constants shared across handlers. Mirrors protocol/errors.py.
+##
+## This `class_name` shipped in v2.3.2 and earlier and must stay reachable
+## through self-update. v2.4.1 dropped it and triggered a "Could not resolve
+## script" cascade for every user upgrading from any earlier version; v2.4.2
+## restored it as a hot-fix. The cascade fires because Godot keeps stale
+## registry entries during the disable -> extract -> enable window when a
+## previously-registered class_name disappears, and that failure mode is
+## independent of the runner's install ordering. See CLAUDE.md's
+## never-delete-published-class_name policy for the shape-aware shim path
+## that retirement (if ever needed) must follow.
+##
+## All consumers use the preload-alias pattern
+## (`const ErrorCodes := preload(...)`) introduced in #412. The alias is
+## stylistic; both `McpErrorCodes.X` and `ErrorCodes.X` resolve through the
+## same Script object cache, so the alias is not a parse-safety boundary
+## under the single-phase runner.
+
+const INVALID_PARAMS := "INVALID_PARAMS"
+const EDITED_SCENE_MISMATCH := "EDITED_SCENE_MISMATCH"
+const EDITOR_NOT_READY := "EDITOR_NOT_READY"
+const UNKNOWN_COMMAND := "UNKNOWN_COMMAND"
+const INTERNAL_ERROR := "INTERNAL_ERROR"
+const DEFERRED_TIMEOUT := "DEFERRED_TIMEOUT"
+## Python-originated transport/attach bridge codes. GDScript has no emit path,
+## but the public registry intentionally mirrors protocol/errors.py.
+const TRANSPORT_OUTCOME_UNKNOWN := "TRANSPORT_OUTCOME_UNKNOWN"
+const TRANSPORT_OVERLOADED := "TRANSPORT_OVERLOADED"
+const NEW_CLIENT_SESSION_REQUIRED := "NEW_CLIENT_SESSION_REQUIRED"
+const ATTACH_LOCK_TIMEOUT := "ATTACH_LOCK_TIMEOUT"
+const ATTACH_LOCK_ERROR := "ATTACH_LOCK_ERROR"
+const ATTACH_RUNTIME_DIR_ERROR := "ATTACH_RUNTIME_DIR_ERROR"
+const PORT_OCCUPIED := "PORT_OCCUPIED"
+const BACKEND_START_FAILED := "BACKEND_START_FAILED"
+const BACKEND_START_TIMEOUT := "BACKEND_START_TIMEOUT"
+# game_eval failure codes (#490) — keep in sync with protocol/errors.py
+const EVAL_COMPILE_ERROR := "EVAL_COMPILE_ERROR"
+const EVAL_RUNTIME_ERROR := "EVAL_RUNTIME_ERROR"
+## #518/#859: the play session is up (EditorInterface.is_playing_scene() is true,
+## so editor_handler's EDITOR_NOT_READY "game is not running" gate already
+## passed) but the game cannot service evals: the helper did not register within
+## EVAL_READY_WAIT_SEC, its main-loop beacon is stale, or its debugger session
+## closed mid-eval. Carved out of INTERNAL_ERROR so these fast,
+## caller-actionable failures do not burn the opaque 10s eval backstop.
+const EVAL_GAME_NOT_READY := "EVAL_GAME_NOT_READY"
+## #518: the eval genuinely never finished inside the timeout ladder — the
+## game-side 8s deadline aborted a hung await, or the editor-side 10s backstop
+## fired because the game never replied at all (CPU-bound loop, frozen /
+## backgrounded idle loop). Carved out of INTERNAL_ERROR — the last big
+## still-unlabeled bucket from #487/#488 — so "your eval code never finished"
+## stops reading as an internal fault in telemetry and agent-facing errors.
+const EVAL_HUNG := "EVAL_HUNG"
+## #518: the eval completed but its serialized result is too large for the
+## debugger + WebSocket pipeline. Without this the reply is dropped silently
+## (the debugger TCP peer discards messages over ~8 MiB) and the request rides
+## to the 10s backstop as a phantom "hang". Failing fast game-side with the
+## real byte count makes the failure actionable (return a smaller slice).
+const EVAL_RESULT_TOO_LARGE := "EVAL_RESULT_TOO_LARGE"
+## #777: a game-side request (currently editor_screenshot source="game")
+## reached a live, registered game helper but no reply came back before the
+## editor-side timer fired. Every editor gate already passed
+## (is_playing_scene, helper hello) so this is a TOP-LEVEL code, not an
+## EDITOR_NOT_READY sub-code: the game process itself failed to respond —
+## backgrounded with a frozen main loop and nothing rendered to fall back
+## on, main thread blocked, or the helper died mid-run. Carved out of
+## INTERNAL_ERROR (the largest opaque timeout bucket fleet-wide) so the
+## residual timeout is attributable and actionable.
+const GAME_HELPER_TIMEOUT := "GAME_HELPER_TIMEOUT"
+## audit-v2 #21 (issue #365): finer-grained codes carved out of the 471
+## INVALID_PARAMS sites so agents can distinguish recoverable input
+## errors from structural ones. INVALID_PARAMS stays for genuinely
+## catch-all input errors that don't fit any of the buckets below.
+##
+## - NODE_NOT_FOUND: scene-tree/autoload node lookup failed (path didn't
+## resolve to a Node).
+## - RESOURCE_NOT_FOUND: a `res://` path lookup failed (file/.tres/
+## .gdshader/.tscn etc. doesn't exist or couldn't load). Distinct from
+## NODE_NOT_FOUND because the recovery path differs — agents need to
+## know whether to fix a node path vs. create/import a resource.
+## - PROPERTY_NOT_ON_CLASS: property/signal/method/uniform/slot lookup
+## failed on a known instance (path resolved, but the requested
+## member doesn't exist on that class).
+## - VALUE_OUT_OF_RANGE: numeric/index bound violation OR enum value
+## not in the allowed set.
+## - WRONG_TYPE: input was a value (or a loaded resource) of the wrong
+## type — the param was provided, but `typeof` or `is X` failed.
+## - MISSING_REQUIRED_PARAM: required input field was absent or empty.
+const NODE_NOT_FOUND := "NODE_NOT_FOUND"
+const RESOURCE_NOT_FOUND := "RESOURCE_NOT_FOUND"
+const PROPERTY_NOT_ON_CLASS := "PROPERTY_NOT_ON_CLASS"
+const VALUE_OUT_OF_RANGE := "VALUE_OUT_OF_RANGE"
+const WRONG_TYPE := "WRONG_TYPE"
+const MISSING_REQUIRED_PARAM := "MISSING_REQUIRED_PARAM"
+const CUSTOM_TOOL_NOT_UNDOABLE := "CUSTOM_TOOL_NOT_UNDOABLE"
+const CUSTOM_TOOL_DISABLED := "CUSTOM_TOOL_DISABLED"
+
+## #651 stage 1: EDITOR_NOT_READY sub-codes. These travel in
+## `error.data.sub_code`, NEVER as the top-level `error.code` — existing
+## callers and dashboards key on EDITOR_NOT_READY, so the top-level code is
+## frozen. Each sub-code names the concrete editor state at rejection time,
+## limited to states EditorInterface/EditorFileSystem can report
+## deterministically. States we cannot observe (script compilation,
+## resource reload, modal dialogs) intentionally get NO sub-code: a bare
+## EDITOR_NOT_READY stays the honest fallback rather than a guessed label.
+## Keep in sync with protocol/errors.py::EditorNotReadySubCode — enforced
+## by tests/unit/test_editor_not_ready_hint_contract.py.
+const SUB_EDITOR_IMPORTING := "EDITOR_IMPORTING"
+const SUB_EDITOR_PLAYING := "EDITOR_PLAYING"
+const SUB_EDITOR_NO_SCENE := "EDITOR_NO_SCENE"
+const SUB_EDITOR_GAME_NOT_RUNNING := "EDITOR_GAME_NOT_RUNNING"
+const SUB_EDITOR_VIEWPORT_UNAVAILABLE := "EDITOR_VIEWPORT_UNAVAILABLE"
+const SUB_EDITOR_VIEWPORT_NOT_3D := "EDITOR_VIEWPORT_NOT_3D"
+const SUB_EDITOR_VIEWPORT_EMPTY := "EDITOR_VIEWPORT_EMPTY"
+const SUB_EDITOR_UNAVAILABLE := "EDITOR_UNAVAILABLE"
+## Emitted only by the exclusive-run transport servicing path: a command
+## arrived while a synchronous test run holds the main thread, and was
+## rejected (not buffered) so it can't replay stale after its server-side
+## future expires. See connection.gd::service_transport_during_exclusive_run.
+const SUB_EDITOR_TEST_RUNNING := "EDITOR_TEST_RUNNING"
+
+## Terminal code for a test run that hit its between-test abort ceiling
+## before finishing. error.data carries the partial summary; full partial
+## results stay retrievable via get_test_results.
+const TEST_RUN_TIMEOUT := "TEST_RUN_TIMEOUT"
+
+
+## Build a standard error response dictionary.
+static func make(code: String, message: String) -> Dictionary:
+ return {"status": "error", "error": {"code": code, "message": message}}
+
+
+## Build an EDITOR_NOT_READY error carrying the #651 stage-1 attribution
+## payload: `data.sub_code` + `retryable` + `hint`. Mirrors the shape
+## scene_path.gd::require_edited_scene established (editor_state/retryable/
+## hint). `hint` may be empty when `message` already IS the recovery hint —
+## the server's GodotCommandError string-appends every data key, so
+## duplicating the message into data would double the agent-visible text.
+static func make_not_ready(
+ sub_code: String, message: String, retryable: bool, hint: String = ""
+) -> Dictionary:
+ var err := make(EDITOR_NOT_READY, message)
+ var data := {"sub_code": sub_code, "retryable": retryable}
+ if not hint.is_empty():
+ data["hint"] = hint
+ err["error"]["data"] = data
+ return err
+
+
+## Return a NEW error dict with the original code and a prefixed message.
+## Prefer this over mutating `err["error"]["message"]` in place — callers
+## that want to add context ("Property '%s': …") shouldn't need to know
+## the internal shape of the dict returned by `make`. Empty `prefix`
+## returns `err` unchanged so callers don't need their own guard.
+static func prefix_message(err: Dictionary, prefix: String) -> Dictionary:
+ if prefix.is_empty():
+ return err
+ var inner: Dictionary = err.get("error", {})
+ var code: String = inner.get("code", INTERNAL_ERROR)
+ var message: String = inner.get("message", "")
+ return make(code, "%s: %s" % [prefix, message])
diff --git a/addons/godot_ai/utils/error_codes.gd.uid b/addons/godot_ai/utils/error_codes.gd.uid
new file mode 100644
index 0000000..fcc1e82
--- /dev/null
+++ b/addons/godot_ai/utils/error_codes.gd.uid
@@ -0,0 +1 @@
+uid://d2klnglf5p861
diff --git a/addons/godot_ai/utils/fuzzy_suggestions.gd b/addons/godot_ai/utils/fuzzy_suggestions.gd
new file mode 100644
index 0000000..de7cc19
--- /dev/null
+++ b/addons/godot_ai/utils/fuzzy_suggestions.gd
@@ -0,0 +1,39 @@
+@tool
+extends RefCounted
+
+## Shared fuzzy ranking for typo suggestions.
+
+
+static func rank(
+ needle: String,
+ candidates: Array,
+ limit: int = 5,
+ threshold: float = 0.4,
+ substring_bonus: float = 0.5,
+ prefix_bonus: float = 1.0
+) -> Array[String]:
+ if needle.is_empty() or candidates.is_empty():
+ return []
+ var needle_lower := needle.to_lower()
+ var scored: Array = []
+ for raw_candidate in candidates:
+ var candidate := str(raw_candidate)
+ var candidate_lower := candidate.to_lower()
+ var score := needle.similarity(candidate)
+ if prefix_bonus != 0.0 and candidate_lower.begins_with(needle_lower):
+ score += prefix_bonus
+ elif substring_bonus != 0.0 and (
+ candidate_lower.contains(needle_lower) or needle_lower.contains(candidate_lower)
+ ):
+ score += substring_bonus
+ if score >= threshold:
+ scored.append([score, candidate])
+ scored.sort_custom(func(a, b):
+ if a[0] == b[0]:
+ return a[1] < b[1]
+ return a[0] > b[0]
+ )
+ var result: Array[String] = []
+ for index in range(min(limit, scored.size())):
+ result.append(scored[index][1])
+ return result
diff --git a/addons/godot_ai/utils/fuzzy_suggestions.gd.uid b/addons/godot_ai/utils/fuzzy_suggestions.gd.uid
new file mode 100644
index 0000000..c663d01
--- /dev/null
+++ b/addons/godot_ai/utils/fuzzy_suggestions.gd.uid
@@ -0,0 +1 @@
+uid://bxwaws6w0xw60
diff --git a/addons/godot_ai/utils/game_log_buffer.gd b/addons/godot_ai/utils/game_log_buffer.gd
new file mode 100644
index 0000000..2f1b3b0
--- /dev/null
+++ b/addons/godot_ai/utils/game_log_buffer.gd
@@ -0,0 +1,105 @@
+@tool
+class_name McpGameLogBuffer
+extends McpStructuredLogRing
+
+## Ring buffer for game-process log lines (print, push_warning, push_error)
+## ferried back from the playing game over the EngineDebugger channel.
+##
+## Larger cap than McpEditorLogBuffer because games can be noisy. `run_id`
+## rotates at play-start, giving agents a stable cursor for "lines from
+## this run" even when the game never reaches the mcp:hello boot beacon.
+##
+## Single-threaded — game_helper.gd drains its logger from `_process` and
+## calls `append` from the main thread, so this subclass can use the base
+## ring's lockless reads/writes directly.
+
+const MAX_LINES := 2000
+
+var _run_id := ""
+var _run_seq := 0
+var _error_warn_total := 0
+var _error_total := 0
+
+
+func _init() -> void:
+ super._init(MAX_LINES)
+
+
+func append(level: String, text: String, details: Dictionary = {}) -> void:
+ var coerced_level := _coerce_level(level)
+ var entry := {
+ "source": "game",
+ "level": coerced_level,
+ "text": text,
+ "run_id": _run_id,
+ }
+ if not details.is_empty():
+ entry["details"] = details.duplicate(true)
+ _append_entry(entry)
+ if coerced_level in ["warn", "error"]:
+ _error_warn_total += 1
+ if coerced_level == "error":
+ _error_total += 1
+
+
+## Rotate the run identifier without dropping buffered entries. Called at
+## play-start so even no-hello parse failures get a fresh current-run identity.
+## Historical lines stay tagged with their original run_id and can still be
+## queried explicitly.
+func clear_for_new_run() -> String:
+ _run_id = _generate_run_id()
+ _error_warn_total = 0
+ _error_total = 0
+ return _run_id
+
+
+func run_id() -> String:
+ return _run_id
+
+
+func error_warn_total() -> int:
+ return _error_warn_total
+
+
+func error_total() -> int:
+ return _error_total
+
+
+## Warn-level lines for the current run: the combined error+warn tally minus
+## the error-only tally. Feeds the `game_warn` watermark component so a run
+## that only emitted push_warning is no longer reported as clean.
+func warn_total() -> int:
+ return _error_warn_total - _error_total
+
+
+func get_run_range(run_id: String, offset: int, count: int) -> Array[Dictionary]:
+ return get_run_page(run_id, offset, count).entries
+
+
+func get_run_page(run_id: String, offset: int, count: int) -> Dictionary:
+ var entries := _entries_for_run(run_id)
+ var start := mini(maxi(0, offset), entries.size())
+ var stop := mini(entries.size(), start + maxi(0, count))
+ var out: Array[Dictionary] = []
+ for i in range(start, stop):
+ out.append(entries[i])
+ return {
+ "entries": out,
+ "total_count": entries.size(),
+ }
+
+
+func _entries_for_run(run_id: String) -> Array[Dictionary]:
+ var out: Array[Dictionary] = []
+ for entry in get_range(0, total_count()):
+ if str(entry.get("run_id", "")) == run_id:
+ out.append(entry)
+ return out
+
+
+func _generate_run_id() -> String:
+ ## Opaque to agents — they only check equality. Time-based is plenty
+ ## unique within a single editor session; the local sequence protects
+ ## fast back-to-back test runs within the same millisecond.
+ _run_seq += 1
+ return "r%d-%d" % [Time.get_ticks_msec(), _run_seq]
diff --git a/addons/godot_ai/utils/game_log_buffer.gd.uid b/addons/godot_ai/utils/game_log_buffer.gd.uid
new file mode 100644
index 0000000..6c7ce7d
--- /dev/null
+++ b/addons/godot_ai/utils/game_log_buffer.gd.uid
@@ -0,0 +1 @@
+uid://biojw0xl64haw
diff --git a/addons/godot_ai/utils/json_values.gd b/addons/godot_ai/utils/json_values.gd
new file mode 100644
index 0000000..6100836
--- /dev/null
+++ b/addons/godot_ai/utils/json_values.gd
@@ -0,0 +1,108 @@
+@tool
+class_name McpJsonValues
+extends RefCounted
+
+## Canonical JSON→Variant parsers for the wire shapes agents send.
+##
+## One parser family instead of five drifted per-handler copies (#714) —
+## the canonical color set is the maintainer decision recorded on that
+## issue. parse_color accepts: Color passthrough; "#rrggbb"/"#rrggbbaa"
+## hex or named-color strings (two-sentinel Color.from_string
+## validation); {r,g,b[,a]} dicts; [r,g,b[,a]] arrays. parse_vector2/3
+## accept the Vector passthrough, {x,y[,z]} dicts, and [x,y[,z]] arrays.
+##
+## Strict WITHIN each shape (the #123/#126 contract): wrong dict keys,
+## wrong array lengths, or non-numeric components return null instead of
+## guessing zeros — callers turn null into their own typed error.
+
+const COLOR_KEYS: Array[String] = ["r", "g", "b"]
+const VECTOR2_KEYS: Array[String] = ["x", "y"]
+const VECTOR3_KEYS: Array[String] = ["x", "y", "z"]
+
+
+static func parse_color(value: Variant) -> Variant:
+ if value is Color:
+ return value
+ if value is String:
+ ## Color.from_string returns the fallback on parse failure — call
+ ## twice with distinct sentinels; agreement means a real parse.
+ var a := Color.from_string(value, Color(0, 0, 0, 0))
+ var b := Color.from_string(value, Color(1, 1, 1, 1))
+ if a != b:
+ return null
+ return a
+ if value is Dictionary:
+ var d: Dictionary = value
+ if not d.has_all(COLOR_KEYS):
+ return null
+ var alpha: Variant = d.get("a", 1.0)
+ if not (_is_number(d.r) and _is_number(d.g) and _is_number(d.b) and _is_number(alpha)):
+ return null
+ return Color(float(d.r), float(d.g), float(d.b), float(alpha))
+ if value is Array:
+ var arr: Array = value
+ if arr.size() != 3 and arr.size() != 4:
+ return null
+ for item in arr:
+ if not _is_number(item):
+ return null
+ var a4 := float(arr[3]) if arr.size() == 4 else 1.0
+ return Color(float(arr[0]), float(arr[1]), float(arr[2]), a4)
+ return null
+
+
+static func parse_vector2(value: Variant) -> Variant:
+ if value is Vector2:
+ return value
+ if value is Dictionary:
+ var d: Dictionary = value
+ if not d.has_all(VECTOR2_KEYS) or not (_is_number(d.x) and _is_number(d.y)):
+ return null
+ return Vector2(float(d.x), float(d.y))
+ if value is Array:
+ var arr: Array = value
+ if arr.size() != 2 or not (_is_number(arr[0]) and _is_number(arr[1])):
+ return null
+ return Vector2(float(arr[0]), float(arr[1]))
+ return null
+
+
+static func parse_vector3(value: Variant) -> Variant:
+ if value is Vector3:
+ return value
+ if value is Dictionary:
+ var d: Dictionary = value
+ if not d.has_all(VECTOR3_KEYS):
+ return null
+ if not (_is_number(d.x) and _is_number(d.y) and _is_number(d.z)):
+ return null
+ return Vector3(float(d.x), float(d.y), float(d.z))
+ if value is Array:
+ var arr: Array = value
+ if arr.size() != 3:
+ return null
+ for item in arr:
+ if not _is_number(item):
+ return null
+ return Vector3(float(arr[0]), float(arr[1]), float(arr[2]))
+ return null
+
+
+## Parse a scalar float from the wire shapes agents send: float passthrough,
+## int (JSON integers land as numbers, not strings), and strictly-numeric
+## strings — some MCP clients stringify float arguments ("4.0"; #964).
+## Anything else returns null so callers turn it into their own typed
+## error; null input stays null (callers gate on the input's type first
+## when null is a meaningful "clear" value).
+static func parse_float(value: Variant) -> Variant:
+ if _is_number(value):
+ return float(value)
+ if value is String:
+ var text := value as String
+ if text.is_valid_float():
+ return text.to_float()
+ return null
+
+
+static func _is_number(v: Variant) -> bool:
+ return v is int or v is float
diff --git a/addons/godot_ai/utils/json_values.gd.uid b/addons/godot_ai/utils/json_values.gd.uid
new file mode 100644
index 0000000..b3eeba9
--- /dev/null
+++ b/addons/godot_ai/utils/json_values.gd.uid
@@ -0,0 +1 @@
+uid://4wbf83hwckms
diff --git a/addons/godot_ai/utils/log_backtrace.gd b/addons/godot_ai/utils/log_backtrace.gd
new file mode 100644
index 0000000..3fdf838
--- /dev/null
+++ b/addons/godot_ai/utils/log_backtrace.gd
@@ -0,0 +1,113 @@
+@tool
+class_name McpLogBacktrace
+extends RefCounted
+
+## Helpers for interpreting Godot's `_log_error` virtual arguments.
+## (Named `McpLogBacktrace`, not `ScriptBacktrace`: Godot ships a built-in
+## `ScriptBacktrace` class — the type of `script_backtraces[i]` entries
+## — so class_name'ing ours the same would collide. Verified against
+## the engine's `--doctool` output in 4.6.)
+##
+## Both `editor_logger.gd` and `game_logger.gd` need to:
+## - Map `error_type` (0=ERROR, 1=WARNING, 2=SCRIPT, 3=SHADER) to a
+## two-bucket "error" / "warn" string so callers can filter without
+## consulting the enum.
+## - Fall back to `code` when `rationale` is empty — single-arg
+## `push_error("msg")` leaves rationale empty and stuffs the user's
+## string into `code`; without the fallback the user message is
+## silently lost. The two-arg form `push_error(code, rationale)`
+## populates both and rationale wins.
+## - Remap the source location to the first frame of `script_backtraces[0]`
+## when present. `push_error` / `push_warning` always report
+## `file=core/variant/variant_utility.cpp`; the actual user GDScript
+## caller is in the backtrace.
+##
+## Centralising the rules keeps the next push_error semantics shift
+## (already happened once between 4.5 and 4.6, see PR #78) a one-place
+## fix instead of a two-place hunt.
+
+
+## Coalesce the per-virtual-arg shape Godot hands `_log_error` into a
+## flat record. Always walks `script_backtraces` for the first non-empty
+## frame; loggers that need to filter by source path call this first and
+## then check the resolved `path` field.
+##
+## Returns: `{level, message, path, line, function, details}`
+## - `level`: "error" or "warn" (warn iff `error_type == 1`).
+## - `message`: `rationale` when non-empty, else `code`.
+## - `path` / `line` / `function`: first backtrace frame when one is
+## available; otherwise the original `file` / `line` / `function`.
+## - `details`: original `_log_error` fields plus the first non-empty
+## backtrace as frames, mirroring the debugger Errors tab context.
+const ERROR_TYPE_NAMES := {
+ 0: "error",
+ 1: "warning",
+ 2: "script",
+ 3: "shader",
+}
+
+
+static func resolve_error(
+ function: String,
+ file: String,
+ line: int,
+ code: String,
+ rationale: String,
+ error_type: int,
+ script_backtraces: Array,
+) -> Dictionary:
+ var src_file := file
+ var src_line := line
+ var src_function := function
+ var frames: Array[Dictionary] = []
+ ## First non-empty frame wins, not just `script_backtraces[0]` —
+ ## chained errors can leave the leading entry empty with the actual
+ ## user frame in `script_backtraces[1]`.
+ for bt in script_backtraces:
+ if bt != null and bt.get_frame_count() > 0:
+ frames = _frames_from_backtrace(bt)
+ src_file = str(frames[0].get("path", ""))
+ src_line = int(frames[0].get("line", 0))
+ src_function = str(frames[0].get("function", ""))
+ break
+ var message := rationale if not rationale.is_empty() else code
+ return {
+ "level": "warn" if error_type == 1 else "error",
+ "message": message,
+ "path": src_file,
+ "line": src_line,
+ "function": src_function,
+ "details": {
+ "message": message,
+ "code": code,
+ "rationale": rationale,
+ "error_type": error_type,
+ "error_type_name": _error_type_name(error_type),
+ "source": {
+ "path": file,
+ "line": line,
+ "function": function,
+ },
+ "resolved": {
+ "path": src_file,
+ "line": src_line,
+ "function": src_function,
+ },
+ "frames": frames,
+ },
+ }
+
+
+static func _frames_from_backtrace(bt) -> Array[Dictionary]:
+ var frames: Array[Dictionary] = []
+ for i in bt.get_frame_count():
+ frames.append({
+ "path": bt.get_frame_file(i),
+ "line": bt.get_frame_line(i),
+ "function": bt.get_frame_function(i),
+ })
+ return frames
+
+
+static func _error_type_name(error_type: int) -> String:
+ return str(ERROR_TYPE_NAMES.get(error_type, "unknown"))
diff --git a/addons/godot_ai/utils/log_backtrace.gd.uid b/addons/godot_ai/utils/log_backtrace.gd.uid
new file mode 100644
index 0000000..4845ad8
--- /dev/null
+++ b/addons/godot_ai/utils/log_backtrace.gd.uid
@@ -0,0 +1 @@
+uid://b8t9kznr2pqxa
diff --git a/addons/godot_ai/utils/log_buffer.gd b/addons/godot_ai/utils/log_buffer.gd
new file mode 100644
index 0000000..34bd2b5
--- /dev/null
+++ b/addons/godot_ai/utils/log_buffer.gd
@@ -0,0 +1,67 @@
+@tool
+class_name McpLogBuffer
+extends RefCounted
+
+## Ring buffer for MCP log lines. Also prints to Godot console.
+
+const MAX_LINES := 500
+
+## When false, `log()` still records into the ring buffer but does not echo the
+## line to the Godot console. The test runner flips this off for the duration
+## of a run so negative-path suites (which intentionally drive a 500-line ring
+## fill and malformed-result error logging) don't bury an all-green run in
+## console noise. Ring *contents* — what tests assert on via `get_recent()` /
+## `total_logged()` — are unaffected. Engine-level C++ errors raised by
+## negative-path tests are not routed through here and still surface.
+static var console_echo := true
+
+var _lines: Array[String] = []
+## Monotonic count of every line ever passed to `log()` since the last
+## `clear()`. Distinct from `_lines.size()`, which is bounded at MAX_LINES.
+## Consumers that need to detect "new lines arrived" (e.g. `LogViewer.tick`)
+## must track this rather than the bounded size — once the ring fills, the
+## size stays at MAX_LINES on every subsequent append, so a size-based
+## cursor would freeze and the consumer would stop seeing new entries.
+var _total_logged: int = 0
+var enabled := true
+
+
+## `echo=false` records the line into the ring (so the dock's log panel shows
+## it) without printing to the Godot console. Used for high-frequency
+## machine-driven lines like readiness flips, which spammed the console of
+## every install (#626) — filesystem scans toggle readiness on each import.
+func log(msg: String, echo: bool = true) -> void:
+ var line := "MCP | %s" % msg
+ if enabled and console_echo and echo:
+ print(line)
+ _lines.append(line)
+ if _lines.size() > MAX_LINES:
+ _lines = _lines.slice(-MAX_LINES)
+ _total_logged += 1
+
+
+func get_recent(count: int = 50) -> Array[String]:
+ var start := maxi(0, _lines.size() - count)
+ var result: Array[String] = []
+ result.assign(_lines.slice(start))
+ return result
+
+
+func clear() -> void:
+ _lines.clear()
+ ## Reset the monotonic counter so a viewer's `seq < _last_seq` shrink
+ ## detection still recognizes the clear. Callers that want a cumulative
+ ## ever-produced count across clears can wrap their own counter.
+ _total_logged = 0
+
+
+func total_count() -> int:
+ return _lines.size()
+
+
+## Monotonic sequence — number of lines ever appended via `log()` since
+## the last `clear()`. Strictly increases per append, even once the ring
+## has filled and `total_count()` is pinned at MAX_LINES. See `_total_logged`
+## for rationale.
+func total_logged() -> int:
+ return _total_logged
diff --git a/addons/godot_ai/utils/log_buffer.gd.uid b/addons/godot_ai/utils/log_buffer.gd.uid
new file mode 100644
index 0000000..58341ca
--- /dev/null
+++ b/addons/godot_ai/utils/log_buffer.gd.uid
@@ -0,0 +1 @@
+uid://ddkslse7511e6
diff --git a/addons/godot_ai/utils/mcp_adoption_label.gd b/addons/godot_ai/utils/mcp_adoption_label.gd
new file mode 100644
index 0000000..d3537c4
--- /dev/null
+++ b/addons/godot_ai/utils/mcp_adoption_label.gd
@@ -0,0 +1,8 @@
+@tool
+class_name McpAdoptionLabel
+extends RefCounted
+
+## Retired v4 lifecycle vocabulary kept only because this class_name and its
+## constants were published. Runtime ownership now lives in the tagged episode.
+const MANAGED := "managed"
+const EXTERNAL := "external"
diff --git a/addons/godot_ai/utils/mcp_adoption_label.gd.uid b/addons/godot_ai/utils/mcp_adoption_label.gd.uid
new file mode 100644
index 0000000..aee58fe
--- /dev/null
+++ b/addons/godot_ai/utils/mcp_adoption_label.gd.uid
@@ -0,0 +1 @@
+uid://klhsu1cuhcue
diff --git a/addons/godot_ai/utils/mcp_client_refresh_state.gd b/addons/godot_ai/utils/mcp_client_refresh_state.gd
new file mode 100644
index 0000000..7b88c34
--- /dev/null
+++ b/addons/godot_ai/utils/mcp_client_refresh_state.gd
@@ -0,0 +1,101 @@
+@tool
+class_name McpClientRefreshState
+extends RefCounted
+
+## State machine for the plugin-lifetime client's status refresh sweep. Single
+## source of truth — supersedes the seven booleans + deadline previously
+## scattered across `mcp_dock.gd` (`_client_status_refresh_in_flight`,
+## `_client_status_refresh_pending`, `_client_status_refresh_pending_force`,
+## `_client_status_refresh_timed_out`, `_client_status_refresh_started_msec`,
+## `_client_status_refresh_deferred_until_filesystem_ready`,
+## `_client_status_refresh_deferred_force`,
+## `_client_status_refresh_deferred_initial`,
+## `_client_status_refresh_shutdown_requested`).
+##
+## The ints are stable for tests; reordering is a breaking change.
+
+## No worker running, no pending request. Default state.
+const IDLE := 0
+## A refresh request landed but the editor filesystem is busy
+## (`EditorInterface.get_resource_filesystem().is_scanning()` is true);
+## the job owner parks the request and retries on the next `_process` after
+## the scan settles. A separate force bit preserves an explicit retry request.
+const DEFERRED_FOR_FILESYSTEM := 1
+## Worker thread is alive and probing client status off-main. The
+## Dock snapshots paint "(checking...)" in the clients summary and the owner accepts
+## additional requests as `pending`.
+const RUNNING := 2
+## Worker has been alive past CLIENT_STATUS_REFRESH_TIMEOUT_MSEC. The
+## dock paints "(client probe still running)"; further requests coalesce into
+## one retry after this sole worker exits.
+const RUNNING_TIMED_OUT := 3
+## Plugin teardown / update activation is draining workers. New refresh
+## requests are rejected outright until an explicitly failed handoff resumes
+## the same owner.
+const SHUTTING_DOWN := 4
+
+const _NAMES := {
+ IDLE: "idle",
+ DEFERRED_FOR_FILESYSTEM: "deferred_for_filesystem",
+ RUNNING: "running",
+ RUNNING_TIMED_OUT: "running_timed_out",
+ SHUTTING_DOWN: "shutting_down",
+}
+
+
+static func name_of(state: int) -> String:
+ return _NAMES.get(state, "unknown(%d)" % state)
+
+
+## True when a worker thread should be alive in this state. Combined
+## state — RUNNING or RUNNING_TIMED_OUT both have the sole worker running.
+static func has_worker_alive(state: int) -> bool:
+ return state == RUNNING or state == RUNNING_TIMED_OUT
+
+
+## True while the status worker is still within its healthy budget. Once a
+## refresh has timed out, the dock keeps the warning badge but must let users
+## retry Configure / Configure all instead of stranding the controls behind an
+## orphaned, uninterruptible worker.
+static func should_disable_client_actions(state: int) -> bool:
+ return state == RUNNING
+
+
+## True when the dock should reject new refresh spawns. Used by the
+## job owner's deferred entrypoint and status-refresh scheduler.
+static func is_blocked_for_spawn(state: int) -> bool:
+ return state == SHUTTING_DOWN
+
+
+## True when the summary label should show the in-flight badge.
+static func should_show_checking_badge(state: int) -> bool:
+ return state == RUNNING or state == RUNNING_TIMED_OUT
+
+
+## Transition table. Same shape as McpServerState — illegal transitions
+## return false; callers `push_warning` and no-op.
+static func can_transition(from: int, to: int) -> bool:
+ if from == to:
+ return true
+ ## Shutdown is sticky.
+ if from == SHUTTING_DOWN:
+ return false
+ ## Anything → SHUTTING_DOWN is legal (drain on _exit_tree / install).
+ if to == SHUTTING_DOWN:
+ return true
+ match from:
+ IDLE:
+ return to == RUNNING or to == DEFERRED_FOR_FILESYSTEM
+ DEFERRED_FOR_FILESYSTEM:
+ ## When the filesystem scan settles we either spawn a worker
+ ## (RUNNING) or roll back to IDLE if no rows need probing.
+ return to == RUNNING or to == IDLE
+ RUNNING:
+ ## Worker finishes -> IDLE. Worker outlives budget ->
+ ## RUNNING_TIMED_OUT.
+ return to == IDLE or to == RUNNING_TIMED_OUT
+ RUNNING_TIMED_OUT:
+ ## The sole late-arriving worker drops back to IDLE. A coalesced
+ ## retry then starts through the ordinary IDLE -> RUNNING edge.
+ return to == IDLE
+ return false
diff --git a/addons/godot_ai/utils/mcp_client_refresh_state.gd.uid b/addons/godot_ai/utils/mcp_client_refresh_state.gd.uid
new file mode 100644
index 0000000..9e4129c
--- /dev/null
+++ b/addons/godot_ai/utils/mcp_client_refresh_state.gd.uid
@@ -0,0 +1 @@
+uid://dv4tukg6eioww
diff --git a/addons/godot_ai/utils/mcp_server_state.gd b/addons/godot_ai/utils/mcp_server_state.gd
new file mode 100644
index 0000000..f127015
--- /dev/null
+++ b/addons/godot_ai/utils/mcp_server_state.gd
@@ -0,0 +1,57 @@
+@tool
+class_name McpServerState
+extends RefCounted
+## Stable presentation values consumed by the Dock and editor-status API.
+## McpServerLifecycleManager's tagged episode is the only transition owner.
+const UNINITIALIZED := 0
+const SPAWNING := 1
+# Slot 2 stays unused so existing status consumers keep stable values.
+const READY := 3
+const INCOMPATIBLE := 4
+const CRASHED := 5
+const NO_COMMAND := 6
+const PORT_EXCLUDED := 7
+const FOREIGN_PORT := 8
+const STOPPING := 10
+const STOPPED := 11
+
+const _NAMES := {
+ UNINITIALIZED: "uninitialized",
+ SPAWNING: "spawning",
+ READY: "ready",
+ INCOMPATIBLE: "incompatible",
+ CRASHED: "crashed",
+ NO_COMMAND: "no_command",
+ PORT_EXCLUDED: "port_excluded",
+ FOREIGN_PORT: "foreign_port",
+ STOPPING: "stopping",
+ STOPPED: "stopped",
+}
+
+
+## Human-readable label. Used in startup-trace logs and transition
+## warnings. Falls back to `unknown()` for unrecognised values so
+## a future enum addition won't crash the formatter.
+static func name_of(state: int) -> String:
+ return _NAMES.get(state, "unknown(%d)" % state)
+
+
+## True for any state the dock should render as a non-OK diagnostic
+## panel. Used as the "should we hide the spawn-failure panel?" gate.
+static func is_terminal_diagnosis(state: int) -> bool:
+ return (
+ state == CRASHED
+ or state == NO_COMMAND
+ or state == PORT_EXCLUDED
+ or state == INCOMPATIBLE
+ or state == FOREIGN_PORT
+ )
+
+
+## True when the dock should skip interpreting client health (incompatible
+## tool surface). This must NOT block Configure writes — those take an
+## explicit url and the live plugin version (#916). Currently just
+## INCOMPATIBLE — FOREIGN_PORT is transitional and may resolve to READY
+## if the foreign occupant turns out to speak our handshake.
+static func blocks_client_health(state: int) -> bool:
+ return state == INCOMPATIBLE
diff --git a/addons/godot_ai/utils/mcp_server_state.gd.uid b/addons/godot_ai/utils/mcp_server_state.gd.uid
new file mode 100644
index 0000000..dc5bef9
--- /dev/null
+++ b/addons/godot_ai/utils/mcp_server_state.gd.uid
@@ -0,0 +1 @@
+uid://d3ial4erjonlq
diff --git a/addons/godot_ai/utils/mcp_startup_path.gd b/addons/godot_ai/utils/mcp_startup_path.gd
new file mode 100644
index 0000000..dc0ae16
--- /dev/null
+++ b/addons/godot_ai/utils/mcp_startup_path.gd
@@ -0,0 +1,15 @@
+@tool
+class_name McpStartupPath
+extends RefCounted
+
+## Retired v4 lifecycle vocabulary kept only because this class_name and its
+## constants were published. Runtime startup state lives in one tagged episode.
+const UNSET := ""
+const GUARDED := "guarded"
+const ADOPTED := "adopted"
+const SPAWNED := "spawned"
+const CRASHED := "crashed"
+const RESERVED := "reserved"
+const NO_COMMAND := "no_command"
+const INCOMPATIBLE := "incompatible"
+const FREE := "free"
diff --git a/addons/godot_ai/utils/mcp_startup_path.gd.uid b/addons/godot_ai/utils/mcp_startup_path.gd.uid
new file mode 100644
index 0000000..fd01066
--- /dev/null
+++ b/addons/godot_ai/utils/mcp_startup_path.gd.uid
@@ -0,0 +1 @@
+uid://cikdvq2x4vs4x
diff --git a/addons/godot_ai/utils/path_validator.gd b/addons/godot_ai/utils/path_validator.gd
new file mode 100644
index 0000000..8cdc77c
--- /dev/null
+++ b/addons/godot_ai/utils/path_validator.gd
@@ -0,0 +1,216 @@
+@tool
+class_name McpPathValidator
+extends RefCounted
+
+const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
+
+## Validates `res://`-rooted paths against directory-traversal escape.
+##
+## Issue #347 (audit-v2 #3): handlers were accepting `res://../etc/passwd.gd`
+## because the only check was `path.begins_with("res://")`. LLM-driven path
+## generation (prompt injection, agent typos, untrusted issue/PR text in
+## context) can produce traversal payloads for the write tools that produce
+## arbitrary disk content (`script_create`, `filesystem_write_text`,
+## `patch_script`) and for the matching reads (info disclosure surface).
+##
+## Two entry points:
+## * `validate_resource_path` — for paths that name a `res://` disk file the
+## plugin will read or (with `for_write`) write. This is the strict one.
+## * `validate_loadable_path` — for paths handed to `ResourceLoader`, which
+## also accepts `uid://` (an opaque resource-DB id that cannot express
+## traversal) and `user://` (the per-project user data sandbox). Load
+## handlers must use this so `uid://` references copied out of `.tscn`
+## ExtResource / `.uid` sidecars and `user://` runtime assets keep loading.
+##
+## Error wrapping: callers should use `path_error` / `loadable_error`, which
+## return a ready `ErrorCodes.make(VALUE_OUT_OF_RANGE, …)` dict (or null). A
+## bad path is a value-domain error, and funneling every site through one
+## wrapper keeps the error code consistent across all handlers.
+##
+## Known limitation: containment is lexical (`globalize_path` + `simplify_path`
+## prefix match). It does NOT resolve symlinks — GDScript exposes no realpath.
+## A symlink *inside* the project that points outside it can therefore defeat
+## the under-root check. This matches the engine's own `res://` resolution and
+## is accepted; the loopback trust boundary is the primary control.
+
+
+# Cached project / user roots. `globalize_path` is stable across the editor's
+# lifetime — caching avoids redundant resolution on every call. Matters most
+# for `reimport`, which loops the validator over each path in a batch.
+# Lazy-init on first call so static-load timing can't see a half-initialised
+# ProjectSettings.
+static var _cached_res_root: String = ""
+static var _cached_user_root: String = ""
+
+
+static func _res_root() -> String:
+ if _cached_res_root.is_empty():
+ _cached_res_root = ProjectSettings.globalize_path("res://").simplify_path()
+ return _cached_res_root
+
+
+static func _user_root() -> String:
+ if _cached_user_root.is_empty():
+ _cached_user_root = ProjectSettings.globalize_path("user://").simplify_path()
+ return _cached_user_root
+
+
+## True when `path` carries a codepoint that must never reach a filesystem API.
+##
+## Issue #889: the previous guard built its sentinel with `String.chr(0)`, and
+## Godot emits `Unicode parsing error … Unexpected NUL character` while
+## *constructing* that string. Every validated call — including reads that then
+## failed with "File not found" — printed an alarming engine error that had
+## nothing to do with the caller's path. Reading codepoints instead never
+## materialises a NUL String, so the noise is gone.
+##
+## Both codepoints are rejected, for different reasons:
+## * `0x0000` — a real embedded NUL can truncate a C string, so the path that
+## is validated is not the path that gets opened. Current Godot cannot
+## retain one in a String, making this a no-op today; it stays as
+## defense-in-depth for any build or input path that can.
+## * `0xFFFD` — what current Godot actually substitutes for a NUL or any other
+## undecodable input. Once the engine has replaced the byte there is no way
+## left to tell an attempted NUL from malformed UTF-8 from a literal
+## replacement character, so the only safe reading at a security boundary is
+## to refuse all three. A legitimate filename containing U+FFFD is rejected
+## too; that false positive is deliberate and vanishingly rare.
+static func _contains_invalid_path_codepoint(path: String) -> bool:
+ for i in path.length():
+ var codepoint := path.unicode_at(i)
+ if codepoint == 0x0000 or codepoint == 0xFFFD:
+ return true
+ return false
+
+
+## Returns "" when the path is a safe `res://`-rooted reference inside the
+## project root. Returns a human-readable error message otherwise.
+## Prefer `path_error` over calling this directly — it wraps the message in the
+## canonical error code.
+##
+## Pass `for_write = true` for any handler that creates/overwrites the file
+## (write_file, create_script, patch_script, ResourceSaver-backed saves,
+## scene saves). Write callers additionally refuse the project manifest and
+## startup override, plus the `.godot/` metadata dir. Reads default to
+## `for_write = false`, which permits inspecting those files.
+static func validate_resource_path(path: String, for_write: bool = false) -> String:
+ if path.is_empty():
+ return "Missing required param: path"
+ if _contains_invalid_path_codepoint(path):
+ return "Path must not contain null bytes or invalid Unicode"
+ if not path.begins_with("res://"):
+ return "Path must start with res://"
+ var confine_err := _confine_under(path, _res_root(), "res://")
+ if not confine_err.is_empty():
+ return confine_err
+ if for_write:
+ return _reject_sensitive_write(path)
+ return ""
+
+
+## Returns "" when `path` is safe to hand to `ResourceLoader.load` / `.exists`.
+## Accepts, in addition to confined `res://` paths:
+## * `uid://` — an opaque 64-bit resource id; it cannot express a path
+## and the engine only ever resolves it to a resource already in the
+## project, so there is nothing to confine.
+## * `user://…` — the per-project user data dir, confined under its root the
+## same way `res://` is (so `user://../…` can't escape the sandbox).
+static func validate_loadable_path(path: String) -> String:
+ if path.is_empty():
+ return "Missing required param: path"
+ if _contains_invalid_path_codepoint(path):
+ return "Path must not contain null bytes or invalid Unicode"
+ if path.begins_with("uid://"):
+ return ""
+ if path.begins_with("user://"):
+ return _confine_under(path, _user_root(), "user://")
+ if path.begins_with("res://"):
+ return _confine_under(path, _res_root(), "res://")
+ return "Path must start with res://, uid://, or user://"
+
+
+## Shared traversal + under-root containment. `root` must already be simplified.
+static func _confine_under(path: String, root: String, label: String) -> String:
+ if ".." in path:
+ return "Path must not contain '..' (path traversal not allowed)"
+ var globalized := ProjectSettings.globalize_path(path).simplify_path()
+ # Append a separator so `/proj_evil/...` can't pretend to be inside `/proj`
+ # via prefix match. `globalized == root` covers the bare `res://` / `user://`.
+ if globalized != root and not globalized.begins_with(root + "/"):
+ return "Path must resolve under %s root" % label
+ return ""
+
+
+## Refuse writes that would clobber project-critical files. The path is already
+## confirmed `res://`-rooted and traversal-free by the caller.
+##
+## Comparisons are case-folded: macOS (APFS) and Windows (NTFS) are
+## case-insensitive by default, so `res://Project.godot` resolves to the real
+## `project.godot` and must be refused too.
+##
+## `.import` sidecars are deliberately NOT blocked — editing an asset's import
+## options then re-importing is a legitimate, recoverable workflow (the file is
+## source-controlled). The blocked set is the startup-execution surface only:
+## the manifest, its `override.cfg` shadow, and the `.godot/` cache dir.
+static func _reject_sensitive_write(path: String) -> String:
+ # Normalize once, then reuse for filename and segment checks. The writer
+ # (`FileAccess::fix_path` / `FileAccessWindows`) folds `\`, `//`, and `.`
+ # the same way, so `res://project.godot/.` and `res://override.cfg/.` must
+ # not slip past `get_file()` as a trailing `.` while still opening the
+ # protected file.
+ #
+ # - `res://.godot\uid_cache.bin` used to collapse to one fused segment
+ # and match neither this check nor the loaded-plugin one below, while the
+ # write itself still landed in `.godot/`.
+ # - a `.` segment shifts the *anchored* plugin-tree check below, so
+ # `res://./addons/godot_ai/plugin.gd` slipped past it even once the
+ # separators were normalized.
+ #
+ # `..` never reaches here -- `_confine_under` refuses it first -- so its
+ # folding is inert. Because the engine normalizes backslashes on Linux too,
+ # a POSIX file literally named `addons\godot_ai\x.gd` is not addressable
+ # through `res://` at all, so normalizing costs no legitimate write.
+ var normalized := path.replace("\\", "/").simplify_path()
+ var file_lower := normalized.get_file().to_lower()
+ if file_lower == "project.godot":
+ return "Refusing to write res://project.godot (project manifest)"
+ if file_lower == "override.cfg":
+ return "Refusing to write res://override.cfg (startup config override)"
+ # Reject the `.godot/` editor-metadata dir at any depth. Split drops empty
+ # segments so a trailing slash can't hide a segment from the check.
+ var segments := normalized.trim_prefix("res://").split("/", false)
+ for segment in segments:
+ if segment.to_lower() == ".godot":
+ return "Refusing to write under res://.godot/ (editor metadata)"
+ # Reject the currently-loaded plugin's own script tree. Overwriting a
+ # loaded .gd here (plus the immediate reimport triggered by update_file())
+ # can SIGABRT the editor mid-call, or silently corrupt the installed
+ # plugin so the next enable fails to resolve scripts.
+ if segments.size() >= 2 and segments[0].to_lower() == "addons" and segments[1].to_lower() == "godot_ai":
+ return "Refusing to write under res://addons/godot_ai/ (overwriting a loaded plugin script can crash the editor)"
+ return ""
+
+
+## Validate a write/read `res://` path and return a ready error dict, or null
+## when the path is fine. The single wrapper every handler should use so the
+## error code (VALUE_OUT_OF_RANGE — a bad path is a value-domain error) stays
+## consistent. `param_name` is prefixed onto the message for context.
+static func path_error(path: String, param_name: String = "path", for_write: bool = false) -> Variant:
+ if path.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: %s" % param_name)
+ var err := validate_resource_path(path, for_write)
+ if err.is_empty():
+ return null
+ return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "%s: %s" % [param_name, err])
+
+
+## Same as `path_error` but for paths handed to `ResourceLoader` (allows
+## `uid://` / `user://`). Returns a ready error dict or null. An empty path is
+## reported as MISSING_REQUIRED_PARAM rather than a value error.
+static func loadable_error(path: String, param_name: String = "path") -> Variant:
+ if path.is_empty():
+ return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: %s" % param_name)
+ var err := validate_loadable_path(path)
+ if err.is_empty():
+ return null
+ return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "%s: %s" % [param_name, err])
diff --git a/addons/godot_ai/utils/path_validator.gd.uid b/addons/godot_ai/utils/path_validator.gd.uid
new file mode 100644
index 0000000..8ed4acf
--- /dev/null
+++ b/addons/godot_ai/utils/path_validator.gd.uid
@@ -0,0 +1 @@
+uid://blxntmd65ljyu
diff --git a/addons/godot_ai/utils/plugin_reload.gd b/addons/godot_ai/utils/plugin_reload.gd
new file mode 100644
index 0000000..5577f51
--- /dev/null
+++ b/addons/godot_ai/utils/plugin_reload.gd
@@ -0,0 +1,125 @@
+@tool
+extends RefCounted
+
+## Ordinary in-editor reload only. Transaction activation keeps its own
+## quiescence, lock and readiness protocol; this is not a hot-update grant.
+const PLUGIN_CFG := "res://addons/godot_ai/plugin.cfg"
+const ScriptWork := preload("res://addons/godot_ai/utils/script_work.gd")
+## A reload waits for the filesystem scan it asks for. An editor that is
+## still importing at startup can take well over 5 s to answer on a slow
+## host, and a reload abandoned then leaves the plugin unchanged while the
+## caller keeps waiting; stay under the server's 90 s reconnect budget.
+const SCAN_TIMEOUT_SECONDS := 60.0
+## The native timer's budget; tests shorten it to prove the timer itself
+## (not its length) settles an unfinished scan under a paused time scale.
+static var _scan_timeout_seconds: float = SCAN_TIMEOUT_SECONDS
+
+## One command-scoped native-signal handoff, not a suspended GDScript frame.
+## Named callbacks survive the source reload caused by the scan itself.
+static var _pending_scan: Dictionary = {}
+
+
+static func is_reload_pending() -> bool:
+ return not _pending_scan.is_empty()
+
+
+## Reserve before deferring the scan so the current dispatcher tick cannot
+## start another handler which pumps the already-scheduled reload callback.
+static func reserve_reload() -> int:
+ if is_reload_pending():
+ return 0
+ var work := ScriptWork.begin("reload_plugin")
+ _pending_scan = {"work": work}
+ return work
+
+
+static func reload_after_scan(work: int = 0) -> void:
+ if work == 0:
+ work = reserve_reload()
+ if work == 0:
+ return
+ elif _pending_scan.get("work", 0) != work or bool(_pending_scan.get("toggling", false)):
+ return # A direct reload or cancellation consumed this deferred request.
+ _start_scan(EditorInterface.get_resource_filesystem(), null, work)
+
+
+static func _start_scan(filesystem: Object, timer: Object, work: int) -> void:
+ if not _pending_scan.is_empty() and (
+ _pending_scan.get("work", 0) != work or _pending_scan.has("filesystem")
+ or bool(_pending_scan.get("toggling", false))
+ ):
+ if _pending_scan.get("work", 0) != work:
+ ScriptWork.finish(work)
+ push_error("MCP | a plugin reload is already waiting for its filesystem scan")
+ return
+ if timer == null:
+ # Editor deadlines must not inherit a project's pause or time scale.
+ timer = Engine.get_main_loop().create_timer(_scan_timeout_seconds, true, false, true)
+ var complete := _finish_scan.bind(work, false)
+ var timeout := _finish_scan.bind(work, true)
+ _pending_scan = {"work": work, "filesystem": filesystem, "timer": timer,
+ "complete": complete, "timeout": timeout}
+ filesystem.filesystem_changed.connect(complete, CONNECT_ONE_SHOT | CONNECT_DEFERRED)
+ timer.timeout.connect(timeout, CONNECT_ONE_SHOT)
+ filesystem.scan()
+
+
+static func _take_scan() -> Dictionary:
+ var pending := _pending_scan
+ _pending_scan = {}
+ if pending.has("filesystem"):
+ if pending.filesystem.filesystem_changed.is_connected(pending.complete):
+ pending.filesystem.filesystem_changed.disconnect(pending.complete)
+ if pending.timer.timeout.is_connected(pending.timeout):
+ pending.timer.timeout.disconnect(pending.timeout)
+ return pending
+
+
+static func _finish_scan(work: int, timed_out: bool) -> void:
+ # A queued callback from an earlier scan can never consume a later request.
+ if _pending_scan.get("work", 0) != work or not _pending_scan.has("filesystem"):
+ return
+ if timed_out:
+ var pending := _take_scan()
+ ScriptWork.finish(pending.work)
+ push_error(
+ "MCP | filesystem scan did not finish within %d s; plugin left unchanged, retry reload"
+ % int(_scan_timeout_seconds)
+ )
+ else:
+ reload_enabled_plugin()
+
+
+static func reload_enabled_plugin() -> Error:
+ # Keep the existing reservation through teardown: toggling the plugin can
+ # itself pump frames. Direct callers also gate dispatch during the toggle.
+ if not is_reload_pending():
+ reserve_reload()
+ elif bool(_pending_scan.get("toggling", false)):
+ return ERR_BUSY
+ # Disconnect scan signals before a toggle pumps frames, but retain the
+ # work reservation so already-queued callbacks cannot start another toggle.
+ var pending := _take_scan()
+ _pending_scan = {"work": pending.work, "toggling": true}
+ var result := _toggle_enabled_plugin()
+ _take_scan()
+ ScriptWork.finish(pending.work)
+ return result
+
+
+static func _toggle_enabled_plugin() -> Error:
+ if not EditorInterface.is_plugin_enabled(PLUGIN_CFG):
+ push_error("MCP | cannot reload a disabled plugin")
+ return ERR_UNAVAILABLE
+ EditorInterface.set_plugin_enabled(PLUGIN_CFG, false)
+ EditorInterface.set_plugin_enabled(PLUGIN_CFG, true)
+ if not EditorInterface.is_plugin_enabled(PLUGIN_CFG):
+ push_error("MCP | plugin could not be re-enabled after reload")
+ return FAILED
+ ## Plugin callbacks save autoload changes while the temporary disabled state
+ ## is still visible. Persist only after set_plugin_enabled has completed, or
+ ## the next editor process can reopen with this working plugin disabled.
+ var error := ProjectSettings.save()
+ if error != OK:
+ push_error("MCP | reloaded plugin enablement could not be saved: %s" % error_string(error))
+ return error
diff --git a/addons/godot_ai/utils/plugin_reload.gd.uid b/addons/godot_ai/utils/plugin_reload.gd.uid
new file mode 100644
index 0000000..711b7a4
--- /dev/null
+++ b/addons/godot_ai/utils/plugin_reload.gd.uid
@@ -0,0 +1 @@
+uid://c287g7fv5n3en
diff --git a/addons/godot_ai/utils/resource_io.gd b/addons/godot_ai/utils/resource_io.gd
new file mode 100644
index 0000000..bde3a51
--- /dev/null
+++ b/addons/godot_ai/utils/resource_io.gd
@@ -0,0 +1,286 @@
+@tool
+class_name McpResourceIO
+extends RefCounted
+
+const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
+
+## Shared helpers for "save a Resource to .tres" and the mutually-exclusive
+## path-vs-resource_path param validation that every resource-authoring
+## handler needs. Extracted to remove 4-way duplication across
+## resource_handler, environment_handler, texture_handler, and curve_handler.
+## Also home to the shared write-a-text-file path + deferred import-settle
+## completion used by both script_handler.create_script and
+## filesystem_handler.write_file (#714).
+
+# Bounded settle window for `ResourceLoader.exists(path)` after a fresh text
+# write registers with the filesystem, so an agent calling
+# create_script/write_file -> attach_script back-to-back doesn't race the
+# editor's import pipeline (#261, extended to write_file by #714). Polled once
+# per frame, with an elapsed-time cap below the dispatcher's deferred timeouts
+# for both commands. If import is still not visible at the cap, we still
+# return committed data instead of letting the already-written file surface
+# as DEFERRED_TIMEOUT.
+const IMPORT_SETTLE_MAX_FRAMES := 300
+const IMPORT_SETTLE_MAX_MSEC := 3500
+
+
+## Validate that exactly one of {path, resource_path} is provided.
+##
+## When `require_property` is true (default), also requires a non-empty
+## `property` param when `path` is given — this matches the semantics of
+## "assign a resource to node.property" (resource_create, texture tools,
+## curve_set_points). Pass false for tools where the path itself IS the
+## target (environment_create assigning to WorldEnvironment.environment).
+##
+## Returns null on success or an error dict on failure.
+static func validate_home(params: Dictionary, require_property: bool = true) -> Variant:
+ var node_path: String = params.get("path", "")
+ var property: String = params.get("property", "")
+ var resource_path: String = params.get("resource_path", "")
+ var has_node_target := not node_path.is_empty()
+ var has_file_target := not resource_path.is_empty()
+
+ if has_node_target and has_file_target:
+ var both_msg := "Provide either path+property or resource_path, not both" if require_property else "Provide either path or resource_path, not both"
+ return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, both_msg)
+ if not has_node_target and not has_file_target:
+ var none_msg := "Must provide either path+property (assign inline) or resource_path (save .tres)" if require_property else "Must provide either path or resource_path"
+ return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, none_msg)
+ if require_property and has_node_target and property.is_empty():
+ return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "Missing required param: property (required when path is given)")
+ return null
+
+
+## Save `res` to `resource_path` as a .tres/.res file.
+##
+## Handles: res:// prefix validation, overwrite check, parent-directory
+## creation, ResourceSaver.save error reporting, and the post-save
+## EditorFileSystem.update_file() so the dock picks up the change.
+##
+## `label` is the human-readable resource-kind for error messages (e.g.
+## "Environment", "Gradient texture", "Curve"). `extra_fields` is merged
+## into the success response alongside the standard fields
+## (`resource_path`, `overwritten`, `undoable: false`, `reason`). Passing
+## a `reason` key in `extra_fields` overrides the default — useful for
+## tools that edit existing files rather than creating fresh ones.
+##
+## `pause_target` should be the handler's `McpConnection`. When supplied,
+## `pause_processing` is flipped on around `ResourceSaver.save()` so the
+## dispatcher's WebSocket pump can't re-enter while Godot pumps
+## `Main::iteration()` for the resource-save's progress UI / script-class
+## update task. Without this guard a queued command landing during the
+## save can trigger another `save_to_disk` that tries to add the same
+## `update_scripts_classes` editor task — "Task already exists" → null
+## deref → SIGSEGV. Same family of bug as godotengine/godot#118545 and
+## the same mitigation as `SceneHandler`'s `save_scene*` wraps. See
+## issue #288.
+##
+## Returns either an error dict or a {"data": {...}} success dict — ready
+## for the handler to return directly.
+static func save_to_disk(
+ res: Resource,
+ resource_path: String,
+ overwrite: bool,
+ label: String,
+ extra_fields: Dictionary = {},
+ pause_target: McpConnection = null,
+) -> Dictionary:
+ var path_err = McpPathValidator.path_error(resource_path, "resource_path", true)
+ if path_err != null:
+ return path_err
+
+ var existed_before := FileAccess.file_exists(resource_path)
+ if existed_before and not overwrite:
+ return ErrorCodes.make(
+ ErrorCodes.INVALID_PARAMS,
+ "%s already exists at %s (pass overwrite=true to replace)" % [label, resource_path]
+ )
+ # Captured BEFORE the overwrite below so a resave of an already-uid'd file
+ # (overwrite=true) can restore its own uid instead of losing it — see
+ # ensure_uid's doc comment.
+ var prior_uid := ResourceLoader.get_resource_uid(resource_path) if existed_before else ResourceUID.INVALID_ID
+
+ var dir_path := resource_path.get_base_dir()
+ var mkdir_err := DirAccess.make_dir_recursive_absolute(dir_path)
+ if mkdir_err != OK and mkdir_err != ERR_ALREADY_EXISTS:
+ return ErrorCodes.make(
+ ErrorCodes.INTERNAL_ERROR,
+ "Failed to create directory %s: %s" % [dir_path, error_string(mkdir_err)]
+ )
+
+ if pause_target != null:
+ pause_target.pause_processing = true
+ var save_err := ResourceSaver.save(res, resource_path)
+ if pause_target != null:
+ pause_target.pause_processing = false
+ if save_err != OK:
+ return ErrorCodes.make(
+ ErrorCodes.INTERNAL_ERROR,
+ "Failed to save %s to %s: %s" % [label, resource_path, error_string(save_err)]
+ )
+ var uid_err := ensure_uid(resource_path, prior_uid)
+ if uid_err != OK:
+ return ErrorCodes.make(
+ ErrorCodes.INTERNAL_ERROR,
+ "%s saved to %s but failed to write its uid: %s" % [label, resource_path, error_string(uid_err)]
+ )
+
+ var efs := EditorInterface.get_resource_filesystem()
+ if efs != null:
+ efs.update_file(resource_path)
+
+ var data := {
+ "resource_path": resource_path,
+ "overwritten": existed_before,
+ "undoable": false,
+ "reason": "File creation is persistent; delete the file manually to revert",
+ }
+ attach_cleanup_hint(data, existed_before, [resource_path])
+ # merge with overwrite=true so callers (e.g. curve_set_points editing an
+ # existing .tres) can supply a domain-specific `reason`.
+ data.merge(extra_fields, true)
+ return {"data": data}
+
+
+## Save `res` to `resource_path` with the same `pause_processing` re-entrancy
+## guard as `save_to_disk` (see its doc for the #288 SIGSEGV background), for
+## call sites that need to pick their own error handling / overwrite policy
+## instead of `save_to_disk`'s full validate+mkdir+overwrite-guard bundle
+## (undo/redo callables reloading-mutating-resaving an existing resource,
+## `apply_to_node`'s inline-then-save branch). Returns the raw
+## `ResourceSaver.save` error code.
+static func guarded_save(res: Resource, resource_path: String, pause_target: McpConnection) -> int:
+ var prior_uid := ResourceLoader.get_resource_uid(resource_path) if FileAccess.file_exists(resource_path) else ResourceUID.INVALID_ID
+ if pause_target != null:
+ pause_target.pause_processing = true
+ var save_err := ResourceSaver.save(res, resource_path)
+ if pause_target != null:
+ pause_target.pause_processing = false
+ if save_err != OK:
+ return save_err
+ return ensure_uid(resource_path, prior_uid)
+
+
+## Make `resource_path` carry a stable uid after a successful
+## `ResourceSaver.save()`, matching what Godot's own "New Scene"/"New
+## Resource" editor flows always embed. A bare `ResourceSaver.save()` call
+## does neither on its own: a brand-new file gets no `uid=` at all, and
+## resaving a file that already had one silently drops it (#737). Call this
+## immediately after every successful save.
+##
+## `prior_uid` is whatever `ResourceLoader.get_resource_uid(resource_path)`
+## returned BEFORE this save overwrote the file (pass `ResourceUID.INVALID_ID`
+## for a brand-new path). Reusing the prior id — instead of always minting a
+## fresh one — keeps any `uid://...` references elsewhere in the project
+## resolving to the same file.
+##
+## Returns the `Error` from `ResourceSaver.set_uid()` so callers can surface a
+## uid-write failure instead of silently reporting success on a file that
+## didn't end up with the uid it was supposed to get.
+static func ensure_uid(resource_path: String, prior_uid: int) -> Error:
+ var id := prior_uid
+ if id == ResourceUID.INVALID_ID:
+ id = ResourceUID.create_id()
+ return ResourceSaver.set_uid(resource_path, id)
+
+
+## Attach a `cleanup.rm` hint listing `paths` to `data` — only when the call
+## just created a new file (`existed_before == false`). On overwrite the field
+## is omitted because the caller already had the file on disk, and handing
+## them a cleanup list would invite dropping user content instead of just
+## scratch artifacts. Used by write-and-return handlers (create_script,
+## filesystem_write_text, resource_create/save_to_disk) so callers running
+## transient smoke tests can rm artifacts without tracking paths. See #82.
+static func attach_cleanup_hint(data: Dictionary, existed_before: bool, paths: Array) -> void:
+ if existed_before:
+ return
+ data["cleanup"] = {"rm": paths}
+
+
+## Shared write-a-text-file path (#714): parent-directory mkdir, write +
+## flush with an explicit error check so a truncated write (disk full,
+## permission flip mid-write) surfaces as an error instead of plain success.
+## Deliberately does NOT call `EditorFileSystem.update_file()` — callers
+## register the file themselves after assembling their response fields, so
+## the registration comment (the dsarno/godot#6 scan-stacking rationale)
+## stays next to the call. Returns null on success or an error dict ready
+## to return from the handler.
+static func write_text_to_disk(path: String, content: String) -> Variant:
+ var dir_path := path.get_base_dir()
+ if not DirAccess.dir_exists_absolute(dir_path):
+ var err := DirAccess.make_dir_recursive_absolute(dir_path)
+ if err != OK:
+ return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to create directory: %s" % dir_path)
+
+ var file := FileAccess.open(path, FileAccess.WRITE)
+ if file == null:
+ return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to open file for writing: %s" % path)
+
+ file.store_string(content)
+ file.flush()
+ var write_err := file.get_error()
+ file.close()
+ if write_err != OK:
+ return ErrorCodes.make(
+ ErrorCodes.INTERNAL_ERROR,
+ "Write failed for %s (%s); file may be truncated" % [path, error_string(write_err)]
+ )
+ return null
+
+
+# `static` is load-bearing: the deferred completion captures no `self`, so the
+# coroutine survives even if the calling handler RefCounted is freed mid-await.
+# Under concurrent create storms with editor_reload_plugin fired during the
+# burst, an instance-method coroutine is otherwise GC'd between `await` and
+# resume, producing "Resumed function ... after await, but class instance is
+# gone" errors and dropping the response. Keep this function static and
+# parameterise everything it needs explicitly — do not reference instance
+# state. Shared by create_script and write_file's fresh-`.gd` path (#714).
+static func finish_text_write_deferred(
+ connection: McpConnection,
+ request_id: String,
+ path: String,
+ data: Dictionary,
+) -> void:
+ var ledger := preload("res://addons/godot_ai/utils/script_work.gd")
+ var work: int = ledger.begin("write_file")
+ await _settle_text_write(connection, request_id, path, data)
+ ledger.finish(work)
+
+
+static func _settle_text_write(
+ connection: McpConnection, request_id: String, path: String, data: Dictionary,
+) -> void:
+ if not is_instance_valid(connection):
+ return
+ var tree := connection.get_tree()
+ if tree == null:
+ return
+ var deadline_ms := Time.get_ticks_msec() + IMPORT_SETTLE_MAX_MSEC
+ # Let _dispatch() return DEFERRED_RESPONSE and register the request before
+ # this coroutine can send a committed result. ResourceLoader.exists(path)
+ # may already be true on fast imports; without this handoff the connection
+ # treats the response as late/unregistered and drops it, then the dispatcher
+ # times out a file that was already written (#324). The deadline starts
+ # before this await so a slow handoff frame is counted against the bounded
+ # settle window.
+ await tree.process_frame
+ var frames := 0
+ while (
+ frames < IMPORT_SETTLE_MAX_FRAMES
+ and Time.get_ticks_msec() < deadline_ms
+ and not ResourceLoader.exists(path)
+ ):
+ await tree.process_frame
+ frames += 1
+ # If the plugin tears down (_exit_tree frees the connection) during the
+ # await, is_instance_valid() goes false and we drop the response silently —
+ # the server's request timeout will surface the failure to the caller.
+ if not is_instance_valid(connection):
+ return
+ var payload := data.duplicate()
+ var settled := ResourceLoader.exists(path)
+ payload["import_settled"] = settled
+ payload["import_settle"] = "settled" if settled else "timeout"
+ payload["import_pending"] = not settled
+ connection.send_deferred_response(request_id, {"data": payload})
diff --git a/addons/godot_ai/utils/resource_io.gd.uid b/addons/godot_ai/utils/resource_io.gd.uid
new file mode 100644
index 0000000..94da4c9
--- /dev/null
+++ b/addons/godot_ai/utils/resource_io.gd.uid
@@ -0,0 +1 @@
+uid://de2rwdoa4wabf
diff --git a/addons/godot_ai/utils/scene_path.gd b/addons/godot_ai/utils/scene_path.gd
new file mode 100644
index 0000000..782aea1
--- /dev/null
+++ b/addons/godot_ai/utils/scene_path.gd
@@ -0,0 +1,155 @@
+@tool
+class_name McpScenePath
+extends RefCounted
+
+const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
+
+## Utility for converting between Godot internal node paths and clean
+## scene-relative paths like /Main/Camera3D.
+
+
+## Return a clean path relative to the scene root (e.g. /Main/Camera3D).
+## Returns "" when `node` is not the scene root or a descendant of it —
+## without the ancestry guard, get_path_to() returns an empty NodePath that
+## concatenates into a plausible-looking but invalid "/Main/".
+static func from_node(node: Node, scene_root: Node) -> String:
+ if scene_root == null or node == null:
+ return ""
+ if node == scene_root:
+ return "/" + scene_root.name
+ if not scene_root.is_ancestor_of(node):
+ return ""
+ var relative := scene_root.get_path_to(node)
+ return "/" + scene_root.name + "/" + str(relative)
+
+
+## Resolve a clean scene path like "/Main/Camera3D" to the actual node.
+##
+## Accepts forms relative to the edited scene root:
+## "/Main" — explicit root prefix (canonical)
+## "/Main/Camera3D" — descendant path
+## "Camera3D" — bare relative to scene_root
+## "World/Ground" — nested bare relative to scene_root
+##
+## Also accepts SceneTree-style "/root/[/...]" as an alias for
+## the edited scene root. Agents reach for /root/Foo right after creating a
+## scene because that's where scenes live at runtime; we honor it so the call
+## doesn't fail with a confusing "not found" error. The alias only kicks in
+## when the segment after /root matches the scene root's name — paths like
+## "/root/@EditorNode@.../Main/..." (returned by Node.get_path() in the editor)
+## fall through to the absolute-path fallback unchanged.
+static func resolve(scene_path: String, scene_root: Node) -> Node:
+ if scene_root == null:
+ return null
+
+ ## Bare "/" alias: the most natural first guess for "the scene root".
+ ## There is exactly one edited-scene root, so the alias is unambiguous;
+ ## without it, "/" falls through to get_node_or_null("/") → null and
+ ## every call costs the agent a NODE_NOT_FOUND round trip (issue #624).
+ if scene_path == "/":
+ return scene_root
+
+ ## /root/[/...] alias: strip the /root prefix and recurse.
+ ## Match the scene root by name explicitly so we don't capture editor-
+ ## internal paths that legitimately live under /root.
+ var alias_prefix := "/root/" + scene_root.name
+ if scene_path == alias_prefix or scene_path.begins_with(alias_prefix + "/"):
+ return resolve(scene_path.substr(5), scene_root) # keep leading slash
+
+ var root_prefix := "/" + scene_root.name
+ if scene_path == root_prefix:
+ return scene_root
+ if scene_path.begins_with(root_prefix + "/"):
+ var relative := scene_path.substr(root_prefix.length() + 1)
+ return scene_root.get_node_or_null(relative)
+
+ # Try as-is (relative path, or absolute SceneTree path).
+ return scene_root.get_node_or_null(scene_path)
+
+
+## Return the edited scene root, or an error dict if the editor has no open
+## scene or the open scene doesn't match `expected_scene_file`.
+##
+## `expected_scene_file` is the caller's `scene_file` parameter — an empty
+## string means "target whatever is currently edited" (current behaviour,
+## no guard). A non-empty value must match `scene_file_path` on the current
+## edited scene root exactly, or we return EDITED_SCENE_MISMATCH so the
+## caller can re-open the right scene.
+##
+## Shape on success: {"node": }. Shape on error matches
+## `ErrorCodes.make()` so callers can propagate the result directly.
+static func require_edited_scene(expected_scene_file: String) -> Dictionary:
+ var root := EditorInterface.get_edited_scene_root()
+ if root == null:
+ # Mirrors the structured payload that the Python-side require_writable
+ # gate attaches for `playing` / `importing`. Together these cover the
+ # three recoverable editor *states* (playing / importing / no_scene)
+ # — the EDITOR_NOT_READY paths an AI caller can act on. Other
+ # EDITOR_NOT_READY callsites describing internal-state failures
+ # ("EditorFileSystem not available" etc.) carry sub_code + retryable
+ # via ErrorCodes.make_not_ready (#651 stage 1) but intentionally
+ # omit the hint — there's no useful caller action to name.
+ var err := ErrorCodes.make(ErrorCodes.EDITOR_NOT_READY, "No scene open")
+ err["error"]["data"] = {
+ "sub_code": ErrorCodes.SUB_EDITOR_NO_SCENE,
+ "editor_state": "no_scene",
+ "retryable": false,
+ "hint": (
+ "No scene is open. Call scene_open with a scene path "
+ + "(e.g. \"res://main.tscn\") before issuing scene-mutating tools."
+ ),
+ }
+ return err
+ if not expected_scene_file.is_empty() and root.scene_file_path != expected_scene_file:
+ var actual := root.scene_file_path if not root.scene_file_path.is_empty() else ""
+ return ErrorCodes.make(
+ ErrorCodes.EDITED_SCENE_MISMATCH,
+ (
+ "Expected edited scene \"%s\" but \"%s\" is active. "
+ + "Call scene_open(\"%s\") first, or omit scene_file to target the active scene."
+ ) % [expected_scene_file, actual, expected_scene_file],
+ )
+ return {"node": root}
+
+
+## Format a "parent not found" error that names the path convention.
+## Agents routinely try /root/Foo or absolute SceneTree paths; the bare
+## "Parent not found: X" gave them no hint that paths are scene-relative.
+## Wording is generic ("Paths are relative...") so the helper works for any
+## param name (parent_path, new_parent, …).
+static func format_parent_error(path: String, scene_root: Node) -> String:
+ if scene_root == null:
+ return "Parent not found: %s. No edited scene is open." % path
+ var root_name := str(scene_root.name)
+ return "Parent not found: %s. Paths are relative to the edited scene root (e.g. \"/%s\" or \"\"), not the SceneTree. Scene root is \"/%s\"." % [path, root_name, root_name]
+
+
+## Format a "node not found" error that names the path convention and, when
+## possible, suggests a corrected path. Agents routinely pass /root/Foo
+## (runtime SceneTree) or unprefixed names; the bare "Node not found: X"
+## gives no hint that paths are edited-scene-relative.
+##
+## Suggestion logic (highest-confidence first):
+## 1. /root/[/...] where is not the scene root → suggest //[/...]
+## 2. path doesn't start with "/" → suggest "//"
+## 3. otherwise no concrete "did you mean", just the convention reminder.
+static func format_node_error(path: String, scene_root: Node) -> String:
+ if scene_root == null:
+ return "Node not found: %s. No edited scene is open." % path
+ var root_name := str(scene_root.name)
+ var suggestion := ""
+
+ if path.begins_with("/root/"):
+ var after_root := path.substr(6) # "/root/" is 6 chars
+ # Only suggest if the segment after /root/ isn't already the scene root
+ # (resolve() handles /root//... as an alias, so a failure
+ # with that prefix means a deeper segment is wrong — no clean rewrite).
+ var first_seg := after_root.split("/")[0]
+ if first_seg != root_name and not first_seg.is_empty():
+ suggestion = "/" + root_name + "/" + after_root
+ elif not path.begins_with("/") and not path.is_empty():
+ suggestion = "/" + root_name + "/" + path
+
+ if suggestion.is_empty():
+ return "Node not found: %s. Paths are relative to the edited scene root (e.g. \"/%s/Child\"), not runtime /root/... paths. Scene root is \"/%s\"." % [path, root_name, root_name]
+ return "Node not found: %s. Did you mean \"%s\"? Paths are relative to the edited scene root, not runtime /root/... paths. Scene root is \"/%s\"." % [path, suggestion, root_name]
diff --git a/addons/godot_ai/utils/scene_path.gd.uid b/addons/godot_ai/utils/scene_path.gd.uid
new file mode 100644
index 0000000..85795a9
--- /dev/null
+++ b/addons/godot_ai/utils/scene_path.gd.uid
@@ -0,0 +1 @@
+uid://c1irdrss0amex
diff --git a/addons/godot_ai/utils/screenshot_encode.gd b/addons/godot_ai/utils/screenshot_encode.gd
new file mode 100644
index 0000000..e3b0210
--- /dev/null
+++ b/addons/godot_ai/utils/screenshot_encode.gd
@@ -0,0 +1,38 @@
+@tool
+class_name McpScreenshotEncode
+extends RefCounted
+
+## Shared downscale + PNG + base64 block for the screenshot paths (#716).
+##
+## Two call sites straddle the editor/game process boundary — the editor's
+## take_screenshot (editor_handler) and the game-process autoload
+## (runtime/game_helper) — and were maintained as manually synchronized
+## copies. Pure static, no editor APIs, so it loads safely in the game
+## process too.
+
+
+## Downscale `image` in place so its longest edge is at most
+## `max_resolution` (0 = no cap), then PNG-encode. Returns
+## {base64, width, height, original_width, original_height}.
+static func downscale_and_encode(image: Image, max_resolution: int) -> Dictionary:
+ var original_width := image.get_width()
+ var original_height := image.get_height()
+
+ if max_resolution > 0:
+ var longest := maxi(original_width, original_height)
+ if longest > max_resolution:
+ var scale := float(max_resolution) / float(longest)
+ ## Clamp to 1px min: extreme aspect ratios at very small
+ ## max_resolution could otherwise compute a zero dimension and
+ ## crash image.resize().
+ var new_w := maxi(1, int(original_width * scale))
+ var new_h := maxi(1, int(original_height * scale))
+ image.resize(new_w, new_h, Image.INTERPOLATE_LANCZOS)
+
+ return {
+ "base64": Marshalls.raw_to_base64(image.save_png_to_buffer()),
+ "width": image.get_width(),
+ "height": image.get_height(),
+ "original_width": original_width,
+ "original_height": original_height,
+ }
diff --git a/addons/godot_ai/utils/screenshot_encode.gd.uid b/addons/godot_ai/utils/screenshot_encode.gd.uid
new file mode 100644
index 0000000..0bcdac2
--- /dev/null
+++ b/addons/godot_ai/utils/screenshot_encode.gd.uid
@@ -0,0 +1 @@
+uid://cl0xhoxwsbmow
diff --git a/addons/godot_ai/utils/script_work.gd b/addons/godot_ai/utils/script_work.gd
new file mode 100644
index 0000000..bb919a4
--- /dev/null
+++ b/addons/godot_ai/utils/script_work.gd
@@ -0,0 +1,29 @@
+@tool
+extends RefCounted
+
+## A response timeout is not proof that its coroutine has returned. Track
+## actual script execution across frame yields, independently of transport
+## bookkeeping. This is process-wide deliberately: an older composition may
+## still own a coroutine in the same scripts after an ordinary plugin reload.
+## Only the main thread may use this ledger; OS workers have separate joins.
+static var _next_id := 0
+static var _active: Dictionary = {}
+
+
+static func begin(label: String) -> int:
+ _next_id += 1
+ _active[_next_id] = label
+ return _next_id
+
+
+static func finish(id: int) -> void:
+ _active.erase(id)
+
+
+static func quiescence() -> Dictionary:
+ if not _active.is_empty():
+ return {
+ "ok": false,
+ "error": "Wait for active editor work before updating: %s" % str(_active.values()),
+ }
+ return {"ok": true}
diff --git a/addons/godot_ai/utils/script_work.gd.uid b/addons/godot_ai/utils/script_work.gd.uid
new file mode 100644
index 0000000..a97dd01
--- /dev/null
+++ b/addons/godot_ai/utils/script_work.gd.uid
@@ -0,0 +1 @@
+uid://ghcyiy3v3nsk
diff --git a/addons/godot_ai/utils/server_authority.gd b/addons/godot_ai/utils/server_authority.gd
new file mode 100644
index 0000000..eec177f
--- /dev/null
+++ b/addons/godot_ai/utils/server_authority.gd
@@ -0,0 +1,171 @@
+@tool
+extends RefCounted
+
+const TransportCapability := preload("res://addons/godot_ai/utils/transport_capability.gd")
+
+## Capability and process-control values used by the lifecycle owner. None of
+## these objects performs work or retains another owner. Keeping the three
+## grants distinct makes it impossible to turn "I can talk to this server"
+## into "I may kill or replace it" by accident.
+
+
+class TransportAuthority:
+ extends RefCounted
+
+ var _http_port: int
+ var _ws_port: int
+ var _server_instance_id: String
+ var _http_capability: String
+ var _ws_capability: String
+
+
+ func _init(
+ p_http_port: int,
+ p_ws_port: int,
+ p_server_instance_id: String,
+ p_http_capability: String,
+ p_ws_capability: String,
+ ) -> void:
+ _http_port = p_http_port
+ _ws_port = p_ws_port
+ _server_instance_id = p_server_instance_id
+ _http_capability = p_http_capability
+ _ws_capability = p_ws_capability
+
+
+ func is_valid() -> bool:
+ return (
+ _http_port > 0
+ and _http_port <= 65535
+ and _ws_port > 0
+ and _ws_port <= 65535
+ and TransportCapability.is_lower_hex(_server_instance_id, 32)
+ and TransportCapability.is_http_capability(_http_capability)
+ and TransportCapability.is_lower_hex(_ws_capability, 64)
+ and _http_capability != _ws_capability
+ )
+
+
+ func http_capability() -> String:
+ return _http_capability
+
+
+ func ws_capability() -> String:
+ return _ws_capability
+
+
+ func http_port() -> int:
+ return _http_port
+
+
+ func ws_port() -> int:
+ return _ws_port
+
+
+ func server_instance_id() -> String:
+ return _server_instance_id
+
+
+ ## Safe for UI/status fanout: secrets never leave the lifecycle/connection
+ ## boundary through a generic Dictionary snapshot.
+ func public_snapshot() -> Dictionary:
+ return {
+ "http_port": _http_port,
+ "ws_port": _ws_port,
+ "server_instance_id": _server_instance_id,
+ }
+
+
+class OwnedProcessGrant:
+ extends RefCounted
+
+ var _pid: int
+ var _process_fingerprint: String
+ var _issued_at_msec: int
+
+
+ func _init(
+ p_pid: int,
+ p_process_fingerprint: String,
+ p_issued_at_msec: int,
+ ) -> void:
+ _pid = p_pid
+ _process_fingerprint = p_process_fingerprint
+ _issued_at_msec = p_issued_at_msec
+
+
+ func is_valid() -> bool:
+ return (
+ _pid > 1
+ and _pid != OS.get_process_id()
+ and not _process_fingerprint.is_empty()
+ and _issued_at_msec > 0
+ )
+
+
+ func process_id() -> int:
+ return _pid
+
+
+ func matches(p_pid: int, p_fingerprint: String) -> bool:
+ return (
+ is_valid()
+ and _pid == p_pid
+ and _process_fingerprint == p_fingerprint
+ )
+
+
+class ReplacementAuthorization:
+ extends RefCounted
+
+ var _target_instance_id: String
+ var _target_version: String
+ var _target_port: int
+ var _expires_at_msec: int
+ var _spent := false
+
+
+ func _init(
+ p_target_instance_id: String,
+ p_target_version: String,
+ p_target_port: int,
+ p_expires_at_msec: int,
+ ) -> void:
+ _target_instance_id = p_target_instance_id
+ _target_version = p_target_version
+ _target_port = p_target_port
+ _expires_at_msec = p_expires_at_msec
+
+
+ func is_available(now_msec: int) -> bool:
+ return (
+ not _spent
+ and now_msec <= _expires_at_msec
+ and _target_port > 0
+ and not _target_instance_id.is_empty()
+ and not _target_version.is_empty()
+ )
+
+
+ ## Spend is intentionally binding and destructive. A caller cannot probe
+ ## one target and then reuse the same click-authority against another.
+ func spend(
+ now_msec: int,
+ p_target_instance_id: String,
+ p_target_version: String,
+ p_target_port: int,
+ ) -> bool:
+ if not is_available(now_msec):
+ return false
+ if (
+ _target_instance_id != p_target_instance_id
+ or _target_version != p_target_version
+ or _target_port != p_target_port
+ ):
+ return false
+ _spent = true
+ return true
+
+
+ func is_spent() -> bool:
+ return _spent
diff --git a/addons/godot_ai/utils/server_authority.gd.uid b/addons/godot_ai/utils/server_authority.gd.uid
new file mode 100644
index 0000000..7c17adf
--- /dev/null
+++ b/addons/godot_ai/utils/server_authority.gd.uid
@@ -0,0 +1 @@
+uid://2gx74pk3lpq2
diff --git a/addons/godot_ai/utils/server_lifecycle.gd b/addons/godot_ai/utils/server_lifecycle.gd
new file mode 100644
index 0000000..b4f1795
--- /dev/null
+++ b/addons/godot_ai/utils/server_lifecycle.gd
@@ -0,0 +1,1721 @@
+@tool
+class_name McpServerLifecycleManager
+extends RefCounted
+
+## One serialized lifecycle episode. Late effect results carry its ID.
+signal snapshot_changed(snapshot: Dictionary)
+signal transport_ready(ws_port: int, ws_capability: String)
+signal transport_cleared(reason: String)
+signal effect_requested(episode_id: int, effect: String, payload: Dictionary)
+signal startup_finished(path: String)
+
+const Authority := preload("res://addons/godot_ai/utils/server_authority.gd")
+const ClientConfigurator := preload("res://addons/godot_ai/client_configurator.gd")
+const PortResolver := preload("res://addons/godot_ai/utils/port_resolver.gd")
+const ServerState := preload("res://addons/godot_ai/utils/mcp_server_state.gd")
+const ServerVersionCheck := preload("res://addons/godot_ai/utils/server_version_check.gd")
+const TransportCapability := preload("res://addons/godot_ai/utils/transport_capability.gd")
+const UvResolution := preload("res://addons/godot_ai/utils/uv_resolution_policy.gd")
+
+const DORMANT := "DORMANT"
+const STARTING := "STARTING"
+const READY := "READY"
+const BLOCKED := "BLOCKED"
+const RECOVERING := "RECOVERING"
+## How long a server launched to replace an occupant may wait for the port.
+## A Windows update took 25.262 s from the child's port-wait phase until the
+## incumbent released the port. Keep time for those exact identity checks
+## and termination before the child gives up. Python caps this at the same
+## 60 s; the later capability proof has its own 180 s deadline.
+const REPLACEMENT_WAIT_FOR_PORT_MS := 60_000
+## How long the replacement gives its launched server to reach that port
+## wait before it kills the occupant. The launch may be uvx installing the
+## version the update just brought in; until the server reports it is at its
+## bind loop, the occupant keeps serving and the port never looks free to an
+## attach bridge that would spawn a backend of its own into the gap.
+const REPLACEMENT_LAUNCH_READY_TIMEOUT_MS := 120_000
+const STARTUP_PHASE_WAITING_FOR_PORT := "waiting_for_port"
+const STOPPING := "STOPPING"
+
+const PROBE := "PROBE"
+const LAUNCH := "LAUNCH"
+const PROVE := "PROVE"
+const REPLACE := "REPLACE"
+const STOP := "STOP"
+
+const STATUS_PATH := "/godot-ai/status"
+## Normal starts and recovery need the same settling window as post-update
+## adoption. A healthy authenticated status response can take over a second.
+const DEFAULT_PROBE_TIMEOUT_MS := 3000
+const STALE_PRE_V4_HINT := "stale_pre_v4_server"
+const DEFAULT_PROVE_TIMEOUT_MS := 180_000
+const LAUNCH_FINGERPRINT_TIMEOUT_MS := 15_000
+const REPLACEMENT_TTL_MS := 15_000
+## An authenticated endpoint that drops (a late keepalive, a server restart)
+## is re-probed automatically with this backoff, each attempt re-proving the
+## server exactly as a start does; after the last one the block stays until
+## the dock's Restart (#962). A server that stays ready this long earns a
+## fresh budget, so a flapping one cannot loop forever.
+const ENDPOINT_RECOVERY_DELAYS_SECONDS: Array[float] = [1.0, 2.0, 4.0, 8.0, 16.0]
+const ENDPOINT_RECOVERY_STABLE_MS := 60_000
+
+var _endpoint_recovery_attempts := 0
+## When the current READY began; a loss after a stable minute earns a fresh budget.
+var _ready_since_msec := 0
+## The episode whose re-probe is scheduled; 0 when none is pending.
+var _endpoint_recovery_pending_episode := 0
+const MAX_STATUS_BODY_BYTES := 8 * 1024
+
+var _episode: Dictionary = _dormant_episode(0)
+var _next_episode_id := 0
+var _plan: Dictionary = {}
+var _transport
+var _process_grant
+var _replacement_authorization
+var _effect: Dictionary = {}
+
+func configure(plan: Dictionary) -> void:
+ ## Capture caller-owned values once. Configuration is intentionally inert.
+ if not _plan.is_empty():
+ return
+ _plan = plan.duplicate(true)
+ _plan["http_port"] = int(_plan.get("http_port", ClientConfigurator.DEFAULT_HTTP_PORT))
+ _plan["ws_port"] = int(_plan.get("ws_port", ClientConfigurator.DEFAULT_WS_PORT))
+ _plan["expected_version"] = str(_plan.get("expected_version", ""))
+ _plan["capability_path"] = str(_plan.get("capability_path", ""))
+ _plan["probe_timeout_ms"] = int(_plan.get("probe_timeout_ms", DEFAULT_PROBE_TIMEOUT_MS))
+ _plan["prove_timeout_ms"] = int(_plan.get("prove_timeout_ms", DEFAULT_PROVE_TIMEOUT_MS))
+ _plan["keep_alive"] = bool(_plan.get("keep_alive", false))
+ _plan["automatic_effects"] = bool(_plan.get("automatic_effects", true))
+ _plan["defer_effects"] = bool(_plan.get("defer_effects", true))
+
+func start_server() -> void:
+ if _plan.is_empty():
+ _block_without_effect("not_configured", "Server lifecycle is not configured.")
+ return
+ if str(_episode.get("state")) in [STARTING, RECOVERING, STOPPING]:
+ return
+ if _process_grant != null:
+ force_restart_server()
+ return
+ _begin_start_episode()
+
+
+func _begin_start_episode(existing_id := 0, probe := true) -> void:
+ _cancel_effect()
+ _replacement_authorization = null
+ _transport = null
+ if existing_id <= 0:
+ _next_episode_id += 1
+ existing_id = _next_episode_id
+ _episode = {
+ "id": existing_id,
+ "state": STARTING,
+ "phase": PROBE if probe else LAUNCH,
+ "reason": "",
+ "message": "Probing the configured server endpoint." if probe else "Launching the managed server.",
+ "ready_kind": "",
+ "effect": "",
+ "expected_version": str(_plan.get("expected_version", "")),
+ "actual_version": "",
+ "blocked_target": {},
+ "launch": {},
+ "after_stop": "",
+ }
+ _publish()
+ if not probe:
+ return
+ _request_effect(PROBE, {
+ "http_port": int(_plan.http_port),
+ "expected_version": str(_plan.expected_version),
+ "expected_ws_port": int(_plan.ws_port),
+ "timeout_ms": int(_plan.probe_timeout_ms),
+ "grant": _process_grant,
+ })
+
+
+func stop_server(force_inline := false) -> void:
+ ## Ordinary duplicate Stop clicks coalesce. Update/exit teardown passes
+ ## force_inline: it must join even an existing STOP worker before scripts can
+ ## be replaced, then re-check the same exact grant synchronously.
+ if str(_episode.get("state")) == STOPPING and not force_inline:
+ return
+ _adopt_cancelled_launch(_cancel_effect())
+ _next_episode_id += 1
+ var launch: Dictionary = _episode.get("launch", {}).duplicate(true)
+ _episode = _stopping_episode(_next_episode_id, launch, "")
+ _transport = null
+ _replacement_authorization = null
+ transport_cleared.emit("server stopping")
+ _publish()
+ if _process_grant == null:
+ _finish_stop()
+ return
+ _request_effect(STOP, _stop_payload(launch), force_inline)
+
+
+func force_restart_server() -> bool:
+ if not has_managed_server():
+ return false
+ _cancel_effect()
+ _next_episode_id += 1
+ var launch: Dictionary = _episode.get("launch", {}).duplicate(true)
+ _episode = _stopping_episode(_next_episode_id, launch, "start")
+ _episode["message"] = "Restarting the managed server."
+ _transport = null
+ transport_cleared.emit("server restarting")
+ _publish()
+ _request_effect(STOP, _stop_payload(launch))
+ return true
+
+
+func authorize_replacement(now_msec := -1, ttl_msec := REPLACEMENT_TTL_MS) -> bool:
+ if str(_episode.get("state")) != BLOCKED:
+ return false
+ var target: Dictionary = _episode.get("blocked_target", {})
+ if not bool(target.get("replaceable", false)):
+ return false
+ if now_msec < 0:
+ now_msec = Time.get_ticks_msec()
+ _replacement_authorization = Authority.ReplacementAuthorization.new(
+ str(target.get("instance_id", "")),
+ str(target.get("version", "")),
+ int(target.get("port", 0)),
+ now_msec + maxi(0, ttl_msec),
+ )
+ return _replacement_authorization.is_available(now_msec)
+
+
+func replace_authorized(now_msec := -1) -> bool:
+ if str(_episode.get("state")) != BLOCKED or _replacement_authorization == null:
+ return false
+ if now_msec < 0:
+ now_msec = Time.get_ticks_msec()
+ var target: Dictionary = _episode.get("blocked_target", {}).duplicate(true)
+ if not _replacement_authorization.spend(
+ now_msec,
+ str(target.get("instance_id", "")),
+ str(target.get("version", "")),
+ int(target.get("port", 0)),
+ ):
+ _replacement_authorization = null
+ return false
+ _replacement_authorization = null
+ _episode["state"] = RECOVERING
+ _episode["phase"] = ""
+ _episode["message"] = "Replacing the explicitly authorized server."
+ _episode["effect"] = ""
+ _publish()
+ ## The replacement launches our server first and kills the target only
+ ## then, so the port never looks free to a bridge polling to spawn its
+ ## own backend; the launched server waits for the port to free.
+ var launch_plan := _plan.duplicate(true)
+ launch_plan["wait_for_port_ms"] = REPLACEMENT_WAIT_FOR_PORT_MS
+ _request_effect(REPLACE, {
+ "target": target,
+ "timeout_ms": int(_plan.get("probe_timeout_ms", DEFAULT_PROBE_TIMEOUT_MS)),
+ "launch": launch_plan,
+ })
+ return true
+
+
+func request_replacement() -> bool:
+ var now := Time.get_ticks_msec()
+ return authorize_replacement(now) and replace_authorized(now)
+
+
+func can_recover_incompatible_server() -> bool:
+ return (
+ str(_episode.get("state")) == BLOCKED
+ and bool(_episode.get("blocked_target", {}).get("replaceable", false))
+ )
+
+
+func prepare_for_update_reload() -> Dictionary:
+ stop_server(true)
+ var state := str(_episode.get("state", ""))
+ if state == DORMANT and _process_grant == null:
+ return {"ok": true}
+ return {
+ "ok": false,
+ "reason": str(_episode.get("reason", "stop_incomplete")),
+ "message": str(_episode.get(
+ "message", "The managed server did not stop before update activation."
+ )),
+ }
+
+
+func teardown_for_editor_exit(live_snapshot: Dictionary = {}) -> void:
+ ## A launch effect can create the process immediately before teardown joins
+ ## it. Recover that exact PID+fingerprint result before deciding whether
+ ## there is an owned child to stop or intentionally detach.
+ _adopt_cancelled_launch(_cancel_effect())
+ if _process_grant == null:
+ _detach_to_dormant("adopted server left running")
+ return
+ if bool(_plan.get("keep_alive", false)):
+ _detach_to_dormant("keep_server_on_exit")
+ return
+ var live := live_snapshot.duplicate(true) if not live_snapshot.is_empty() else _probe_current_transport()
+ if active_lease_count(live) > 0:
+ _detach_to_dormant("active attach lease")
+ return
+ stop_server(true)
+
+
+func detach_server(reason := "server left running") -> void:
+ _detach_to_dormant(reason)
+
+
+func _detach_to_dormant(reason: String) -> void:
+ _cancel_effect()
+ _process_grant = null
+ _transport = null
+ _replacement_authorization = null
+ _next_episode_id += 1
+ _episode = _dormant_episode(_next_episode_id)
+ _episode["message"] = reason
+ transport_cleared.emit(reason)
+ _publish()
+
+
+func transport_authenticated(version: String) -> void:
+ if str(_episode.get("state")) != READY:
+ return
+ var expected := str(_episode.get("expected_version", ""))
+ if _server_version_compatibility(version, expected).get("compatible", false):
+ _episode["actual_version"] = version
+ _publish()
+ return
+ var public_transport: Dictionary = _transport.public_snapshot() if _transport != null else {}
+ _block(
+ "incompatible",
+ "Server v%s does not match plugin v%s. Choose Replace to continue."
+ % [version, expected],
+ {
+ "instance_id": str(public_transport.get("server_instance_id", "")),
+ "version": version,
+ "port": int(public_transport.get("http_port", 0)),
+ "replaceable": not str(public_transport.get("server_instance_id", "")).is_empty(),
+ },
+ )
+
+
+func transport_lost(reason := "Authenticated server endpoint was lost.") -> void:
+ if str(_episode.get("state")) != READY:
+ return
+ _transport = null
+ ## Stability is measured from the moment the server became READY, not
+ ## from when a recovery started, so a slow recovery cannot refresh its
+ ## own budget and a server that held for a minute does.
+ if _ready_since_msec != 0 and Time.get_ticks_msec() - _ready_since_msec >= ENDPOINT_RECOVERY_STABLE_MS:
+ _endpoint_recovery_attempts = 0
+ _ready_since_msec = 0
+ var limit := ENDPOINT_RECOVERY_DELAYS_SECONDS.size()
+ if _endpoint_recovery_attempts >= limit:
+ _block(
+ "endpoint_lost",
+ "%s Automatic re-probing gave up after %d attempts; click Restart." % [reason, limit],
+ )
+ return
+ _endpoint_recovery_attempts += 1
+ var delay := float(ENDPOINT_RECOVERY_DELAYS_SECONDS[_endpoint_recovery_attempts - 1])
+ _block(
+ "endpoint_lost",
+ "%s Re-probing in %ds (attempt %d of %d)." % [
+ reason, int(delay), _endpoint_recovery_attempts, limit,
+ ],
+ )
+ print(
+ "MCP | server endpoint lost (%s); re-probing in %ds (attempt %d of %d)"
+ % [reason, int(delay), _endpoint_recovery_attempts, limit]
+ )
+ _endpoint_recovery_pending_episode = int(_episode.get("id", 0))
+ if bool(_plan.get("automatic_effects", true)):
+ _recover_lost_endpoint_after(delay, _endpoint_recovery_pending_episode)
+
+
+## The scheduled re-probe. Only the episode that lost its endpoint, and only
+## while its attempt is still pending, may start it: a dock Restart or a
+## newer loss in between supersedes the timer.
+func recover_lost_endpoint(episode_id: int) -> bool:
+ if episode_id <= 0 or episode_id != _endpoint_recovery_pending_episode:
+ return false
+ if episode_id != int(_episode.get("id", -1)):
+ _endpoint_recovery_pending_episode = 0
+ return false
+ if str(_episode.get("state")) != BLOCKED or str(_episode.get("reason")) != "endpoint_lost":
+ _endpoint_recovery_pending_episode = 0
+ return false
+ _endpoint_recovery_pending_episode = 0
+ ## Losing a socket does not prove that the shared backend needs restarting.
+ ## The probe retains ownership only after checking the exact listener again.
+ _begin_start_episode()
+ return str(_episode.get("state")) != BLOCKED
+
+
+func _recover_lost_endpoint_after(delay_seconds: float, episode_id: int) -> void:
+ var tree := Engine.get_main_loop()
+ if tree is SceneTree:
+ await (tree as SceneTree).create_timer(delay_seconds).timeout
+ if not is_instance_valid(self):
+ return
+ recover_lost_endpoint(episode_id)
+
+
+func complete_effect(episode_id: int, effect: String, result: Dictionary) -> bool:
+ if episode_id != int(_episode.get("id", -1)):
+ return false
+ if effect != str(_episode.get("effect", "")):
+ return false
+ _episode["effect"] = ""
+ match effect:
+ PROBE:
+ _complete_probe(result)
+ LAUNCH:
+ _complete_launch(result)
+ PROVE:
+ _complete_prove(result)
+ REPLACE:
+ _complete_replace(result)
+ STOP:
+ _complete_stop(result)
+ _:
+ return false
+ return true
+
+
+func _complete_probe(result: Dictionary) -> void:
+ match str(result.get("outcome", "blocked")):
+ "compatible":
+ var transport = result.get("transport")
+ if transport == null or not transport.is_valid():
+ _block("invalid_transport", "The server returned invalid transport authority.")
+ return
+ var disposition := str(result.get("owned_disposition", ""))
+ if _process_grant != null and disposition == "owned":
+ _ready("owned", transport, str(result.get("version", "")))
+ elif _process_grant == null or disposition in ["gone", "replaced"]:
+ _process_grant = null
+ _ready("adopted", transport, str(result.get("version", "")))
+ else:
+ _block("process_authority_mismatch", "Managed process identity could not be proven during recovery. Click Restart to retry.")
+ "free":
+ if _process_grant != null:
+ if str(result.get("owned_disposition", "")) not in ["gone", "replaced"]:
+ _block("process_authority_mismatch", "The managed process is still running without a proven endpoint. Click Restart to retry.")
+ return
+ _process_grant = null
+ _episode["phase"] = LAUNCH
+ _episode["message"] = "Launching the managed server."
+ _publish()
+ var launch_plan := _plan.duplicate(true)
+ launch_plan["baseline_instance_id"] = str(result.get("baseline_instance_id", ""))
+ _request_effect(LAUNCH, launch_plan)
+ _:
+ _block(
+ str(result.get("reason", "occupied")),
+ str(result.get("message", "The configured endpoint is unavailable.")),
+ result.get("target", {}),
+ )
+
+
+func _complete_launch(result: Dictionary) -> void:
+ if not bool(result.get("ok", false)):
+ ## The process usually died before its identity could be captured
+ ## because it refused to start; it says why in its startup report.
+ _block(
+ str(result.get("reason", "launch_failed")),
+ str(result.get("message", "Server launch failed."))
+ + startup_report_summary(str(_plan.get("startup_report", ""))),
+ )
+ return
+ var pid := int(result.get("pid", 0))
+ var fingerprint := str(result.get("fingerprint", ""))
+ var launch := {
+ "pid": pid,
+ "fingerprint": fingerprint,
+ "http_capability": str(result.get("http_capability", "")),
+ "ws_capability": str(result.get("ws_capability", "")),
+ "baseline_instance_id": str(result.get("baseline_instance_id", "")),
+ }
+ _process_grant = _owned_process_grant(pid, fingerprint)
+ if not _process_grant.is_valid():
+ _process_grant = null
+ _block("launch_unproven", "The launched process identity could not be proven.")
+ return
+ _episode["launch"] = launch
+ _episode["phase"] = PROVE
+ _episode["prove_deadline_msec"] = (
+ Time.get_ticks_msec() + int(_plan.get("prove_timeout_ms", DEFAULT_PROVE_TIMEOUT_MS))
+ )
+ _episode["message"] = "Waiting for the managed server capability record."
+ _publish()
+ _request_effect(PROVE, _prove_payload())
+
+
+func _complete_prove(result: Dictionary) -> void:
+ if bool(result.get("pending", false)):
+ _episode["proof_pending_reason"] = str(result.get("reason", "unknown"))
+ if Time.get_ticks_msec() >= int(_episode.get("prove_deadline_msec", 0)):
+ _block(
+ "proof_timeout",
+ "The managed server proof timed out at %s."
+ % str(_episode.get("proof_pending_reason", "unknown"))
+ + startup_report_summary(str(_plan.get("startup_report", ""))),
+ )
+ return
+ _retry_effect_after(PROVE, _prove_payload(), 0.15)
+ return
+ if not bool(result.get("ok", false)):
+ var message := str(result.get("message", "Managed server proof failed."))
+ var pending_reason := str(_episode.get("proof_pending_reason", ""))
+ if not pending_reason.is_empty():
+ message += " Last pending proof: %s." % pending_reason
+ _block(str(result.get("reason", "proof_failed")), message)
+ return
+ var launch: Dictionary = _episode.get("launch", {})
+ var pid := int(result.get("pid", 0))
+ var fingerprint := str(result.get("fingerprint", ""))
+ var exact_grant = _owned_process_grant(pid, fingerprint)
+ if not exact_grant.is_valid():
+ _block("process_proof_failed", "The server process identity changed before proof completed.")
+ return
+ _process_grant = exact_grant
+ launch["pid"] = pid
+ launch["fingerprint"] = fingerprint
+ _episode["launch"] = launch
+ _ready("owned", result.get("transport"), str(result.get("version", "")))
+
+
+func _complete_replace(result: Dictionary) -> void:
+ if not bool(result.get("ok", false)):
+ _block(str(result.get("reason", "replacement_failed")), str(result.get("message", "Authorized replacement failed.")))
+ return
+ _process_grant = null
+ _transport = null
+ var launch: Dictionary = result.get("launch", {})
+ if launch.is_empty():
+ _begin_start_episode(int(_episode.get("id", 0)))
+ return
+ ## Our server was launched before the target was killed and is taking
+ ## the port now; continue exactly as a fresh launch would, at its proof.
+ _begin_start_episode(int(_episode.get("id", 0)), false)
+ _complete_launch(launch)
+
+
+func _complete_stop(result: Dictionary) -> void:
+ if not bool(result.get("ok", false)):
+ _block(str(result.get("reason", "stop_refused")), str(result.get("message", "Managed stop authority no longer matches the process.")))
+ return
+ _finish_stop()
+
+
+func _finish_stop() -> void:
+ var continue_with_start := str(_episode.get("after_stop", "")) == "start"
+ var stopped_pid := get_server_pid()
+ _process_grant = null
+ _transport = null
+ _replacement_authorization = null
+ var pid_file := str(_plan.get("pid_file", ""))
+ if not pid_file.is_empty() and FileAccess.file_exists(pid_file):
+ DirAccess.remove_absolute(pid_file)
+ var completed_id := int(_episode.get("id", 0))
+ _episode = _dormant_episode(completed_id)
+ _publish()
+ if stopped_pid > 1:
+ print("MCP | stopped server (PID %d)" % stopped_pid)
+ if continue_with_start:
+ _begin_start_episode()
+
+
+func _ready(kind: String, transport, version: String) -> void:
+ if transport == null or not transport.is_valid():
+ _block("invalid_transport", "The server returned invalid transport authority.")
+ return
+ _transport = transport
+ _ready_since_msec = maxi(1, Time.get_ticks_msec())
+ _episode["state"] = READY
+ _episode["phase"] = ""
+ _episode["ready_kind"] = kind
+ _episode["actual_version"] = version
+ _episode["reason"] = ""
+ _episode["message"] = "Server ready (%s)." % kind
+ _episode["blocked_target"] = {}
+ _episode["launch"] = {}
+ _publish()
+ if kind == "owned":
+ print("MCP | started server (PID %d)" % get_server_pid())
+ elif kind == "adopted":
+ print("MCP | adopted external server")
+ transport_ready.emit(transport.ws_port(), transport.ws_capability())
+ startup_finished.emit(kind)
+
+
+func _block(reason: String, message: String, target: Dictionary = {}) -> void:
+ _transport = null
+ _replacement_authorization = null
+ _episode["state"] = BLOCKED
+ _episode["phase"] = ""
+ _episode["reason"] = reason
+ _episode["message"] = message
+ _episode["blocked_target"] = target.duplicate(true)
+ _episode["effect"] = ""
+ _publish()
+ transport_cleared.emit(message)
+ startup_finished.emit("blocked:%s" % reason)
+
+
+func _block_without_effect(reason: String, message: String) -> void:
+ _next_episode_id += 1
+ _episode = _dormant_episode(_next_episode_id)
+ _episode["state"] = BLOCKED
+ _episode["reason"] = reason
+ _episode["message"] = message
+ _publish()
+
+
+func _request_effect(kind: String, payload: Dictionary, force_inline := false) -> void:
+ if not _effect.is_empty():
+ _block("effect_overlap", "Lifecycle attempted overlapping effects.")
+ return
+ _episode["effect"] = kind
+ _publish()
+ var episode_id := int(_episode.id)
+ var copied := payload.duplicate(true)
+ if not bool(_plan.get("automatic_effects", true)):
+ effect_requested.emit(episode_id, kind, copied)
+ return
+ if force_inline or not bool(_plan.get("defer_effects", true)) or not (Engine.get_main_loop() is SceneTree):
+ complete_effect(episode_id, kind, _execute_effect(kind, copied))
+ return
+ var thread := Thread.new()
+ if thread.start(_execute_effect.bind(kind, copied)) != OK:
+ complete_effect(episode_id, kind, {"ok": false, "reason": "worker_start_failed"})
+ return
+ _effect = {"thread": thread, "episode_id": episode_id, "kind": kind}
+ _await_effect(thread, episode_id, kind)
+
+
+func _await_effect(thread: Thread, episode_id: int, kind: String) -> void:
+ var tree := Engine.get_main_loop()
+ while thread.is_alive() and tree is SceneTree:
+ await (tree as SceneTree).process_frame
+ if _effect.get("thread") != thread:
+ return
+ var result: Variant = thread.wait_to_finish()
+ _effect.clear()
+ if result is Dictionary:
+ complete_effect(episode_id, kind, result)
+ else:
+ complete_effect(episode_id, kind, {"ok": false, "reason": "invalid_effect_result"})
+
+
+func _cancel_effect() -> Dictionary:
+ if _effect.is_empty():
+ return {}
+ var kind := str(_effect.get("kind", ""))
+ var thread: Thread = _effect.get("thread")
+ _effect.clear()
+ if thread == null:
+ return {"kind": kind, "result": {"ok": false, "reason": "missing_effect_thread"}}
+ var result: Variant = thread.wait_to_finish()
+ return {
+ "kind": kind,
+ "result": (
+ result
+ if result is Dictionary
+ else {"ok": false, "reason": "invalid_effect_result"}
+ ),
+ }
+
+
+## Preserve process authority when cancellation joins a launch that already
+## spawned. The caller may then stop by exact fingerprint; it never kills by a
+## bare PID and never treats a failed/unproven launch as owned.
+func _adopt_cancelled_launch(cancelled: Dictionary) -> bool:
+ if str(cancelled.get("kind", "")) != LAUNCH:
+ return false
+ var result: Dictionary = cancelled.get("result", {})
+ if not bool(result.get("ok", false)):
+ return false
+ var grant = _owned_process_grant(
+ int(result.get("pid", 0)), str(result.get("fingerprint", ""))
+ )
+ if not grant.is_valid():
+ return false
+ _process_grant = grant
+ _episode["launch"] = {
+ "pid": int(result.get("pid", 0)),
+ "fingerprint": str(result.get("fingerprint", "")),
+ "http_capability": str(result.get("http_capability", "")),
+ "ws_capability": str(result.get("ws_capability", "")),
+ "baseline_instance_id": str(result.get("baseline_instance_id", "")),
+ }
+ return true
+
+
+static func _owned_process_grant(pid: int, fingerprint: String):
+ return Authority.OwnedProcessGrant.new(
+ pid, fingerprint, maxi(1, Time.get_ticks_msec())
+ )
+
+
+func _retry_effect_after(kind: String, payload: Dictionary, delay_seconds: float) -> void:
+ var episode_id := int(_episode.get("id", 0))
+ var tree := Engine.get_main_loop()
+ if tree is SceneTree:
+ await (tree as SceneTree).create_timer(delay_seconds).timeout
+ if episode_id == int(_episode.get("id", -1)) and str(_episode.get("state")) == STARTING:
+ _request_effect(kind, payload)
+
+
+func _execute_effect(kind: String, payload: Dictionary) -> Dictionary:
+ match kind:
+ PROBE:
+ return _effect_probe(payload)
+ LAUNCH:
+ return _effect_launch(payload)
+ PROVE:
+ return _effect_prove(payload)
+ REPLACE:
+ return _effect_replace(payload)
+ STOP:
+ return _effect_stop(payload)
+ return {"ok": false, "reason": "unknown_effect"}
+
+
+func _effect_probe(payload: Dictionary) -> Dictionary:
+ var port := int(payload.http_port)
+ var expected_version := str(payload.expected_version)
+ var expected_ws_port := int(payload.expected_ws_port)
+ var grant = payload.get("grant")
+ var disposition := "gone"
+ if grant != null:
+ var pid := int(grant.process_id())
+ var alive := PortResolver.pid_alive(pid)
+ disposition = _owned_process_disposition(
+ grant, alive, PortResolver.process_fingerprint(pid) if alive else "",
+ )
+ if disposition == "unproven":
+ return _blocked_probe_result("process_authority_mismatch", port, {}, false,
+ "managed process identity could not be proven; click Restart to retry")
+ var capability := _read_capability(port)
+ var live := _probe_with_capability(port, capability, int(payload.timeout_ms))
+ if _authenticated_status_matches_record(live, capability):
+ var version := str(live.get("version", ""))
+ var ws_port := int(live.get("ws_port", 0))
+ if _server_status_compatibility(version, expected_version, ws_port, expected_ws_port).get("compatible", false):
+ if disposition == "owned" and (
+ not PortResolver.find_all_pids_on_port(port).has(int(grant.process_id()))
+ or not grant.matches(int(grant.process_id()), PortResolver.process_fingerprint(int(grant.process_id())))
+ ):
+ return _blocked_probe_result("process_authority_mismatch", port, live, false,
+ "the authenticated endpoint does not match the managed process; click Restart to retry")
+ return {
+ "outcome": "compatible",
+ "owned_disposition": disposition,
+ "version": version,
+ "transport": _transport_from(port, ws_port, live, capability),
+ }
+ return _blocked_probe_result("incompatible", port, live, true)
+ if PortResolver.is_port_in_use(port):
+ var detail := _record_probe_failure_detail(capability, live)
+ var blocked := _blocked_probe_result("occupied", port, live, false, detail)
+ var pre_v4 := _untrusted_pre_v4_occupant_version(port, int(payload.timeout_ms))
+ if not pre_v4.is_empty():
+ blocked["message"] = stale_pre_v4_message(port, pre_v4)
+ blocked["target"]["hint"] = STALE_PRE_V4_HINT
+ return blocked
+ ## The server binds both ports before it publishes anything. A held
+ ## WebSocket port (a server moved off the HTTP port, or another editor)
+ ## would kill the launch at preflight; say so now and name the setting.
+ if expected_ws_port > 0 and PortResolver.is_port_in_use(expected_ws_port):
+ return ws_port_blocked_result(expected_ws_port)
+ return {
+ "outcome": "free",
+ "owned_disposition": disposition,
+ "baseline_instance_id": str(capability.get("instance_nonce", "")),
+ }
+
+
+## Why an existing capability record did not authenticate the occupant, so a
+## "held by another process" report can be acted on. Empty when there was no
+## record to try, or when the probe succeeded and the mismatch is elsewhere.
+static func _record_probe_failure_detail(capability: Dictionary, live: Dictionary) -> String:
+ if str(capability.get("http", "")).is_empty():
+ return ""
+ var error := str(live.get("error", "")).strip_edges()
+ if error.is_empty():
+ if str(live.get("name", "")) != "godot-ai":
+ return "a godot-ai record for this port exists, but the listener did not answer as godot-ai"
+ return "a godot-ai record for this port exists, but it belongs to a different server instance"
+ return "a godot-ai record for this port exists, but its status probe failed: %s" % error
+
+
+static func ws_port_blocked_result(ws_port: int) -> Dictionary:
+ return {
+ "outcome": "blocked",
+ "reason": "ws_occupied",
+ "message": (
+ "WebSocket port %d is already in use by another process. "
+ + "Set `godot_ai/ws_port` in Editor Settings to a free port "
+ + "(the dock's port picker moves both ports), then reconfigure your AI clients."
+ ) % ws_port,
+ "target": {"instance_id": "", "version": "", "port": ws_port, "replaceable": false},
+ }
+
+
+func _effect_launch(payload: Dictionary) -> Dictionary:
+ var port := int(payload.http_port)
+ if bool(payload.get("http_port_reserved", false)):
+ return {"ok": false, "reason": "port_reserved", "message": "Port %d is reserved by Windows." % port}
+ var server_command: Array = payload.get("server_command", [])
+ if server_command.is_empty():
+ return {"ok": false, "reason": "no_command", "message": "No godot-ai server command was found."}
+ var listener_problem := PortResolver.listener_tools_problem()
+ if not listener_problem.is_empty():
+ return {"ok": false, "reason": "listener_tools_missing", "message": listener_problem}
+ ## Do not spawn into a directory the server cannot publish from (#988):
+ ## the failure would only surface as a proof timeout three minutes later.
+ var directory_problem := TransportCapability.directory_write_problem(port)
+ if not directory_problem.is_empty():
+ return {"ok": false, "reason": "capability_dir_unwritable", "message": directory_problem}
+ var args: Array[String] = []
+ args.assign(server_command.slice(1))
+ args.append_array(_server_flags(payload))
+ var environment := {
+ "GODOT_AI_PLUGIN_SPAWNED": "1",
+ }
+ if int(payload.get("wait_for_port_ms", 0)) > 0:
+ environment["GODOT_AI_WAIT_FOR_PORT_MS"] = str(int(payload.get("wait_for_port_ms", 0)))
+ if OS.get_name() != "Windows" and not bool(payload.get("keep_alive", false)):
+ environment["GODOT_AI_OWNER_PID"] = str(OS.get_process_id())
+ if bool(payload.get("keep_alive", false)):
+ environment["GODOT_AI_NO_IDLE_EXIT"] = "1"
+ if bool(payload.get("disable_telemetry", false)):
+ environment["GODOT_AI_DISABLE_TELEMETRY"] = "true"
+ var worktree_src := str(payload.get("worktree_src", ""))
+ if not worktree_src.is_empty():
+ var old_pythonpath := str(payload.get("ambient_pythonpath", ""))
+ var separator := ";" if OS.get_name() == "Windows" else ":"
+ environment["PYTHONPATH"] = (
+ worktree_src if old_pythonpath.is_empty() else worktree_src + separator + old_pythonpath
+ )
+ var pid_file := str(payload.get("pid_file", ""))
+ if not pid_file.is_empty() and FileAccess.file_exists(pid_file):
+ DirAccess.remove_absolute(pid_file)
+ var startup_report := str(payload.get("startup_report", ""))
+ if not startup_report.is_empty() and FileAccess.file_exists(startup_report):
+ DirAccess.remove_absolute(startup_report)
+ ## A report this launch could not clear would be read as this
+ ## launch's: a stale "waiting_for_port" phase would let a replacement
+ ## kill its occupant before the new server holds the port.
+ if FileAccess.file_exists(startup_report):
+ return {
+ "ok": false,
+ "reason": "stale_startup_report",
+ "message": "A previous server's startup report could not be removed: %s" % startup_report,
+ }
+ ## Names this launch in what the server reports, so a phase read from the
+ ## report is never another launch's.
+ var launch_id := "%d-%d-%d" % [OS.get_process_id(), Time.get_ticks_usec(), randi()]
+ environment["GODOT_AI_LAUNCH_ID"] = launch_id
+ var spawned := spawn_capability_process(str(server_command[0]), args, environment)
+ var pid := int(spawned.pid)
+ if pid <= 1:
+ return {"ok": false, "reason": "launch_failed", "message": "The server process could not be launched."}
+ ## OS.create_process can return before POSIX exec replaces the child image.
+ ## Capture only after the command is branded and its exact fingerprint is
+ ## stable across two reads; otherwise a legitimate exec looks like PID reuse.
+ var capture_started := Time.get_ticks_msec()
+ var attempts: Array = []
+ var exact_grant := PortResolver.capture_process_kill_grant(pid, true, attempts)
+ var fingerprint_deadline := capture_started + LAUNCH_FINGERPRINT_TIMEOUT_MS
+ while exact_grant.is_empty() and Time.get_ticks_msec() < fingerprint_deadline:
+ OS.delay_msec(100)
+ exact_grant = PortResolver.capture_process_kill_grant(pid, true, attempts)
+ var fingerprint := str(exact_grant.get("fingerprint", ""))
+ return {
+ "ok": not fingerprint.is_empty(),
+ "reason": "launch_unproven" if fingerprint.is_empty() else "",
+ "message": (
+ _launch_unproven_message(pid, attempts, Time.get_ticks_msec() - capture_started)
+ if fingerprint.is_empty()
+ else ""
+ ),
+ "pid": pid,
+ "fingerprint": fingerprint,
+ "http_capability": spawned.http,
+ "ws_capability": spawned.websocket,
+ "baseline_instance_id": str(payload.get("baseline_instance_id", "")),
+ "launch_id": launch_id,
+ }
+
+
+## Name which identity check failed so a Windows report can be acted on
+## (#988, #1012 both surfaced as this bare sentence). Diagnostic only: the
+## values are read once more after the capture budget and never grant
+## authority.
+func _launch_unproven_message(pid: int, attempts: Array, elapsed_ms: int) -> String:
+ var snapshot: Variant = _capture_process_snapshot(pid)
+ var alive := PortResolver.pid_alive(pid, snapshot)
+ var commandline := PortResolver.process_commandline(pid, snapshot) if alive else ""
+ if commandline.length() > 200:
+ commandline = commandline.substr(0, 199) + "…"
+ ## Summarise the per-attempt refusals as "reason×count" in first-seen order.
+ var counts := {}
+ var order: Array[String] = []
+ for entry in attempts:
+ var key := str(entry)
+ if not counts.has(key):
+ counts[key] = 0
+ order.append(key)
+ counts[key] = int(counts[key]) + 1
+ var summary: Array[String] = []
+ for key in order:
+ summary.append("%s×%d" % [key, int(counts[key])])
+ return (
+ "The launched process identity could not be captured in %d attempts over %.1f s "
+ + "(pid %d, now alive=%s, refusals: %s%s)."
+ ) % [
+ attempts.size(),
+ elapsed_ms / 1000.0,
+ pid,
+ "unknown" if PortResolver.capture_failed(snapshot) else ("yes" if alive else "no"),
+ ", ".join(summary) if not summary.is_empty() else "none recorded",
+ "" if commandline.is_empty() else "; command: " + commandline,
+ ]
+
+
+func _capture_process_snapshot(pid: int) -> Variant:
+ return PortResolver.capture_process_snapshot(pid)
+
+
+func _effect_prove(payload: Dictionary) -> Dictionary:
+ var launch_grant = payload.get("grant")
+ var launch_pid := int(launch_grant.process_id()) if launch_grant != null else -1
+ ## The pid file is an untrusted hint. Reuse its ancestor snapshot only
+ ## when it includes the launcher; the original PID read and all final
+ ## capability, listener, identity, and lineage checks still follow.
+ var hinted_pid := -1
+ var first_snapshot: Variant = null
+ var launch_snapshot: Variant = null
+ if OS.get_name() == "Windows" and launch_pid > 1:
+ hinted_pid = PortResolver.read_pid_file(str(payload.get("pid_file", "")))
+ if hinted_pid > 1:
+ first_snapshot = _capture_process_snapshot(hinted_pid)
+ if PortResolver.process_descends_from(hinted_pid, launch_pid, first_snapshot):
+ launch_snapshot = first_snapshot
+ if launch_snapshot == null:
+ launch_snapshot = _capture_process_snapshot(launch_pid)
+ first_snapshot = null
+ if PortResolver.capture_failed(launch_snapshot):
+ return {"pending": true, "reason": "identity_unavailable"}
+ var launch_alive := PortResolver.pid_alive(launch_pid, launch_snapshot)
+ var launch_fingerprint := (
+ PortResolver.process_fingerprint(launch_pid, launch_snapshot)
+ if launch_alive
+ else ""
+ )
+ var launch_disposition := _owned_process_disposition(
+ launch_grant,
+ launch_alive,
+ launch_fingerprint,
+ )
+ if launch_disposition != "owned":
+ return {
+ "ok": false,
+ "reason": "launch_%s" % launch_disposition,
+ "message": (
+ "The launched server process exited or changed identity before publishing capabilities."
+ + startup_report_summary(str(payload.get("startup_report", "")))
+ ),
+ }
+ var port := int(payload.http_port)
+ var capability := _read_capability(port)
+ if capability.is_empty() or str(capability.get("instance_nonce", "")) == str(payload.get("baseline_instance_id", "")):
+ return {"pending": true, "reason": "capability_record"}
+ if (
+ str(capability.get("http", "")) != str(payload.get("http_capability", ""))
+ or str(capability.get("websocket", "")) != str(payload.get("ws_capability", ""))
+ ):
+ return {"pending": true, "reason": "capability_pair"}
+ var live := _probe_with_capability(port, capability, int(payload.timeout_ms))
+ if not _authenticated_status_matches_record(live, capability):
+ return {"pending": true, "reason": "authenticated_status"}
+ var version := str(live.get("version", ""))
+ var ws_port := int(live.get("ws_port", 0))
+ if not _server_status_compatibility(
+ version, str(payload.expected_version), ws_port, int(payload.expected_ws_port)
+ ).get("compatible", false):
+ return {"ok": false, "reason": "incompatible", "message": "The launched server does not match this plugin."}
+ var pid := PortResolver.read_pid_file(str(payload.get("pid_file", "")))
+ if pid <= 1:
+ return {"pending": true, "reason": "pid_file"}
+ if not PortResolver.find_all_pids_on_port(port).has(pid):
+ return {"pending": true, "reason": "listener_pid"}
+ if first_snapshot == null or pid != hinted_pid:
+ first_snapshot = (
+ launch_snapshot if pid == launch_pid else _capture_process_snapshot(pid)
+ )
+ if PortResolver.capture_failed(first_snapshot):
+ return {"pending": true, "reason": "identity_unavailable"}
+ if not PortResolver.pid_cmdline_is_godot_ai(pid, first_snapshot):
+ return {"pending": true, "reason": "process_brand"}
+ if not PortResolver.process_descends_from(pid, launch_pid, first_snapshot):
+ return {"pending": true, "reason": "launch_lineage"}
+ var fingerprint := PortResolver.process_fingerprint(pid, first_snapshot)
+ if fingerprint.is_empty():
+ return {"pending": true, "reason": "process_fingerprint"}
+ ## Close every capture window before minting process authority. The same
+ ## launch-lineage PID must still own the listener, the private capability
+ ## record must still name the authenticated instance, and the process
+ ## fingerprint must remain unchanged after the final probe.
+ var final_capability := _read_capability(port)
+ var final_live := _probe_with_capability(port, final_capability, int(payload.timeout_ms))
+ var final_snapshot: Variant = _capture_process_snapshot(pid)
+ if PortResolver.capture_failed(final_snapshot):
+ return {"pending": true, "reason": "identity_unavailable"}
+ if (
+ PortResolver.read_pid_file(str(payload.get("pid_file", ""))) != pid
+ or not PortResolver.find_all_pids_on_port(port).has(pid)
+ or not PortResolver.process_descends_from(pid, launch_pid, final_snapshot)
+ or not PortResolver.pid_cmdline_is_godot_ai(pid, final_snapshot)
+ or PortResolver.process_fingerprint(pid, final_snapshot) != fingerprint
+ or final_capability != capability
+ or not _authenticated_status_matches_record(final_live, final_capability)
+ or str(final_live.get("instance_id", "")) != str(live.get("instance_id", ""))
+ ):
+ return {"pending": true, "reason": "final_capture_window"}
+ var final_version := str(final_live.get("version", ""))
+ var final_ws_port := int(final_live.get("ws_port", 0))
+ if not _server_status_compatibility(
+ final_version,
+ str(payload.expected_version),
+ final_ws_port,
+ int(payload.expected_ws_port),
+ ).get("compatible", false):
+ return {
+ "ok": false,
+ "reason": "incompatible",
+ "message": "The launched server changed compatibility before authority was granted.",
+ }
+ return {
+ "ok": true,
+ "pid": pid,
+ "fingerprint": fingerprint,
+ "version": final_version,
+ "transport": _transport_from(port, final_ws_port, final_live, final_capability),
+ }
+
+
+func _effect_replace(payload: Dictionary) -> Dictionary:
+ var target: Dictionary = payload.get("target", {})
+ var port := int(target.get("port", 0))
+ var capability := _read_capability(port)
+ var live := _probe_with_capability(port, capability, int(payload.timeout_ms))
+ if not _replacement_target_matches(target, live, capability):
+ return {"ok": false, "reason": "replacement_target_changed", "message": "The authorized server changed before replacement."}
+ var candidates: Array[int] = []
+ for pid in PortResolver.find_all_pids_on_port(port):
+ if PortResolver.pid_cmdline_is_godot_ai(pid):
+ candidates.append(pid)
+ if candidates.size() != 1:
+ return {"ok": false, "reason": "replacement_unproven", "message": "The authorized server process could not be proven."}
+ var pid := candidates[0]
+ var fingerprint := PortResolver.process_fingerprint(pid)
+ if fingerprint.is_empty():
+ return {"ok": false, "reason": "replacement_unproven", "message": "The authorized server process could not be fingerprinted."}
+ # Reprobe, then bind the kill to the captured PID + start fingerprint.
+ live = _probe_with_capability(port, capability, int(payload.timeout_ms))
+ if (
+ not _replacement_target_matches(target, live, capability)
+ or not PortResolver.find_all_pids_on_port(port).has(pid)
+ or PortResolver.process_fingerprint(pid) != fingerprint
+ ):
+ return {"ok": false, "reason": "replacement_target_changed", "message": "The authorized server changed before replacement."}
+ ## Launch our server before the kill so the port passes straight from
+ ## the target to it (the server waits for the port); a bridge polling
+ ## for a free port to spawn its own backend never sees one. The launch
+ ## baseline is the record the target published, which the proof must
+ ## see replaced.
+ var launch_plan: Dictionary = payload.get("launch", {})
+ var launch := {}
+ if not launch_plan.is_empty():
+ launch_plan = launch_plan.duplicate(true)
+ launch_plan["baseline_instance_id"] = str(capability.get("instance_nonce", ""))
+ launch = _effect_launch(launch_plan)
+ if not bool(launch.get("ok", false)):
+ return {
+ "ok": false,
+ "reason": str(launch.get("reason", "launch_failed")),
+ "message": str(launch.get("message", "Server launch failed.")),
+ }
+ ## Kill only once the launched server is at its bind loop; a launch
+ ## that spends seconds getting there (uvx installing the new version)
+ ## would otherwise leave the port free for a bridge to spawn into.
+ var ready := _wait_for_launch_port_wait(
+ str(launch_plan.get("startup_report", "")),
+ str(launch.get("launch_id", "")),
+ int(launch.pid),
+ str(launch.fingerprint),
+ )
+ if not bool(ready.get("ok", false)):
+ PortResolver.kill_exact_processes(
+ [{"pid": int(launch.pid), "fingerprint": str(launch.fingerprint)}], true
+ )
+ return {"ok": false, "reason": str(ready.reason), "message": str(ready.message)}
+ if (
+ not PortResolver.find_all_pids_on_port(port).has(pid)
+ or PortResolver.process_fingerprint(pid) != fingerprint
+ ):
+ PortResolver.kill_exact_processes(
+ [{"pid": int(launch.pid), "fingerprint": str(launch.fingerprint)}], true
+ )
+ return {"ok": false, "reason": "replacement_target_changed", "message": "The authorized server changed before replacement."}
+ var killed := PortResolver.kill_exact_processes(
+ [{"pid": pid, "fingerprint": fingerprint}],
+ true,
+ )
+ if not killed.has(pid):
+ if not launch.is_empty():
+ PortResolver.kill_exact_processes(
+ [{"pid": int(launch.pid), "fingerprint": str(launch.fingerprint)}], true
+ )
+ return {"ok": false, "reason": "replacement_failed"}
+ if launch.is_empty():
+ PortResolver.wait_for_port_free(port, 5.0)
+ return {"ok": not PortResolver.is_port_in_use(port), "reason": ""}
+ return {"ok": true, "reason": "", "launch": launch}
+
+
+## Poll the launched server's startup report until it says the port wait is
+## running, the process is gone, or the budget is spent. No report path
+## (a plan without one) keeps the old ordering: kill straight away.
+func _wait_for_launch_port_wait(
+ startup_report: String, launch_id: String, pid: int, fingerprint: String
+) -> Dictionary:
+ if startup_report.is_empty():
+ return {"ok": true}
+ var deadline := Time.get_ticks_msec() + REPLACEMENT_LAUNCH_READY_TIMEOUT_MS
+ while true:
+ var reached := launch_reached_port_wait(startup_report, launch_id)
+ ## The phase counts only from a process still there to hold the
+ ## port: one that exited after writing it must not cost the occupant.
+ if PortResolver.process_fingerprint(pid) != fingerprint:
+ return {
+ "ok": false,
+ "reason": "replacement_launch_exited",
+ "message": (
+ "The replacement server exited before it could wait for the port."
+ + startup_report_summary(startup_report)
+ ),
+ }
+ if reached:
+ return {"ok": true}
+ if Time.get_ticks_msec() >= deadline:
+ return {
+ "ok": false,
+ "reason": "replacement_launch_stalled",
+ "message": "The replacement server did not reach its port wait within %d s." % int(REPLACEMENT_LAUNCH_READY_TIMEOUT_MS / 1000),
+ }
+ OS.delay_msec(100)
+ return {"ok": true}
+
+
+## Whether the startup report records the server of `launch_id` at its port
+## wait (the phase it writes before its first bind attempt when the plugin
+## asked it to wait for the port). A failure written later replaces the
+## phase; a report naming another launch, or none, is never this launch's.
+static func launch_reached_port_wait(startup_report: String, launch_id: String) -> bool:
+ if startup_report.is_empty() or launch_id.is_empty() or not FileAccess.file_exists(startup_report):
+ return false
+ var file := FileAccess.open(startup_report, FileAccess.READ)
+ if file == null:
+ return false
+ var raw := file.get_as_text().strip_edges()
+ file.close()
+ if raw.is_empty() or raw.length() > 8192:
+ return false
+ var parsed: Variant = JSON.parse_string(raw)
+ if not (parsed is Dictionary):
+ return false
+ return (
+ str(parsed.get("phase", "")) == STARTUP_PHASE_WAITING_FOR_PORT
+ and str(parsed.get("launch_id", "")) == launch_id
+ )
+
+
+func _effect_stop(payload: Dictionary) -> Dictionary:
+ var grant = payload.get("grant")
+ if grant == null:
+ return {"ok": true}
+ var pid := int(grant.process_id())
+ var port := int(payload.get("http_port", 0))
+ var alive := PortResolver.pid_alive(pid)
+ var fingerprint := PortResolver.process_fingerprint(pid) if alive else ""
+ var disposition := _owned_process_disposition(grant, alive, fingerprint)
+ if disposition == "unproven":
+ return {"ok": false, "reason": "process_authority_mismatch", "message": "Managed process identity no longer matches its stop grant."}
+ var grants: Array[Dictionary] = []
+ var server_grant := _launched_server_kill_grant(payload, pid)
+ if not server_grant.is_empty():
+ grants.append(server_grant)
+ if disposition == "owned":
+ grants.append({"pid": pid, "fingerprint": fingerprint})
+ PortResolver.kill_exact_processes(grants, true)
+ if port > 0 and not grants.is_empty():
+ PortResolver.wait_for_port_free(port, 3.0)
+ var targets_gone := true
+ for exact in grants:
+ if PortResolver.process_fingerprint(int(exact.pid)) == str(exact.fingerprint):
+ targets_gone = false
+ var port_free := port <= 0 or not PortResolver.is_port_in_use(port)
+ ## Killing something of ours must also free its listener. When there was
+ ## nothing of ours left to kill, whoever holds the port now is not ours to
+ ## prove stopped; the next start's probe deals with that occupant.
+ var stopped := targets_gone and (grants.is_empty() or port_free)
+ return {
+ "ok": stopped,
+ "already_gone": grants.is_empty() and port_free,
+ "reason": "" if stopped else "process_stop_failed",
+ "message": "" if stopped else "A launched server process or listener could not be proven stopped.",
+ }
+
+
+func _launched_server_kill_grant(payload: Dictionary, launch_pid: int) -> Dictionary:
+ var launch: Dictionary = payload.get("launch", {})
+ if launch.is_empty():
+ return {}
+ var server_pid := PortResolver.read_pid_file(str(payload.get("pid_file", "")))
+ var port := int(payload.get("http_port", 0))
+ if (
+ server_pid <= 1
+ or server_pid == launch_pid
+ or not PortResolver.process_descends_from(server_pid, launch_pid)
+ or not PortResolver.find_all_pids_on_port(port).has(server_pid)
+ ):
+ return {}
+ var capability := _read_capability(port)
+ if (
+ str(capability.get("http", "")) != str(launch.get("http_capability", ""))
+ or str(capability.get("websocket", "")) != str(launch.get("ws_capability", ""))
+ ):
+ return {}
+ var live := _probe_with_capability(
+ port, capability, int(payload.get("timeout_ms", DEFAULT_PROBE_TIMEOUT_MS))
+ )
+ if not _authenticated_status_matches_record(live, capability):
+ return {}
+ var exact := PortResolver.capture_process_kill_grant(server_pid, true)
+ if exact.is_empty():
+ return {}
+ if (
+ PortResolver.read_pid_file(str(payload.get("pid_file", ""))) != server_pid
+ or not PortResolver.process_descends_from(server_pid, launch_pid)
+ or not PortResolver.find_all_pids_on_port(port).has(server_pid)
+ or PortResolver.process_fingerprint(server_pid) != str(exact.fingerprint)
+ ):
+ return {}
+ return exact
+
+
+static func _owned_process_disposition(grant, alive: bool, fingerprint: String) -> String:
+ if grant == null:
+ return "gone"
+ if not alive:
+ return "gone"
+ var pid := int(grant.process_id())
+ if fingerprint.is_empty():
+ return "unproven"
+ if not grant.matches(pid, fingerprint):
+ return "replaced"
+ return "owned"
+
+
+func _prove_payload() -> Dictionary:
+ var launch: Dictionary = _episode.get("launch", {})
+ return {
+ "http_port": int(_plan.http_port),
+ "expected_ws_port": int(_plan.ws_port),
+ "expected_version": str(_plan.expected_version),
+ "timeout_ms": int(_plan.probe_timeout_ms),
+ "pid_file": str(_plan.get("pid_file", "")),
+ "startup_report": str(_plan.get("startup_report", "")),
+ "http_capability": str(launch.get("http_capability", "")),
+ "ws_capability": str(launch.get("ws_capability", "")),
+ "baseline_instance_id": str(launch.get("baseline_instance_id", "")),
+ "grant": _process_grant,
+ }
+
+
+func _stop_payload(launch: Dictionary) -> Dictionary:
+ return {
+ "grant": _process_grant,
+ "http_port": int(_plan.get("http_port", 0)),
+ "pid_file": str(_plan.get("pid_file", "")),
+ "timeout_ms": int(_plan.get("probe_timeout_ms", DEFAULT_PROBE_TIMEOUT_MS)),
+ "launch": launch.duplicate(true),
+ }
+
+
+func _probe_current_transport() -> Dictionary:
+ if _transport == null:
+ return {}
+ return _probe_with_capability(
+ _transport.http_port(),
+ {"http": _transport.http_capability()},
+ int(_plan.get("probe_timeout_ms", DEFAULT_PROBE_TIMEOUT_MS)),
+ )
+
+
+func _read_capability(port: int) -> Dictionary:
+ return TransportCapability.read_for_http_port(
+ port, str(_plan.get("capability_path", ""))
+ )
+
+
+static func probe_live_server_status(
+ port: int, timeout_ms := DEFAULT_PROBE_TIMEOUT_MS, captured_path := ""
+) -> Dictionary:
+ var capability := TransportCapability.read_for_http_port(port, captured_path)
+ return _probe_with_capability(port, capability, timeout_ms)
+
+
+static func _probe_with_capability(port: int, capability: Dictionary, timeout_ms: int) -> Dictionary:
+ var result := {"reachable": false, "name": "", "version": "", "ws_port": 0, "instance_id": "", "status_code": 0, "error": ""}
+ var http_capability := str(capability.get("http", ""))
+ if http_capability.is_empty():
+ result.error = "missing_capability"
+ return result
+ ## Windows can otherwise spend the whole connect deadline on an unbound
+ ## loopback port. This is only an unreachable probe; callers still check
+ ## occupancy and prove any later listener before using it.
+ if OS.get_name() == "Windows" and PortResolver.can_bind_local_port(port):
+ result.error = "port_unbound"
+ return result
+ var client := HTTPClient.new()
+ var error := client.connect_to_host("127.0.0.1", port)
+ if error != OK:
+ result.error = "connect_%d" % error
+ return result
+ var deadline := Time.get_ticks_msec() + maxi(1, timeout_ms)
+ while client.get_status() in [HTTPClient.STATUS_RESOLVING, HTTPClient.STATUS_CONNECTING]:
+ client.poll()
+ if Time.get_ticks_msec() >= deadline:
+ result.error = "connect_timeout"
+ return result
+ OS.delay_msec(10)
+ if client.get_status() != HTTPClient.STATUS_CONNECTED:
+ result.error = "connect_status_%d" % client.get_status()
+ return result
+ error = client.request(HTTPClient.METHOD_GET, STATUS_PATH, [
+ "Accept: application/json", "Authorization: Bearer %s" % http_capability,
+ ])
+ if error != OK:
+ result.error = "request_%d" % error
+ return result
+ var body := PackedByteArray()
+ while true:
+ var status := client.get_status()
+ if status == HTTPClient.STATUS_REQUESTING:
+ client.poll()
+ elif status == HTTPClient.STATUS_BODY:
+ client.poll()
+ var chunk := client.read_response_body_chunk()
+ if body.size() + chunk.size() > MAX_STATUS_BODY_BYTES:
+ result.error = "response_too_large"
+ return result
+ body.append_array(chunk)
+ elif status == HTTPClient.STATUS_CONNECTED:
+ break
+ else:
+ result.error = "response_status_%d" % status
+ return result
+ if Time.get_ticks_msec() >= deadline:
+ result.error = "response_timeout"
+ return result
+ OS.delay_msec(10)
+ result.status_code = client.get_response_code()
+ if int(result.status_code) != 200:
+ result.error = "http_%d" % int(result.status_code)
+ return result
+ var parsed: Variant = JSON.parse_string(body.get_string_from_utf8())
+ if not (parsed is Dictionary):
+ result.error = "invalid_json"
+ return result
+ result.merge(project_status_payload(parsed), true)
+ return result
+
+
+static func project_status_payload(parsed: Dictionary) -> Dictionary:
+ var projected := {
+ "reachable": true,
+ "name": str(parsed.get("name", "")),
+ "version": str(parsed.get("server_version", parsed.get("version", ""))),
+ "ws_port": int(parsed.get("ws_port", 0)),
+ "instance_id": str(parsed.get("instance_id", "")),
+ "package_path": str(parsed.get("package_path", "")),
+ }
+ var leases: Variant = parsed.get("active_lease_count")
+ if leases is int or leases is float:
+ var numeric := float(leases)
+ if is_finite(numeric) and numeric == floor(numeric):
+ projected.active_lease_count = maxi(0, int(numeric))
+ var telemetry: Variant = parsed.get("telemetry_enabled")
+ if telemetry is bool:
+ projected.telemetry_enabled = telemetry
+ return projected
+
+
+static func _authenticated_status_matches_record(live: Dictionary, capability: Dictionary) -> bool:
+ return (
+ bool(live.get("reachable", false))
+ and str(live.get("name", "")) == "godot-ai"
+ and not str(capability.get("instance_nonce", "")).is_empty()
+ and str(live.get("instance_id", "")) == str(capability.get("instance_nonce", ""))
+ )
+
+
+static func _replacement_target_matches(
+ target: Dictionary, live: Dictionary, capability: Dictionary
+) -> bool:
+ return (
+ _authenticated_status_matches_record(live, capability)
+ and str(live.get("instance_id", "")) == str(target.get("instance_id", ""))
+ and str(live.get("version", "")) == str(target.get("version", ""))
+ )
+
+
+static func _transport_from(port: int, ws_port: int, live: Dictionary, capability: Dictionary):
+ return Authority.TransportAuthority.new(
+ port,
+ ws_port,
+ str(live.get("instance_id", "")),
+ str(capability.get("http", "")),
+ str(capability.get("websocket", "")),
+ )
+
+
+static func _blocked_probe_result(
+ reason: String, port: int, live: Dictionary, allow_replacement := false, detail := ""
+) -> Dictionary:
+ var instance_id := str(live.get("instance_id", ""))
+ var version := str(live.get("version", ""))
+ var replaceable := (
+ allow_replacement
+ and str(live.get("name", "")) == "godot-ai"
+ and not instance_id.is_empty()
+ and not version.is_empty()
+ )
+ var message := "Port %d is occupied by another process." % port
+ if not detail.is_empty():
+ message = "Port %d is occupied by another process (%s)." % [port, detail]
+ if replaceable:
+ message = "Port %d is occupied by godot-ai v%s; choose Replace to continue." % [port, version]
+ return {
+ "outcome": "blocked",
+ "reason": reason,
+ "message": message,
+ "target": {"instance_id": instance_id, "version": version, "port": port, "replaceable": replaceable},
+ }
+
+
+static func generate_capability_pair() -> Dictionary:
+ var http := _random_hex(32)
+ var websocket := _random_hex(32)
+ while websocket == http:
+ websocket = _random_hex(32)
+ return {"http": http, "websocket": websocket}
+
+
+static func spawn_capability_process(
+ command: String, args: Array[String], extra_environment: Dictionary = {}
+) -> Dictionary:
+ var pair := generate_capability_pair()
+ var environment := extra_environment.duplicate(true)
+ environment["GODOT_AI_HTTP_CAPABILITY"] = pair.http
+ environment["GODOT_AI_WS_TOKEN"] = pair.websocket
+ return {
+ "pid": _create_process_with_environment(command, args, environment),
+ "http": pair.http,
+ "websocket": pair.websocket,
+ }
+
+
+static func _random_hex(byte_count: int) -> String:
+ return Crypto.new().generate_random_bytes(byte_count).hex_encode()
+
+
+static func _create_process_with_environment(command: String, args: Array[String], environment: Dictionary) -> int:
+ var previous := {}
+ PortResolver.lock_process_spawn()
+ var full_command: Array = [command]
+ full_command.append_array(args)
+ var saved_uv_environment := (
+ UvResolution.isolate_environment()
+ if UvResolution.is_production_command(full_command)
+ else {}
+ )
+ for key in environment:
+ previous[key] = {"present": OS.has_environment(key), "value": OS.get_environment(key)}
+ OS.set_environment(key, str(environment[key]))
+ var pid := OS.create_process(command, args)
+ for key in previous:
+ if bool(previous[key].present):
+ OS.set_environment(key, str(previous[key].value))
+ else:
+ OS.unset_environment(key)
+ UvResolution.restore_environment(saved_uv_environment)
+ PortResolver.unlock_process_spawn()
+ return pid
+
+
+static func _server_flags(plan: Dictionary) -> Array[String]:
+ var flags: Array[String] = [
+ "--transport", "streamable-http",
+ "--port", str(plan.http_port),
+ "--ws-port", str(plan.ws_port),
+ "--pid-file", str(plan.get("pid_file", "")),
+ ]
+ var startup_report := str(plan.get("startup_report", ""))
+ if not startup_report.is_empty():
+ flags.append_array(["--startup-report", startup_report])
+ var excluded := str(plan.get("excluded_domains", ""))
+ if not excluded.is_empty():
+ flags.append_array(["--exclude-domains", excluded])
+ var allow_hosts := str(plan.get("allow_hosts", ""))
+ if not allow_hosts.is_empty():
+ flags.append_array(["--allow-host", allow_hosts])
+ return flags
+
+
+## " Server reported: : " from the launch's startup
+## report, or "" when there is none. Bounded and single-line: the report is a
+## file the spawned server wrote, so it is quoted, never interpreted.
+static func startup_report_summary(path: String, max_chars := 600) -> String:
+ if path.is_empty() or not FileAccess.file_exists(path):
+ return ""
+ var file := FileAccess.open(path, FileAccess.READ)
+ if file == null:
+ return ""
+ var raw := file.get_as_text().strip_edges()
+ file.close()
+ if raw.is_empty() or raw.length() > 8192:
+ return ""
+ var parsed: Variant = JSON.parse_string(raw)
+ if not (parsed is Dictionary):
+ return ""
+ var error := str(parsed.get("error", "")).strip_edges()
+ var message := str(parsed.get("message", "")).strip_edges()
+ var hint := str(parsed.get("hint", "")).strip_edges()
+ if error.is_empty() and message.is_empty():
+ return ""
+ var text := " Server reported: "
+ if not error.is_empty():
+ text += error + (": " if not message.is_empty() else "")
+ text += message
+ if not hint.is_empty():
+ text += " " + hint
+ text = text.replace("\n", " ").replace("\r", " ")
+ if text.length() > max_chars:
+ text = text.substr(0, max_chars - 1) + "…"
+ return text
+## Untrusted, bounded, tokenless read of `/godot-ai/status`, used ONLY to
+## word the BLOCKED message when a pre-v4 godot-ai server holds the port. A
+## v3 server never wrote a v4 capability record, so the authenticated probe
+## cannot even name it, and the dock could only say "another process". This
+## peek is not a fallback: its result never enters the probe outcome, never
+## becomes a transport, and never contributes to replacement or kill
+## authority; `_blocked_probe_result` keeps `replaceable` false. It answers
+## one question, "is that a Godot AI 3.x server?", so the user is told to
+## quit the AI client whose bridge keeps it alive instead of hunting for a
+## foreign process. docs/server-lifecycle.md states the exception.
+static func _untrusted_pre_v4_occupant_version(port: int, timeout_ms: int) -> String:
+ var client := HTTPClient.new()
+ if client.connect_to_host("127.0.0.1", port) != OK:
+ return ""
+ var deadline := Time.get_ticks_msec() + maxi(1, timeout_ms)
+ while client.get_status() in [HTTPClient.STATUS_RESOLVING, HTTPClient.STATUS_CONNECTING]:
+ client.poll()
+ if Time.get_ticks_msec() >= deadline:
+ return ""
+ OS.delay_msec(10)
+ if client.get_status() != HTTPClient.STATUS_CONNECTED:
+ return ""
+ if client.request(HTTPClient.METHOD_GET, STATUS_PATH, ["Accept: application/json"]) != OK:
+ return ""
+ var body := PackedByteArray()
+ while true:
+ var status := client.get_status()
+ if status == HTTPClient.STATUS_REQUESTING:
+ client.poll()
+ elif status == HTTPClient.STATUS_BODY:
+ client.poll()
+ var chunk := client.read_response_body_chunk()
+ if body.size() + chunk.size() > MAX_STATUS_BODY_BYTES:
+ return ""
+ body.append_array(chunk)
+ elif status == HTTPClient.STATUS_CONNECTED:
+ break
+ else:
+ return ""
+ if Time.get_ticks_msec() >= deadline:
+ return ""
+ OS.delay_msec(10)
+ if client.get_response_code() != 200:
+ return ""
+ var parsed: Variant = JSON.parse_string(body.get_string_from_utf8())
+ return pre_v4_version_from_status(parsed)
+
+
+## "3.2.4" for a payload that claims to be a godot-ai server below v4, else "".
+## Pure and bounded so the contract is unit-testable without a socket.
+static func pre_v4_version_from_status(parsed: Variant) -> String:
+ if not (parsed is Dictionary):
+ return ""
+ if str(parsed.get("name", "")) != "godot-ai":
+ return ""
+ var version := str(parsed.get("server_version", "")).strip_edges()
+ if version.length() > 32 or not version.begins_with("3."):
+ return ""
+ ## Every dot-separated component must be digits and non-empty: "3.",
+ ## "3..2" and "3.2." are not versions and must not earn the stale-server
+ ## wording or the slow post-update retry.
+ for part in version.split("."):
+ if part.is_empty():
+ return ""
+ for index in range(part.length()):
+ var code := part.unicode_at(index)
+ if code < 48 or code > 57:
+ return ""
+ return version
+
+
+static func stale_pre_v4_message(port: int, version: String) -> String:
+ return (
+ "Port %d is held by a Godot AI %s server that an AI client attached before the update "
+ + "keeps alive. Quit and relaunch that client (restarting its MCP server is not enough); "
+ + "the old server exits on its own within about three minutes, and the editor retries "
+ + "meanwhile. Otherwise click Restart Server afterwards."
+ ) % [port, version]
+
+
+static func active_lease_count(live: Dictionary) -> int:
+ if str(live.get("name", "")) != "godot-ai":
+ return 0
+ return maxi(0, int(live.get("active_lease_count", 0)))
+
+
+static func _server_version_compatibility(actual_version: String, expected_version: String) -> Dictionary:
+ return ServerVersionCheck.evaluate(actual_version, expected_version)
+
+
+static func _server_status_compatibility(actual_version: String, expected_version: String, actual_ws_port: int, expected_ws_port: int) -> Dictionary:
+ var version := _server_version_compatibility(actual_version, expected_version)
+ if not bool(version.compatible):
+ return version
+ if actual_ws_port != expected_ws_port:
+ return {"compatible": false, "reason": "ws_port_mismatch"}
+ return {"compatible": true, "reason": ""}
+
+
+func get_status_dict() -> Dictionary:
+ var state := str(_episode.get("state", DORMANT))
+ var reason := str(_episode.get("reason", ""))
+ return {
+ "episode_id": int(_episode.get("id", 0)),
+ "episode_state": state,
+ "phase": str(_episode.get("phase", "")),
+ "ready_kind": str(_episode.get("ready_kind", "")),
+ "state": _dock_state(state, reason),
+ "expected_version": str(_episode.get("expected_version", _plan.get("expected_version", ""))),
+ "actual_version": str(_episode.get("actual_version", "")),
+ "actual_name": "godot-ai" if state == READY else "",
+ "message": str(_episode.get("message", "")),
+ "connection_blocked": state != READY,
+ "can_recover_incompatible": can_recover_incompatible_server(),
+ "conflict_port": int(_episode.get("blocked_target", {}).get("port", 0)),
+ "conflict_version": str(_episode.get("blocked_target", {}).get("version", "")),
+ ## Wording-only hint from the untrusted pre-v4 peek; never authority.
+ "blocked_hint": str(_episode.get("blocked_target", {}).get("hint", "")),
+ "keep_alive": bool(_plan.get("keep_alive", false)),
+ "recovery_attempt": _endpoint_recovery_attempts,
+ "recovery_limit": ENDPOINT_RECOVERY_DELAYS_SECONDS.size(),
+ }
+
+
+func get_server_pid() -> int:
+ return int(_process_grant.process_id()) if _process_grant != null else -1
+
+
+func is_connection_blocked() -> bool:
+ return str(_episode.get("state")) != READY
+
+
+func has_managed_server() -> bool:
+ return _process_grant != null and _process_grant.is_valid()
+
+
+func can_restart_managed_server() -> bool:
+ return has_managed_server()
+
+
+func episode_snapshot() -> Dictionary:
+ var snapshot := _episode.duplicate(true)
+ var launch: Dictionary = snapshot.get("launch", {})
+ launch.erase("http_capability")
+ launch.erase("ws_capability")
+ snapshot["launch"] = launch
+ return snapshot
+
+
+func authority_snapshot() -> Dictionary:
+ return {
+ "transport": _transport.public_snapshot() if _transport != null else {},
+ "owned_pid": get_server_pid(),
+ "replacement_available": _replacement_authorization != null,
+ }
+
+
+func _publish() -> void:
+ snapshot_changed.emit(get_status_dict().duplicate(true))
+
+
+static func _dormant_episode(episode_id: int) -> Dictionary:
+ return {
+ "id": episode_id, "state": DORMANT, "phase": "", "reason": "",
+ "message": "Server lifecycle is dormant.", "ready_kind": "", "effect": "",
+ "expected_version": "", "actual_version": "", "blocked_target": {},
+ "launch": {}, "after_stop": "",
+ }
+
+
+static func _stopping_episode(episode_id: int, launch: Dictionary, after_stop: String) -> Dictionary:
+ return {
+ "id": episode_id, "state": STOPPING, "phase": "", "reason": "",
+ "message": "Stopping the managed server.", "ready_kind": "", "effect": "",
+ "expected_version": "", "actual_version": "", "blocked_target": {},
+ "launch": launch, "after_stop": after_stop,
+ }
+
+
+static func _dock_state(state: String, reason: String) -> int:
+ match state:
+ DORMANT:
+ return ServerState.STOPPED
+ STARTING:
+ return ServerState.SPAWNING
+ READY:
+ return ServerState.READY
+ RECOVERING, STOPPING:
+ return ServerState.STOPPING
+ BLOCKED:
+ match reason:
+ "incompatible": return ServerState.INCOMPATIBLE
+ "no_command": return ServerState.NO_COMMAND
+ "port_reserved": return ServerState.PORT_EXCLUDED
+ "occupied", "replacement_unproven": return ServerState.FOREIGN_PORT
+ _: return ServerState.CRASHED
+ return ServerState.UNINITIALIZED
diff --git a/addons/godot_ai/utils/server_lifecycle.gd.uid b/addons/godot_ai/utils/server_lifecycle.gd.uid
new file mode 100644
index 0000000..8e62667
--- /dev/null
+++ b/addons/godot_ai/utils/server_lifecycle.gd.uid
@@ -0,0 +1 @@
+uid://bwfx8b0w2mgf6
diff --git a/addons/godot_ai/utils/server_version_check.gd b/addons/godot_ai/utils/server_version_check.gd
new file mode 100644
index 0000000..b6f2172
--- /dev/null
+++ b/addons/godot_ai/utils/server_version_check.gd
@@ -0,0 +1,64 @@
+@tool
+class_name McpServerVersionCheck
+extends RefCounted
+
+## Published v4 compatibility value. Lifecycle owns the authenticated
+## handshake and episode transition; this class intentionally owns no timer,
+## connection, or manager reference.
+
+## First release whose attached bridge follows servers of the same major.
+const FIRST_BRIDGE_TOLERANT_VERSION := "4.0.4"
+
+
+## Whether clients attached at `from_version` can reconnect after updating
+## to `to_version` without restarting their bridge (#1024).
+static func attached_bridges_follow(from_version: String, to_version: String) -> bool:
+ var from_tuple := version_tuple(from_version)
+ var to_tuple := version_tuple(to_version)
+ if from_tuple.is_empty() or to_tuple.is_empty():
+ return false
+ if int(from_tuple[0]) != int(to_tuple[0]):
+ return false
+ return compare(from_tuple, version_tuple(FIRST_BRIDGE_TOLERANT_VERSION)) >= 0
+
+
+## Leading numeric `major.minor.patch` of a version as `[major, minor, patch]`,
+## or `[]` when it does not start that way (a dev build, a malformed pin).
+## A suffix after the patch (`4.0.3+local.1`, `4.1.0-rc1`) is ignored; a
+## dangling separator (`4.0.3+`) is not a version.
+static func version_tuple(version: String) -> Array:
+ var regex := RegEx.create_from_string("^(\\d+)\\.(\\d+)\\.(\\d+)(?:$|[.+-][0-9A-Za-z][0-9A-Za-z.+-]*$)")
+ var found := regex.search(version.strip_edges())
+ if found == null:
+ return []
+ return [int(found.get_string(1)), int(found.get_string(2)), int(found.get_string(3))]
+
+
+static func compare(a: Array, b: Array) -> int:
+ for index in range(3):
+ if int(a[index]) != int(b[index]):
+ return -1 if int(a[index]) < int(b[index]) else 1
+ return 0
+
+
+## `candidate` is a parseable version older than `reference` within the same
+## major version. Unparseable, equal, newer, or another major all read false:
+## a caller may only replace what it can prove is older than itself.
+static func is_older_same_major(candidate: String, reference: String) -> bool:
+ var candidate_tuple := version_tuple(candidate)
+ var reference_tuple := version_tuple(reference)
+ if candidate_tuple.is_empty() or reference_tuple.is_empty():
+ return false
+ if int(candidate_tuple[0]) != int(reference_tuple[0]):
+ return false
+ return compare(candidate_tuple, reference_tuple) < 0
+
+
+static func evaluate(actual_version: String, expected_version: String) -> Dictionary:
+ if actual_version.is_empty():
+ return {"compatible": false, "reason": "missing_version"}
+ var compatible := actual_version == expected_version
+ return {
+ "compatible": compatible,
+ "reason": "" if compatible else "version_mismatch",
+ }
diff --git a/addons/godot_ai/utils/server_version_check.gd.uid b/addons/godot_ai/utils/server_version_check.gd.uid
new file mode 100644
index 0000000..7baacfd
--- /dev/null
+++ b/addons/godot_ai/utils/server_version_check.gd.uid
@@ -0,0 +1 @@
+uid://ciqldbuaq8i8u
diff --git a/addons/godot_ai/utils/settings.gd b/addons/godot_ai/utils/settings.gd
new file mode 100644
index 0000000..9ce2c68
--- /dev/null
+++ b/addons/godot_ai/utils/settings.gd
@@ -0,0 +1,135 @@
+@tool
+class_name McpSettings
+extends RefCounted
+
+## Shared EditorSettings key constants for the godot_ai/* namespace.
+##
+## Centralised here so lightweight files (e.g. telemetry.gd) can reference
+## settings keys without pulling in the full client_configurator.gd dep tree.
+## All keys must keep their raw string values stable across releases because
+## they are persisted in the user's editor_settings-4.tres.
+
+const SETTING_HTTP_PORT := "godot_ai/http_port"
+## Comma-separated list of tool domains excluded from the server at spawn time.
+const SETTING_EXCLUDED_DOMAINS := "godot_ai/excluded_domains"
+const SETTING_TELEMETRY_ENABLED := "godot_ai/telemetry_enabled"
+## Comma-separated CIDRs / bare IPs passed to the server as `--allow-host`
+## at spawn time (#507, server core #421). Empty means loopback-only.
+const SETTING_ALLOW_HOSTS := "godot_ai/allow_remote_hosts"
+## Whether MCP log lines echo to the Godot console (dock "Log" toggle).
+## The dock's ring-buffer log panel keeps recording regardless.
+const SETTING_MCP_LOGGING := "godot_ai/mcp_logging"
+## Which scope CLI-configured clients register the server in. Claude Code's
+## `--scope user` writes the global block of ~/.claude.json, which that client
+## loads in EVERY workspace — so the server gets spawned in unrelated,
+## non-Godot projects. `project` writes /.mcp.json instead, so the
+## entry travels with the project. Defaults to `user` to preserve the
+## historical behaviour for existing installs.
+const SETTING_CLIENT_SCOPE := "godot_ai/mcp_client_scope"
+
+## Scopes accepted for SETTING_CLIENT_SCOPE. These are the values the Claude
+## Code CLI's `--scope` flag takes; an unrecognised setting falls back to
+## DEFAULT_CLIENT_SCOPE rather than passing junk to the CLI.
+const CLIENT_SCOPES := ["user", "project", "local"]
+const DEFAULT_CLIENT_SCOPE := "user"
+
+
+## Returns true if the string value is truthy
+## ("1", "true", "yes", "on", case-insensitive, whitespace-trimmed).
+static func truthy(value: String) -> bool:
+ return value.strip_edges().to_lower() in ["1", "true", "yes", "on"]
+
+
+## Returns true if the named environment variable is set to a truthy value.
+static func env_truthy(var_name: String) -> bool:
+ return truthy(OS.get_environment(var_name))
+
+
+## #691: EditorInterface / EditorSettings are not thread-safe, and the dock
+## runs Configure / Remove on a worker Thread (mcp_dock.gd
+## `_run_client_action_worker` -> McpClientConfigurator -> McpCliStrategy
+## `_format_args` -> client_scope()). Same contract as
+## McpClientConfigurator._editor_setting_lookup: the main thread does a live
+## read and refreshes the snapshot; worker threads read the snapshot only, so
+## a never-warmed value reads as null (unset) rather than touching
+## EditorInterface off-thread. Warmed on the main thread by
+## McpClientConfigurator.warm_env_snapshot() before each worker dispatch.
+##
+## Kept here rather than reusing the configurator's `_editor_setting_lookup`
+## so utils/settings.gd stays free of the client_configurator.gd dep tree —
+## see this file's header.
+static var _client_scope_snapshot: Variant = null
+static var _client_scope_mutex := Mutex.new()
+
+
+static func _client_scope_setting() -> Variant:
+ if OS.get_thread_caller_id() == OS.get_main_thread_id():
+ var live: Variant = null
+ if Engine.is_editor_hint():
+ var es := EditorInterface.get_editor_settings()
+ if es != null and es.has_setting(SETTING_CLIENT_SCOPE):
+ live = es.get_setting(SETTING_CLIENT_SCOPE)
+ _client_scope_mutex.lock()
+ _client_scope_snapshot = live
+ _client_scope_mutex.unlock()
+ return live
+ _client_scope_mutex.lock()
+ var cached: Variant = _client_scope_snapshot
+ _client_scope_mutex.unlock()
+ return cached
+
+
+## Main-thread pre-warm of the scope snapshot so the dock's Configure/Remove
+## worker resolves `{scope}` from a value captured while EditorSettings access
+## was safe. Idempotent; called from warm_env_snapshot().
+static func warm_client_scope() -> void:
+ _client_scope_setting()
+
+
+## Returns the scope CLI clients should register the MCP server in, as
+## substituted into `{scope}` in a descriptor's cli_register_template /
+## cli_unregister_template. Safe to call from either thread (see
+## _client_scope_setting). Unrecognised values fall back to
+## DEFAULT_CLIENT_SCOPE so a hand-edited editor_settings-4.tres can never make
+## the plugin shell out with a bad flag.
+static func client_scope() -> String:
+ var value: Variant = _client_scope_setting()
+ ## String(null) is a hard error (#850) — an unset/never-warmed key must
+ ## short-circuit before the cast.
+ if value == null:
+ return DEFAULT_CLIENT_SCOPE
+ var raw := String(value).strip_edges().to_lower()
+ if raw in CLIENT_SCOPES:
+ return raw
+ return DEFAULT_CLIENT_SCOPE
+
+
+## Returns true if telemetry should be active, checking in priority order:
+## 1. GODOT_AI_DISABLE_TELEMETRY / DISABLE_TELEMETRY env vars
+## 2. The godot_ai/telemetry_enabled EditorSetting written by the dock UI
+## Defaults to true when neither source has set a preference.
+static func telemetry_enabled() -> bool:
+ if env_truthy("GODOT_AI_DISABLE_TELEMETRY") or env_truthy("DISABLE_TELEMETRY"):
+ return false
+ var es := EditorInterface.get_editor_settings()
+ if es != null and es.has_setting(SETTING_TELEMETRY_ENABLED):
+ return bool(es.get_setting(SETTING_TELEMETRY_ENABLED))
+ return true
+
+
+## Returns whether MCP log lines should echo to the Godot console. Read at
+## plugin startup (to apply the persisted choice to the log buffer and
+## dispatcher) and by the dock's LogViewer toggle for its initial state.
+## Defaults to true when the user has never touched the toggle.
+static func mcp_logging_enabled() -> bool:
+ var es := EditorInterface.get_editor_settings()
+ if es != null and es.has_setting(SETTING_MCP_LOGGING):
+ return bool(es.get_setting(SETTING_MCP_LOGGING))
+ return true
+
+
+## Persist the dock "Log" toggle so the choice survives editor restarts (#626).
+static func set_mcp_logging_enabled(enabled: bool) -> void:
+ var es := EditorInterface.get_editor_settings()
+ if es != null:
+ es.set_setting(SETTING_MCP_LOGGING, enabled)
diff --git a/addons/godot_ai/utils/settings.gd.uid b/addons/godot_ai/utils/settings.gd.uid
new file mode 100644
index 0000000..b8b1547
--- /dev/null
+++ b/addons/godot_ai/utils/settings.gd.uid
@@ -0,0 +1 @@
+uid://pefrtofs7ijw
diff --git a/addons/godot_ai/utils/structured_log_ring.gd b/addons/godot_ai/utils/structured_log_ring.gd
new file mode 100644
index 0000000..3e00b4e
--- /dev/null
+++ b/addons/godot_ai/utils/structured_log_ring.gd
@@ -0,0 +1,156 @@
+@tool
+class_name McpStructuredLogRing
+extends RefCounted
+
+## Head-indexed circular buffer of structured log entries shared by
+## game_log_buffer and editor_log_buffer.
+##
+## Once `_max_lines` (set in subclass `_init`) is reached, new appends
+## overwrite the oldest slot at `_head`, keeping append O(1) on overflow
+## — the previous slice() approach reallocated the full retained array
+## on every drop, which a chatty game would pay for thousands of times
+## per second.
+##
+## Lockless. Subclasses needing thread-safety (editor_log_buffer is
+## written from any thread a Godot Logger virtual can fire on) wrap each
+## public method with their own Mutex around the `_*_unlocked` helpers.
+## Keeping the base lockless means the hot game-side path (single thread,
+## called from _process) doesn't pay an unused mutex cost.
+##
+## Entry shape is owned by subclasses — `_append_entry` takes a
+## ready-built Dictionary so each buffer can carry the fields it needs
+## (game: `source/level/text`; editor: adds `path/line/function`).
+
+const VALID_LEVELS := ["info", "warn", "error"]
+
+var _max_lines: int
+var _storage: Array[Dictionary] = []
+## Next write position within `_storage`. While filling (before first
+## wrap) equals `_storage.size()`; once full, points at the oldest entry
+## (the one about to be overwritten).
+var _head := 0
+var _dropped_count := 0
+## Monotonic number of entries appended since this ring was created. Unlike
+## `_storage.size()` and `_dropped_count`, this intentionally survives clear()
+## so callers can use it as a stable "next entry to read" cursor.
+var _appended_total := 0
+
+
+func _init(max_lines: int) -> void:
+ _max_lines = max_lines
+
+
+## Append `entry` to the ring, evicting the oldest slot when full.
+## Subclasses build the dict with their per-source shape and pass it in.
+func _append_entry(entry: Dictionary) -> void:
+ if _storage.size() < _max_lines:
+ _storage.append(entry)
+ _head = _storage.size() % _max_lines
+ else:
+ ## Full — overwrite oldest in place, advance head, count the drop.
+ _storage[_head] = entry
+ _head = (_head + 1) % _max_lines
+ _dropped_count += 1
+ _appended_total += 1
+
+
+## Lockless slice. Subclasses with a mutex wrap their `get_range` /
+## `get_recent` overrides around this; the lockless base implementations
+## of those public methods just delegate here.
+func _get_range_unlocked(offset: int, count: int) -> Array[Dictionary]:
+ var size := _storage.size()
+ var start := maxi(0, offset)
+ var stop := mini(size, start + count)
+ var out: Array[Dictionary] = []
+ for i in range(start, stop):
+ out.append(_storage[_logical_to_physical(i)])
+ return out
+
+
+func get_range(offset: int, count: int) -> Array[Dictionary]:
+ return _get_range_unlocked(offset, count)
+
+
+func get_recent(count: int) -> Array[Dictionary]:
+ var size := _storage.size()
+ var start := maxi(0, size - count)
+ return _get_range_unlocked(start, size - start)
+
+
+## Lockless cursor read. The cursor is the next sequence to read: calling
+## get_since(appended_total()) after a snapshot returns only later appends.
+func _get_since_unlocked(since_seq: int, limit: int = -1) -> Dictionary:
+ var size := _storage.size()
+ var oldest_seq := _appended_total - size
+ var start_seq := mini(maxi(since_seq, oldest_seq), _appended_total)
+ var start := start_seq - oldest_seq
+ var available := maxi(0, size - start)
+ var count := available
+ if limit >= 0:
+ count = mini(available, limit)
+ var entries := _get_range_unlocked(start, count)
+ var next_cursor := start_seq + entries.size()
+ return {
+ "cursor": since_seq,
+ "oldest_cursor": oldest_seq,
+ "next_cursor": next_cursor,
+ "appended_total": _appended_total,
+ "truncated": since_seq < oldest_seq,
+ "has_more": next_cursor < _appended_total,
+ "entries": entries,
+ }
+
+
+func get_since(since_seq: int, limit: int = -1) -> Dictionary:
+ return _get_since_unlocked(since_seq, limit)
+
+
+## Lockless accessors. Subclasses with a mutex use these under their lock
+## so the field reads stay encapsulated in the base instead of leaking
+## `_storage` / `_dropped_count` reach-through into the subclass.
+func _total_count_unlocked() -> int:
+ return _storage.size()
+
+
+func _dropped_count_unlocked() -> int:
+ return _dropped_count
+
+
+func _appended_total_unlocked() -> int:
+ return _appended_total
+
+
+func total_count() -> int:
+ return _total_count_unlocked()
+
+
+func dropped_count() -> int:
+ return _dropped_count_unlocked()
+
+
+func appended_total() -> int:
+ return _appended_total_unlocked()
+
+
+## Translate a logical index (0 = oldest retained) to a physical
+## `_storage` slot. Before the first wrap, storage-order is logical-
+## order. After wrapping, the oldest entry lives at `_head`.
+func _logical_to_physical(logical: int) -> int:
+ if _storage.size() < _max_lines:
+ return logical
+ return (_head + logical) % _max_lines
+
+
+## Reset the ring to empty. Subclasses with a mutex wrap this with their
+## lock; subclasses that surface `clear` to callers (McpEditorLogBuffer)
+## return the prior size from their wrapper.
+func _clear_storage() -> void:
+ _storage.clear()
+ _head = 0
+ _dropped_count = 0
+
+
+## Coerce unknown levels to "info" so a misbehaving sender can't poison
+## downstream filters with arbitrary strings.
+static func _coerce_level(level: String) -> String:
+ return level if level in VALID_LEVELS else "info"
diff --git a/addons/godot_ai/utils/structured_log_ring.gd.uid b/addons/godot_ai/utils/structured_log_ring.gd.uid
new file mode 100644
index 0000000..57012ba
--- /dev/null
+++ b/addons/godot_ai/utils/structured_log_ring.gd.uid
@@ -0,0 +1 @@
+uid://c4yh3jqfn6dwe
diff --git a/addons/godot_ai/utils/surfaced_error_tracker.gd b/addons/godot_ai/utils/surfaced_error_tracker.gd
new file mode 100644
index 0000000..5bd3022
--- /dev/null
+++ b/addons/godot_ai/utils/surfaced_error_tracker.gd
@@ -0,0 +1,642 @@
+@tool
+class_name McpSurfacedErrorTracker
+extends RefCounted
+
+## Central source for "errors the agent should know exist".
+##
+## Editor log cursors only cover McpEditorLogBuffer. Runtime errors from the
+## game subprocess can land solely in the Debugger Errors tab, so this tracker
+## promotes visible Debugger-tab rows into a monotonic sequence before the
+## dispatcher stamps a watermark on each response envelope.
+
+const MAX_PROMOTED_DEBUGGER_ENTRIES := 500
+const MAX_PROMOTED_DEBUGGER_KEYS := 5000
+const DEBUGGER_REFRESH_MIN_INTERVAL_MS := 250
+const DEBUGGER_SCAN_AFTER_STOP_MS := 5000
+## #641: delays for the self-scheduled forced scans armed on run stop (and on
+## game-helper hello, via McpDebuggerPlugin). Two ticks: an early one for rows
+## the remote debugger delivers right around the event, and a late one past
+## Godot's per-frame Errors-tab insertion throttle for error floods.
+const DEFERRED_SCAN_DELAYS_SEC: Array[float] = [1.0, 5.0]
+## #635: cap on accounted per-key row-time signatures. The live Errors tab is
+## itself bounded, so this only guards a pathological flood of same-keyed rows
+## with distinct time texts; past the cap the set resets to the current scan.
+const MAX_ACCOUNTED_ROW_TIMES_PER_KEY := 512
+
+var _editor_log_buffer
+var _game_log_buffer
+var _debugger_errors_root: Node
+var _debugger_search_root_cache: Node
+var _promoted_debugger_keys: Dictionary = {}
+## #635: per-key set of Errors-tab row time texts already promoted, so a row
+## observed after a clear+repopulate that no scan saw as empty still counts as
+## new (see the re-promotion comment in refresh_debugger_errors).
+var _promoted_debugger_row_times: Dictionary = {}
+var _promoted_debugger_key_order: Array[String] = []
+var _promoted_debugger_entries: Array[Dictionary] = []
+var _debugger_promoted_total := 0
+var _run_seq := 0
+var _oldest_retained_debugger_sequence := 1
+var _last_debugger_refresh_msec := -DEBUGGER_REFRESH_MIN_INTERVAL_MS
+var _debugger_scan_active := false
+var _debugger_scan_until_msec := 0
+var _deferred_scans_scheduled_total := 0
+
+
+func _init(editor_log_buffer = null, game_log_buffer = null, debugger_errors_root: Node = null) -> void:
+ _editor_log_buffer = editor_log_buffer
+ _game_log_buffer = game_log_buffer
+ _debugger_errors_root = debugger_errors_root
+
+
+func note_game_run_started(sticky_scan: bool = true) -> void:
+ _run_seq += 1
+ _debugger_scan_active = sticky_scan
+ _debugger_scan_until_msec = 0
+ if not sticky_scan:
+ _debugger_scan_until_msec = Time.get_ticks_msec() + DEBUGGER_SCAN_AFTER_STOP_MS
+ refresh_debugger_errors(true)
+
+
+func note_game_run_stopped() -> void:
+ _debugger_scan_active = false
+ _debugger_scan_until_msec = Time.get_ticks_msec() + DEBUGGER_SCAN_AFTER_STOP_MS
+ schedule_deferred_scans()
+
+
+## #641: promotion into the watermark used to depend on a tool call arriving
+## while the scan gate was open (run active, or within DEBUGGER_SCAN_AFTER_STOP_MS
+## of stop). Boot parse errors that landed in the Errors tab with no tool call
+## in that window were never promoted, so the agent never got the
+## new_errors_since_last_call hint. These editor-side timers force a scan
+## regardless of tool-call cadence; the next stamped response then carries the
+## already-promoted count even after the gate closes. Scans are content-keyed
+## and idempotent, so a timer firing after an unrelated new run is harmless.
+func schedule_deferred_scans(delays: Array = DEFERRED_SCAN_DELAYS_SEC) -> void:
+ var tree := Engine.get_main_loop() as SceneTree
+ if tree == null:
+ return
+ for delay in delays:
+ var timer := tree.create_timer(maxf(0.05, float(delay)))
+ timer.timeout.connect(_on_deferred_scan_timeout)
+ _deferred_scans_scheduled_total += 1
+
+
+func deferred_scans_scheduled_total() -> int:
+ return _deferred_scans_scheduled_total
+
+
+func _on_deferred_scan_timeout() -> void:
+ refresh_debugger_errors(true)
+
+
+func refresh_debugger_errors(force: bool = true) -> void:
+ var now := Time.get_ticks_msec()
+ if not force and not _should_scan_debugger_for_cached_watermark(now):
+ return
+ _last_debugger_refresh_msec = now
+ var current_by_key: Dictionary = {}
+ for entry in _raw_debugger_error_entries():
+ if str(entry.get("level", "")) != "error":
+ continue
+ var key := _log_entry_key(entry)
+ var info: Dictionary = current_by_key.get(key, {"count": 0, "entry": entry, "times": {}})
+ info["count"] = int(info.get("count", 0)) + 1
+ var time_text := _row_time_text(entry)
+ if not time_text.is_empty():
+ (info["times"] as Dictionary)[time_text] = true
+ current_by_key[key] = info
+ for key in _promoted_debugger_keys.keys():
+ if not current_by_key.has(key):
+ _promoted_debugger_keys[key] = 0
+ for key in current_by_key.keys():
+ var info: Dictionary = current_by_key[key]
+ var current := int(info.get("count", 0))
+ var stored := int(_promoted_debugger_keys.get(key, 0))
+ ## #635: a count increase alone misses rows observed after a run
+ ## boundary. Godot clears the Errors tab at run start; when the new run
+ ## re-fires an error identical to one promoted before the clear, and no
+ ## scan happened to observe the tab empty in between, the per-key count
+ ## never dips — so the row kept its pre-run sequence and run-scoping
+ ## (editor_entries_since against the run-start cursor) misclassified an
+ ## in-run error as retained_recent. Each Errors-tab row carries its own
+ ## time text; an unaccounted (key, time) signature is a row we have not
+ ## promoted yet, so it earns a fresh sequence even at an equal or lower
+ ## count. Boundary condition: rows with an empty time text, or a
+ ## repopulated row whose time text is byte-identical to a pre-clear row,
+ ## fall back to count-only dedup and can still be missed.
+ var unseen_times := _unaccounted_row_times(key, info.get("times", {}))
+ var delta := current - stored
+ if delta <= 0 and not unseen_times.is_empty():
+ delta = mini(unseen_times.size(), current)
+ if delta <= 0:
+ if current != stored:
+ _promoted_debugger_keys[key] = current
+ continue
+ if not _promoted_debugger_keys.has(key):
+ _promoted_debugger_key_order.append(key)
+ _promoted_debugger_keys[key] = current
+ _account_row_times(key, info.get("times", {}))
+ _debugger_promoted_total += delta
+ var source_entry: Dictionary = info.get("entry", {})
+ var promoted := source_entry.duplicate(true)
+ promoted["_debugger_key"] = key
+ promoted["_debugger_occurrences"] = current
+ promoted["_debugger_sequence"] = _debugger_promoted_total
+ _remove_promoted_debugger_entry(key)
+ _promoted_debugger_entries.append(promoted)
+ _trim_promoted_debugger_entries()
+ _trim_promoted_debugger_key_counts()
+
+
+## #645: promote an error record that has no Errors-tab row to scrape — e.g. a
+## boot-time parse error that parked the game in a remote-debugger break before
+## any surface got a record. The entry joins the same promoted sequence as
+## scraped Debugger rows, so run-scoping (editor_entries_since), the retained
+## fallback, and the response watermark all see it with no extra plumbing.
+## Re-recording the same key later (the same script still broken on the next
+## run) re-promotes it with a fresh sequence, mirroring how re-appearing
+## Errors-tab rows behave; scan reconciliation zeroes the key's count once the
+## break ends since the row never exists in the live tab.
+func record_synthetic_error(entry: Dictionary) -> void:
+ var key := _log_entry_key(entry)
+ var occurrences := int(_promoted_debugger_keys.get(key, 0)) + 1
+ if not _promoted_debugger_keys.has(key):
+ _promoted_debugger_key_order.append(key)
+ _promoted_debugger_keys[key] = occurrences
+ _debugger_promoted_total += 1
+ var promoted := entry.duplicate(true)
+ promoted["_debugger_key"] = key
+ promoted["_debugger_occurrences"] = occurrences
+ promoted["_debugger_sequence"] = _debugger_promoted_total
+ promoted["_debugger_synthetic"] = true
+ _remove_promoted_debugger_entry(key)
+ _promoted_debugger_entries.append(promoted)
+ _trim_promoted_debugger_entries()
+ _trim_promoted_debugger_key_counts()
+
+
+## Monotonicity contract (#767): run_seq and the session-scoped components
+## (editor_ring, debugger_promoted, editor_ring_warn) must NEVER decrease
+## within an editor session, and the per-run components (game_error_warn,
+## game_warn) must never decrease within a run — they may reset only when
+## run_seq increments in the same stamp (the run boundary that rotates the
+## game buffer's counters). Released servers diff consecutive stamps
+## (websocket.py::_sync_error_watermark_for_session) and treat any other
+## decrease as a counter reset, counting the FULL current value as new — one
+## dip makes every old server out there over-report errors. Any future
+## hold/classification feature must therefore DEFER an increment until its
+## entry is released, never subtract an already-stamped one: a stamp like
+## `raw_total - currently_held_entries` is exactly the regression this
+## guards against. Producer-side coverage:
+## test_editor.gd::test_surfaced_error_tracker_watermark_components_never_decrease.
+func watermark(force_debugger_scan: bool = false) -> Dictionary:
+ refresh_debugger_errors(force_debugger_scan)
+ return {
+ "run_seq": _run_seq,
+ "editor_ring": _error_appended_total(),
+ "debugger_promoted": _debugger_promoted_total,
+ ## Historically misnamed: carries game-process ERROR counts only.
+ "game_error_warn": _game_error_total(),
+ ## Warn-level components, parallel to the error counts above. The server
+ ## diffs these into `new_warnings_since_last_call` so a warning-only run
+ ## surfaces instead of reading as clean. Debugger Errors-tab warning rows
+ ## are not promoted here yet (buffers cover push_warning from the game and
+ ## editor parse/@tool warnings) — tracked as a follow-up.
+ "editor_ring_warn": _warn_appended_total(),
+ "game_warn": _game_warn_total(),
+ }
+
+
+static func stamp_watermark(response: Dictionary, tracker) -> void:
+ if tracker == null:
+ return
+ if not tracker.has_method("watermark"):
+ return
+ response["error_watermark"] = tracker.watermark()
+
+
+func debugger_promoted_total(force_debugger_scan: bool = true) -> int:
+ refresh_debugger_errors(force_debugger_scan)
+ return _debugger_promoted_total
+
+
+func collect_editor_log_entries() -> Array[Dictionary]:
+ refresh_debugger_errors(true)
+ var entries: Array[Dictionary] = []
+ var seen_keys: Dictionary = {}
+ if _editor_log_buffer != null:
+ for entry in _editor_log_buffer.get_range(0, _editor_log_buffer.total_count()):
+ seen_keys[_log_entry_key(entry)] = true
+ entries.append(entry)
+ for entry in read_debugger_error_entries():
+ var key := _log_entry_key(entry)
+ if seen_keys.has(key):
+ continue
+ seen_keys[key] = true
+ entries.append(entry)
+ ## #645: synthesized break records have no live Errors-tab row to scrape —
+ ## merge them from the promoted list so logs_read(source="editor") shows
+ ## the record that run/game responses point at.
+ for entry in _promoted_debugger_entries:
+ if not bool(entry.get("_debugger_synthetic", false)):
+ continue
+ var key := _log_entry_key(entry)
+ if seen_keys.has(key):
+ continue
+ seen_keys[key] = true
+ entries.append(_strip_promotion_bookkeeping(entry))
+ return entries
+
+
+static func _strip_promotion_bookkeeping(entry: Dictionary) -> Dictionary:
+ var clean := entry.duplicate(true)
+ for key in ["_debugger_key", "_debugger_occurrences", "_debugger_sequence", "_debugger_synthetic"]:
+ clean.erase(key)
+ return clean
+
+
+func editor_entries_since(editor_cursor: int, debugger_cursor: int, force_debugger_scan: bool = true) -> Dictionary:
+ refresh_debugger_errors(force_debugger_scan)
+ var entries: Array[Dictionary] = []
+ var seen_keys: Dictionary = {}
+ var truncated := false
+ if _editor_log_buffer != null:
+ var captured: Dictionary = _editor_log_buffer.get_since(maxi(0, editor_cursor), -1)
+ truncated = bool(captured.get("truncated", false))
+ for entry in captured.get("entries", []):
+ seen_keys[_log_entry_key(entry)] = true
+ entries.append(entry)
+ if debugger_cursor < _oldest_retained_debugger_sequence - 1:
+ truncated = true
+ for entry in _promoted_debugger_entries:
+ if int(entry.get("_debugger_sequence", 0)) <= debugger_cursor:
+ continue
+ var key := _log_entry_key(entry)
+ if seen_keys.has(key):
+ continue
+ seen_keys[key] = true
+ entries.append(entry)
+ return {
+ "entries": entries,
+ "truncated": truncated,
+ }
+
+
+func retained_recent_editor_entries() -> Array[Dictionary]:
+ ## There is no shared timestamp across the editor logger ring and Godot's
+ ## Debugger Errors tree. Preserve the pre-PR fallback contract: newest
+ ## buffered editor entries first, then debugger-only rows that were not in
+ ## the ring, so stale Debugger rows cannot outrank newer ring entries.
+ var entries: Array[Dictionary] = []
+ var seen_keys: Dictionary = {}
+ if _editor_log_buffer != null:
+ entries = _editor_log_buffer.get_recent(_editor_log_buffer.total_count())
+ entries.reverse()
+ for entry in entries:
+ seen_keys[_log_entry_key(entry)] = true
+ for entry in collect_editor_log_entries():
+ var key := _log_entry_key(entry)
+ if seen_keys.has(key):
+ continue
+ seen_keys[key] = true
+ entries.append(entry)
+ return entries
+
+
+func read_debugger_error_entries() -> Array[Dictionary]:
+ var entries: Array[Dictionary] = []
+ var seen_keys: Dictionary = {}
+ for entry in _raw_debugger_error_entries():
+ var key := _log_entry_key(entry)
+ if seen_keys.has(key):
+ continue
+ seen_keys[key] = true
+ entries.append(entry)
+ return entries
+
+
+func locate_debugger_error_trees() -> Array[Tree]:
+ var trees: Array[Tree] = []
+ var root: Node = _debugger_errors_root
+ ## #641: a deferred-scan timer can outlive an injected root (tests,
+ ## teardown). A freed root must not fall through to the live editor UI —
+ ## that would promote unrelated real errors into a tracker scoped to the
+ ## dead root — so treat it as "nothing to scan".
+ if root != null and not is_instance_valid(root):
+ return trees
+ if root == null:
+ root = _debugger_search_root()
+ if root == null:
+ return trees
+ _collect_debugger_error_trees(root, trees)
+ return trees
+
+
+func clear_debugger_error_trees() -> int:
+ var cleared := 0
+ for tree in locate_debugger_error_trees():
+ cleared += entries_from_debugger_error_tree(tree).size()
+ if not _press_debugger_clear_button(tree):
+ ## Synthetic roots in tests do not have Godot's Clear button.
+ tree.clear()
+ return cleared
+
+
+func _debugger_search_root() -> Node:
+ if is_instance_valid(_debugger_search_root_cache):
+ return _debugger_search_root_cache
+ _debugger_search_root_cache = null
+ var base := EditorInterface.get_base_control()
+ if base == null:
+ return null
+ _debugger_search_root_cache = _find_first_of_class(base, "EditorDebuggerNode")
+ if _debugger_search_root_cache == null:
+ return base
+ return _debugger_search_root_cache
+
+
+static func _find_first_of_class(node: Node, klass: String) -> Node:
+ if node.get_class() == klass:
+ return node
+ for child in node.get_children():
+ var found := _find_first_of_class(child, klass)
+ if found != null:
+ return found
+ return null
+
+
+static func _collect_debugger_error_trees(node: Node, out: Array[Tree]) -> void:
+ if node is Tree and _tree_has_debugger_errors(node as Tree):
+ out.append(node as Tree)
+ for child in node.get_children():
+ if child is Node:
+ _collect_debugger_error_trees(child as Node, out)
+
+
+static func _tree_has_debugger_errors(tree: Tree) -> bool:
+ var root := tree.get_root()
+ if root == null:
+ return false
+ var item := root.get_first_child()
+ while item != null:
+ if _is_debugger_error_item(item):
+ return true
+ item = item.get_next()
+ return false
+
+
+static func _press_debugger_clear_button(tree: Tree) -> bool:
+ var parent := tree.get_parent()
+ if parent == null:
+ return false
+ var stack: Array[Node] = [parent]
+ while not stack.is_empty():
+ var node: Node = stack.pop_back()
+ if node is BaseButton:
+ for conn in node.get_signal_connection_list("pressed"):
+ if str(conn.get("callable", "")).contains("_clear_errors_list"):
+ node.emit_signal("pressed")
+ return true
+ for child in node.get_children():
+ stack.push_back(child)
+ return false
+
+
+static func entries_from_debugger_error_tree(tree: Tree) -> Array[Dictionary]:
+ var entries: Array[Dictionary] = []
+ var root := tree.get_root()
+ if root == null:
+ return entries
+ var item := root.get_first_child()
+ while item != null:
+ if _is_debugger_error_item(item):
+ entries.append(_entry_from_debugger_error_item(item))
+ item = item.get_next()
+ return entries
+
+
+static func _entry_from_debugger_error_item(item: TreeItem) -> Dictionary:
+ var title := item.get_text(1)
+ var loc := _location_from_metadata(item.get_metadata(0))
+ var function := _function_from_title(title)
+ return {
+ "source": "editor",
+ "level": "warn" if item.has_meta("_is_warning") else "error",
+ "text": title,
+ "path": str(loc.get("path", "")),
+ "line": int(loc.get("line", 0)),
+ "function": function,
+ "details": _details_from_debugger_error_item(item, loc, function),
+ }
+
+
+static func _details_from_debugger_error_item(item: TreeItem, loc: Dictionary, function: String) -> Dictionary:
+ var children: Array[Dictionary] = []
+ var child := item.get_first_child()
+ while child != null:
+ var child_loc := _location_from_metadata(child.get_metadata(0))
+ children.append({
+ "label": child.get_text(0),
+ "text": child.get_text(1),
+ "path": str(child_loc.get("path", "")),
+ "line": int(child_loc.get("line", 0)),
+ })
+ child = child.get_next()
+ return {
+ "debugger_tab": "Errors",
+ "time": item.get_text(0),
+ "message": item.get_text(1),
+ "error_type_name": "warning" if item.has_meta("_is_warning") else "error",
+ "source": {
+ "path": str(loc.get("path", "")),
+ "line": int(loc.get("line", 0)),
+ "function": function,
+ },
+ "resolved": {
+ "path": str(loc.get("path", "")),
+ "line": int(loc.get("line", 0)),
+ "function": function,
+ },
+ "children": children,
+ "frames": _frames_from_error_children(children),
+ }
+
+
+static func _is_debugger_error_item(item: TreeItem) -> bool:
+ return item.has_meta("_is_warning") or item.has_meta("_is_error")
+
+
+static func _frames_from_error_children(children: Array[Dictionary]) -> Array[Dictionary]:
+ var start := -1
+ for i in children.size():
+ if str(children[i].label).contains("Stack Trace"):
+ start = i
+ break
+ if start < 0:
+ for i in children.size():
+ if str(children[i].label).is_empty() and not str(children[i].path).is_empty():
+ start = maxi(i - 1, 0)
+ break
+ if start < 0:
+ return []
+ var frames: Array[Dictionary] = []
+ for i in range(start, children.size()):
+ if str(children[i].path).is_empty():
+ continue
+ frames.append({
+ "path": children[i].path,
+ "line": children[i].line,
+ "function": _function_from_frame_text(children[i].text),
+ })
+ return frames
+
+
+static func _location_from_metadata(meta: Variant) -> Dictionary:
+ if meta is Array and meta.size() >= 2:
+ return {"path": str(meta[0]), "line": int(meta[1])}
+ return {"path": "", "line": 0}
+
+
+static func _function_from_title(title: String) -> String:
+ var colon := title.find(": ")
+ if colon <= 0:
+ return ""
+ return title.substr(0, colon)
+
+
+static func _function_from_frame_text(text: String) -> String:
+ var marker := text.find(" @ ")
+ if marker < 0:
+ return ""
+ var fn := text.substr(marker + 3).strip_edges()
+ if fn.ends_with("()"):
+ fn = fn.substr(0, fn.length() - 2)
+ return fn
+
+
+## Shared one-line rendering of a compact editor-error entry for messages and
+## hints ("text (path:line)"). Single home so the debugger plugin, project
+## handler, and editor handler can't drift apart.
+static func format_editor_error_summary(entry: Dictionary) -> String:
+ var text := str(entry.get("text", "editor error"))
+ var path := str(entry.get("path", ""))
+ var line := int(entry.get("line", 0))
+ if not path.is_empty() and line > 0:
+ return "%s (%s:%d)" % [text, path, line]
+ if not path.is_empty():
+ return "%s (%s)" % [text, path]
+ return text
+
+
+static func _log_entry_key(entry: Dictionary) -> String:
+ return "%s|%s|%s|%s" % [
+ str(entry.get("level", "")),
+ str(entry.get("text", "")),
+ str(entry.get("path", "")),
+ str(entry.get("line", 0)),
+ ]
+
+
+func _error_appended_total() -> int:
+ if _editor_log_buffer == null:
+ return 0
+ if _editor_log_buffer.has_method("error_appended_total"):
+ return int(_editor_log_buffer.call("error_appended_total"))
+ return 0
+
+
+func _game_error_total() -> int:
+ if _game_log_buffer == null:
+ return 0
+ if _game_log_buffer.has_method("error_total"):
+ return int(_game_log_buffer.call("error_total"))
+ return 0
+
+
+func _warn_appended_total() -> int:
+ if _editor_log_buffer == null:
+ return 0
+ if _editor_log_buffer.has_method("warn_appended_total"):
+ return int(_editor_log_buffer.call("warn_appended_total"))
+ return 0
+
+
+func _game_warn_total() -> int:
+ if _game_log_buffer == null:
+ return 0
+ if _game_log_buffer.has_method("warn_total"):
+ return int(_game_log_buffer.call("warn_total"))
+ return 0
+
+
+func _should_scan_debugger_for_cached_watermark(now_msec: int) -> bool:
+ if not _debugger_scan_active and now_msec > _debugger_scan_until_msec:
+ return false
+ return now_msec - _last_debugger_refresh_msec >= DEBUGGER_REFRESH_MIN_INTERVAL_MS
+
+
+func _trim_promoted_debugger_entries() -> void:
+ while _promoted_debugger_entries.size() > MAX_PROMOTED_DEBUGGER_ENTRIES:
+ _promoted_debugger_entries.pop_front()
+ if _promoted_debugger_entries.is_empty():
+ _oldest_retained_debugger_sequence = _debugger_promoted_total + 1
+ else:
+ _oldest_retained_debugger_sequence = int(_promoted_debugger_entries[0].get("_debugger_sequence", 1))
+
+
+func _trim_promoted_debugger_key_counts() -> void:
+ while _promoted_debugger_key_order.size() > MAX_PROMOTED_DEBUGGER_KEYS:
+ var key := _promoted_debugger_key_order.pop_front()
+ _promoted_debugger_keys.erase(key)
+ _promoted_debugger_row_times.erase(key)
+
+
+## #635: per-row time text from a scraped Errors-tab entry (column 0 of the
+## row, carried in details.time). Empty when the entry has no details — e.g.
+## synthetic records — which keeps those on count-only dedup.
+static func _row_time_text(entry: Dictionary) -> String:
+ var details: Variant = entry.get("details", {})
+ if details is Dictionary:
+ return str((details as Dictionary).get("time", ""))
+ return ""
+
+
+func _unaccounted_row_times(key: String, times: Dictionary) -> Array:
+ var accounted: Dictionary = _promoted_debugger_row_times.get(key, {})
+ var unseen := []
+ for time_text in times.keys():
+ if not accounted.has(time_text):
+ unseen.append(time_text)
+ return unseen
+
+
+func _account_row_times(key: String, times: Dictionary) -> void:
+ if times.is_empty():
+ return
+ var accounted: Dictionary = _promoted_debugger_row_times.get(key, {})
+ for time_text in times.keys():
+ accounted[time_text] = true
+ ## Enforce the bound AFTER merging: a pre-merge `>` check let the set
+ ## reach the cap and keep growing (and a batch of new times could jump
+ ## past it). Past the cap, reset to just this scan's times — the live
+ ## Errors tab is itself bounded, so this only fires under a pathological
+ ## same-key flood, where "recent scan only" is an acceptable memory of
+ ## what was promoted (worst case: a re-observed ancient row re-promotes).
+ if accounted.size() > MAX_ACCOUNTED_ROW_TIMES_PER_KEY:
+ accounted = times.duplicate()
+ _promoted_debugger_row_times[key] = accounted
+
+
+func _remove_promoted_debugger_entry(key: String) -> void:
+ for i in range(_promoted_debugger_entries.size() - 1, -1, -1):
+ if str(_promoted_debugger_entries[i].get("_debugger_key", "")) == key:
+ _promoted_debugger_entries.remove_at(i)
+ return
+
+
+func _raw_debugger_error_entries() -> Array[Dictionary]:
+ var entries: Array[Dictionary] = []
+ for tree in locate_debugger_error_trees():
+ entries.append_array(entries_from_debugger_error_tree(tree))
+ return entries
diff --git a/addons/godot_ai/utils/surfaced_error_tracker.gd.uid b/addons/godot_ai/utils/surfaced_error_tracker.gd.uid
new file mode 100644
index 0000000..433a191
--- /dev/null
+++ b/addons/godot_ai/utils/surfaced_error_tracker.gd.uid
@@ -0,0 +1 @@
+uid://o0ulahkt83re
diff --git a/addons/godot_ai/utils/transport_capability.gd b/addons/godot_ai/utils/transport_capability.gd
new file mode 100644
index 0000000..2f9f7b6
--- /dev/null
+++ b/addons/godot_ai/utils/transport_capability.gd
@@ -0,0 +1,305 @@
+@tool
+class_name McpTransportCapability
+extends RefCounted
+
+## Reads Python's atomic, private capability record as one indivisible value.
+## This is a static boundary, not another state owner.
+
+const CAPABILITY_DIR_ENV := "GODOT_AI_CAPABILITY_DIR"
+const RECORD_VERSION := 1
+const MAX_RECORD_BYTES := 1024
+const _POSIX_PERMISSION_MASK := 0x1ff ## 0777
+const _GROUP_OTHER_PERMISSION_MASK := 0x3f ## 0077
+const _GROUP_OTHER_WRITE_MASK := 0x12 ## 0022
+const _MAX_LINK_HOPS := 8
+const _SYSTEM_TEMP_ROOTS: Array[String] = ["/tmp", "/private/tmp", "/var/tmp"]
+const _KEYS: Array[String] = [
+ "version", "http", "websocket", "instance_nonce",
+]
+
+
+static func read_for_http_port(http_port: int, captured_path := "") -> Dictionary:
+ var path := str(captured_path)
+ if not path.is_empty() and path.get_file() != "http-%d.json" % http_port:
+ return {}
+ return _read_path(path if not path.is_empty() else path_for_http_port(http_port))
+
+
+static func _read_path(path: String) -> Dictionary:
+ if path.is_empty() or not path.is_absolute_path():
+ return {}
+ var resolved := _resolve_trusted_path(path)
+ if resolved.is_empty():
+ return {}
+ var directory := resolved.get_base_dir()
+ if (
+ not _safe_posix_ancestors(directory)
+ or not _private_path(directory, true)
+ or not _private_path(resolved, false)
+ ):
+ return {}
+ var file := FileAccess.open(resolved, FileAccess.READ)
+ if file == null:
+ return {}
+ var length := file.get_length()
+ if length < 1 or length > MAX_RECORD_BYTES + 1:
+ return {}
+ var bytes := file.get_buffer(length)
+ if bytes.size() != length:
+ return {}
+ if bytes[bytes.size() - 1] == 10:
+ bytes.resize(bytes.size() - 1)
+ if bytes.is_empty() or bytes.size() > MAX_RECORD_BYTES:
+ return {}
+ for byte in bytes:
+ if byte > 127:
+ return {}
+ var raw := bytes.get_string_from_ascii()
+ ## Godot resolves duplicate JSON keys. Tokens need no escapes, so reject
+ ## escaping and require each literal key exactly once before parsing.
+ if raw.contains("\\"):
+ return {}
+ for key in _KEYS:
+ if raw.count("\"%s\"" % key) != 1:
+ return {}
+ var parsed: Variant = JSON.parse_string(raw)
+ if not (parsed is Dictionary) or parsed.size() != _KEYS.size():
+ return {}
+ for key in _KEYS:
+ if not parsed.has(key):
+ return {}
+ var version: Variant = parsed["version"]
+ if not (version is int or version is float) or float(version) != RECORD_VERSION:
+ return {}
+ if (
+ not (parsed["http"] is String)
+ or not (parsed["websocket"] is String)
+ or not (parsed["instance_nonce"] is String)
+ ):
+ return {}
+ var http: String = parsed["http"]
+ var websocket: String = parsed["websocket"]
+ var nonce: String = parsed["instance_nonce"]
+ if not is_http_capability(http) or not is_lower_hex(websocket, 64):
+ return {}
+ if not is_lower_hex(nonce, 32) or http == websocket:
+ return {}
+ return {
+ "http": http,
+ "websocket": websocket,
+ "instance_nonce": nonce.to_lower(),
+ }
+
+
+static func _private_path(path: String, directory: bool) -> bool:
+ if directory:
+ if DirAccess.open(path) == null:
+ return false
+ elif not FileAccess.file_exists(path):
+ return false
+ if OS.get_name() == "Windows":
+ ## Godot's detectable link/reparse surface was checked by _path_has_link.
+ ## v4 deliberately makes no Windows DACL secrecy/integrity claim.
+ return true
+ ## Godot exposes POSIX mode bits but not the owning UID. This proves only
+ ## that group/other access is closed; it is not an owner-identity proof.
+ var mode := FileAccess.get_unix_permissions(path) & _POSIX_PERMISSION_MASK
+ return mode != 0 and (mode & _GROUP_OTHER_PERMISSION_MASK) == 0
+
+
+## Walk `path` from its root and return it with every accepted link component
+## replaced by its target, or "" when the path must not be trusted. A link is
+## followed only when the directory holding it is closed to group and other
+## writes, so only that directory's owner or root could have placed it; ostree
+## distributions need this for `/home -> /var/home` (#993). Godot exposes no
+## owning UID, so this is the strongest proof available here; Python, which
+## publishes the record, additionally requires such a link to be root-owned.
+## The record file itself is never followed, and a chain longer than
+## `_MAX_LINK_HOPS` fails closed. Windows keeps rejecting every detectable
+## link or reparse point.
+static func _resolve_trusted_path(path: String) -> String:
+ if OS.get_name() == "Windows":
+ return "" if _path_has_link(path) else path
+ var remaining := path.simplify_path().split("/", false)
+ var current := "/"
+ var hops := 0
+ while not remaining.is_empty():
+ var part := remaining[0]
+ remaining.remove_at(0)
+ var candidate := current.path_join(part)
+ var parent := DirAccess.open(current)
+ if parent == null:
+ return ""
+ if parent.is_link(candidate):
+ if (
+ remaining.is_empty()
+ or hops >= _MAX_LINK_HOPS
+ or not _closed_to_group_and_other(current)
+ ):
+ return ""
+ hops += 1
+ var target := parent.read_link(candidate)
+ if target.is_empty():
+ return ""
+ if not target.is_absolute_path():
+ target = current.path_join(target)
+ var target_parts := target.simplify_path().split("/", false)
+ target_parts.append_array(remaining)
+ remaining = target_parts
+ current = "/"
+ continue
+ current = candidate
+ return current
+
+
+static func _closed_to_group_and_other(directory: String) -> bool:
+ var mode := FileAccess.get_unix_permissions(directory) & _POSIX_PERMISSION_MASK
+ return mode != 0 and (mode & _GROUP_OTHER_WRITE_MASK) == 0
+
+
+static func _path_has_link(path: String) -> bool:
+ var current := path
+ while not current.is_empty():
+ var parent := current.get_base_dir()
+ if parent.is_empty() or parent == current:
+ return false
+ var directory := DirAccess.open(parent)
+ if directory == null or directory.is_link(current):
+ return true
+ current = parent
+ return false
+
+
+static func _safe_posix_ancestors(path: String) -> bool:
+ if OS.get_name() == "Windows":
+ return true
+ var current := path.simplify_path()
+ while not current.is_empty():
+ if DirAccess.open(current) != null:
+ var permissions := FileAccess.get_unix_permissions(current)
+ if not _safe_posix_ancestor_mode(current, permissions):
+ return false
+ var parent := current.get_base_dir()
+ if parent.is_empty() or parent == current:
+ break
+ current = parent
+ return true
+
+
+static func _safe_posix_ancestor_mode(path: String, permissions: int) -> bool:
+ var mode := permissions & _POSIX_PERMISSION_MASK
+ if mode == 0:
+ return false
+ if (mode & _GROUP_OTHER_WRITE_MASK) == 0:
+ return true
+ ## Godot exposes no UID. Accept the conventional system temp boundary only
+ ## when its sticky bit is present; Python additionally proves root ownership
+ ## before it publishes a record below the same canonical roots.
+ return (
+ path.simplify_path() in _SYSTEM_TEMP_ROOTS
+ and (permissions & FileAccess.UNIX_RESTRICTED_DELETE) != 0
+ )
+
+
+static func is_http_capability(value: String) -> bool:
+ var bytes := value.to_ascii_buffer()
+ if bytes.size() < 32 or bytes.size() > 128:
+ return false
+ for byte in bytes:
+ if not (
+ (byte >= 48 and byte <= 57)
+ or (byte >= 65 and byte <= 90)
+ or (byte >= 97 and byte <= 122)
+ or byte in [43, 45, 46, 47, 61, 95, 126]
+ ):
+ return false
+ return true
+
+
+static func is_hex(value: String, length: int) -> bool:
+ if value.length() != length:
+ return false
+ for index in range(length):
+ var code := value.unicode_at(index)
+ if not (
+ (code >= 48 and code <= 57)
+ or (code >= 65 and code <= 70)
+ or (code >= 97 and code <= 102)
+ ):
+ return false
+ return true
+
+
+static func is_lower_hex(value: String, length: int) -> bool:
+ return is_hex(value, length) and value == value.to_lower()
+
+
+## Windows only: "" when this account can create and write the capability
+## directory, else a repair message. Python creates the directory with
+## ``mode=0o700`` on POSIX, but on Windows that mode yields a DACL of SYSTEM,
+## Administrators and OWNER RIGHTS alone; a directory first created by an
+## elevated process is then owned by Administrators and unusable from the
+## user's own unelevated editor, server and bridge (#988). Creating it here
+## first inherits the per-user %LOCALAPPDATA% DACL, and probing it before the
+## spawn turns a silent "proof timed out at capability_record" into a message
+## that names the directory and the fix.
+static func directory_write_problem(http_port: int) -> String:
+ if OS.get_name() != "Windows":
+ return ""
+ var record := path_for_http_port(http_port)
+ if record.is_empty():
+ return ""
+ return directory_write_problem_for(record.get_base_dir())
+
+
+static func directory_write_problem_for(directory: String) -> String:
+ if DirAccess.open(directory) == null:
+ var created := DirAccess.make_dir_recursive_absolute(directory)
+ if created != OK:
+ return windows_repair_hint(directory, error_string(created))
+ var probe := directory.path_join(".access-probe-%d-%s" % [
+ OS.get_process_id(), Crypto.new().generate_random_bytes(4).hex_encode(),
+ ])
+ var file := FileAccess.open(probe, FileAccess.WRITE)
+ if file == null:
+ return windows_repair_hint(directory, error_string(FileAccess.get_open_error()))
+ file.close()
+ DirAccess.remove_absolute(probe)
+ return ""
+
+
+static func windows_repair_hint(directory: String, detail: String) -> String:
+ return (
+ "Godot AI cannot use the directory %s (%s): this Windows account cannot "
+ + "access it. This can happen when an elevated (Run as administrator) "
+ + "process created it. Check this directory's permissions and grant your "
+ + "Windows account access, then reopen Godot and your AI clients without "
+ + "Run as administrator."
+ ) % [directory, detail]
+
+
+static func path_for_http_port(http_port: int) -> String:
+ if http_port < 1 or http_port > 65535:
+ return ""
+ var override := OS.get_environment(CAPABILITY_DIR_ENV).strip_edges()
+ if OS.get_name() == "Windows" and not override.is_empty():
+ return ""
+ var directory := override
+ if directory.is_empty():
+ if OS.get_name() == "Windows":
+ directory = OS.get_environment("LOCALAPPDATA").strip_edges()
+ if directory.is_empty():
+ return ""
+ directory = directory.path_join("godot-ai/capabilities")
+ elif OS.get_name() == "macOS":
+ directory = OS.get_environment("HOME").path_join(
+ "Library/Application Support/godot-ai/capabilities"
+ )
+ else:
+ directory = OS.get_environment("XDG_CONFIG_HOME").strip_edges()
+ if directory.is_empty():
+ directory = OS.get_environment("HOME").path_join(".config")
+ directory = directory.path_join("godot-ai/capabilities")
+ if directory.is_empty() or not directory.is_absolute_path():
+ return ""
+ return directory.simplify_path().path_join("http-%d.json" % http_port)
diff --git a/addons/godot_ai/utils/transport_capability.gd.uid b/addons/godot_ai/utils/transport_capability.gd.uid
new file mode 100644
index 0000000..9f165f0
--- /dev/null
+++ b/addons/godot_ai/utils/transport_capability.gd.uid
@@ -0,0 +1 @@
+uid://b7j8n63he8ljy
diff --git a/addons/godot_ai/utils/update_manager.gd b/addons/godot_ai/utils/update_manager.gd
new file mode 100644
index 0000000..4f6fabb
--- /dev/null
+++ b/addons/godot_ai/utils/update_manager.gd
@@ -0,0 +1,533 @@
+@tool
+class_name McpUpdateManager
+extends Node
+
+## v4-only release discovery and download preparation. Python authenticates
+## and extracts the three canonical assets before this manager asks the root to
+## quiesce or disable anything; the transaction actor alone mutates live code.
+
+const RELEASES_URL := "https://api.github.com/repos/hi-godot/godot-ai/releases/latest"
+const RELEASES_PAGE := "https://github.com/hi-godot/godot-ai/releases/latest"
+const REPOSITORY := "hi-godot/godot-ai"
+const ASSET_NAME := "godot-ai-v4-plugin.zip"
+const MANIFEST_NAME := "godot-ai-v4-plugin.manifest.json"
+const SIGNATURE_NAME := "godot-ai-v4-plugin.manifest.sig"
+const LEGACY_ASSET_NAME := "godot-ai-plugin.zip"
+const LEGACY_CHECKSUM_NAME := "godot-ai-plugin.zip.sha256"
+const LEGACY_SIGNATURE_NAME := "godot-ai-plugin.zip.sha256.sig"
+const MAX_RELEASE_METADATA_BYTES := 1024 * 1024
+const MAX_ARCHIVE_SIZE_BYTES := 64 * 1024 * 1024
+const MAX_MANIFEST_SIZE_BYTES := 1024 * 1024
+const MAX_REDIRECTS := 5
+const QUALIFICATION_SWITCH_ENV := "GODOT_AI_QUALIFICATION_RELEASE"
+const QUALIFICATION_URL_ENV := "GODOT_AI_QUALIFICATION_RELEASE_URL"
+const QUALIFICATION_ASSET_PREFIX_ENV := "GODOT_AI_QUALIFICATION_ASSET_PREFIX"
+const QUALIFICATION_TOKEN_ENV := "GODOT_AI_QUALIFICATION_TOKEN"
+const _ASSET_LIMITS := {
+ ASSET_NAME: MAX_ARCHIVE_SIZE_BYTES,
+ MANIFEST_NAME: MAX_MANIFEST_SIZE_BYTES,
+ SIGNATURE_NAME: 512,
+}
+## Stable releases also carry one signed, temporary v3 migration capsule.
+## v4 never downloads it, but requires the exact six-name release envelope so
+## an unexpected extra executable asset remains a fail-closed condition.
+const _RELEASE_ASSET_LIMITS := {
+ ASSET_NAME: MAX_ARCHIVE_SIZE_BYTES,
+ MANIFEST_NAME: MAX_MANIFEST_SIZE_BYTES,
+ SIGNATURE_NAME: 512,
+ LEGACY_ASSET_NAME: 66 * 1024 * 1024,
+ LEGACY_CHECKSUM_NAME: 1024,
+ LEGACY_SIGNATURE_NAME: 512,
+}
+const ClientConfigurator := preload("res://addons/godot_ai/client_configurator.gd")
+const TransportCapability := preload("res://addons/godot_ai/utils/transport_capability.gd")
+
+## Kept byte-for-byte aligned with src/godot_ai/release_verify.py and the
+## standalone verifier. Python performs the mandatory strict verification;
+## embedding it here also lets release packaging fail on trust-anchor drift.
+const RELEASE_SIGNING_PUBLIC_KEY_PEM := """-----BEGIN PUBLIC KEY-----
+MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAr4OmbONFTONGFcXSUQ2p
+e54YaUhWDA75wxeDWhOc476vsdo53YnXEFT7EPr2hUKqeNxv++LqKOkFuAsxSNZy
+wBe6P1tmQA4Og6Ezv4CGnZdEj1uhlDJFK9ShQ29oWfC6bf/84625SvvBxZos2Br9
+yPKl7h5wzqDoeUSpv+f0ynTiC0i/HAUo/NQBlkgGwkomK2Fr3pP1VDxxq2xvgHSk
+lU6Qcomr9WjJxI+HkDN5tRPPn0pDrg6YFx2J18OfD8KIa/kMGxuXOcHlPyRYpjyu
+qTtg2oL0NyUIG+1TmJ3DcN4GlKC55eOrkfJ04vudS5pxdnUIFRmkGBXZLdaetoPc
+ixtlD4w6gi8KIH1CTG+/TtHP1KVdOogCWDcjRCAmMJPFZe6eEKXmGQUZDb9wfnbx
+h++XiVe5tq83BTLWmaFTy+fZbNo12uhNCNS1LJ42/yj+S1xvo0yMbkkNr1hIYk0P
+584XnBQeBSVJDf3667NZXaxnWv94K9zbb+1OvOvPwhbOdgi2Ymcw5QEOQIavtg86
+XLLcWzG+SJsycz1imikjv6sStWh8WHneKSTMq6A7V6PBj7oJyEJp10696BDw287k
+YlH+9VGqowPEMXpWX57wOBKiWb4K1kw1LfxjT8W1e/pcX9pJqiv0DkjTXUxo9CDG
+1X1+ZXBBR3MkGuFAOCjy0x8CAwEAAQ==
+-----END PUBLIC KEY-----
+"""
+
+const _TRUSTED_PATHS := {
+ "github.com": "/hi-godot/godot-ai/releases/download/",
+ "www.github.com": "/hi-godot/godot-ai/releases/download/",
+ "api.github.com": "/repos/hi-godot/godot-ai/releases/assets/",
+ "objects.githubusercontent.com": "/github-production-release-asset-",
+ "release-assets.githubusercontent.com": "/github-production-release-asset-",
+}
+
+signal update_check_completed(result: Dictionary)
+signal install_state_changed(state: Dictionary)
+signal activation_requested(package: Dictionary)
+
+var _check_request: HTTPRequest
+var _asset_request: HTTPRequest
+var _release: Dictionary = {}
+var _queue: Array[String] = []
+var _active_asset := ""
+var _redirect_count := 0
+var _qualification: Dictionary = {}
+var _download_root := ""
+
+
+func check_for_updates() -> void:
+ _release.clear()
+ _qualification = _qualification_from_environment()
+ if bool(_qualification.get("invalid", false)):
+ _qualification.clear()
+ push_error("MCP | invalid private qualification release capability")
+ return
+ if ClientConfigurator.is_dev_checkout():
+ return
+ if _check_request != null:
+ _check_request.queue_free()
+ _check_request = HTTPRequest.new()
+ _check_request.max_redirects = 0
+ _check_request.body_size_limit = MAX_RELEASE_METADATA_BYTES
+ _check_request.request_completed.connect(_on_check_completed)
+ add_child(_check_request)
+ var url := str(_qualification.get("release_url", RELEASES_URL))
+ if _check_request.request(url, _request_headers(url, _qualification, true)) != OK:
+ _check_request.queue_free()
+ _check_request = null
+
+
+func start_install(preflight: Dictionary) -> void:
+ if is_install_in_flight():
+ install_state_changed.emit({
+ "status_text": "Update already in progress",
+ "button_disabled": true,
+ })
+ return
+ if _release.is_empty():
+ OS.shell_open(RELEASES_PAGE)
+ return
+ if not bool(preflight.get("ok", false)):
+ install_state_changed.emit({
+ "install_in_flight": false,
+ "status_text": "Update blocked — resolve recovery state",
+ "button_disabled": false,
+ })
+ return
+ var directory := str(preflight.get("download_root", ""))
+ if (
+ directory.is_empty()
+ or not directory.is_absolute_path()
+ or not _directory_is_empty(directory)
+ ):
+ install_state_changed.emit({
+ "install_in_flight": false,
+ "status_text": "Update blocked — private download directory unavailable",
+ "button_disabled": false,
+ })
+ return
+ _download_root = directory
+ _queue.assign([ASSET_NAME, MANIFEST_NAME, SIGNATURE_NAME])
+ install_state_changed.emit({
+ "install_in_flight": true,
+ "status_text": "Downloading…",
+ "button_disabled": true,
+ })
+ _download_next()
+
+
+func is_install_in_flight() -> bool:
+ return _asset_request != null or not _download_root.is_empty()
+
+
+func has_install_candidate() -> bool:
+ return not is_install_in_flight() and not _release.is_empty()
+
+
+static func _directory_is_empty(path: String) -> bool:
+ var directory := DirAccess.open(path)
+ if directory == null:
+ return false
+ directory.list_dir_begin()
+ var first := directory.get_next()
+ directory.list_dir_end()
+ return first.is_empty()
+
+
+func cancel_install() -> void:
+ if _asset_request != null:
+ _asset_request.cancel_request()
+ _asset_request.queue_free()
+ _asset_request = null
+ _queue.clear()
+ _qualification.clear()
+ discard_downloads()
+
+
+func _exit_tree() -> void:
+ cancel_install()
+
+
+## Download roots are exclusively allocated by the Python transaction actor.
+## This manager never creates, reuses, or recursively deletes a predictable
+## user:// namespace; it removes only the three exact files it requested and
+## then the now-empty transaction directory.
+func discard_downloads() -> void:
+ if _download_root.is_empty():
+ return
+ for name in [ASSET_NAME, MANIFEST_NAME, SIGNATURE_NAME]:
+ DirAccess.remove_absolute(_download_root.path_join(name))
+ DirAccess.remove_absolute(_download_root)
+ _download_root = ""
+
+
+func _download_path(name: String) -> String:
+ if _download_root.is_empty() or not _ASSET_LIMITS.has(name):
+ return ""
+ return _download_root.path_join(name)
+
+
+static func parse_releases_response(
+ result: int,
+ response_code: int,
+ body: PackedByteArray,
+ local_version: String = "",
+ qualification: Dictionary = {},
+) -> Dictionary:
+ var empty := {
+ "has_update": false,
+ "version": "",
+ "tag": "",
+ "channel": "",
+ "label_text": "",
+ "urls": {},
+ "sizes": {},
+ }
+ if (
+ result != HTTPRequest.RESULT_SUCCESS
+ or response_code != 200
+ or body.size() > MAX_RELEASE_METADATA_BYTES
+ ):
+ return empty
+ var parsed: Variant = JSON.parse_string(body.get_string_from_utf8())
+ if not parsed is Dictionary:
+ return empty
+ var tag := str(parsed.get("tag_name", ""))
+ var version := tag.trim_prefix("v")
+ var local := local_version if not local_version.is_empty() else ClientConfigurator.get_plugin_version()
+ if not tag.begins_with("v4.") or not _is_newer(version, local):
+ return empty
+ var assets: Variant = parsed.get("assets", [])
+ if not assets is Array or assets.size() != _RELEASE_ASSET_LIMITS.size():
+ return empty
+ var urls := {}
+ var sizes := {}
+ for value in assets:
+ if not value is Dictionary:
+ return empty
+ var name := str(value.get("name", ""))
+ if not _RELEASE_ASSET_LIMITS.has(name) or sizes.has(name):
+ return empty
+ var size := int(value.get("size", -1))
+ if size <= 0 or size > int(_RELEASE_ASSET_LIMITS[name]):
+ return empty
+ if name in [SIGNATURE_NAME, LEGACY_SIGNATURE_NAME] and size != 512:
+ return empty
+ var url := str(value.get("browser_download_url", ""))
+ if not _is_trusted_download_url(url, qualification):
+ return empty
+ sizes[name] = size
+ if _ASSET_LIMITS.has(name):
+ urls[name] = url
+ if urls.size() != 3:
+ return empty
+ return {
+ "has_update": true,
+ "version": version,
+ "tag": tag,
+ "channel": "stable",
+ "label_text": "Update available: %s" % tag,
+ "urls": urls,
+ "sizes": sizes,
+ }
+
+
+static func _is_newer(remote: String, local: String) -> bool:
+ var remote_parts := _version_parts(remote)
+ var local_parts := _version_parts(local)
+ if remote_parts.is_empty() or local_parts.is_empty():
+ return false
+ for index in 3:
+ if remote_parts[index] != local_parts[index]:
+ return remote_parts[index] > local_parts[index]
+ return false
+
+
+static func _version_parts(version: String) -> Array[int]:
+ var expression := RegEx.new()
+ if expression.compile("^4\\.(\\d+)\\.(\\d+)$") != OK:
+ return []
+ var found := expression.search(version)
+ if found == null:
+ return []
+ return [4, int(found.get_string(1)), int(found.get_string(2))]
+
+
+static func _https_url_parts(url: String) -> Dictionary:
+ const SCHEME := "https://"
+ if not url.begins_with(SCHEME) or url.contains("\\") or url.contains("#"):
+ return {}
+ for index in url.length():
+ var code: int = url.unicode_at(index)
+ if code <= 0x20 or code == 0x7f:
+ return {}
+ var rest := url.substr(SCHEME.length())
+ var slash := rest.find("/")
+ if slash < 0:
+ return {}
+ var authority := rest.substr(0, slash)
+ var path := rest.substr(slash)
+ if authority.is_empty() or authority.contains("@") or authority.count(":") > 1:
+ return {}
+ var host := authority
+ var colon := authority.find(":")
+ if colon >= 0:
+ if authority.substr(colon + 1) != "443":
+ return {}
+ host = authority.substr(0, colon)
+ if (
+ host.is_empty()
+ or host != host.to_lower()
+ or host.begins_with(".")
+ or host.ends_with(".")
+ or host.contains("..")
+ ):
+ return {}
+ for index in host.length():
+ var code := host.unicode_at(index)
+ if not (code >= 97 and code <= 122) and not (code >= 48 and code <= 57) and code not in [45, 46]:
+ return {}
+ var query := path.find("?")
+ if query >= 0:
+ path = path.substr(0, query)
+ var lower := path.to_lower()
+ for needle in ["/../", "/..", "%2e", "%2f", "%5c"]:
+ if lower.contains(needle):
+ return {}
+ return {"host": host, "origin": SCHEME + host, "path": path}
+
+
+static func _is_trusted_download_url(url: String, qualification: Dictionary = {}) -> bool:
+ var parts := _https_url_parts(url)
+ if parts.is_empty():
+ return false
+ ## Current GitHub release assets use a slash-delimited repository ID;
+ ## retain the older CDN namespace below for existing release URLs.
+ if (
+ parts.host == "release-assets.githubusercontent.com"
+ and str(parts.path).begins_with("/github-production-release-asset/1208239711/")
+ ):
+ return true
+ if (
+ str(parts.origin) == str(qualification.get("asset_origin", ""))
+ and str(parts.path).begins_with(str(qualification.get("asset_path", "")))
+ and not str(qualification.get("asset_path", "")).is_empty()
+ ):
+ return true
+ return _TRUSTED_PATHS.has(parts.host) and str(parts.path).begins_with(
+ str(_TRUSTED_PATHS[parts.host])
+ )
+
+
+static func _qualification_config(
+ enabled: String, release_url: String, asset_prefix: String, token: String
+) -> Dictionary:
+ if enabled.is_empty() and release_url.is_empty() and asset_prefix.is_empty() and token.is_empty():
+ return {}
+ var release := _https_url_parts(release_url)
+ var assets := _https_url_parts(asset_prefix)
+ if (
+ enabled != "1"
+ or release.is_empty()
+ or assets.is_empty()
+ or not asset_prefix.ends_with("/")
+ or asset_prefix.contains("?")
+ or release.origin != assets.origin
+ or not TransportCapability.is_http_capability(token)
+ ):
+ return {"invalid": true}
+ return {
+ "asset_origin": str(assets.origin),
+ "asset_path": str(assets.path),
+ "release_url": release_url,
+ "token": token,
+ }
+
+
+static func _qualification_from_environment() -> Dictionary:
+ return _qualification_config(
+ OS.get_environment(QUALIFICATION_SWITCH_ENV),
+ OS.get_environment(QUALIFICATION_URL_ENV),
+ OS.get_environment(QUALIFICATION_ASSET_PREFIX_ENV),
+ OS.get_environment(QUALIFICATION_TOKEN_ENV),
+ )
+
+
+static func _request_headers(
+ url: String, qualification: Dictionary, metadata: bool = false
+) -> PackedStringArray:
+ var headers := PackedStringArray(["Accept: application/vnd.github+json"] if metadata else [])
+ var authorized := url == str(qualification.get("release_url", ""))
+ if not authorized:
+ var parts := _https_url_parts(url)
+ authorized = (
+ not parts.is_empty()
+ and parts.origin == qualification.get("asset_origin", "")
+ and str(parts.path).begins_with(str(qualification.get("asset_path", "")))
+ and not str(qualification.get("asset_path", "")).is_empty()
+ )
+ if authorized:
+ headers.append("Authorization: Bearer " + str(qualification.token))
+ return headers
+
+
+static func _redirect_url(headers: PackedStringArray) -> String:
+ var location := ""
+ for header in headers:
+ var colon := header.find(":")
+ if colon < 0 or header.substr(0, colon).strip_edges().to_lower() != "location":
+ continue
+ if not location.is_empty():
+ return ""
+ location = header.substr(colon + 1).strip_edges()
+ return location
+
+
+func _on_check_completed(
+ result: int,
+ response_code: int,
+ _headers: PackedStringArray,
+ body: PackedByteArray
+) -> void:
+ if _check_request != null:
+ _check_request.queue_free()
+ _check_request = null
+ var release := parse_releases_response(result, response_code, body, "", _qualification)
+ if not bool(release.get("has_update", false)):
+ _release.clear()
+ _qualification.clear()
+ return
+ _release = release.duplicate(true)
+ update_check_completed.emit(release.duplicate(true))
+
+
+func _download_next() -> void:
+ if _queue.is_empty():
+ _finish_downloads()
+ return
+ _active_asset = _queue.pop_front()
+ _redirect_count = 0
+ _request_active_asset(str((_release.get("urls", {}) as Dictionary).get(_active_asset, "")))
+
+
+func _request_active_asset(url: String) -> void:
+ _asset_request = HTTPRequest.new()
+ ## Follow redirects ourselves so every hop remains inside the pinned GitHub
+ ## release/CDN namespace; HTTPRequest's automatic mode exposes no hop hook.
+ _asset_request.max_redirects = 0
+ _asset_request.body_size_limit = int((_release.get("sizes", {}) as Dictionary).get(_active_asset, 0))
+ _asset_request.download_file = _download_path(_active_asset)
+ _asset_request.request_completed.connect(_on_asset_completed)
+ add_child(_asset_request)
+ if _asset_request.request(url, _request_headers(url, _qualification)) != OK:
+ _fail_download("request failed")
+
+
+func _on_asset_completed(
+ result: int,
+ response_code: int,
+ headers: PackedStringArray,
+ _body: PackedByteArray
+) -> void:
+ if _asset_request != null:
+ _asset_request.queue_free()
+ _asset_request = null
+ ## With max_redirects = 0 Godot returns REDIRECT_LIMIT_REACHED on the
+ ## first redirect, including its status and Location. Validate that hop
+ ## ourselves; other transport failures must still fail closed.
+ if (
+ result in [HTTPRequest.RESULT_SUCCESS, HTTPRequest.RESULT_REDIRECT_LIMIT_REACHED]
+ and response_code in [301, 302, 303, 307, 308]
+ ):
+ var redirect := _redirect_url(headers)
+ DirAccess.remove_absolute(_download_path(_active_asset))
+ if (
+ _redirect_count >= MAX_REDIRECTS
+ or not _is_trusted_download_url(redirect, _qualification)
+ ):
+ _fail_download("untrusted or excessive redirect")
+ return
+ _redirect_count += 1
+ _request_active_asset(redirect)
+ return
+ if result != HTTPRequest.RESULT_SUCCESS or response_code != 200:
+ _fail_download("download failed (%d)" % response_code)
+ return
+ var path := _download_path(_active_asset)
+ var file := FileAccess.open(path, FileAccess.READ)
+ var expected := int((_release.get("sizes", {}) as Dictionary).get(_active_asset, -1))
+ if file == null:
+ _fail_download("downloaded asset size differs from release metadata")
+ return
+ var actual_size := file.get_length()
+ file.close()
+ if actual_size != expected:
+ _fail_download("downloaded asset size differs from release metadata")
+ return
+ _download_next()
+
+
+func _finish_downloads() -> void:
+ var directory := _download_root
+ var manifest_path := directory.path_join(MANIFEST_NAME)
+ var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string(manifest_path))
+ if not parsed is Dictionary or str(parsed.get("source_commit", "")).is_empty():
+ _fail_download("manifest is unreadable")
+ return
+ activation_requested.emit({
+ "archive": directory.path_join(ASSET_NAME),
+ "manifest": manifest_path,
+ "signature": directory.path_join(SIGNATURE_NAME),
+ "repository": REPOSITORY,
+ "channel": str(_release.channel),
+ "tag": str(_release.tag),
+ "version": str(_release.version),
+ "source": str(parsed.source_commit),
+ "download_root": directory,
+ })
+ _qualification.clear()
+
+
+func _fail_download(reason: String) -> void:
+ if _asset_request != null:
+ _asset_request.queue_free()
+ _asset_request = null
+ _queue.clear()
+ _qualification.clear()
+ discard_downloads()
+ push_error("MCP | v4 update preparation failed: %s" % reason)
+ ## The click-time lock and any quiesced client work are the plugin's to
+ ## release, and it only does so when it hears the install is over.
+ install_state_changed.emit({
+ "install_in_flight": false,
+ "status_text": "Update preparation failed",
+ "button_disabled": false,
+ })
diff --git a/addons/godot_ai/utils/update_manager.gd.uid b/addons/godot_ai/utils/update_manager.gd.uid
new file mode 100644
index 0000000..4089a72
--- /dev/null
+++ b/addons/godot_ai/utils/update_manager.gd.uid
@@ -0,0 +1 @@
+uid://cegiyw3fjcwev
diff --git a/addons/godot_ai/utils/update_mixed_state.gd b/addons/godot_ai/utils/update_mixed_state.gd
new file mode 100644
index 0000000..f212c95
--- /dev/null
+++ b/addons/godot_ai/utils/update_mixed_state.gd
@@ -0,0 +1,141 @@
+@tool
+extends RefCounted
+
+## Scanner that detects whether `addons/godot_ai/` is in a half-installed
+## state left behind by a self-update whose rollback couldn't restore the
+## previous addon contents (`UpdateReloadRunner.InstallStatus.FAILED_MIXED`).
+##
+## Without this surface the user sees "plugin won't start" with no actionable
+## context, re-runs the update, and compounds the mismatch (issue #354 /
+## audit-v2 #10). The dock paints a banner from `diagnose()` and
+## `editor_handler.gd::get_editor_state` includes the same Dictionary so an
+## MCP agent can see and report the state.
+
+const ADDON_DIR := "res://addons/godot_ai/"
+## Producer is `update_reload_runner.gd::INSTALL_BACKUP_SUFFIX`. Inlined as a
+## literal because old two-phase runners can parse this diagnostic script
+## against stale runner Script-object content during their mixed-snapshot
+## scan. `test_update_backup_suffix_stays_in_sync` guards against drift.
+const BACKUP_SUFFIX := ".update_backup"
+## Cap so a runaway addons tree (someone parented the wrong dir, an old
+## crashed install left thousands of artifacts) can't blow the
+## `editor_state` payload size or freeze the editor on first paint.
+const MAX_BACKUP_RESULTS := 200
+## TTL for the `diagnose()` cache. `editor_state` is one of the highest-
+## traffic MCP tools (agents poll it constantly) and a recursive
+## `DirAccess` walk on every call would put I/O on the 4ms `_process()`
+## budget. Mixed-state is rare and persistent across editor restarts, so
+## a few seconds of staleness is acceptable; the dock's Re-scan button
+## bypasses the cache via `force=true` for immediate feedback.
+const CACHE_TTL_MSEC := 5000
+
+static var _cache_value: Dictionary = {}
+static var _cache_timestamp_msec: int = -1
+
+
+## Walk `dir` recursively and return every `res://`-relative path that ends
+## in `.update_backup`, sorted ascending. Truncates at `MAX_BACKUP_RESULTS`
+## — the truncation flag is exposed via `diagnose()`.
+##
+## Walk order is deterministic: entries within each directory are sorted
+## alphabetically, subdirs pushed reverse-sorted so DFS pops them in
+## ascending order. Without this two scans of the same mixed tree could
+## return different 200-file slices when truncation kicks in (Godot's
+## `list_dir` order isn't guaranteed stable across filesystems).
+static func find_backups(dir: String = ADDON_DIR) -> Array:
+ var results: Array = []
+ var stack: Array = [dir]
+ while not stack.is_empty():
+ if results.size() >= MAX_BACKUP_RESULTS:
+ break
+ var current: String = stack.pop_back()
+ var d := DirAccess.open(current)
+ ## Missing dir, permission error, or unreadable junction — skip
+ ## silently. A missing addons dir is the bare-clone case; mid-walk
+ ## errors stay quiet so a single permission glitch can't block the
+ ## diagnostic the rest of the scan would have produced.
+ if d == null:
+ continue
+ var entries: Array = []
+ d.list_dir_begin()
+ while true:
+ var entry := d.get_next()
+ if entry.is_empty():
+ break
+ if entry == "." or entry == "..":
+ continue
+ entries.append({"name": entry, "is_dir": d.current_is_dir()})
+ d.list_dir_end()
+ entries.sort_custom(func(a, b): return a["name"] < b["name"])
+ ## Push subdirs reverse-sorted so the next outer iteration pops
+ ## them in ascending order — see method docstring for why this
+ ## determinism matters for the truncated case.
+ for i in range(entries.size() - 1, -1, -1):
+ var entry: Dictionary = entries[i]
+ var child_path := current.path_join(entry["name"])
+ if entry["is_dir"] and not d.is_link(child_path):
+ stack.append(child_path)
+ for entry in entries:
+ if entry["is_dir"]:
+ continue
+ if not String(entry["name"]).ends_with(BACKUP_SUFFIX):
+ continue
+ results.append(current.path_join(entry["name"]))
+ if results.size() >= MAX_BACKUP_RESULTS:
+ break
+ results.sort()
+ return results
+
+
+## Build the structured diagnostic Dictionary surfaced via `editor_state`
+## and the dock banner. Empty when the addons tree is clean — callers
+## gate banner visibility / response field on `is_empty()`.
+##
+## Cached for `CACHE_TTL_MSEC` when scanning the default `ADDON_DIR` so
+## per-`editor_state` polls don't re-walk the addons tree every frame.
+## Tests passing a custom `dir` always see a fresh scan (cache only
+## tracks the production path). `force=true` bypasses the cache — used
+## by the dock's Re-scan button so a manual fix is reflected immediately.
+static func diagnose(dir: String = ADDON_DIR, force: bool = false) -> Dictionary:
+ var use_cache := dir == ADDON_DIR and not force
+ if use_cache and _cache_timestamp_msec >= 0:
+ if Time.get_ticks_msec() - _cache_timestamp_msec < CACHE_TTL_MSEC:
+ return _cache_value.duplicate(true)
+
+ var backups := find_backups(dir)
+ var result: Dictionary = {}
+ if not backups.is_empty():
+ ## Most commonly produced by `_rollback_paths_written` returning
+ ## FAILED_MIXED, but `_finalize_install_success` removes backups on
+ ## a best-effort basis so a successful install can also leave them
+ ## behind if the cleanup `remove_absolute` hit a permission error.
+ ## The recovery action — delete the *.update_backup files — is the
+ ## same in both cases, so the message acknowledges both
+ ## possibilities rather than asserting the alarming one.
+ result = {
+ "addon_dir": dir,
+ "backup_files": backups,
+ "backup_count": backups.size(),
+ "truncated": backups.size() >= MAX_BACKUP_RESULTS,
+ "message": (
+ "Found .update_backup files in addons/godot_ai/. This usually"
+ + " means a self-update rollback couldn't restore the previous"
+ + " addon contents (FAILED_MIXED) — the plugin may load a mix"
+ + " of old and new files. Restore the addon from your VCS or a"
+ + " fresh release ZIP, then delete the listed *.update_backup"
+ + " files. If the plugin runs without issues these are likely"
+ + " stale from a successful install and safe to delete."
+ ),
+ }
+ if use_cache:
+ _cache_value = result.duplicate(true)
+ _cache_timestamp_msec = Time.get_ticks_msec()
+ return result
+
+
+## Reset the `diagnose()` cache. Tests that flip the addons-tree state
+## between calls use this to avoid TTL-bound flakiness; the dock's
+## Re-scan button uses `force=true` instead.
+static func clear_cache() -> void:
+ _cache_value = {}
+ _cache_timestamp_msec = -1
diff --git a/addons/godot_ai/utils/update_mixed_state.gd.uid b/addons/godot_ai/utils/update_mixed_state.gd.uid
new file mode 100644
index 0000000..6d608d9
--- /dev/null
+++ b/addons/godot_ai/utils/update_mixed_state.gd.uid
@@ -0,0 +1 @@
+uid://dd5rti52vgs71
diff --git a/addons/godot_ai/utils/uv_cache_cleanup.gd b/addons/godot_ai/utils/uv_cache_cleanup.gd
new file mode 100644
index 0000000..86edbe1
--- /dev/null
+++ b/addons/godot_ai/utils/uv_cache_cleanup.gd
@@ -0,0 +1,161 @@
+@tool
+class_name McpUvCacheCleanup
+extends RefCounted
+
+## Sweeps stale `.tmp*` build venvs out of `%LOCALAPPDATA%\uv\cache\builds-v0`.
+##
+## Background
+## ----------
+## When an MCP client's attach launcher invokes
+## `uvx --from godot-ai==VERSION godot-ai attach ...`, uv builds an ephemeral venv under
+## `builds-v0\.tmpXXXXXX\`. To save disk it hard-links shared C extensions
+## (notably `pydantic_core/_pydantic_core.cp313-win_amd64.pyd`) from
+## `archive-v0\\Lib\site-packages\...` into the build venv.
+##
+## If the godot-ai server's own Python child has that same `.pyd` mapped via
+## `LoadLibrary` (it does — godot-ai imports pydantic), the file is locked
+## under BOTH paths because hard links share the inode and Windows tracks
+## handles per-file, not per-path. uv's post-install cleanup of the build
+## venv then dies with:
+##
+## Failed to install: pywin32-311-cp313-cp313-win_amd64.whl (pywin32==311)
+## Caused by: failed to remove directory `...\.tmpXXXXXX\Lib\site-packages\pywin32-311.data`
+## 다른 프로세스가 파일을 사용 중이기 때문에 ... (os error 32)
+##
+## (the `pywin32` mention is incidental — the actual lock is on the earlier
+## hard-linked `_pydantic_core.pyd`; pywin32 is just the last install step
+## in the wheel-resolution order that triggers the cleanup pass).
+##
+## What this does
+## --------------
+## After the plugin stops/restarts the managed server — i.e. the moment when
+## the archive-v0 `.pyd` mappings drop and the hard-linked builds-v0 copy
+## becomes deletable — sweep `builds-v0\` for `.tmp*` orphans:
+##
+## 1. Rename each `.tmpXXX` to `_dead_.tmpXXX`. Rename succeeds even when
+## AV scanners hold the file open without `FILE_SHARE_DELETE` (Defender
+## and Softcamp SDS both do this), so this step always advances.
+## 2. Recursively remove the renamed dir, swallowing per-file
+## access-denied. Anything still genuinely locked is left for the next
+## sweep — uv won't reuse the renamed name, so no future build collides.
+##
+## No-op on non-Windows (uv's hard-link strategy only causes this lock
+## pattern on NTFS) and when the cache directory doesn't exist.
+
+const DEAD_PREFIX := "_dead_"
+const TMP_PREFIX := ".tmp"
+
+
+## Live entrypoint. Resolves `%LOCALAPPDATA%\uv\cache\builds-v0` and runs
+## the sweep. Returns the same counts the testable `purge_directory` returns,
+## or all zeros on non-Windows / missing cache.
+static func purge_stale_builds() -> Dictionary:
+ if OS.get_name() != "Windows":
+ return _empty_result()
+ var local_appdata := OS.get_environment("LOCALAPPDATA")
+ if local_appdata.is_empty():
+ return _empty_result()
+ var builds_root := local_appdata.replace("\\", "/").path_join("uv/cache/builds-v0")
+ return purge_directory(builds_root)
+
+
+## Pure-ish entrypoint that takes a directory path. Returns
+## `{ "scanned": int, "renamed": int, "deleted": int, "remaining": int }`.
+## - `scanned`: how many `.tmp*` subdirs we saw on entry.
+## - `renamed`: how many we successfully renamed to `_dead_*`.
+## - `deleted`: how many we then fully removed.
+## - `remaining`: how many `_dead_*` dirs are still on disk after the sweep
+## (left for the next call to retry).
+##
+## Errors are swallowed — the caller is on a server-stop hot path and
+## must not raise.
+static func purge_directory(builds_root: String) -> Dictionary:
+ var result := _empty_result()
+ if not DirAccess.dir_exists_absolute(builds_root):
+ return result
+ var dir := DirAccess.open(builds_root)
+ if dir == null:
+ return result
+ dir.include_hidden = true
+
+ ## Pass 1: collect names. Iterating + renaming in the same walk would
+ ## confuse DirAccess's internal cursor on NTFS.
+ var tmp_names: Array[String] = []
+ var dead_names: Array[String] = []
+ dir.list_dir_begin()
+ var entry := dir.get_next()
+ while entry != "":
+ if dir.current_is_dir() and not (entry == "." or entry == ".."):
+ if entry.begins_with(TMP_PREFIX):
+ tmp_names.append(entry)
+ elif entry.begins_with(DEAD_PREFIX):
+ dead_names.append(entry)
+ entry = dir.get_next()
+ dir.list_dir_end()
+ result.scanned = tmp_names.size()
+
+ ## Pass 2: rename `.tmp*` → `_dead_.tmp*`. Rename works even on
+ ## AV-locked files (Defender opens without FILE_SHARE_DELETE, but rename
+ ## doesn't need delete share). Any rename failure is non-fatal.
+ for name in tmp_names:
+ var src := builds_root.path_join(name)
+ var dst := builds_root.path_join(DEAD_PREFIX + name)
+ if dir.rename(src, dst) == OK:
+ result.renamed += 1
+ dead_names.append(DEAD_PREFIX + name)
+
+ ## Pass 3: best-effort recursive delete of every `_dead_*`, including
+ ## ones left over from earlier sweeps that couldn't be cleaned then.
+ for name in dead_names:
+ var path := builds_root.path_join(name)
+ if _remove_recursive(path):
+ result.deleted += 1
+
+ ## Final pass: count `_dead_*` survivors so the caller (and tests) can
+ ## see how many genuinely-locked dirs we couldn't reach.
+ var dir2 := DirAccess.open(builds_root)
+ if dir2 != null:
+ dir2.include_hidden = true
+ dir2.list_dir_begin()
+ var e := dir2.get_next()
+ while e != "":
+ if dir2.current_is_dir() and e.begins_with(DEAD_PREFIX):
+ result.remaining += 1
+ e = dir2.get_next()
+ dir2.list_dir_end()
+
+ return result
+
+
+## Recursive `rm -rf` that swallows access-denied per-file. Returns true
+## only when the target directory itself was removed.
+static func _remove_recursive(path: String) -> bool:
+ var dir := DirAccess.open(path)
+ if dir == null:
+ ## Already gone, or unreadable — try a direct remove just in case
+ ## (an empty dir handle-leak path) and report based on existence.
+ DirAccess.remove_absolute(path)
+ return not DirAccess.dir_exists_absolute(path)
+ dir.include_hidden = true
+ dir.list_dir_begin()
+ var entry := dir.get_next()
+ while entry != "":
+ if entry == "." or entry == "..":
+ entry = dir.get_next()
+ continue
+ var child := path.path_join(entry)
+ if dir.current_is_dir():
+ _remove_recursive(child)
+ else:
+ DirAccess.remove_absolute(child)
+ entry = dir.get_next()
+ dir.list_dir_end()
+ ## Remove the (hopefully now empty) dir itself. If a hard-linked .pyd is
+ ## still mapped by a surviving process, this fails silently and the
+ ## caller sees `remaining > 0` so it can retry on the next sweep.
+ DirAccess.remove_absolute(path)
+ return not DirAccess.dir_exists_absolute(path)
+
+
+static func _empty_result() -> Dictionary:
+ return { "scanned": 0, "renamed": 0, "deleted": 0, "remaining": 0 }
diff --git a/addons/godot_ai/utils/uv_cache_cleanup.gd.uid b/addons/godot_ai/utils/uv_cache_cleanup.gd.uid
new file mode 100644
index 0000000..321659d
--- /dev/null
+++ b/addons/godot_ai/utils/uv_cache_cleanup.gd.uid
@@ -0,0 +1 @@
+uid://d33ukg65qf7q0
diff --git a/addons/godot_ai/utils/uv_resolution_policy.gd b/addons/godot_ai/utils/uv_resolution_policy.gd
new file mode 100644
index 0000000..dbaa8b1
--- /dev/null
+++ b/addons/godot_ai/utils/uv_resolution_policy.gd
@@ -0,0 +1,97 @@
+@tool
+extends RefCounted
+
+## One policy for every production uvx launch. Package identity JSON is a
+## compatibility check, not authentication: production code is resolved only
+## from the canonical PyPI index, with config files and installed tool envs
+## disabled. The explicit process-local qualification switch is the sole path
+## that leaves index selection to the qualification environment.
+
+const PathTemplate := preload("res://addons/godot_ai/clients/_path_template.gd")
+const PUBLIC_INDEX := "https://pypi.org/simple"
+const PUBLIC_FLAT_INDEX := "https://pypi.org/simple/godot-ai/"
+const QUALIFICATION_INDEX_ENV := "GODOT_AI_QUALIFICATION_PYTHON_INDEX"
+const _BASE_ARGS := [
+ "--isolated",
+ "--no-config",
+ "--no-env-file",
+ "--no-sources",
+ "--no-build",
+ "--index-strategy", "first-index",
+ "--keyring-provider", "disabled",
+]
+
+## Known uv inputs capable of changing package source, candidate selection,
+## interpreter, or cache. Production processes spawned by Godot temporarily
+## clear them under the global process-spawn mutex; command-line policy remains
+## pinned as a second boundary and for client-owned attach launches.
+const _RESOLUTION_ENVIRONMENT := [
+ "UV_INDEX", "UV_DEFAULT_INDEX", "UV_INDEX_URL", "UV_EXTRA_INDEX_URL",
+ "UV_FIND_LINKS", "UV_INDEX_STRATEGY", "UV_KEYRING_PROVIDER",
+ "UV_CONFIG_FILE", "UV_CONSTRAINT", "UV_BUILD_CONSTRAINT", "UV_OVERRIDE",
+ "UV_NO_CONFIG", "UV_NO_SOURCES", "UV_NO_BUILD", "UV_NO_BINARY",
+ "UV_NO_BINARY_PACKAGE", "UV_NO_VERIFY_HASHES", "UV_PRERELEASE",
+ "UV_RESOLUTION", "UV_FORK_STRATEGY", "UV_EXCLUDE_NEWER",
+ "UV_EXCLUDE_NEWER_PACKAGE", "UV_INSECURE_HOST", "UV_SYSTEM_CERTS",
+ "UV_NATIVE_TLS", "UV_PYTHON", "UV_PYTHON_DOWNLOADS", "UV_PROJECT",
+ "UV_WORKING_DIR", "UV_ENV_FILE", "UV_CACHE_DIR", "UV_TOOL_DIR",
+ "UV_TOOL_BIN_DIR", "UV_LINK_MODE", "UV_REFRESH", "UV_REFRESH_PACKAGE",
+ "UV_REINSTALL", "UV_REINSTALL_PACKAGE",
+]
+
+
+static func warm_environment() -> void:
+ PathTemplate.warm_env_snapshot(PackedStringArray([QUALIFICATION_INDEX_ENV]))
+
+
+static func qualification_authorized() -> bool:
+ return PathTemplate.env_lookup(QUALIFICATION_INDEX_ENV) == "1"
+
+
+static func args() -> Array[String]:
+ var result: Array[String] = []
+ result.assign(_BASE_ARGS)
+ if not qualification_authorized():
+ result.append_array([
+ "--index", PUBLIC_INDEX,
+ "--default-index", PUBLIC_INDEX,
+ "--find-links", PUBLIC_FLAT_INDEX,
+ ])
+ return result
+
+
+static func is_production_command(command: Array) -> bool:
+ if command.is_empty() or not str(command[0]).get_file().begins_with("uvx"):
+ return false
+ return (
+ _option_equals(command, "--index", PUBLIC_INDEX)
+ and _option_equals(command, "--default-index", PUBLIC_INDEX)
+ and _option_equals(command, "--find-links", PUBLIC_FLAT_INDEX)
+ )
+
+
+## Call only while the process-spawn mutex is held. Qualification commands do
+## not reach this function: their explicitly authorized UV_INDEX and credentials
+## must remain process-local and are never rendered into persisted argv.
+static func isolate_environment() -> Dictionary:
+ var previous := {}
+ for key in _RESOLUTION_ENVIRONMENT:
+ previous[key] = {
+ "present": OS.has_environment(key),
+ "value": OS.get_environment(key),
+ }
+ OS.unset_environment(key)
+ return previous
+
+
+static func restore_environment(previous: Dictionary) -> void:
+ for key in previous:
+ if bool(previous[key].present):
+ OS.set_environment(key, str(previous[key].value))
+ else:
+ OS.unset_environment(key)
+
+
+static func _option_equals(command: Array, option: String, expected: String) -> bool:
+ var index := command.find(option)
+ return index >= 0 and index + 1 < command.size() and str(command[index + 1]) == expected
diff --git a/addons/godot_ai/utils/uv_resolution_policy.gd.uid b/addons/godot_ai/utils/uv_resolution_policy.gd.uid
new file mode 100644
index 0000000..f729a81
--- /dev/null
+++ b/addons/godot_ai/utils/uv_resolution_policy.gd.uid
@@ -0,0 +1 @@
+uid://b3y73hby0e7j8
diff --git a/addons/godot_ai/utils/variant_serializer.gd b/addons/godot_ai/utils/variant_serializer.gd
new file mode 100644
index 0000000..8e17a16
--- /dev/null
+++ b/addons/godot_ai/utils/variant_serializer.gd
@@ -0,0 +1,102 @@
+@tool
+extends RefCounted
+
+## Converts Godot Variants into values that can be encoded as JSON.
+
+
+## Non-finite floats (NaN/INF) have no JSON representation: JSON.stringify
+## emits them as the bare tokens `inf`/`nan`, which are invalid JSON — the
+## server drops the whole frame and the pending request times out (#688).
+## Serialize them as null instead (the same choice web JSON.stringify makes),
+## applied uniformly across the v4 support floor — no version gate, so wire
+## output is identical on every supported engine.
+static func _safe_float(f: float) -> Variant:
+ return f if is_finite(f) else null
+
+
+static func serialize(value: Variant) -> Variant:
+ if value == null:
+ return null
+ match typeof(value):
+ TYPE_BOOL, TYPE_INT, TYPE_STRING:
+ return value
+ TYPE_FLOAT:
+ return _safe_float(value)
+ TYPE_STRING_NAME:
+ return str(value)
+ # Integer vector types are listed separately from their float twins so
+ # int components stay ints on the wire (no float coercion via
+ # _safe_float's typed parameter).
+ TYPE_VECTOR2I:
+ return {"x": value.x, "y": value.y}
+ TYPE_VECTOR2:
+ return {"x": _safe_float(value.x), "y": _safe_float(value.y)}
+ TYPE_VECTOR3I:
+ return {"x": value.x, "y": value.y, "z": value.z}
+ TYPE_VECTOR3:
+ return {"x": _safe_float(value.x), "y": _safe_float(value.y), "z": _safe_float(value.z)}
+ TYPE_VECTOR4I:
+ return {"x": value.x, "y": value.y, "z": value.z, "w": value.w}
+ TYPE_VECTOR4, TYPE_QUATERNION:
+ return {
+ "x": _safe_float(value.x),
+ "y": _safe_float(value.y),
+ "z": _safe_float(value.z),
+ "w": _safe_float(value.w),
+ }
+ TYPE_COLOR:
+ return {
+ "r": _safe_float(value.r),
+ "g": _safe_float(value.g),
+ "b": _safe_float(value.b),
+ "a": _safe_float(value.a),
+ }
+ TYPE_RECT2, TYPE_RECT2I, TYPE_AABB:
+ return {
+ "position": serialize(value.position),
+ "size": serialize(value.size),
+ }
+ TYPE_PLANE:
+ return {"normal": serialize(value.normal), "d": _safe_float(value.d)}
+ TYPE_BASIS:
+ return {
+ "x": serialize(value.x),
+ "y": serialize(value.y),
+ "z": serialize(value.z),
+ }
+ TYPE_TRANSFORM2D:
+ return {
+ "x": serialize(value.x),
+ "y": serialize(value.y),
+ "origin": serialize(value.origin),
+ }
+ TYPE_TRANSFORM3D:
+ return {
+ "basis": serialize(value.basis),
+ "origin": serialize(value.origin),
+ }
+ TYPE_PROJECTION:
+ return {
+ "x": serialize(value.x),
+ "y": serialize(value.y),
+ "z": serialize(value.z),
+ "w": serialize(value.w),
+ }
+ TYPE_NODE_PATH:
+ return str(value)
+ TYPE_ARRAY, TYPE_PACKED_BYTE_ARRAY, TYPE_PACKED_INT32_ARRAY, TYPE_PACKED_INT64_ARRAY, TYPE_PACKED_FLOAT32_ARRAY, TYPE_PACKED_FLOAT64_ARRAY, TYPE_PACKED_STRING_ARRAY, TYPE_PACKED_VECTOR2_ARRAY, TYPE_PACKED_VECTOR3_ARRAY, TYPE_PACKED_VECTOR4_ARRAY, TYPE_PACKED_COLOR_ARRAY:
+ var arr: Array = []
+ for item in value:
+ arr.append(serialize(item))
+ return arr
+ TYPE_DICTIONARY:
+ var out := {}
+ for key in value:
+ out[str(key)] = serialize(value[key])
+ return out
+ TYPE_OBJECT:
+ if value is Resource and value.resource_path:
+ return value.resource_path
+ return str(value)
+ _:
+ return str(value)
diff --git a/addons/godot_ai/utils/variant_serializer.gd.uid b/addons/godot_ai/utils/variant_serializer.gd.uid
new file mode 100644
index 0000000..b8e0a28
--- /dev/null
+++ b/addons/godot_ai/utils/variant_serializer.gd.uid
@@ -0,0 +1 @@
+uid://cte37mtbd61n3
diff --git a/addons/godot_ai/vision_routing.gd b/addons/godot_ai/vision_routing.gd
new file mode 100644
index 0000000..5edc677
--- /dev/null
+++ b/addons/godot_ai/vision_routing.gd
@@ -0,0 +1,1090 @@
+@tool
+extends RefCounted
+
+## Vision Routing - route screenshot-tool images through a curated vision API.
+##
+## Models without image support (e.g. DeepSeek) cannot read the image blocks the
+## screenshot tool returns. When routing is enabled, every single-image capture is sent to a
+## vision model on a worker thread and the resulting text description is
+## returned to the AI instead:
+##
+## - Editor (non-game) screenshots are captured normally by
+## editor_handler.gd, then described on a worker thread; the reply is
+## deferred until the description is ready.
+## - Game screenshots are intercepted in mcp_debugger_plugin.gd the same way.
+## - On success the response keeps a valid but tiny 2x2 placeholder image and
+## carries the description as text metadata (`vision_description` plus a
+## `note`, which the server forwards to the model).
+## - On failure (missing key, network, API error) the original image payload
+## passes through unchanged, so the screenshot tool never breaks.
+##
+## Providers are curated: label, API dialect, endpoint shape, environment
+## variable and encrypted key slot are fixed in the PROVIDERS table. The model
+## id is NOT - it is required per provider and entered by the user (stored in
+## Editor Settings next to the key), because third-party model ids retire and
+## only the account holder gets notified. Switching providers switches to that
+## provider's entered model, so the software never guesses a model name:
+## - Groq (free tier) - OpenAI chat-completions
+## - Google Gemini (free tier, AI Studio key) - generateContent REST
+## - xAI Grok (paid) - OpenAI chat-completions
+## Both dialects are handled in this file.
+##
+## Settings live in Editor Settings (`vision_routing/enabled`,
+## `vision_routing/provider`, plus one encrypted key slot and one model-id
+## slot per provider). Keys are stored encrypted (AES-256-CBC, key derived
+## from this machine) rather than in plain text, and each provider's
+## environment variable (GROQ_API_KEY / GOOGLE_API_KEY / XAI_API_KEY) takes
+## priority over the stored key.
+##
+## UI: a "Vision Routing" section inside the Clients & Tools Settings tab.
+
+## Curated providers: label, dialect, endpoint shape, env var and key/model
+## setting slots are fixed here. The model id itself is user-entered per
+## provider (see `_resolved_model`), so provider rows carry a `model_setting`
+## slot and a `model_placeholder` suggestion instead of a baked-in id -
+## third-party model ids retire, and only the account holder gets notified
+## when one does.
+const PROVIDERS := {
+ "groq": {
+ "label": "Groq",
+ "dialect": "openai",
+ "model_setting": "vision_routing/groq_model",
+ "model_placeholder": "qwen/qwen3.6-27b",
+ "host": "api.groq.com",
+ "port": 443,
+ "path": "/openai/v1/chat/completions",
+ "env": "GROQ_API_KEY",
+ "setting": "vision_routing/api_key_enc",
+ "placeholder": "gsk_...",
+ "key_label": "Groq API key (free tier: console.groq.com)",
+ "reasoning_effort": "none",
+ },
+ "google": {
+ "label": "Google Gemini",
+ "dialect": "gemini",
+ "host": "generativelanguage.googleapis.com",
+ "port": 443,
+ "path": "/v1beta/models/{model}:generateContent",
+ "model_setting": "vision_routing/google_model",
+ "model_placeholder": "gemini-flash-latest",
+ "env": "GOOGLE_API_KEY",
+ "setting": "vision_routing/google_api_key_enc",
+ "placeholder": "AIza...",
+ "key_label": "Google AI Studio API key (free tier: aistudio.google.com)",
+ },
+ "grok": {
+ "label": "xAI Grok",
+ "dialect": "openai",
+ "model_setting": "vision_routing/grok_model",
+ "model_placeholder": "grok-4.5",
+ "host": "api.x.ai",
+ "port": 443,
+ "path": "/v1/chat/completions",
+ "env": "XAI_API_KEY",
+ "setting": "vision_routing/grok_api_key_enc",
+ "placeholder": "xai-...",
+ "key_label": "xAI API key (api.x.ai)",
+ },
+}
+const PROVIDER_ORDER := ["groq", "google", "grok"]
+
+const SETTING_ENABLED := "vision_routing/enabled"
+const SETTING_PROVIDER := "vision_routing/provider"
+const SETTING_API_KEY_ENC := "vision_routing/api_key_enc"
+const TAB_NAME := "Vision Routing"
+
+const MAX_IMAGE_EDGE := 1024
+const CONNECT_TIMEOUT_MS := 4000
+const REQUEST_TIMEOUT_MS := 8000
+## Total budget for the whole provider exchange (connect + request + body).
+## Kept under the server's 15s editor_screenshot window so the fallback
+## pass-through always lands before the server gives up on slow providers.
+const TOTAL_ROUTE_BUDGET_MS := 10000
+## Response body cap: the deadline bounds time but not bytes, and the socket
+## talks to third-party hosts - a faulty or hostile provider must not be able
+## to stream an unbounded body inside the window.
+const MAX_RESPONSE_BODY_BYTES := 1048576
+## Output-token budget shared by both dialects (OpenAI `max_tokens`,
+## Gemini `generationConfig.maxOutputTokens`) so routed descriptions
+## stay bounded.
+const MAX_OUTPUT_TOKENS := 512
+
+const _ENC_PREFIX := "v1"
+const _SALT := "vision_routing::v1::godot-ai"
+const _PLACEHOLDER_PNG := "iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAALSURBVBhXY2BABwAAEgABp3qZbgAAAABJRU5ErkJggg=="
+
+## Plugin log buffer (McpLogBuffer), set by plugin.gd; null-safe.
+var log_buffer: Object = null
+
+var _active := true
+var _active_mutex := Mutex.new()
+var _pending: Dictionary = {} # request_id -> {payload, data, connection, provider_id, provider, params}
+var _threads: Dictionary = {} # request_id -> Thread ("_test" = ping thread)
+var _ping_context: Dictionary = {}
+var _ui_loading := false
+
+# UI references, kept in sync by _sync_ui_states().
+var _tab_provider: OptionButton = null
+var _tab_enable: CheckButton = null
+var _tab_key_label: Label = null
+var _tab_key_hint: Label = null
+var _tab_key_edit: LineEdit = null
+var _tab_model_label: Label = null
+var _tab_model_hint: Label = null
+var _tab_model_edit: LineEdit = null
+var _tab_status: Label = null
+var _tab_test_button: Button = null
+
+
+func _is_active() -> bool:
+ _active_mutex.lock()
+ var active := _active
+ _active_mutex.unlock()
+ return active
+
+
+func _set_active(active: bool) -> void:
+ _active_mutex.lock()
+ _active = active
+ _active_mutex.unlock()
+
+
+## Plugin teardown: stop workers and join in-flight threads so Godot never
+## destroys a Thread mid-execution during a plugin reload.
+func shutdown() -> void:
+ _set_active(false)
+ ## Workers poll the synchronized active flag between HTTP polls, so this
+ ## returns within one
+ ## poll interval (~50ms) unless a request is mid-flight in the OS; worst
+ ## case is a connect/request timeout.
+ for rid in _threads:
+ var thread: Thread = _threads[rid]
+ if thread != null and thread.is_started():
+ thread.wait_to_finish()
+ _threads.clear()
+ _pending.clear()
+ _ping_context.clear()
+ _tab_provider = null
+ _tab_enable = null
+ _tab_key_label = null
+ _tab_key_hint = null
+ _tab_key_edit = null
+ _tab_status = null
+ _tab_test_button = null
+ _tab_model_label = null
+ _tab_model_hint = null
+ _tab_model_edit = null
+
+
+## Drain completed Thread return values on the main thread. Workers never
+## enqueue old-script Callables: shutdown can set the stop flag, join every
+## worker, and discard its result with no deferred callback surviving into an
+## exact-tree update or plugin reload.
+func poll_completed() -> void:
+ if not _is_active():
+ return
+ for rid_value in _threads.keys():
+ var rid := str(rid_value)
+ var thread: Thread = _threads.get(rid)
+ if thread == null or not thread.is_started():
+ _threads.erase(rid)
+ continue
+ if thread.is_alive():
+ continue
+ var outcome: Variant = thread.wait_to_finish()
+ _threads.erase(rid)
+ if not _is_active():
+ return
+ if not outcome is Dictionary:
+ if rid == "_test":
+ _finish_ping_result({"ok": false, "error": "worker returned an invalid result"})
+ else:
+ _on_route_complete(rid, {"desc": "", "error": "worker returned an invalid result"})
+ continue
+ match str(outcome.get("kind", "")):
+ "route":
+ _on_route_complete(str(outcome.get("request_id", rid)), outcome.get("result"))
+ "ping":
+ _finish_ping_result(outcome.get("result", {}))
+ _:
+ _pending.erase(rid)
+
+
+# --- routing ------------------------------------------------------------------
+
+## Entry point called from editor_handler.take_screenshot when routing is
+## enabled. Runs the real capture (via `original`), then routes the image
+## through the selected provider's vision API on a worker thread. Returns the deferred-response sentinel
+## when the worker owns the reply, otherwise the capture result unchanged.
+func route_editor_screenshot(params: Dictionary, original: Callable, connection: Object) -> Dictionary:
+ var rid := str(params.get("_request_id", ""))
+ if rid.is_empty():
+ ## No deferred channel (e.g. batch_execute / dispatch_direct) - keep
+ ## the original synchronous result untouched.
+ return original.call(params)
+ var result := original.call(params)
+ if not (result is Dictionary) or not result.has("data"):
+ ## Deferred capture (source="game"): the frame arrives later through
+ ## route_game_payload. Stash the caller params (e.g. user_prompt) by
+ ## request id so the routed description keeps the agent's context.
+ if result is Dictionary and result.get("_deferred", false):
+ _pending[rid] = {"params": params}
+ return result
+ var data: Variant = result["data"]
+ if not (data is Dictionary) or not data.has("image_base64"):
+ return result
+ if _start_route(rid, str(params.get("source", "viewport")), result, data, connection, params):
+ ## Keep the deferred ledger inside the server's 15s editor_screenshot
+ ## window (the worker itself is capped at TOTAL_ROUTE_BUDGET_MS), so
+ ## a hung provider surfaces as a clean plugin timeout, not a
+ ## server-side abort.
+ return {"_deferred": true, "_deferred_timeout_ms": 13000}
+ return result
+
+
+## Entry point called from McpDebuggerPlugin._on_screenshot_response before
+## the frame is sent. Returns true when a worker owns the reply (the caller
+## must NOT send the payload itself); false means pass through unchanged.
+func route_game_payload(connection: Object, rid: String, payload: Dictionary) -> bool:
+ var data: Variant = payload.get("data")
+ if not (data is Dictionary) or not data.has("image_base64"):
+ ## Not a routeable frame - drop any params stashed by
+ ## route_editor_screenshot for this request id.
+ _pending.erase(rid)
+ return false
+ ## The editor handler stashed the caller params (user_prompt etc.) by
+ ## request id when it deferred the capture; hand them to the prompt
+ ## builder. Absent (older helper, direct call) = generic prompt.
+ var params: Dictionary = _pending.get(rid, {}).get("params", {})
+ return _start_route(rid, "game", payload, data, connection, params)
+
+
+## Returns true when a worker owns the reply; false means the caller should
+## pass the original payload through unchanged.
+func _start_route(rid: String, source: String, payload: Dictionary, data: Dictionary, connection: Object, params: Dictionary) -> bool:
+ if not _is_active():
+ return false
+ var provider_id := _active_provider_id()
+ var base_provider: Dictionary = PROVIDERS.get(provider_id, PROVIDERS["groq"])
+ ## Worker-thread snapshot: the entered model id is resolved into the
+ ## provider dict here, once, so the thread sees a stable copy and
+ ## routed_via can report the exact model that was pinged.
+ var provider := _provider_with_model(provider_id)
+ var api_key := _resolved_api_key(provider_id)
+ if api_key.is_empty():
+ _log("vision routing: no API key for %s (set %s or paste one in the Vision Routing section) - screenshot %s passed through" % [provider_id, base_provider.get("env", ""), rid])
+ _pending.erase(rid)
+ return false
+ if str(provider.get("model", "")).is_empty():
+ ## Empty model behaves exactly like empty key: routing declines, the
+ ## image passes through, and the log line says what is missing.
+ _log("vision routing: no model id set for %s - screenshot %s passed through (set a model id in the Vision Routing section)" % [provider_id, rid])
+ _pending.erase(rid)
+ return false
+ _pending[rid] = {"payload": payload, "data": data, "connection": connection, "provider_id": provider_id, "provider": provider, "params": params}
+ var prompt := _build_prompt(params)
+ var thread := Thread.new()
+ var start_err := thread.start(_route_worker.bind(provider, str(data.get("image_base64", "")), prompt, api_key, rid))
+ if start_err != OK:
+ _pending.erase(rid)
+ _log("vision routing: could not start worker for %s: %s - screenshot %s passed through" % [rid, error_string(start_err), source])
+ return false
+ _threads[rid] = thread
+ _log("vision routing: routing %s screenshot %s (%d b64 chars) via %s (%s)" % [source, rid, str(data.get("image_base64", "")).length(), base_provider.get("label", provider_id), provider.get("model", "")])
+ return true
+
+
+func _on_route_complete(rid: String, result: Variant) -> void:
+ if not _is_active():
+ _pending.erase(rid)
+ return
+ var entry: Dictionary = _pending.get(rid, {})
+ _pending.erase(rid)
+ if entry.is_empty():
+ return
+ var connection: Object = entry.get("connection")
+ if connection == null or not is_instance_valid(connection):
+ return
+ var provider_id := str(entry.get("provider_id", "groq"))
+ ## Provider snapshot taken at route start (with the entered model id);
+ ## falls back to a fresh resolve if absent (direct test callers).
+ var provider: Dictionary = entry.get("provider", {})
+ if provider.is_empty():
+ provider = _provider_with_model(provider_id)
+ ## Workers return {"desc": ..., "error": ...}; plain strings are accepted
+ ## for backwards compatibility (tests / older callers).
+ var description_str := ""
+ var error_str := ""
+ if result is Dictionary:
+ description_str = str(result.get("desc", ""))
+ error_str = str(result.get("error", ""))
+ elif result != null:
+ description_str = str(result)
+ if description_str.is_empty():
+ if error_str.is_empty():
+ error_str = "unknown error"
+ _log("vision routing: %s failed for %s (%s) - returning original image with failure note" % [provider_id, rid, error_str])
+ ## Append a short templated reason to the note so text-only agents
+ ## (who otherwise just receive a useless image block) learn the
+ ## feature is down and why.
+ var failed_data: Dictionary = entry.get("data", {}).duplicate()
+ var failure_note := "Vision routing unavailable (%s): %s" % [provider_id, error_str]
+ var existing_note := str(failed_data.get("note", ""))
+ failed_data["note"] = (existing_note + " | " if not existing_note.is_empty() else "") + failure_note
+ _pass_through(connection, rid, {"data": failed_data})
+ return
+ var data: Dictionary = entry.get("data", {}).duplicate()
+ var routed_via := "%s:%s" % [provider_id, provider.get("model", "")]
+ ## The server forwards a fixed whitelist of metadata keys into the text
+ ## result; `note` is the free-form one, so a self-attributing description
+ ## rides there (plus an explicit `vision_description` key) so text-only
+ ## models can read it. The label keeps provider text from reading as if
+ ## the plugin said it. The image is replaced by a valid 2x2 placeholder
+ ## so the payload stays well-formed.
+ data["vision_description"] = description_str
+ data["routed_via"] = routed_via
+ var original_note := str(data.get("note", ""))
+ var labeled := "Vision description (%s): %s" % [routed_via, description_str]
+ data["note"] = (original_note + " | " if not original_note.is_empty() else "") + labeled
+ data["image_base64"] = _PLACEHOLDER_PNG
+ data["format"] = "png"
+ _log("vision routing: description ready for %s (%d chars)" % [rid, description_str.length()])
+ _pass_through(connection, rid, {"data": data})
+
+
+func _pass_through(connection: Object, rid: String, payload: Dictionary) -> void:
+ if connection != null and is_instance_valid(connection):
+ connection.send_deferred_response(rid, payload)
+
+
+func _build_prompt(params: Dictionary) -> String:
+ var lines := PackedStringArray([
+ "You are the vision module of a text-only AI agent driving the Godot editor through MCP.",
+ "Describe this screenshot so the agent can act without seeing it. Report:",
+ "- What is shown: Godot editor viewport, game window, 2D/3D scene, UI panel, dialog, or other.",
+ "- Objects/nodes: what they are, position, color, size, and any labels or text (quote text exactly).",
+ "- UI text: menus, buttons, error dialogs, console output, warnings, line numbers.",
+ "- State: selected node outlines, gizmos, play/stop status, panels that are open.",
+ "- Problems: errors, red highlights, missing textures, black screens, glitches, stretching.",
+ "Be concise (under 200 words), factual, and use exact quotes instead of paraphrase. Do not give advice.",
+ ])
+ var user_prompt := str(params.get("user_prompt", ""))
+ if not user_prompt.is_empty():
+ lines.append("Context from the agent that requested this screenshot: %s" % user_prompt)
+ return "\n".join(lines)
+
+
+# --- routing worker (thread) ---------------------------------------------------
+
+func _route_worker(provider: Dictionary, image_b64: String, prompt: String, api_key: String, rid: String) -> Dictionary:
+ var result := _describe_blocking(provider, image_b64, prompt, api_key)
+ return {"kind": "route", "request_id": rid, "result": result}
+
+
+## Returns {"desc": String, "error": String}; "desc" is empty on failure and
+## "error" carries the reason. Runs on a worker thread, so it never writes
+## shared state - everything it needs is passed in and returned. `provider`
+## is the route-start snapshot (includes the entered model id).
+func _describe_blocking(provider: Dictionary, image_b64: String, prompt: String, api_key: String) -> Dictionary:
+ var b64 := _downscale_image_if_needed(image_b64)
+ var body := _build_request_body(provider, prompt, b64)
+ var headers := _build_headers(provider, api_key)
+ var response := _http_post_json(str(provider.get("host", "")), int(provider.get("port", 443)), _resolve_path(provider), headers, body)
+ return _parse_description(provider, response)
+
+
+## Provider paths may carry a {model} placeholder (Gemini's endpoint embeds
+## the model id); substitute the entered model id verbatim.
+func _resolve_path(provider: Dictionary) -> String:
+ return str(provider.get("path", "")).replace("{model}", str(provider.get("model", "")))
+
+
+func _build_request_body(provider: Dictionary, prompt: String, image_b64: String) -> String:
+ var model := str(provider.get("model", ""))
+ if str(provider.get("dialect", "")) == "gemini":
+ return JSON.stringify({
+ "contents": [{
+ "role": "user",
+ "parts": [
+ {"text": prompt},
+ {"inline_data": {"mime_type": "image/png", "data": image_b64}},
+ ],
+ }],
+ "generationConfig": {"maxOutputTokens": MAX_OUTPUT_TOKENS},
+ })
+ var payload := {
+ "model": model,
+ "messages": [{
+ "role": "user",
+ "content": [
+ {"type": "text", "text": prompt},
+ {"type": "image_url", "image_url": {"url": "data:image/png;base64," + image_b64}},
+ ],
+ }],
+ "max_tokens": MAX_OUTPUT_TOKENS,
+ "temperature": 0.2,
+ }
+ if provider.has("reasoning_effort"):
+ payload["reasoning_effort"] = provider["reasoning_effort"]
+ return JSON.stringify(payload)
+
+
+func _build_headers(provider: Dictionary, api_key: String) -> PackedStringArray:
+ if str(provider.get("dialect", "")) == "gemini":
+ return PackedStringArray([
+ "Content-Type: application/json",
+ "x-goog-api-key: %s" % api_key,
+ ])
+ return PackedStringArray([
+ "Content-Type: application/json",
+ "Authorization: Bearer %s" % api_key,
+ ])
+
+
+func _parse_description(provider: Dictionary, response: Dictionary) -> Dictionary:
+ var code: int = int(response.get("code", 0))
+ var label := str(provider.get("label", str(provider.get("model", "?"))))
+ if code == 0:
+ return {"desc": "", "error": str(response.get("error", "HTTP request failed"))}
+ if code != 200:
+ return {"desc": "", "error": "%s HTTP %d: %s" % [label, code, _body_snippet(str(response.get("text", "")))]}
+ var parsed: Variant = JSON.parse_string(str(response.get("text", "")))
+ if not (parsed is Dictionary):
+ return {"desc": "", "error": "%s response was not JSON: %s" % [label, _body_snippet(str(response.get("text", "")))]}
+ if str(provider.get("dialect", "")) == "gemini":
+ return _parse_gemini(parsed, label, response)
+ return _parse_openai(parsed, label, response)
+
+
+func _parse_openai(parsed: Dictionary, label: String, response: Dictionary) -> Dictionary:
+ var choices: Variant = parsed.get("choices")
+ if not (choices is Array) or choices.is_empty():
+ return {"desc": "", "error": "%s response had no choices: %s" % [label, _body_snippet(str(response.get("text", "")))]}
+ if not (choices[0] is Dictionary):
+ return {"desc": "", "error": "%s response choice was not an object: %s" % [label, _body_snippet(str(response.get("text", "")))]}
+ var message: Variant = choices[0].get("message", {})
+ if not (message is Dictionary):
+ return {"desc": "", "error": "%s response message was not an object: %s" % [label, _body_snippet(str(response.get("text", "")))]}
+ var content: Variant = message.get("content", "")
+ if content == null:
+ return {"desc": "", "error": "%s response content was null: %s" % [label, _body_snippet(str(response.get("text", "")))]}
+ return {"desc": _strip_think(str(content)), "error": ""}
+
+
+func _parse_gemini(parsed: Dictionary, label: String, response: Dictionary) -> Dictionary:
+ var candidates: Variant = parsed.get("candidates")
+ if not (candidates is Array) or candidates.is_empty():
+ var reason := ""
+ var feedback: Variant = parsed.get("promptFeedback")
+ if feedback is Dictionary:
+ reason = str(feedback.get("blockReason", ""))
+ var reason_part := ""
+ if not reason.is_empty():
+ reason_part = " (blocked: %s)" % reason
+ return {"desc": "", "error": "%s response had no candidates%s: %s" % [label, reason_part, _body_snippet(str(response.get("text", "")))]}
+ if not (candidates[0] is Dictionary):
+ return {"desc": "", "error": "%s response candidate was not an object: %s" % [label, _body_snippet(str(response.get("text", "")))]}
+ var content: Variant = candidates[0].get("content", {})
+ if not (content is Dictionary):
+ return {"desc": "", "error": "%s response content was missing: %s" % [label, _body_snippet(str(response.get("text", "")))]}
+ var parts: Variant = content.get("parts")
+ if not (parts is Array) or parts.is_empty():
+ return {"desc": "", "error": "%s response had no parts: %s" % [label, _body_snippet(str(response.get("text", "")))]}
+ var texts := PackedStringArray()
+ for part in parts:
+ if part is Dictionary:
+ var part_text := str(part.get("text", ""))
+ if not part_text.is_empty():
+ texts.append(part_text)
+ if texts.is_empty():
+ return {"desc": "", "error": "%s response parts had no text: %s" % [label, _body_snippet(str(response.get("text", "")))]}
+ return {"desc": _strip_think("\n".join(texts)), "error": ""}
+
+
+func _strip_think(text: String) -> String:
+ var out := text.strip_edges()
+ ## Reasoning models may wrap their answer in ... blocks.
+ var think_end := out.rfind("")
+ if think_end != -1:
+ out = out.substr(think_end + "".length()).strip_edges()
+ return out
+
+
+## Minimal vision call used by the "Test connection" button. Sends the same
+## image-bearing body shape as real routing (with the 2x2 placeholder PNG),
+## so one click validates key + model existence + image capability - a
+## text-only or retired model id fails loudly here instead of at screenshot
+## time. `provider` is the route-style snapshot (includes the model id).
+func _ping_blocking(provider: Dictionary, api_key: String) -> Dictionary:
+ var body := _build_request_body(provider, "Reply with exactly: OK", _PLACEHOLDER_PNG)
+ var response := _http_post_json(str(provider.get("host", "")), int(provider.get("port", 443)), _resolve_path(provider), _build_headers(provider, api_key), body)
+ var code: int = int(response.get("code", 0))
+ if code == 200:
+ return {"ok": true, "error": ""}
+ if code == 0:
+ return {"ok": false, "error": str(response.get("error", "request failed"))}
+ return {"ok": false, "error": "%s HTTP %d: %s" % [provider.get("label", "provider"), code, _body_snippet(str(response.get("text", "")))]}
+
+
+func _http_post_json(host: String, port: int, path: String, headers: PackedStringArray, body: String) -> Dictionary:
+ if host.is_empty() or body.is_empty():
+ return {"code": 0, "error": "invalid request (empty host or body)"}
+ var http := HTTPClient.new()
+ var connect_err := http.connect_to_host(host, port, TLSOptions.client())
+ if connect_err != OK:
+ http.close()
+ return {"code": 0, "error": "connect_to_host failed: %s" % error_string(connect_err)}
+ ## One total deadline for the whole exchange (see TOTAL_ROUTE_BUDGET_MS),
+ ## so connect + request + body can never exceed it.
+ var budget_deadline := Time.get_ticks_msec() + TOTAL_ROUTE_BUDGET_MS
+ var deadline := mini(Time.get_ticks_msec() + CONNECT_TIMEOUT_MS, budget_deadline)
+ var first_poll := true
+ var connected := false
+ var last_status := -1
+ while Time.get_ticks_msec() < deadline:
+ if not _is_active():
+ http.close()
+ return {"code": 0, "error": "aborted (plugin teardown)"}
+ http.poll()
+ var status := http.get_status()
+ last_status = status
+ if status == HTTPClient.STATUS_CONNECTED:
+ connected = true
+ break
+ if status == HTTPClient.STATUS_DISCONNECTED and not first_poll:
+ break
+ first_poll = false
+ OS.delay_msec(50)
+ if not connected:
+ http.close()
+ return {"code": 0, "error": "could not connect (status %d)" % last_status}
+ if http.request(HTTPClient.METHOD_POST, path, headers, body) != OK:
+ http.close()
+ return {"code": 0, "error": "request() failed"}
+ deadline = mini(Time.get_ticks_msec() + REQUEST_TIMEOUT_MS, budget_deadline)
+ var timed_out := false
+ var status_at_timeout := -1
+ while http.get_status() == HTTPClient.STATUS_REQUESTING:
+ if not _is_active():
+ http.close()
+ return {"code": 0, "error": "aborted (plugin teardown)"}
+ http.poll()
+ if Time.get_ticks_msec() > deadline:
+ timed_out = true
+ status_at_timeout = http.get_status()
+ break
+ OS.delay_msec(50)
+ if timed_out:
+ http.close()
+ return {"code": 0, "error": "request timed out (status %d)" % status_at_timeout}
+ if not http.has_response():
+ var st := http.get_status()
+ http.close()
+ return {"code": 0, "error": "no HTTP response (status %d)" % st}
+ var code := http.get_response_code()
+ var chunks := PackedByteArray()
+ var body_deadline := mini(Time.get_ticks_msec() + REQUEST_TIMEOUT_MS, budget_deadline)
+ while http.get_status() == HTTPClient.STATUS_BODY:
+ if not _is_active():
+ http.close()
+ return {"code": 0, "error": "aborted (plugin teardown)"}
+ chunks.append_array(http.read_response_body_chunk())
+ if chunks.size() > MAX_RESPONSE_BODY_BYTES:
+ http.close()
+ return {"code": 0, "error": "response body exceeded %d bytes" % MAX_RESPONSE_BODY_BYTES}
+ http.poll()
+ if Time.get_ticks_msec() > body_deadline:
+ break
+ OS.delay_msec(10)
+ http.close()
+ return {"code": code, "text": chunks.get_string_from_utf8()}
+
+
+func _body_snippet(text: String) -> String:
+ if text.is_empty():
+ return "(empty body)"
+ if text.length() > 300:
+ return text.substr(0, 300) + "..."
+ return text
+
+
+func _downscale_image_if_needed(image_b64: String) -> String:
+ if image_b64.is_empty():
+ return image_b64
+ var raw := Marshalls.base64_to_raw(image_b64)
+ if raw.is_empty():
+ return image_b64
+ var image := Image.new()
+ if image.load_png_from_buffer(raw) != OK:
+ return image_b64
+ var width := image.get_width()
+ var height := image.get_height()
+ if width <= MAX_IMAGE_EDGE and height <= MAX_IMAGE_EDGE:
+ return image_b64
+ if width >= height:
+ height = maxi(1, int(round(height * MAX_IMAGE_EDGE / float(width))))
+ width = MAX_IMAGE_EDGE
+ else:
+ width = maxi(1, int(round(width * MAX_IMAGE_EDGE / float(height))))
+ height = MAX_IMAGE_EDGE
+ image.resize(width, height, Image.INTERPOLATE_BILINEAR)
+ var out := image.save_png_to_buffer()
+ if out.is_empty():
+ return image_b64
+ return Marshalls.raw_to_base64(out)
+
+
+func _ping_worker(provider: Dictionary, api_key: String) -> Dictionary:
+ var result := _ping_blocking(provider, api_key)
+ return {"kind": "ping", "result": result}
+
+
+func _finish_ping_result(result: Dictionary) -> void:
+ var provider: Dictionary = _ping_context.get("provider", {})
+ var status_label: Label = _ping_context.get("status_label")
+ var key_source := str(_ping_context.get("key_source", "stored key"))
+ _ping_context.clear()
+ if _tab_test_button != null and is_instance_valid(_tab_test_button):
+ _tab_test_button.disabled = false
+ if status_label != null and is_instance_valid(status_label):
+ var label := str(provider.get("label", "provider"))
+ if result.get("ok", false):
+ status_label.text = "OK - %s responded (%s)." % [label, key_source]
+ else:
+ status_label.text = "FAILED - %s (%s)." % [result.get("error", "unknown error"), key_source]
+ _log("vision routing: ping result: %s" % status_label.text)
+
+
+# --- settings / key storage ---------------------------------------------------
+
+func _settings() -> EditorSettings:
+ return EditorInterface.get_editor_settings()
+
+
+func is_routing_enabled() -> bool:
+ var es := _settings()
+ if es == null or not es.has_setting(SETTING_ENABLED):
+ return false
+ var value = es.get_setting(SETTING_ENABLED)
+ return value != null and value
+
+
+func _active_provider_id() -> String:
+ var es := _settings()
+ if es == null:
+ return "groq"
+ var stored := ""
+ if es.has_setting(SETTING_PROVIDER):
+ stored = str(es.get_setting(SETTING_PROVIDER))
+ if stored.is_empty() or not PROVIDERS.has(stored):
+ return "groq"
+ return stored
+
+
+func _provider_setting(provider_id: String) -> String:
+ return str(PROVIDERS.get(provider_id, PROVIDERS["groq"]).get("setting", SETTING_API_KEY_ENC))
+
+
+func _resolved_api_key(provider_id: String) -> String:
+ ## Environment variable takes priority over the stored (encrypted) key.
+ var provider: Dictionary = PROVIDERS.get(provider_id, PROVIDERS["groq"])
+ var env_key := OS.get_environment(str(provider.get("env", "")))
+ if not env_key.is_empty():
+ return env_key
+ var es := _settings()
+ if es == null:
+ return ""
+ var setting := _provider_setting(provider_id)
+ var blob := ""
+ if es.has_setting(setting):
+ blob = str(es.get_setting(setting))
+ if blob.is_empty():
+ return ""
+ return _decrypt(blob)
+
+
+func _decrypted_key(provider_id: String) -> String:
+ var es := _settings()
+ if es == null:
+ return ""
+ var setting := _provider_setting(provider_id)
+ if not es.has_setting(setting):
+ return ""
+ return _decrypt(str(es.get_setting(setting)))
+
+
+func set_api_key(provider_id: String, plain: String) -> void:
+ var es := _settings()
+ if es == null:
+ return
+ if plain.is_empty():
+ es.set_setting(_provider_setting(provider_id), "")
+ return
+ var blob := _encrypt(plain)
+ if blob.is_empty():
+ _log("vision routing: cannot store %s key - this machine reports no unique id; set %s instead" % [provider_id, PROVIDERS.get(provider_id, PROVIDERS["groq"]).get("env", "")])
+ return
+ es.set_setting(_provider_setting(provider_id), blob)
+
+
+## The model id is stored per provider next to the key (a plain Editor
+## Setting - it is not a secret). Empty clears the slot.
+func set_model_id(provider_id: String, model: String) -> void:
+ var es := _settings()
+ if es == null:
+ return
+ es.set_setting(_model_setting(provider_id), model.strip_edges())
+
+
+func _model_setting(provider_id: String) -> String:
+ return str(PROVIDERS.get(provider_id, PROVIDERS["groq"]).get("model_setting", ""))
+
+
+func _resolved_model(provider_id: String) -> String:
+ var es := _settings()
+ if es == null:
+ return ""
+ var setting := _model_setting(provider_id)
+ if not es.has_setting(setting):
+ return ""
+ return str(es.get_setting(setting)).strip_edges()
+
+
+## Route-start snapshot: the curated provider table plus the user's entered
+## model id. Used by the worker thread and by _on_route_complete for the
+## routed_via label, so it always reports the model that was actually pinged.
+func _provider_with_model(provider_id: String) -> Dictionary:
+ var provider: Dictionary = PROVIDERS.get(provider_id, PROVIDERS["groq"]).duplicate()
+ provider["model"] = _resolved_model(provider_id)
+ return provider
+
+
+## Machine-derived key: not a password, but enough that a casually-opened
+## editor_settings-4.tres does not reveal the key in plain text. Separate
+## domain tags give AES and HMAC independent keys so neither reuses the
+## other's bytes.
+func _derive_key(tag: String) -> PackedByteArray:
+ var parts := PackedStringArray([
+ OS.get_unique_id(),
+ OS.get_environment("USERNAME"),
+ OS.get_environment("USERPROFILE"),
+ OS.get_environment("USER"),
+ OS.get_environment("HOME"),
+ OS.get_name(),
+ _SALT,
+ tag,
+ ])
+ var ctx := HashingContext.new()
+ ctx.start(HashingContext.HASH_SHA256)
+ ctx.update(("|".join(parts)).to_utf8_buffer())
+ return ctx.finish()
+
+
+func _encrypt(plain: String) -> String:
+ if OS.get_unique_id().is_empty():
+ ## Without a machine id the key would be near-constant across users
+ ## on the same machine - refuse, and let callers fall back to the
+ ## provider's environment variable.
+ return ""
+ var aes_key := _derive_key("aes")
+ var mac_key := _derive_key("mac")
+ var iv := Crypto.new().generate_random_bytes(16)
+ var raw := plain.to_utf8_buffer()
+ ## PKCS7 padding.
+ var pad := 16 - (raw.size() % 16)
+ var padded := raw.duplicate()
+ for i in pad:
+ padded.append(pad)
+ var aes := AESContext.new()
+ aes.start(AESContext.MODE_CBC_ENCRYPT, aes_key, iv)
+ var cipher := aes.update(padded)
+ aes.finish()
+ var hmac := HMACContext.new()
+ hmac.start(HashingContext.HASH_SHA256, mac_key)
+ hmac.update(iv)
+ hmac.update(cipher)
+ var mac := hmac.finish()
+ return "%s:%s:%s:%s" % [_ENC_PREFIX, Marshalls.raw_to_base64(iv), Marshalls.raw_to_base64(mac), Marshalls.raw_to_base64(cipher)]
+
+
+func _decrypt(blob: String) -> String:
+ var parts := blob.split(":")
+ if parts.size() != 4 or parts[0] != _ENC_PREFIX:
+ return ""
+ var iv := Marshalls.base64_to_raw(parts[1])
+ var mac := Marshalls.base64_to_raw(parts[2])
+ var cipher := Marshalls.base64_to_raw(parts[3])
+ if iv.size() != 16 or cipher.is_empty() or cipher.size() % 16 != 0:
+ return ""
+ var aes_key := _derive_key("aes")
+ var mac_key := _derive_key("mac")
+ var hmac := HMACContext.new()
+ hmac.start(HashingContext.HASH_SHA256, mac_key)
+ hmac.update(iv)
+ hmac.update(cipher)
+ var expected := hmac.finish()
+ if expected != mac:
+ return ""
+ var aes := AESContext.new()
+ aes.start(AESContext.MODE_CBC_DECRYPT, aes_key, iv)
+ var padded := aes.update(cipher)
+ aes.finish()
+ if padded.is_empty():
+ return ""
+ var pad := int(padded[padded.size() - 1])
+ if pad < 1 or pad > 16 or pad > padded.size():
+ return ""
+ var raw := padded.slice(0, padded.size() - pad)
+ return raw.get_string_from_utf8()
+
+
+# --- UI: tab in Clients & Tools -------------------------------------------------
+
+## Builds the "Vision Routing" section inside the Clients & Tools Settings
+## tab. Called by mcp_dock._build_settings_tab; `refresh_ui()` re-syncs the
+## controls from Editor Settings each time the window opens.
+func build_section(parent: VBoxContainer) -> void:
+ var box := VBoxContainer.new()
+ box.add_theme_constant_override("separation", 8)
+ parent.add_child(box)
+
+ var header := Label.new()
+ header.text = "Vision Routing"
+ header.add_theme_font_size_override("font_size", 18)
+ box.add_child(header)
+
+ var provider_row := HBoxContainer.new()
+ var provider_label := Label.new()
+ provider_label.text = "Provider"
+ provider_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ provider_row.add_child(provider_label)
+ _tab_provider = OptionButton.new()
+ _tab_provider.tooltip_text = "Which vision provider routes the screenshots. The model id is required per provider and is yours to maintain."
+ var active_provider := _active_provider_id()
+ for provider_index in PROVIDER_ORDER.size():
+ var provider_item: Dictionary = PROVIDERS[PROVIDER_ORDER[provider_index]]
+ _tab_provider.add_item(str(provider_item.get("label", PROVIDER_ORDER[provider_index])), provider_index)
+ if PROVIDER_ORDER[provider_index] == active_provider:
+ _tab_provider.select(provider_index)
+ _tab_provider.item_selected.connect(_on_provider_changed)
+ provider_row.add_child(_tab_provider)
+ box.add_child(provider_row)
+
+ var enable_row := HBoxContainer.new()
+ var enable_label := Label.new()
+ enable_label.text = "Enable routing"
+ enable_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ enable_row.add_child(enable_label)
+ _tab_enable = CheckButton.new()
+ _tab_enable.button_pressed = is_routing_enabled()
+ _tab_enable.toggled.connect(_on_enable_toggled)
+ enable_row.add_child(_tab_enable)
+ box.add_child(enable_row)
+
+ var description := Label.new()
+ description.text = (
+ "When enabled, every single-image screenshot the AI model takes through "
+ + "the godot-ai screenshot tool is sent to the selected provider's vision model (see "
+ + "the Provider dropdown) for a text description. The description is "
+ + "returned to the AI instead of the raw image, so models without image "
+ + "support (e.g. DeepSeek) can still \"see\" the editor and game. When "
+ + "the connected model analyzes images itself, switch this off here so "
+ + "screenshots pass through unchanged."
+ )
+ description.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
+ description.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ box.add_child(description)
+
+ _tab_key_label = Label.new()
+ box.add_child(_tab_key_label)
+
+ var key_row := HBoxContainer.new()
+ _tab_key_edit = LineEdit.new()
+ _tab_key_edit.secret = true
+ _tab_key_edit.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ ## Persist on commit (Enter / focus loss) instead of per keystroke, so
+ ## pasting a key does not re-encrypt and rewrite Editor Settings dozens
+ ## of times.
+ _tab_key_edit.text_submitted.connect(_on_key_committed)
+ _tab_key_edit.focus_exited.connect(func() -> void: _on_key_committed(_tab_key_edit.text))
+ key_row.add_child(_tab_key_edit)
+ var show_button := CheckButton.new()
+ show_button.tooltip_text = "Show / hide key"
+ show_button.toggled.connect(func(show: bool) -> void: _tab_key_edit.secret = not show)
+ key_row.add_child(show_button)
+ box.add_child(key_row)
+
+ _tab_key_hint = Label.new()
+ _tab_key_hint.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
+ _tab_key_hint.add_theme_color_override("font_color", Color(0.55, 0.55, 0.55))
+ box.add_child(_tab_key_hint)
+
+ _tab_model_label = Label.new()
+ box.add_child(_tab_model_label)
+
+ var model_row := HBoxContainer.new()
+ _tab_model_edit = LineEdit.new()
+ _tab_model_edit.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ ## Persist on commit (Enter / focus loss), same pattern as the key field.
+ _tab_model_edit.text_submitted.connect(_on_model_committed)
+ _tab_model_edit.focus_exited.connect(func() -> void: _on_model_committed(_tab_model_edit.text))
+ model_row.add_child(_tab_model_edit)
+ box.add_child(model_row)
+
+ _tab_model_hint = Label.new()
+ _tab_model_hint.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
+ _tab_model_hint.add_theme_color_override("font_color", Color(0.55, 0.55, 0.55))
+ box.add_child(_tab_model_hint)
+
+ var test_row := HBoxContainer.new()
+ var test_button := Button.new()
+ test_button.text = "Test connection"
+ test_button.pressed.connect(_on_test_connection)
+ _tab_test_button = test_button
+ test_row.add_child(test_button)
+ _tab_status = Label.new()
+ _tab_status.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ _tab_status.add_theme_color_override("font_color", Color(0.55, 0.55, 0.55))
+ test_row.add_child(_tab_status)
+ box.add_child(test_row)
+
+ refresh_ui()
+
+
+## Re-syncs every control from Editor Settings. Called after the section is
+## built and again by mcp_dock each time the Clients & Tools window opens,
+## so a change made in another editor instance (or a hand-edit of
+## editor_settings-4.tres) is reflected.
+func refresh_ui() -> void:
+ _sync_ui_states()
+ _sync_provider_ui()
+ _ui_loading = true
+ if _tab_key_edit != null and is_instance_valid(_tab_key_edit):
+ _tab_key_edit.text = _decrypted_key(_active_provider_id())
+ if _tab_model_edit != null and is_instance_valid(_tab_model_edit):
+ _tab_model_edit.text = _resolved_model(_active_provider_id())
+ _ui_loading = false
+
+
+func _on_key_committed(_new_text: String) -> void:
+ if _ui_loading:
+ return
+ set_api_key(_active_provider_id(), _tab_key_edit.text)
+
+
+func _on_model_committed(_new_text: String) -> void:
+ if _ui_loading:
+ return
+ set_model_id(_active_provider_id(), _tab_model_edit.text)
+
+
+func _on_provider_changed(index: int) -> void:
+ if index < 0 or index >= PROVIDER_ORDER.size():
+ return
+ var provider_id: String = PROVIDER_ORDER[index]
+ var es := _settings()
+ if es != null:
+ es.set_setting(SETTING_PROVIDER, provider_id)
+ _ui_loading = true
+ if _tab_key_edit != null and is_instance_valid(_tab_key_edit):
+ _tab_key_edit.text = _decrypted_key(provider_id)
+ if _tab_model_edit != null and is_instance_valid(_tab_model_edit):
+ _tab_model_edit.text = _resolved_model(provider_id)
+ _ui_loading = false
+ _sync_provider_ui()
+ _log("vision routing: provider changed to %s (%s)" % [provider_id, PROVIDERS[provider_id].get("label", provider_id)])
+
+
+func _sync_provider_ui() -> void:
+ var provider_id := _active_provider_id()
+ var provider: Dictionary = PROVIDERS.get(provider_id, PROVIDERS["groq"])
+ if _tab_provider != null and is_instance_valid(_tab_provider):
+ var provider_index := PROVIDER_ORDER.find(provider_id)
+ if provider_index != -1 and _tab_provider.selected != provider_index:
+ _tab_provider.select(provider_index)
+ if _tab_key_label != null and is_instance_valid(_tab_key_label):
+ _tab_key_label.text = str(provider.get("key_label", "API key"))
+ if _tab_key_hint != null and is_instance_valid(_tab_key_hint):
+ var hint := (
+ "Stored encrypted (AES-256, key derived from this machine) in Editor Settings "
+ + "- not plain text, but local obfuscation only. You can also set the "
+ + "%s environment variable; it takes priority over this field."
+ ) % provider.get("env", "")
+ if OS.get_unique_id().is_empty():
+ hint += " This machine reports no unique id, so keys cannot be stored locally - use the %s environment variable." % provider.get("env", "")
+ _tab_key_hint.text = hint
+ if _tab_key_edit != null and is_instance_valid(_tab_key_edit):
+ _tab_key_edit.placeholder_text = str(provider.get("placeholder", ""))
+ if _tab_model_label != null and is_instance_valid(_tab_model_label):
+ _tab_model_label.text = "Model id (required) - %s" % provider.get("label", provider_id)
+ if _tab_model_edit != null and is_instance_valid(_tab_model_edit):
+ _tab_model_edit.placeholder_text = str(provider.get("model_placeholder", ""))
+ _tab_model_edit.tooltip_text = "The vision model this provider should ping. Yours to maintain: when a model id retires, replace it here."
+ if _tab_model_hint != null and is_instance_valid(_tab_model_hint):
+ _tab_model_hint.text = (
+ "Required per provider; empty behaves like an empty key (routing "
+ + "declines and screenshots pass through). Suggested id (as of "
+ + "2026-08 - check your provider console, ids retire): %s."
+ ) % provider.get("model_placeholder", "")
+
+
+func _on_test_connection() -> void:
+ if _tab_status == null:
+ return
+ var provider_id := _active_provider_id()
+ var provider := _provider_with_model(provider_id)
+ var env_name := str(PROVIDERS.get(provider_id, PROVIDERS["groq"]).get("env", ""))
+ if str(provider.get("model", "")).is_empty():
+ _tab_status.text = "No model id set for %s - add one below." % provider.get("label", provider_id)
+ return
+ var api_key := _resolved_api_key(provider_id)
+ if api_key.is_empty():
+ _tab_status.text = "No key set - add one below or set %s." % env_name
+ return
+ var running: Thread = _threads.get("_test")
+ if running != null and running.is_started() and running.is_alive():
+ return
+ var previous_status := _tab_status.text
+ _tab_status.text = "Testing..."
+ if _tab_test_button != null and is_instance_valid(_tab_test_button):
+ _tab_test_button.disabled = true
+ var key_source := "stored key"
+ if not OS.get_environment(env_name).is_empty():
+ key_source = "via %s" % env_name
+ var thread := Thread.new()
+ _threads["_test"] = thread
+ _ping_context = {
+ "provider": provider,
+ "status_label": _tab_status,
+ "key_source": key_source,
+ }
+ var start_err := thread.start(_ping_worker.bind(provider, api_key))
+ if start_err != OK:
+ ## A failed thread start must not leave the button disabled and the
+ ## status stuck on "Testing..." forever.
+ _threads.erase("_test")
+ _ping_context.clear()
+ _tab_status.text = previous_status
+ if _tab_test_button != null and is_instance_valid(_tab_test_button):
+ _tab_test_button.disabled = false
+ _log("vision routing: could not start ping thread: %s" % error_string(start_err))
+
+
+func _on_enable_toggled(enabled: bool) -> void:
+ var es := _settings()
+ if es != null:
+ es.set_setting(SETTING_ENABLED, enabled)
+ _sync_ui_states()
+
+
+func _sync_ui_states() -> void:
+ var enabled := is_routing_enabled()
+ if _tab_enable != null and is_instance_valid(_tab_enable) and _tab_enable.button_pressed != enabled:
+ _tab_enable.set_pressed_no_signal(enabled)
+
+
+# --- logging --------------------------------------------------------------------
+
+func _log(message: String) -> void:
+ if log_buffer != null and is_instance_valid(log_buffer) and log_buffer.has_method("log"):
+ log_buffer.log(message, false)
diff --git a/addons/godot_ai/vision_routing.gd.uid b/addons/godot_ai/vision_routing.gd.uid
new file mode 100644
index 0000000..c4c5bc2
--- /dev/null
+++ b/addons/godot_ai/vision_routing.gd.uid
@@ -0,0 +1 @@
+uid://dp444q4ocpx45