diff --git a/addons/godot_ai/LICENSE b/addons/godot_ai/LICENSE deleted file mode 100644 index 7806d22..0000000 --- a/addons/godot_ai/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -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 deleted file mode 100644 index f8a730d..0000000 --- a/addons/godot_ai/README.md +++ /dev/null @@ -1,53 +0,0 @@ -# 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. Copy `addons/godot_ai/` into your project's `addons/` folder -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.5+ (4.7+ recommended) -- [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 deleted file mode 100644 index ec3806c..0000000 --- a/addons/godot_ai/client_configurator.gd +++ /dev/null @@ -1,1784 +0,0 @@ -@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 WindowsPortReservation := preload("res://addons/godot_ai/utils/windows_port_reservation.gd") -const PortResolver := preload("res://addons/godot_ai/utils/port_resolver.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_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: - 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: - return _read_port_setting(SETTING_WS_PORT, DEFAULT_WS_PORT) - - -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_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 _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) - - -## Snapshot every EditorSettings-backed value needed to render or verify an -## attach launch command. Main-thread calls refresh the snapshot; worker calls -## return that snapshot without touching EditorInterface (#691). Warm it on the -## main thread before dispatching a worker. -static func capture_launch_context() -> 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 captured_http_port := http_port() - var context := { - "http_port": captured_http_port, - "ws_port": ws_port(), - "excluded_domains": 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": McpSettings.telemetry_enabled(), - } - _launch_context_snapshot_mutex.lock() - _launch_context_snapshot = context.duplicate(true) - _launch_context_snapshot_mutex.unlock() - return context - - -## 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} - 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 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. - return _verify_post_state(client, result, Client.Status.CONFIGURED, url, "configure", launch, context) - - -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() -> 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) - _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() - - -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} - - -## Force lazy GDScript bytecode swaps to complete before a client-status -## worker reaches the registry and strategies. Pure-memory only: callers can -## run this on the handler thread without performing CLI or config probes. -static func warm_status_worker_bytecode() -> void: - var ids := client_ids() - if ids.is_empty(): - return - var any_client := ClientRegistry.get_by_id(String(ids[0])) - if any_client != null: - JsonStrategy.verify_entry(any_client, {}, "") - DshStrategy.command_launch_error(any_client, {}) - TomlStrategy.format_body(PackedStringArray(), "") - CliStrategy.format_args(PackedStringArray(), "", "") - # Compile the aggregate worker entry point on main as well. After a plugin - # reload, first-dereferencing this function from Thread can hang in Godot's - # lazy bytecode swap even when every strategy it calls was already warmed. - run_client_status_sweep({}, true) - - -## Worker entry point for the MCP aggregate status command. Every filesystem, -## CLI, and launch-discovery probe stays inside this function; the WebSocket -## handler only schedules it and returns the deferred sentinel. -static func run_client_status_sweep( - fallback_launch_context: Dictionary = {}, warm_only: bool = false -) -> Dictionary: - if warm_only: - return {} - var clients := [] - var launch_context := capture_launch_context() - if launch_context.is_empty(): - launch_context = fallback_launch_context.duplicate(true) - if launch_context.is_empty(): - return {"worker_error": "Client status launch context was not warmed on the main thread."} - var server_url := server_url_from(launch_context) - var resolved_launch := resolve_attach_launch(launch_context) - for client_id in client_ids(): - var probe := client_status_probe_snapshot(client_id) - var details := check_status_details_for_url_with_cli_path( - client_id, - server_url, - str(probe.get("cli_path", "")), - launch_context, - resolved_launch, - ) - clients.append(_client_status_sweep_entry( - client_id, details, bool(probe.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} - 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 result := _dispatch_remove(client, context) - return _verify_post_state(client, result, Client.Status.NOT_CONFIGURED, url, "remove", launch, context) - - -## 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 _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: Array[String] = [ - "--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 := python.get_base_dir().path_join("pythonw.exe") - if FileAccess.file_exists(managed_pythonw): - 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"]) - - -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) - - -## `refresh` forces uvx to re-fetch PyPI index metadata on spawn — used by -## `_start_server`'s one-shot retry when the first attempt exited fast with -## no pid-file on the uvx tier (stale-index-cache failure mode). No-op on -## other tiers: dev_venv and system resolve locally, so the flag has nowhere -## to go. See plugin.gd::_should_retry_with_refresh. -static func get_server_command(refresh: bool = false) -> 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)%s" - % [pypi_version, version, " [refresh]" if refresh else ""] - ) - else: - print("MCP | using uvx (godot-ai==%s)%s" % [pypi_version, " [refresh]" if refresh else ""]) - var cmd: Array[String] = [uvx] - if refresh: - cmd.append("--refresh") - 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" - - -static func find_uvx() -> String: - return CliFinder.find(_uvx_cli_names()) - - -## Pre-build the uvx tool environment for `godot-ai==` in the -## background, so the FIRST spawn of that version is a warm cache hit. -## Fired when a self-update install starts: post-update the old backend is -## killed and the new one spawned, and a cold `uvx --from godot-ai==` -## resolve+install takes seconds — while an attach bridge pinned to the OLD -## version respawns its already-cached backend near-instantly, winning the -## port bind race every time (the INCOMPATIBLE dead end after every -## self-update with a live AI client). Warming the new env while the plugin -## zip downloads flips that race. -## -## `--version` makes the spawned process exit immediately after uv resolves -## and installs the env. Detached fire-and-forget: a failure only means the -## post-update spawn pays the cold cost it always used to. Returns the -## spawned PID, or -1 when skipped (no uvx on this machine — the dev-venv -## and system tiers have no per-version cache to warm — or no version). -static func prewarm_server_package(version: String) -> int: - var args := prewarm_server_package_argv(version) - if args.is_empty(): - return -1 - var uvx := find_uvx() - if uvx.is_empty(): - return -1 - var pid := OS.create_process(uvx, args) - if pid > 0: - print("MCP | pre-warming godot-ai==%s server package for the post-update restart" % version.strip_edges()) - return pid - - -## 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 [] - return ["--from", "godot-ai==%s" % pinned, "godot-ai", "--version"] - - -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 about -## 5s into startup, cascading into an infinite reconnect loop. The retry-with- -## refresh recovery in `plugin.gd::_should_retry_with_refresh` only fires on -## the uvx tier, so the dev_venv misidentification has no escape hatch — the -## detection has to be right the first time. -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 deleted file mode 100644 index 9182096..0000000 --- a/addons/godot_ai/client_configurator.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://1kiy8hqyymyj diff --git a/addons/godot_ai/clients/_atomic_write.gd b/addons/godot_ai/clients/_atomic_write.gd deleted file mode 100644 index f772c10..0000000 --- a/addons/godot_ai/clients/_atomic_write.gd +++ /dev/null @@ -1,206 +0,0 @@ -@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: - # 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 := FileAccess.open(tmp_path, FileAccess.WRITE) - if file == null: - return false - # Lock the temp inode down BEFORE writing any bytes. FileAccess.open creates - # it at the umask default (often 0644); chmod'ing the still-empty file first - # means the config contents are never on disk under a world-readable mode in - # the create->chmod gap. rename preserves the inode mode, so the swapped-in - # file lands correct and is never briefly world-readable under the target name. - _apply_mode(tmp_path, target_mode) - 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. The pre-write chmod above closes - # the world-readable window; this second apply is the authoritative one - # (a chmod issued while the FileAccess handle is still open doesn't reliably - # stick inside the editor) and guarantees the final mode before the rename, - # which preserves it. - _apply_mode(tmp_path, target_mode) - - # 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: - backup_made = true - _apply_mode(backup_path, target_mode) - - 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. - _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. - DirAccess.copy_absolute(backup_path, path) - _apply_mode(path, target_mode) - 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 - - -static func _apply_mode(path: String, mode: int) -> void: - # 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 - var err := FileAccess.set_unix_permissions(path, mode) - # Surface a real chmod failure (not the Windows no-op) so permission - # hardening on a sensitive config doesn't fail completely silently. - if err != OK and err != ERR_UNAVAILABLE: - push_warning("MCP | could not set permissions on %s (error %d)" % [path, err]) - - -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 deleted file mode 100644 index add9f7f..0000000 --- a/addons/godot_ai/clients/_atomic_write.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://6fkb5uau0r4h diff --git a/addons/godot_ai/clients/_base.gd b/addons/godot_ai/clients/_base.gd deleted file mode 100644 index 4a5981a..0000000 --- a/addons/godot_ai/clients/_base.gd +++ /dev/null @@ -1,451 +0,0 @@ -@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" - -# 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 - -## Whether manual instructions may offer the client's native URL transport as -## an alternative to its command shape. This is capability metadata, not a -## consequence of `command_shape`: Codex supports a URL block, while Claude -## Desktop's local `claude_desktop_config.json` entries are stdio-only. -var command_supports_url_fallback: bool = false - -## 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. -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: - ## 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 override := config_home_override() - if not override.is_empty(): - _clear_config_path_warning() - return {"path": override, "error": ""} - 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 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_path := _first_existing_later_candidate(ordered_templates, index + 1) - _clear_config_path_warning() - return {"path": path, "error": "", "seed_path": seed_path} - if fallback_create_path.is_empty(): - fallback_create_path = path - _clear_config_path_warning() - return {"path": fallback_create_path, "error": ""} - - -func _first_existing_later_candidate(templates: Array, start_index: int) -> String: - for index in range(start_index, templates.size()): - var group := McpPathTemplate.expand_path_candidates(str(templates[index])) - # 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 - return "" - - -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 env-var-relocated config path, or "" when no override applies -## (no mapping declared, env var unset, or env var empty/whitespace). -func config_home_override() -> String: - if config_home_env.is_empty() or config_home_env_subpath.is_empty(): - return "" - ## 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 "" - # Expand a leading ~ so `CODEX_HOME=~/codex-alt` behaves like the shell. - return McpPathTemplate.expand(home).path_join(config_home_env_subpath) - - -## 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 diff --git a/addons/godot_ai/clients/_base.gd.uid b/addons/godot_ai/clients/_base.gd.uid deleted file mode 100644 index 5ea77d3..0000000 --- a/addons/godot_ai/clients/_base.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cyowqr1x12ilg diff --git a/addons/godot_ai/clients/_cli_exec.gd b/addons/godot_ai/clients/_cli_exec.gd deleted file mode 100644 index 1e09811..0000000 --- a/addons/godot_ai/clients/_cli_exec.gd +++ /dev/null @@ -1,169 +0,0 @@ -@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 `OS.kill` the orphan 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 we killed the process at the wall-clock budget. -## spawn_failed: true if `OS.execute_with_pipe` didn't return a usable PID. - -const DEFAULT_TIMEOUT_MS := 8000 -const _POLL_INTERVAL_MS := 50 -const _KILL_GRACE_MS := 500 - - -static func run( - exe: String, - args: Array, - timeout_ms: int = DEFAULT_TIMEOUT_MS, - capture_stderr: bool = true -) -> Dictionary: - if exe.is_empty(): - return _spawn_failed_result() - return _run_piped(exe, args, timeout_ms, capture_stderr) - - -static func _run_piped( - exe: String, - args: Array, - timeout_ms: int, - capture_stderr: 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) - - var info := OS.execute_with_pipe(spawn_exe, spawn_args) - 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) - while OS.is_process_running(pid): - if Time.get_ticks_msec() >= deadline: - ## Kill before draining: a pipe read can block while the child is - ## still alive. Once it exits, drain any buffered partial output. - OS.kill(pid) - var kill_deadline := Time.get_ticks_msec() + _KILL_GRACE_MS - while OS.is_process_running(pid) and Time.get_ticks_msec() < kill_deadline: - OS.delay_msec(_POLL_INTERVAL_MS) - - var partial_stdout := "" - var partial_stderr := "" - if not OS.is_process_running(pid): - 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": true, - "spawn_failed": false, - } - 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, - "spawn_failed": false, - } - - -static func _spawn_failed_result() -> Dictionary: - return { - "exit_code": -1, - "stdout": "", - "stderr": "", - "output": "", - "timed_out": false, - "spawn_failed": true, - } - - -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 deleted file mode 100644 index 4a97a2c..0000000 --- a/addons/godot_ai/clients/_cli_exec.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dhoe3ypkhm12v diff --git a/addons/godot_ai/clients/_cli_finder.gd b/addons/godot_ai/clients/_cli_finder.gd deleted file mode 100644 index 394e76c..0000000 --- a/addons/godot_ai/clients/_cli_finder.gd +++ /dev/null @@ -1,181 +0,0 @@ -@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 deleted file mode 100644 index 9985270..0000000 --- a/addons/godot_ai/clients/_cli_finder.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cnp5b6fcwou2y diff --git a/addons/godot_ai/clients/_cli_strategy.gd b/addons/godot_ai/clients/_cli_strategy.gd deleted file mode 100644 index 0714115..0000000 --- a/addons/godot_ai/clients/_cli_strategy.gd +++ /dev/null @@ -1,372 +0,0 @@ -@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 - - -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} - - # Best-effort prior cleanup so re-configure is idempotent. Bounded to - # the same budget — a hung unregister shouldn't block 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 - ## and the result is discarded either way, so the extra passes are - ## safe; they cost one bounded subprocess per additional scope. - for pre_scope in _cleanup_scopes(client): - var pre_args := _format_args( - client.cli_unregister_template, server_name, server_url, {}, pre_scope - ) - McpCliExec.run(cli, pre_args, _REMOVE_TIMEOUT_MS) - - 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) - 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) - 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 = "") -> Dictionary: - return {"status": status, "error_msg": error_msg} - - -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} - 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) - 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]} - - -## 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 - 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", "")) - 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, - ) - - -## 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 "" - - -## 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 -) -> 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) - if not expected_target.is_empty() and text.find(expected_target) < 0: - var drifted := _status_details(McpClient.Status.CONFIGURED_MISMATCH) - 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], - ) - 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 deleted file mode 100644 index f84a92b..0000000 --- a/addons/godot_ai/clients/_cli_strategy.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bvib7d8eabbcm diff --git a/addons/godot_ai/clients/_dsh_strategy.gd b/addons/godot_ai/clients/_dsh_strategy.gd deleted file mode 100644 index e18385c..0000000 --- a/addons/godot_ai/clients/_dsh_strategy.gd +++ /dev/null @@ -1,759 +0,0 @@ -@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": ""} - - -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 deleted file mode 100644 index 36703f4..0000000 --- a/addons/godot_ai/clients/_dsh_strategy.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://ch0brn7swb6qx diff --git a/addons/godot_ai/clients/_json_strategy.gd b/addons/godot_ai/clients/_json_strategy.gd deleted file mode 100644 index 4e883a8..0000000 --- a/addons/godot_ai/clients/_json_strategy.gd +++ /dev/null @@ -1,977 +0,0 @@ -@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) - 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 loaded := _load_merge_tiers(client) - 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) - 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: - return {"status": McpClient.Status.CONFIGURED_MISMATCH, "error_msg": _project_override_message([latest], "update or remove", client.display_name, server_name)} - 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": ""} - - -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. -static func _read_file_text(path: String) -> 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": ""} - var content := file.get_as_text() - file.close() - if content.strip_edges().is_empty(): - return {"exists": true, "ok": true, "data": {}, "original_text": content} - var parse_copy := content - # Strip a UTF-8 BOM if present — some editors (notably on Windows) save - # JSON with a leading , which Godot's JSON.parse rejects outright. - # Previously this landed on the "unparseable → wipe" path. - if parse_copy.begins_with(""): - parse_copy = parse_copy.substr(1) - 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} - - -## 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) -> Dictionary: - var read := _read_file_text(path) - 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 -) -> Dictionary: - var tiers: Array[Dictionary] = [] - for path in _project_candidate_paths(client, project_roots): - var read := _read_or_init(path) - 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) -> Dictionary: - var tiers: Array[Dictionary] = [] - for path in _merge_paths(client): - var read := _read_file_text(path) - 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 project := _load_project_definitions(client, server_name, project_roots) - 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) - 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: - if _uses_merge_tiers(client): - var project := _load_project_definitions(client, server_name, project_roots) - 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) - 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) - 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). - while cursor < text.length() and _is_json_ws(text[cursor]): - cursor += 1 - if cursor < text.length() and 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 deleted file mode 100644 index 5e41fbb..0000000 --- a/addons/godot_ai/clients/_json_strategy.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://g8a4iijpk22w diff --git a/addons/godot_ai/clients/_manual_command.gd b/addons/godot_ai/clients/_manual_command.gd deleted file mode 100644 index 3e178ca..0000000 --- a/addons/godot_ai/clients/_manual_command.gd +++ /dev/null @@ -1,383 +0,0 @@ -@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: - 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 client.command_supports_url_fallback: - lines.append("") - lines.append("Advanced fallback — use this URL-mode entry instead; never configure both shapes together. URL mode depends on your client's own reconnect behavior. If the server is down when the client starts, restarting the client may be required.") - lines.append("Edit %s and add under \"%s\":" % [target_path, key]) - var fallback_entry := McpJsonStrategy.build_url_entry(client, server_url) - lines.append(" \"%s\": %s" % [server_name, _format_entry_inline(fallback_entry)]) - 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"))) - if client.command_supports_url_fallback: - lines.append("") - lines.append("Advanced fallback — replace the command/args block above with this URL-mode block; never configure both shapes together. URL mode depends on your client's own reconnect behavior. If the server is down when the client starts, restarting the client may be required.") - lines.append("Edit %s and add:" % resolved_path) - lines.append(" %s" % header) - lines.append(" url = %s" % McpTomlStrategy.encode_basic_string(server_url)) - 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) - if client.command_supports_url_fallback: - lines.append("") - lines.append("Advanced fallback — use this URL-mode entry instead; never configure both shapes together. URL mode depends on your client's own reconnect behavior. If the server is down when the client starts, restarting the client may be required.") - lines.append("Edit %s and add under '%s':" % [resolved_path, key]) - var fallback_entry := {client.entry_url_field: server_url} - for entry_line in McpYamlStrategy.render_entry_lines(server_name, fallback_entry): - lines.append(String(entry_line)) - 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) - if client.command_supports_url_fallback: - lines.append("") - ## No command/args block was rendered on the launch-error path, so - ## "replace the block above" would point at nothing. - var fallback_lead := ( - "replace the command/args block above with this URL-mode entry" - if launch_error.is_empty() - else "use this URL-mode entry instead" - ) - lines.append("Advanced fallback — %s; never configure both shapes together. URL mode depends on the harness' own reconnect behavior. If the server is down when the harness starts, restarting the harness may be required." % fallback_lead) - var fallback_entry := McpDshStrategy.build_url_entry(client, server_name, server_url) - for row_line in McpDshStrategy.render_insert_row(entry_id_value, fallback_entry): - lines.append(String(row_line)) - 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 deleted file mode 100644 index 07a96f4..0000000 --- a/addons/godot_ai/clients/_manual_command.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://ct1wmgfk408x0 diff --git a/addons/godot_ai/clients/_path_template.gd b/addons/godot_ai/clients/_path_template.gd deleted file mode 100644 index f7493d1..0000000 --- a/addons/godot_ai/clients/_path_template.gd +++ /dev/null @@ -1,206 +0,0 @@ -@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`, `_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. -static func expand(template: String) -> String: - if template.is_empty(): - return "" - var out := template - if out.begins_with("~/") or out == "~": - var home := _home() - 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: - var value := env_lookup(var_name) - if value.is_empty() and var_name == "XDG_CONFIG_HOME": - value = _home().path_join(".config") - if value.is_empty() and var_name == "APPDATA": - value = _home().path_join("AppData/Roaming") - if value.is_empty() and var_name == "LOCALAPPDATA": - value = _home().path_join("AppData/Local") - if value.is_empty() and var_name == "HOME": - value = _home() - out = out.replace(token, value) - return out - - -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 deleted file mode 100644 index f2403d7..0000000 --- a/addons/godot_ai/clients/_path_template.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://5pd418va35ms diff --git a/addons/godot_ai/clients/_registry.gd b/addons/godot_ai/clients/_registry.gd deleted file mode 100644 index f61f667..0000000 --- a/addons/godot_ai/clients/_registry.gd +++ /dev/null @@ -1,156 +0,0 @@ -@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/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/cherry_studio.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 deleted file mode 100644 index 57edcf7..0000000 --- a/addons/godot_ai/clients/_registry.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bxougoq8xwg1 diff --git a/addons/godot_ai/clients/_toml_strategy.gd b/addons/godot_ai/clients/_toml_strategy.gd deleted file mode 100644 index a929dbd..0000000 --- a/addons/godot_ai/clients/_toml_strategy.gd +++ /dev/null @@ -1,730 +0,0 @@ -@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, "\n".join(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, "\n".join(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 - - 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 {"status": McpClient.Status.CONFIGURED_MISMATCH, "error_msg": ""} - if not by_key.has("command") or not by_key.has("args"): - return {"status": McpClient.Status.CONFIGURED_MISMATCH, "error_msg": ""} - 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 {"status": McpClient.Status.CONFIGURED_MISMATCH, "error_msg": ""} - if str(command_value.get("value", "")) != str(launch.get("command", "")): - return {"status": McpClient.Status.CONFIGURED_MISMATCH, "error_msg": ""} - if not _string_arrays_equal(args_value.get("value", []), launch.get("args", [])): - return {"status": McpClient.Status.CONFIGURED_MISMATCH, "error_msg": ""} - if not client.command_transport_key.is_empty(): - var transport_key := client.command_transport_key - if not by_key.has(transport_key): - return {"status": McpClient.Status.CONFIGURED_MISMATCH, "error_msg": ""} - 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 {"status": McpClient.Status.CONFIGURED_MISMATCH, "error_msg": ""} - return {"status": McpClient.Status.CONFIGURED, "error_msg": ""} - - if not by_key.has("url"): - return {"status": McpClient.Status.CONFIGURED_MISMATCH, "error_msg": ""} - 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 {"status": McpClient.Status.CONFIGURED_MISMATCH, "error_msg": ""} - 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, "\n".join(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 _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 deleted file mode 100644 index 723cb79..0000000 --- a/addons/godot_ai/clients/_toml_strategy.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cwdvxgn0aurqv diff --git a/addons/godot_ai/clients/_yaml_strategy.gd b/addons/godot_ai/clients/_yaml_strategy.gd deleted file mode 100644 index 0efd515..0000000 --- a/addons/godot_ai/clients/_yaml_strategy.gd +++ /dev/null @@ -1,549 +0,0 @@ -@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": ""} - - -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 deleted file mode 100644 index d8adce8..0000000 --- a/addons/godot_ai/clients/_yaml_strategy.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cfnw4oe71ra1l diff --git a/addons/godot_ai/clients/antigravity.gd b/addons/godot_ai/clients/antigravity.gd deleted file mode 100644 index a707735..0000000 --- a/addons/godot_ai/clients/antigravity.gd +++ /dev/null @@ -1,39 +0,0 @@ -@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"]) - command_supports_url_fallback = true - ## 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 deleted file mode 100644 index 0721a2f..0000000 --- a/addons/godot_ai/clients/antigravity.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://b4l1g0apa2hch diff --git a/addons/godot_ai/clients/cherry_studio.gd b/addons/godot_ai/clients/cherry_studio.gd deleted file mode 100644 index 873b9fe..0000000 --- a/addons/godot_ai/clients/cherry_studio.gd +++ /dev/null @@ -1,18 +0,0 @@ -@tool -extends McpClient - - -func _init() -> void: - id = "cherry_studio" - display_name = "Cherry Studio" - config_type = "json" - path_template = { - "darwin": "~/Library/Application Support/CherryStudio/mcp_servers.json", - "windows": "$APPDATA/CherryStudio/mcp_servers.json", - "linux": "$XDG_CONFIG_HOME/CherryStudio/mcp_servers.json", - } - server_key_path = PackedStringArray(["mcpServers"]) - entry_extra_fields = {"type": "streamableHttp"} - ## `isActive` is user-state (they may have toggled the server off in the UI). - ## Seed on first Configure but preserve across reconfigure. - entry_initial_fields = {"isActive": true} diff --git a/addons/godot_ai/clients/cherry_studio.gd.uid b/addons/godot_ai/clients/cherry_studio.gd.uid deleted file mode 100644 index 7ada8cd..0000000 --- a/addons/godot_ai/clients/cherry_studio.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dwbuykxvbv5f7 diff --git a/addons/godot_ai/clients/claude_code.gd b/addons/godot_ai/clients/claude_code.gd deleted file mode 100644 index d0378b6..0000000 --- a/addons/godot_ai/clients/claude_code.gd +++ /dev/null @@ -1,84 +0,0 @@ -@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"]) - ## URL-mode shape, used only for the manual-instruction fallback text — - ## `claude mcp add --scope --transport http` writes {type: http, url}, - ## where is whatever `godot_ai/mcp_client_scope` resolves to. - 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"]) - command_supports_url_fallback = true - ## 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 deleted file mode 100644 index 3d3335f..0000000 --- a/addons/godot_ai/clients/claude_code.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cp1u1hdpa6f8d diff --git a/addons/godot_ai/clients/claude_desktop.gd b/addons/godot_ai/clients/claude_desktop.gd deleted file mode 100644 index c1aa9bb..0000000 --- a/addons/godot_ai/clients/claude_desktop.gd +++ /dev/null @@ -1,38 +0,0 @@ -@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 deleted file mode 100644 index 9759a1a..0000000 --- a/addons/godot_ai/clients/claude_desktop.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bilntn5n8oqe3 diff --git a/addons/godot_ai/clients/cline.gd b/addons/godot_ai/clients/cline.gd deleted file mode 100644 index 2a1f025..0000000 --- a/addons/godot_ai/clients/cline.gd +++ /dev/null @@ -1,43 +0,0 @@ -@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"]) - command_supports_url_fallback = true diff --git a/addons/godot_ai/clients/cline.gd.uid b/addons/godot_ai/clients/cline.gd.uid deleted file mode 100644 index 95e20f1..0000000 --- a/addons/godot_ai/clients/cline.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://d36nywn2nkgts diff --git a/addons/godot_ai/clients/codex.gd b/addons/godot_ai/clients/codex.gd deleted file mode 100644 index b8231bc..0000000 --- a/addons/godot_ai/clients/codex.gd +++ /dev/null @@ -1,45 +0,0 @@ -@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_supports_url_fallback = true - 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 deleted file mode 100644 index 1e1c3ae..0000000 --- a/addons/godot_ai/clients/codex.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://hdlwcfdr8mdk diff --git a/addons/godot_ai/clients/cursor.gd b/addons/godot_ai/clients/cursor.gd deleted file mode 100644 index e4fe98d..0000000 --- a/addons/godot_ai/clients/cursor.gd +++ /dev/null @@ -1,21 +0,0 @@ -@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"]) - command_supports_url_fallback = true diff --git a/addons/godot_ai/clients/cursor.gd.uid b/addons/godot_ai/clients/cursor.gd.uid deleted file mode 100644 index e0c7ddf..0000000 --- a/addons/godot_ai/clients/cursor.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bvpbssfanukef diff --git a/addons/godot_ai/clients/deepseek_harness.gd b/addons/godot_ai/clients/deepseek_harness.gd deleted file mode 100644 index b9db2a5..0000000 --- a/addons/godot_ai/clients/deepseek_harness.gd +++ /dev/null @@ -1,45 +0,0 @@ -@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. - command_supports_url_fallback = true diff --git a/addons/godot_ai/clients/deepseek_harness.gd.uid b/addons/godot_ai/clients/deepseek_harness.gd.uid deleted file mode 100644 index 8cc2ed3..0000000 --- a/addons/godot_ai/clients/deepseek_harness.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cw3pghlevtql8 diff --git a/addons/godot_ai/clients/gemini_cli.gd b/addons/godot_ai/clients/gemini_cli.gd deleted file mode 100644 index fda58a3..0000000 --- a/addons/godot_ai/clients/gemini_cli.gd +++ /dev/null @@ -1,25 +0,0 @@ -@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"]) - command_supports_url_fallback = true diff --git a/addons/godot_ai/clients/gemini_cli.gd.uid b/addons/godot_ai/clients/gemini_cli.gd.uid deleted file mode 100644 index 2d4e85a..0000000 --- a/addons/godot_ai/clients/gemini_cli.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://b8288pxninajy diff --git a/addons/godot_ai/clients/grok.gd b/addons/godot_ai/clients/grok.gd deleted file mode 100644 index 7b6710e..0000000 --- a/addons/godot_ai/clients/grok.gd +++ /dev/null @@ -1,35 +0,0 @@ -@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"]) - command_supports_url_fallback = true - detect_paths = PackedStringArray(path_template.values()) diff --git a/addons/godot_ai/clients/grok.gd.uid b/addons/godot_ai/clients/grok.gd.uid deleted file mode 100644 index f2d12c2..0000000 --- a/addons/godot_ai/clients/grok.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://ckchsj5s3q1b0 diff --git a/addons/godot_ai/clients/hermes.gd b/addons/godot_ai/clients/hermes.gd deleted file mode 100644 index 64a66bb..0000000 --- a/addons/godot_ai/clients/hermes.gd +++ /dev/null @@ -1,46 +0,0 @@ -@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"]) - command_supports_url_fallback = true - - # 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 deleted file mode 100644 index 14cabd8..0000000 --- a/addons/godot_ai/clients/hermes.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://ewmadhrvs5d7 diff --git a/addons/godot_ai/clients/kilo_code.gd b/addons/godot_ai/clients/kilo_code.gd deleted file mode 100644 index 4f7b3f7..0000000 --- a/addons/godot_ai/clients/kilo_code.gd +++ /dev/null @@ -1,38 +0,0 @@ -@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"]) - command_supports_url_fallback = true diff --git a/addons/godot_ai/clients/kilo_code.gd.uid b/addons/godot_ai/clients/kilo_code.gd.uid deleted file mode 100644 index 3ee5152..0000000 --- a/addons/godot_ai/clients/kilo_code.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dc1x77i1cmb6w diff --git a/addons/godot_ai/clients/kimi_code.gd b/addons/godot_ai/clients/kimi_code.gd deleted file mode 100644 index a80fef8..0000000 --- a/addons/godot_ai/clients/kimi_code.gd +++ /dev/null @@ -1,34 +0,0 @@ -@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"]) - command_supports_url_fallback = true diff --git a/addons/godot_ai/clients/kimi_code.gd.uid b/addons/godot_ai/clients/kimi_code.gd.uid deleted file mode 100644 index 5a05a0e..0000000 --- a/addons/godot_ai/clients/kimi_code.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://d2whd6a5fofhg diff --git a/addons/godot_ai/clients/kiro.gd b/addons/godot_ai/clients/kiro.gd deleted file mode 100644 index c9d4247..0000000 --- a/addons/godot_ai/clients/kiro.gd +++ /dev/null @@ -1,23 +0,0 @@ -@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"]) - command_supports_url_fallback = true diff --git a/addons/godot_ai/clients/kiro.gd.uid b/addons/godot_ai/clients/kiro.gd.uid deleted file mode 100644 index dde3e87..0000000 --- a/addons/godot_ai/clients/kiro.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dqdmd2jw5qen7 diff --git a/addons/godot_ai/clients/opencode.gd b/addons/godot_ai/clients/opencode.gd deleted file mode 100644 index cee555b..0000000 --- a/addons/godot_ai/clients/opencode.gd +++ /dev/null @@ -1,39 +0,0 @@ -@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" - 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"]) - command_supports_url_fallback = true diff --git a/addons/godot_ai/clients/opencode.gd.uid b/addons/godot_ai/clients/opencode.gd.uid deleted file mode 100644 index dc2ad00..0000000 --- a/addons/godot_ai/clients/opencode.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://s8n0vfirf2pj diff --git a/addons/godot_ai/clients/pi.gd b/addons/godot_ai/clients/pi.gd deleted file mode 100644 index 849fbb4..0000000 --- a/addons/godot_ai/clients/pi.gd +++ /dev/null @@ -1,63 +0,0 @@ -@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). - # Keep the manual-instruction URL fallback alive. - command_supports_url_fallback = true - # ~/.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()) \ No newline at end of file diff --git a/addons/godot_ai/clients/pi.gd.uid b/addons/godot_ai/clients/pi.gd.uid deleted file mode 100644 index 53aeb7c..0000000 --- a/addons/godot_ai/clients/pi.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://it00ohwkm2nd diff --git a/addons/godot_ai/clients/qwen_code.gd b/addons/godot_ai/clients/qwen_code.gd deleted file mode 100644 index 63bc7b2..0000000 --- a/addons/godot_ai/clients/qwen_code.gd +++ /dev/null @@ -1,26 +0,0 @@ -@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"]) - command_supports_url_fallback = true diff --git a/addons/godot_ai/clients/qwen_code.gd.uid b/addons/godot_ai/clients/qwen_code.gd.uid deleted file mode 100644 index 5f2eb1a..0000000 --- a/addons/godot_ai/clients/qwen_code.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://qwb5udkf423q diff --git a/addons/godot_ai/clients/roo_code.gd b/addons/godot_ai/clients/roo_code.gd deleted file mode 100644 index 5d7bd6a..0000000 --- a/addons/godot_ai/clients/roo_code.gd +++ /dev/null @@ -1,42 +0,0 @@ -@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"]) - command_supports_url_fallback = true diff --git a/addons/godot_ai/clients/roo_code.gd.uid b/addons/godot_ai/clients/roo_code.gd.uid deleted file mode 100644 index 1dcae84..0000000 --- a/addons/godot_ai/clients/roo_code.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://denjdf50qrf66 diff --git a/addons/godot_ai/clients/trae.gd b/addons/godot_ai/clients/trae.gd deleted file mode 100644 index afc7a68..0000000 --- a/addons/godot_ai/clients/trae.gd +++ /dev/null @@ -1,23 +0,0 @@ -@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"]) - command_supports_url_fallback = true diff --git a/addons/godot_ai/clients/trae.gd.uid b/addons/godot_ai/clients/trae.gd.uid deleted file mode 100644 index f10046e..0000000 --- a/addons/godot_ai/clients/trae.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cwpu48772vfj1 diff --git a/addons/godot_ai/clients/vscode.gd b/addons/godot_ai/clients/vscode.gd deleted file mode 100644 index a18b97d..0000000 --- a/addons/godot_ai/clients/vscode.gd +++ /dev/null @@ -1,30 +0,0 @@ -@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"]) - command_supports_url_fallback = true diff --git a/addons/godot_ai/clients/vscode.gd.uid b/addons/godot_ai/clients/vscode.gd.uid deleted file mode 100644 index 1c79881..0000000 --- a/addons/godot_ai/clients/vscode.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dl6cm044pihub diff --git a/addons/godot_ai/clients/vscode_insiders.gd b/addons/godot_ai/clients/vscode_insiders.gd deleted file mode 100644 index 7f6a65b..0000000 --- a/addons/godot_ai/clients/vscode_insiders.gd +++ /dev/null @@ -1,23 +0,0 @@ -@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"]) - command_supports_url_fallback = true diff --git a/addons/godot_ai/clients/vscode_insiders.gd.uid b/addons/godot_ai/clients/vscode_insiders.gd.uid deleted file mode 100644 index c763703..0000000 --- a/addons/godot_ai/clients/vscode_insiders.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cad5w4ofyg8a2 diff --git a/addons/godot_ai/clients/windsurf.gd b/addons/godot_ai/clients/windsurf.gd deleted file mode 100644 index b3ef210..0000000 --- a/addons/godot_ai/clients/windsurf.gd +++ /dev/null @@ -1,29 +0,0 @@ -@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"]) - command_supports_url_fallback = true diff --git a/addons/godot_ai/clients/windsurf.gd.uid b/addons/godot_ai/clients/windsurf.gd.uid deleted file mode 100644 index af34b60..0000000 --- a/addons/godot_ai/clients/windsurf.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://b6pqiok2mlsmg diff --git a/addons/godot_ai/clients/zed.gd b/addons/godot_ai/clients/zed.gd deleted file mode 100644 index fbd5f29..0000000 --- a/addons/godot_ai/clients/zed.gd +++ /dev/null @@ -1,29 +0,0 @@ -@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" - 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"]) - command_supports_url_fallback = true diff --git a/addons/godot_ai/clients/zed.gd.uid b/addons/godot_ai/clients/zed.gd.uid deleted file mode 100644 index b9b313a..0000000 --- a/addons/godot_ai/clients/zed.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://d152l0u0r6fsc diff --git a/addons/godot_ai/clients/zoo_code.gd b/addons/godot_ai/clients/zoo_code.gd deleted file mode 100644 index 870ff39..0000000 --- a/addons/godot_ai/clients/zoo_code.gd +++ /dev/null @@ -1,36 +0,0 @@ -@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"]) - command_supports_url_fallback = true diff --git a/addons/godot_ai/clients/zoo_code.gd.uid b/addons/godot_ai/clients/zoo_code.gd.uid deleted file mode 100644 index dd3713e..0000000 --- a/addons/godot_ai/clients/zoo_code.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://fmp0nlzcm3ukl diff --git a/addons/godot_ai/connection.gd b/addons/godot_ai/connection.gd deleted file mode 100644 index 6823500..0000000 --- a/addons/godot_ai/connection.gd +++ /dev/null @@ -1,1048 +0,0 @@ -@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 -## Mirror of the server's application close code for a handshake carrying a -## wrong auth token (#690; `websocket.py::_CLOSE_CODE_AUTH_TOKEN_MISMATCH`). -const CLOSE_CODE_AUTH_TOKEN_MISMATCH := 4003 -## After this many consecutive post-OPEN token-mismatch rejections, drop the -## token and handshake token-less (see `_note_post_open_close`). Two, not -## one: a transient stale-record race during a server swap gets one chance -## to resolve before the token is given up. -const AUTH_MISMATCH_FALLBACK_CLOSES := 2 -const ClientConfigurator := preload("res://addons/godot_ai/client_configurator.gd") -const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") - -## Emitted whenever the underlying WebSocket open/closed state flips. -## Subscribers (e.g. the plugin-side telemetry helper) use this to drain -## events that were enqueued before the socket was ready. Emitted with -## ``true`` on first OPEN per connect, ``false`` on transition to CLOSED -## (including ``disconnect_from_server()``). -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 handshake auth token (#690). Set by plugin.gd from the value -## it generated for the server spawn (also persisted in the managed-server -## editor-settings record so a reloaded plugin instance adopting the same -## server keeps sending it). Empty means "don't send the field" — servers -## we didn't spawn (dev servers, older servers) have no token to match. -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 := "" -## Consecutive post-OPEN closes with CLOSE_CODE_AUTH_TOKEN_MISMATCH. NOT -## reset by `_clear_on_disconnect` — the streak is counted exactly at the -## close events it exists to observe, across reconnect attempts. Reset on -## any other close code and on a successful `handshake_ack`. -var _auth_mismatch_closes := 0 -## Godot-AI Python package version reported by the server in its `handshake_ack` -## reply. Empty until the ack lands. Older servers (pre-handshake_ack) leave -## this empty forever — callers that gate on it (the dock's mismatch banner) -## must treat empty as "unknown, don't raise a false alarm". -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 - ## Symmetric inbound bump (#690): the server sends up to 4 MB - ## (websocket.py max_size), but Godot's inbound default is 64 KB — a - ## large script/text write or batch_execute payload used to overflow - ## the peer buffer, drop the frame, and surface as an opaque 5s - ## timeout + reconnect with no error naming the size. - _peer.inbound_buffer_size = OUTBOUND_BUFFER_LIMIT_BYTES - 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 - _reconnect_attempt = 0 - log_buffer.log("connected to server") - _send_handshake() - ## Reset the edge detectors so the next _check_state_changes - ## tick re-emits any non-default scene/play state — the - ## handshake carries readiness only, so without this a - ## (re)connected server never learns the current scene. - _last_scene_path = "" - _last_play_state = false - connection_state_changed.emit(true) - - _drain_inbound_packets(_peer) - - _check_state_changes() - - if dispatcher: - for response in dispatcher.tick(): - _send_json(response) - - WebSocketPeer.STATE_CLOSED: - if _connected: - _connected = false - ## 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() - var open_elapsed_sec := float(transition.get("previous_elapsed_sec", 0.0)) - var close_diagnostic := _note_post_open_close(code) - 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, - close_diagnostic, - ), - maxi(1, _reconnect_attempt), - true, - ) - 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 - - -func disconnect_from_server() -> void: - if _connected: - _peer.close(1000, "Plugin unloading") - _connected = false - ## This peer reached OPEN and is being closed deliberately, so neither - ## the post-OPEN nor pre-OPEN close diagnostic applies. Consume its one - ## diagnostic before the CLOSED tick observes the pre-cleared flag. - _preopen_failure_logged_for_peer = true - ## Pre-clearing _connected makes the STATE_CLOSED branch skip its - ## _clear_on_disconnect() — run it here so deliberate closes don't - ## leak the old server's version/deferred state into the next one. - _clear_on_disconnect() - connection_state_changed.emit(false) - - -## 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 = "" - ## 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 - ## Keep the reconnect peer symmetric with _ready()'s (#690). - _peer.inbound_buffer_size = OUTBOUND_BUFFER_LIMIT_BYTES - _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, - diagnostic: Dictionary = {} -) -> String: - var message := ( - "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] - ) - match str(diagnostic.get("recovery_action", "")): - "retry_authenticated": - message += " with the current auth token (rejection 1/%d)" % AUTH_MISMATCH_FALLBACK_CLOSES - "retry_tokenless": - message += " with a token-less handshake (rejection %d/%d)" % [ - int(diagnostic.get("occurrence", AUTH_MISMATCH_FALLBACK_CLOSES)), - AUTH_MISMATCH_FALLBACK_CLOSES, - ] - return message - - -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)] - - -## Token-mismatch fallback (#690 follow-up). The server's auth token is -## fixed for its whole launch, so redialing with the same wrong token can -## never succeed — without this the reconnect loop 4003s forever. The -## reproduced multi-editor failure: a duplicate spawn overwrites the shared -## managed-server record with its fresh token, dies unable to bind, and -## this editor is left holding a token the surviving server never saw. -## After AUTH_MISMATCH_FALLBACK_CLOSES consecutive rejections, drop to a -## token-less handshake, which the server accepts by design (older plugins -## and adopted servers have no token, and the field is attacker-omittable — -## see websocket.py; omitting it gives up no security). Scope note: only -## this connection's copy of the token is dropped — the plugin static and -## the persisted record heal via the startup walk's adoption arms. -func _note_post_open_close(code: int) -> Dictionary: - if code != CLOSE_CODE_AUTH_TOKEN_MISMATCH or auth_token.is_empty(): - _auth_mismatch_closes = 0 - return {} - _auth_mismatch_closes += 1 - var occurrence := _auth_mismatch_closes - if _auth_mismatch_closes < AUTH_MISMATCH_FALLBACK_CLOSES: - return { - "reason_code": "auth_token_mismatch", - "reason": "Server rejected the editor auth token; retrying once in case of a server-swap race.", - "occurrence": occurrence, - "recovery_action": "retry_authenticated", - } - auth_token = "" - _auth_mismatch_closes = 0 - return { - "reason_code": "auth_token_mismatch", - "reason": "Server rejected the editor auth token twice; the next handshake will omit it.", - "occurrence": occurrence, - "recovery_action": "retry_tokenless", - } - - -## 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 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_handshake() -> void: - _last_readiness = get_readiness() - _send_json(_build_handshake()) - - -## Split from _send_handshake so tests can assert the payload shape -## without a live WebSocket peer. -func _build_handshake() -> Dictionary: - var payload := { - "type": "handshake", - "session_id": _session_id, - "godot_version": Engine.get_version_info().get("string", "unknown"), - "project_path": ProjectSettings.globalize_path("res://"), - "plugin_version": ClientConfigurator.get_plugin_version(), - "protocol_version": 1, - "readiness": _last_readiness, - "editor_pid": OS.get_process_id(), - "server_launch_mode": ClientConfigurator.get_server_launch_mode(), - } - ## Omit rather than send "" — the server treats an ABSENT token as a - ## compat-accepted older plugin, but a PRESENT wrong one as hostile. - if not auth_token.is_empty(): - payload["auth_token"] = auth_token - return payload - - -## 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: -## "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 message: %s" % raw) - return {"kind": "ignore", "parsed": null} - if not (parsed is Dictionary): - return {"kind": "ignore", "parsed": null} - 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: - var classified := _classify_message(raw) - match classified["kind"]: - "ack": - _handle_handshake_ack(classified["parsed"]) - "command": - if dispatcher: - dispatcher.enqueue(classified["parsed"]) - "malformed_command": - _reply_malformed_command(classified["parsed"]) - - -func _handle_handshake_ack(parsed: Dictionary) -> void: - server_version = str(parsed.get("server_version", "")) - ## The server accepted our handshake — any token-mismatch streak is - ## over; a later unrelated 4003 starts a fresh one. - _auth_mismatch_closes = 0 - _transient_diagnostic.clear() - - -## 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 -## (20s ping interval / 20s timeout) 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"]: - "ack": - _handle_handshake_ack(classified["parsed"]) - "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) - - -## Playing→stopped edge for game-run bookkeeping. 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 not playing and debugger_plugin != null: - 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) -> bool: - if not _connected: - return false - var text := JSON.stringify(data) - 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 "@<4hex>" 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 suffix := _rand_hex(4) - 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 _rand_hex(n: int) -> String: - var bytes := PackedByteArray() - var byte_count := int(ceil(float(n) / 2.0)) - for i in byte_count: - bytes.append(randi() % 256) - return bytes.hex_encode().substr(0, n) diff --git a/addons/godot_ai/connection.gd.uid b/addons/godot_ai/connection.gd.uid deleted file mode 100644 index ff78405..0000000 --- a/addons/godot_ai/connection.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bmnk8rsotiks2 diff --git a/addons/godot_ai/custom_tools/custom_tool_wrapper.gd b/addons/godot_ai/custom_tools/custom_tool_wrapper.gd deleted file mode 100644 index 45ace63..0000000 --- a/addons/godot_ai/custom_tools/custom_tool_wrapper.gd +++ /dev/null @@ -1,66 +0,0 @@ -@tool -extends RefCounted - -## bridge between dispatcher and addon handler - -const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") - -var _spec: McpCustomToolSpec -var _locator: McpServiceLocator -var _handler_instance = null # lazily loaded from _spec.script_path - -func _init(spec: McpCustomToolSpec, locator: McpServiceLocator) -> void: - _spec = spec - _locator = locator - -## Invoked by dispatcher._call_handler as .call(params) — SINGLE ARG. -## Internally splits into (clean_params, ctx) for the addon handler. -func invoke(params: Dictionary) -> Dictionary: - ## Dock enable/disable gate: a disabled tool is dropped from the - ## catalog push, but a client holding a stale list (or batch_execute) - ## can still name it — reject at dispatch too. - var registry := McpToolRegistry.get_instance() - if registry != null and not registry.is_tool_enabled(_spec.name): - return ErrorCodes.make(ErrorCodes.CUSTOM_TOOL_DISABLED, - "Custom tool '%s' is disabled in the Godot AI dock" % _spec.name) - ## Readiness gate (https://github.com/hi-godot/godot-ai/issues/781#issuecomment-5036376599 #1): block writes during play/import. - ## Checked BEFORE the lazy load so a gated tool's handler script (and any - ## _init side effects) never runs while the editor is busy. - var _readiness := McpConnection.get_readiness() - if _spec.requires_writable and (_readiness == "importing" or _readiness == "playing"): - return ErrorCodes.make(ErrorCodes.EDITOR_NOT_READY, "Editor is '%s' — write blocked for custom tool '%s'" % [_readiness, _spec.name]) - if _handler_instance == null: - var script := load(_spec.script_path) as GDScript - if script == null: - return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Cannot load %s" % _spec.script_path) - _handler_instance = script.new() # no-arg; per-call context arrives via ctx - if not _handler_instance.has_method(_spec.method): - return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "%s has no method '%s' for custom tool '%s'" % [_spec.script_path, _spec.method, _spec.name]) - ## Extract _request_id (dispatcher injected it) and strip from params - ## so the addon sees clean params matching its declared schema (https://github.com/hi-godot/godot-ai/issues/781#issuecomment-5036376599 #2). - var request_id: String= params.get("_request_id", "") - var clean_params := params.duplicate() - clean_params.erase("_request_id") - ## Construct ctx with transport metadata + live-object locator. - var ctx := McpCallContext.new() - ctx.request_id = request_id - ctx.session_id = _locator.get_connection().get_session_id() - ctx.spec = _spec - ctx.attach_locator(_locator) - ctx.deadline_msec = Time.get_ticks_msec() + _spec.timeout_ms - var result: Dictionary = _handler_instance.call(_spec.method, clean_params, ctx) - if result.get("_deferred", false): - ## Enforce the declared contract: batch_execute and the server's - ## timeout budget both trust spec.deferred, so a non-deferred spec - ## whose handler defers anyway would report success while its real - ## reply arrives uncorrelated later. - if not _spec.deferred: - return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, - "Custom tool '%s' returned a deferred response but its spec declares deferred=false" % _spec.name) - if _spec.timeout_ms > 0: - ## Handlers return the SHARED McpDispatcher.DEFERRED_RESPONSE - ## const, which Godot makes read-only — stamping the per-spec - ## budget on it directly is a script error that aborts the call. - result = result.duplicate() - result["_deferred_timeout_ms"] = _spec.timeout_ms - return result diff --git a/addons/godot_ai/custom_tools/custom_tool_wrapper.gd.uid b/addons/godot_ai/custom_tools/custom_tool_wrapper.gd.uid deleted file mode 100644 index ff02cb9..0000000 --- a/addons/godot_ai/custom_tools/custom_tool_wrapper.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://b68uel6lsb7jr diff --git a/addons/godot_ai/custom_tools/mcp_call_context.gd b/addons/godot_ai/custom_tools/mcp_call_context.gd deleted file mode 100644 index 6e40d86..0000000 --- a/addons/godot_ai/custom_tools/mcp_call_context.gd +++ /dev/null @@ -1,44 +0,0 @@ -@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 deleted file mode 100644 index e37765b..0000000 --- a/addons/godot_ai/custom_tools/mcp_call_context.gd.uid +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index 8c5a551..0000000 --- a/addons/godot_ai/custom_tools/mcp_custom_tool_spec.gd +++ /dev/null @@ -1,66 +0,0 @@ -@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 deleted file mode 100644 index 42fe408..0000000 --- a/addons/godot_ai/custom_tools/mcp_custom_tool_spec.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://h7k8j5tpscwy diff --git a/addons/godot_ai/custom_tools/mcp_service_locator.gd b/addons/godot_ai/custom_tools/mcp_service_locator.gd deleted file mode 100644 index 4e50ac5..0000000 --- a/addons/godot_ai/custom_tools/mcp_service_locator.gd +++ /dev/null @@ -1,16 +0,0 @@ -@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 deleted file mode 100644 index 1815007..0000000 --- a/addons/godot_ai/custom_tools/mcp_service_locator.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dfvphmvo7en3i diff --git a/addons/godot_ai/custom_tools/mcp_tool_registry.gd b/addons/godot_ai/custom_tools/mcp_tool_registry.gd deleted file mode 100644 index 407d405..0000000 --- a/addons/godot_ai/custom_tools/mcp_tool_registry.gd +++ /dev/null @@ -1,277 +0,0 @@ -@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 deleted file mode 100644 index 23b9822..0000000 --- a/addons/godot_ai/custom_tools/mcp_tool_registry.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bq2btxtm8flhb diff --git a/addons/godot_ai/debugger/mcp_debugger_plugin.gd b/addons/godot_ai/debugger/mcp_debugger_plugin.gd deleted file mode 100644 index ceaacb9..0000000 --- a/addons/godot_ai/debugger/mcp_debugger_plugin.gd +++ /dev/null @@ -1,1579 +0,0 @@ -@tool -class_name McpDebuggerPlugin -extends EditorDebuggerPlugin - -const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.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 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 -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 _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, -) -> void: - _game_run_token += 1 - _game_run_active = true - _manual_run_armed = manual_armed - _game_ready = false - _ready_run_token = -1 - _game_session_id = -1 - 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: - run_id = _game_log_buffer.clear_for_new_run() - 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) - - -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." - ) - _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() - - -## 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: - return - end_game_run() - - -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]) - timer.timeout.connect(func() -> void: _on_break_scrape_tick(token, final)) - - -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": - if not _game_run_active: - if _log_buffer: - _log_buffer.log("[debug] ignored mcp:hello with no active game run") - return true - 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 - ## Boot beacon from the game-side autoload. Tells us the - ## game has registered its "mcp" capture and is safe to send - ## take_screenshot to — before this, Godot's debugger would - ## drop our message silently. - _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") - return true - "mcp:eval_liveness_response": - _on_eval_liveness_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 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: - ## #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) - - -## --- 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 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 deleted file mode 100644 index 1d5c148..0000000 --- a/addons/godot_ai/debugger/mcp_debugger_plugin.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bd1k63iye1bsl diff --git a/addons/godot_ai/dispatcher.gd b/addons/godot_ai/dispatcher.gd deleted file mode 100644 index c834e8e..0000000 --- a/addons/godot_ai/dispatcher.gd +++ /dev/null @@ -1,476 +0,0 @@ -@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 _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, - "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") - - -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) - -## 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 clear(), the dispatcher is inert. -func clear() -> void: - ## Stop lazy handlers before releasing the cache. Handler-owned polling - ## coroutines retain any in-flight worker and deferred-response connection - ## across frames, then join only after the worker is no longer alive. - for instance in _lazy_handler_cache.values(): - if is_instance_valid(instance) and instance.has_method("prepare_for_teardown"): - instance.call("prepare_for_teardown") - _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]: - 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 idx > 0: - _command_queue = _command_queue.slice(idx) - - 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 deleted file mode 100644 index 25a05bd..0000000 --- a/addons/godot_ai/dispatcher.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://ctldk7ivsoo3i diff --git a/addons/godot_ai/dock_panels/log_viewer.gd b/addons/godot_ai/dock_panels/log_viewer.gd deleted file mode 100644 index 1d7b986..0000000 --- a/addons/godot_ai/dock_panels/log_viewer.gd +++ /dev/null @@ -1,100 +0,0 @@ -@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. Emits -## `logging_enabled_changed` so the dock can route the flag onto the -## connection dispatcher without the panel knowing the routing exists. -## -## 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") -## Preload (not the McpSettings class_name) for consistency with the parse -## hazard note on `_log_buffer` below. -const Settings := preload("res://addons/godot_ai/utils/settings.gd") - -## Untyped: a `: McpLogBuffer` annotation hits the class_name registry at -## script-load and trips the self-update parse hazard (#398). The type fence -## stays on the `setup(log_buffer: McpLogBuffer)` parameter. -var _log_buffer -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(log_buffer: McpLogBuffer) -> void: - _log_buffer = log_buffer - 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) - - -## Called from McpDock._process when the panel is visible. Appends any new -## log lines since the last tick. -func tick() -> void: - if _log_buffer == null or _log_display == null: - return - var seq: int = _log_buffer.total_logged() - if seq == _last_log_seq: - return - if seq < _last_log_seq: - ## Buffer cleared via `McpLogBuffer.clear()` (the `clear_logs` MCP - ## tool / `logs_clear` handler). The buffer resets `_total_logged` - ## to 0, flipping the sequence backward. Without this branch the - ## display would keep showing pre-clear lines forever — the viewer - ## drifts permanently out of sync with the buffer. Reset display + - ## cursor so the next append paints over a clean slate. - _log_display.clear() - _last_log_seq = 0 - if seq == 0: - return - var new_lines: Array[String] = _log_buffer.get_recent(seq - _last_log_seq) - for line in new_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 deleted file mode 100644 index c261627..0000000 --- a/addons/godot_ai/dock_panels/log_viewer.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cr5nbnd6vj3b8 diff --git a/addons/godot_ai/dock_panels/port_picker_panel.gd b/addons/godot_ai/dock_panels/port_picker_panel.gd deleted file mode 100644 index 30c3ed3..0000000 --- a/addons/godot_ai/dock_panels/port_picker_panel.gd +++ /dev/null @@ -1,78 +0,0 @@ -@tool -extends VBoxContainer - -## Dock subpanel — port-change escape hatch surfaced inside the spawn-failure -## crash panel when the HTTP port is contested (PORT_EXCLUDED, FOREIGN_PORT). -## Emits `port_apply_requested(new_port)` after range-validation; the dock -## handles writing the EditorSetting and reloading 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") - -signal port_apply_requested(new_port: int) - -var _spinbox: SpinBox - - -## 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 = SpinBox.new() - _spinbox.min_value = ClientConfigurator.MIN_PORT - _spinbox.max_value = ClientConfigurator.MAX_PORT - _spinbox.step = 1 - _spinbox.value = ClientConfigurator.http_port() - _spinbox.size_flags_horizontal = Control.SIZE_EXPAND_FILL - picker_row.add_child(_spinbox) - - var apply_btn := Button.new() - apply_btn.text = "Apply + Reload" - apply_btn.tooltip_text = ( - "Saves godot_ai/http_port to Editor Settings and reloads the plugin so" - + " the server spawns on the new port." - ) - apply_btn.pressed.connect(_on_apply_pressed) - picker_row.add_child(apply_btn) - - add_child(picker_row) - - -## Re-seed the spinbox with a fresh suggestion every time the panel surfaces, -## so a stale value from a previous spawn-failure round can't carry over. Note -## that this OVERWRITES any unsaved user input — fine in practice because the -## dock's `_update_crash_panel` only calls this on `server_status` transitions -## (`if server_status == _last_server_status: return` short-circuit), so a -## user typing into the spinbox between transitions keeps their value. If the -## state flips while the picker is visible (e.g. `PORT_EXCLUDED` → `FOREIGN_PORT`), -## the in-flight edit is clobbered — accept that, the suggestion is more current. -func seed_suggested_port() -> void: - if _spinbox == null: - return - _spinbox.value = ClientConfigurator.suggest_free_port( - ClientConfigurator.http_port() + 1 - ) - - -func _on_apply_pressed() -> void: - var new_port: int = int(_spinbox.value) - if new_port < ClientConfigurator.MIN_PORT or new_port > ClientConfigurator.MAX_PORT: - return - port_apply_requested.emit(new_port) diff --git a/addons/godot_ai/dock_panels/port_picker_panel.gd.uid b/addons/godot_ai/dock_panels/port_picker_panel.gd.uid deleted file mode 100644 index 38b6320..0000000 --- a/addons/godot_ai/dock_panels/port_picker_panel.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://hlggbo1q65eq diff --git a/addons/godot_ai/export/mcp_export_plugin.gd b/addons/godot_ai/export/mcp_export_plugin.gd deleted file mode 100644 index 5a8b885..0000000 --- a/addons/godot_ai/export/mcp_export_plugin.gd +++ /dev/null @@ -1,69 +0,0 @@ -@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 deleted file mode 100644 index f2a3634..0000000 --- a/addons/godot_ai/export/mcp_export_plugin.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://np0cp6fwpim7 diff --git a/addons/godot_ai/handlers/_node_validator.gd b/addons/godot_ai/handlers/_node_validator.gd deleted file mode 100644 index 61bbe10..0000000 --- a/addons/godot_ai/handlers/_node_validator.gd +++ /dev/null @@ -1,71 +0,0 @@ -@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 deleted file mode 100644 index b3153a6..0000000 --- a/addons/godot_ai/handlers/_node_validator.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dn75jifad0ghx diff --git a/addons/godot_ai/handlers/_param_validators.gd b/addons/godot_ai/handlers/_param_validators.gd deleted file mode 100644 index a316b32..0000000 --- a/addons/godot_ai/handlers/_param_validators.gd +++ /dev/null @@ -1,30 +0,0 @@ -@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 deleted file mode 100644 index cd2061e..0000000 --- a/addons/godot_ai/handlers/_param_validators.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://difa877m8dsla diff --git a/addons/godot_ai/handlers/_property_errors.gd b/addons/godot_ai/handlers/_property_errors.gd deleted file mode 100644 index 316b941..0000000 --- a/addons/godot_ai/handlers/_property_errors.gd +++ /dev/null @@ -1,82 +0,0 @@ -@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 deleted file mode 100644 index c29d21c..0000000 --- a/addons/godot_ai/handlers/_property_errors.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://c74d560g4l86b diff --git a/addons/godot_ai/handlers/animation_handler.gd b/addons/godot_ai/handlers/animation_handler.gd deleted file mode 100644 index 8000b78..0000000 --- a/addons/godot_ai/handlers/animation_handler.gd +++ /dev/null @@ -1,825 +0,0 @@ -@tool -extends RefCounted - -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 deleted file mode 100644 index 934f665..0000000 --- a/addons/godot_ai/handlers/animation_handler.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://c0jrius46xsd4 diff --git a/addons/godot_ai/handlers/animation_presets.gd b/addons/godot_ai/handlers/animation_presets.gd deleted file mode 100644 index 40f1ddc..0000000 --- a/addons/godot_ai/handlers/animation_presets.gd +++ /dev/null @@ -1,536 +0,0 @@ -@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 deleted file mode 100644 index f463501..0000000 --- a/addons/godot_ai/handlers/animation_presets.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://c4s3h78bwvr6w diff --git a/addons/godot_ai/handlers/animation_values.gd b/addons/godot_ai/handlers/animation_values.gd deleted file mode 100644 index b1553df..0000000 --- a/addons/godot_ai/handlers/animation_values.gd +++ /dev/null @@ -1,442 +0,0 @@ -@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: - if value is int or value is float: - return {"ok": float(value)} - 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 deleted file mode 100644 index 5d2a8b7..0000000 --- a/addons/godot_ai/handlers/animation_values.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bguta2eb8blgf diff --git a/addons/godot_ai/handlers/api_handler.gd b/addons/godot_ai/handlers/api_handler.gd deleted file mode 100644 index acfeda5..0000000 --- a/addons/godot_ai/handlers/api_handler.gd +++ /dev/null @@ -1,89 +0,0 @@ -@tool -extends RefCounted - -## 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 deleted file mode 100644 index f519c48..0000000 --- a/addons/godot_ai/handlers/api_handler.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://v3rkd7ueunii diff --git a/addons/godot_ai/handlers/audio_handler.gd b/addons/godot_ai/handlers/audio_handler.gd deleted file mode 100644 index 79dea56..0000000 --- a/addons/godot_ai/handlers/audio_handler.gd +++ /dev/null @@ -1,361 +0,0 @@ -@tool -extends RefCounted - -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 is allowed -## so JSON integers 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: - if value is float or value is int: - return float(value) - 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 deleted file mode 100644 index 2510ee7..0000000 --- a/addons/godot_ai/handlers/audio_handler.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cjtvod52xxocs diff --git a/addons/godot_ai/handlers/autoload_handler.gd b/addons/godot_ai/handlers/autoload_handler.gd deleted file mode 100644 index 22e7764..0000000 --- a/addons/godot_ai/handlers/autoload_handler.gd +++ /dev/null @@ -1,91 +0,0 @@ -@tool -extends RefCounted - -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 deleted file mode 100644 index 921ed4e..0000000 --- a/addons/godot_ai/handlers/autoload_handler.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bb0inov044jn6 diff --git a/addons/godot_ai/handlers/batch_handler.gd b/addons/godot_ai/handlers/batch_handler.gd deleted file mode 100644 index c6c1474..0000000 --- a/addons/godot_ai/handlers/batch_handler.gd +++ /dev/null @@ -1,190 +0,0 @@ -@tool -extends RefCounted - -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. -## - 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 := { - "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", -} - -## 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 - # Captured after the first successful commit — get_history_undo_redo() - # errors if called before any action exists in the history_map. - var histories: 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 raw_result: Dictionary = _dispatcher.dispatch_direct(cmd_name, sub_params) - 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 - _capture_histories(histories) - - var rolled_back := false - if stopped_at != null and undo and succeeded > 0: - rolled_back = _rollback(succeeded, histories) - - 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} - - -## Capture the scene's UndoRedo reference for batch rollback. Safe to call -## multiple times; appends only the new reference. MCP write handlers all pin -## their actions to the scene history, so the scene UndoRedo is the only one -## rollback needs. Must be called only after at least one action has been -## committed to the scene history. -func _capture_histories(histories: Array) -> void: - var scene_root := EditorInterface.get_edited_scene_root() - if scene_root == null: - return - 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 and not scene_ur in histories: - histories.append(scene_ur) - - -## 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 `count` actions by calling undo() on captured histories in LIFO order. -## Returns true iff all undo calls succeeded. -func _rollback(count: int, histories: Array) -> bool: - if histories.is_empty(): - return false - for _i in range(count): - var undone := false - for ur in histories: - if ur.undo(): - undone = true - break - if not undone: - return false - return true diff --git a/addons/godot_ai/handlers/batch_handler.gd.uid b/addons/godot_ai/handlers/batch_handler.gd.uid deleted file mode 100644 index 630a596..0000000 --- a/addons/godot_ai/handlers/batch_handler.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dt7um75oofdrh diff --git a/addons/godot_ai/handlers/camera_handler.gd b/addons/godot_ai/handlers/camera_handler.gd deleted file mode 100644 index fb986e5..0000000 --- a/addons/godot_ai/handlers/camera_handler.gd +++ /dev/null @@ -1,1145 +0,0 @@ -@tool -extends RefCounted - -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 plus bounded sync -# settling when the viewport hasn't yet reflected the change — headless CI -# occasionally reports `is_current() == false` immediately after a committed -# make_current (observed CI run 24682342469) and symmetrically still reports -# the displaced camera as current immediately after an undo (observed CI runs -# 24682342469, 24692250322, 24696571517, 25079965242 — tracked in #140). -# Later #278 runs broadened the same current-camera timing flake across more -# platforms and assertions, so the settle budget is deliberately above one -# fast local frame. -# -# 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` with bounded synchronous settling. Registered as the -# do/undo callable by `_add_make_current_to_action`. See that function's -# comment for why the undo path needs the retry inside the action itself. -# Safe against a freed camera node — short-circuits if the node is gone -# or not in the tree. -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) - for attempt in range(_CURRENT_SETTLE_ATTEMPTS): - cam.make_current() - _force_camera_refresh(cam) - # Godot's make_current is supposed to atomically displace siblings, - # but on macOS headless the displaced camera occasionally still - # answers is_current() == true after this returns (#140 / #278 / #301). - # Sweep same-class siblings and clear any that lag. - _force_clear_other_currents(cam, type_str, scene_root) - if not _is_current_settled(cam): - _displace_stale_camera_2d(cam) - _force_clear_other_currents(cam, type_str, scene_root) - var waited_this_attempt := false - if _is_current_settled(cam): - if not (cam is Camera2D): - return - OS.delay_msec(_CURRENT_SETTLE_DELAY_MSEC) - waited_this_attempt = true - _force_camera_refresh(cam) - _force_clear_other_currents(cam, type_str, scene_root) - if _is_current_settled(cam): - return - if attempt < _CURRENT_SETTLE_ATTEMPTS - 1 and not waited_this_attempt: - OS.delay_msec(_CURRENT_SETTLE_DELAY_MSEC) - - -# 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 - - -func _displace_stale_camera_2d(target: Node) -> void: - if not (target is Camera2D): - return - var viewport := target.get_viewport() - if viewport == null: - return - var stale := viewport.get_camera_2d() - if stale == null or stale == target or not is_instance_valid(stale): - _nudge_camera_2d_current(target) - return - var was_enabled := stale.enabled - if was_enabled: - stale.enabled = false - target.make_current() - _force_camera_refresh(target) - if was_enabled: - stale.enabled = true - target.make_current() - _force_camera_refresh(target) - - -func _nudge_camera_2d_current(target: Node) -> void: - if not (target is Camera2D): - return - var cam := target as Camera2D - if not cam.enabled: - return - cam.enabled = false - _force_camera_refresh(cam) - cam.enabled = true - cam.make_current() - _force_camera_refresh(cam) - - -# 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 deleted file mode 100644 index 86d2aae..0000000 --- a/addons/godot_ai/handlers/camera_handler.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://c0lcviccrlrl8 diff --git a/addons/godot_ai/handlers/camera_presets.gd b/addons/godot_ai/handlers/camera_presets.gd deleted file mode 100644 index a1922c2..0000000 --- a/addons/godot_ai/handlers/camera_presets.gd +++ /dev/null @@ -1,81 +0,0 @@ -@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 deleted file mode 100644 index 9f9f839..0000000 --- a/addons/godot_ai/handlers/camera_presets.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bl3rfy72o3wy5 diff --git a/addons/godot_ai/handlers/camera_values.gd b/addons/godot_ai/handlers/camera_values.gd deleted file mode 100644 index e3c8dea..0000000 --- a/addons/godot_ai/handlers/camera_values.gd +++ /dev/null @@ -1,132 +0,0 @@ -@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: - if value is float: - return {"ok": true, "value": value} - if value is int: - return {"ok": true, "value": float(value)} - 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 deleted file mode 100644 index 45e3c09..0000000 --- a/addons/godot_ai/handlers/camera_values.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bgjnubgnv6ses diff --git a/addons/godot_ai/handlers/client_handler.gd b/addons/godot_ai/handlers/client_handler.gd deleted file mode 100644 index 40e019f..0000000 --- a/addons/godot_ai/handlers/client_handler.gd +++ /dev/null @@ -1,127 +0,0 @@ -@tool -extends RefCounted - -const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") - -## Handles MCP client configuration commands. - -var _connection -var _fallback_launch_context := {} -var _status_workers: Array[Thread] = [] -var _status_tearing_down := false - - -func _init(connection = null, fallback_launch_context = null) -> void: - _connection = connection - # Lazy-loading this handler can reload ClientConfigurator's static script and - # clear its warmed snapshot. Retain the plugin-start capture as a safe - # fallback; the worker still prefers capture_launch_context()'s live snapshot. - if fallback_launch_context is Dictionary: - _fallback_launch_context = fallback_launch_context.duplicate(true) - - -func configure_client(params: Dictionary) -> 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 result := McpClientConfigurator.configure(client_id) - if result.get("status") == "error": - return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, - result.get("message", "Configuration failed for '%s'" % client_id)) - return {"data": result} - - -func remove_client(params: Dictionary) -> 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 result := McpClientConfigurator.remove(client_id) - if result.get("status") == "error": - return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, - result.get("message", "Removal failed for '%s'" % client_id)) - return {"data": result} - - -func check_client_status(params: Dictionary) -> Dictionary: - var request_id: String = params.get("_request_id", "") - if _connection == null or request_id.is_empty(): - return ErrorCodes.make( - ErrorCodes.INTERNAL_ERROR, - "Client status requires a deferred request context.", - ) - if _status_tearing_down: - return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Client status handler is shutting down.") - # Match the dock refresh worker's cold-load guard. This is pure-memory and - # performs no launcher discovery, CLI lookup, or config/status probe. - McpClientConfigurator.warm_status_worker_bytecode() - # Refresh cwd/project roots on main before the status worker starts while - # preserving the startup launch snapshot for the deferred worker. - var status_context := _fallback_launch_context.duplicate(true) - status_context["project_roots"] = McpClientConfigurator.capture_project_roots() - # The aggregate command has a 30-second budget. Its worker resolves the - # shared Claude Desktop/Codex attach launch once, then reuses it for every - # command-shaped client instead of repeating cold launcher discovery. - # Start the worker from the deferred finisher after its first frame. That - # lets the dispatcher register this request before any probe can complete. - _finish_client_status_deferred( - McpClientConfigurator.run_client_status_sweep.bind(status_context), - request_id, - _connection, - ) - return McpDispatcher.DEFERRED_RESPONSE - - -## Called by McpDispatcher.clear() before releasing this lazy handler. Marking -## teardown is deliberately non-blocking: the in-flight coroutine retains this -## handler and its connection across frames, then joins each worker only after -## is_alive() becomes false. New sweeps are rejected immediately. -func prepare_for_teardown() -> void: - _status_tearing_down = true - - -## This instance coroutine intentionally keeps the lazily-created handler and -## deferred-response connection alive until its worker has been polled and -## joined. The first-frame yield is load-bearing: check_client_status() must -## return the deferred sentinel before the worker can produce a response. -func _finish_client_status_deferred( - worker_callable: Callable, request_id: String, connection -) -> void: - if not is_instance_valid(connection): - return - var tree: SceneTree = connection.get_tree() - if tree == null: - return - await tree.process_frame - if not is_instance_valid(connection) or _status_tearing_down: - return - var worker := Thread.new() - _status_workers.append(worker) - var start_error := worker.start(worker_callable) - if start_error != OK: - _status_workers.erase(worker) - connection.send_deferred_response(request_id, ErrorCodes.make( - ErrorCodes.INTERNAL_ERROR, - "Could not start client status worker (error %d)." % start_error, - )) - return - while worker.is_alive(): - await tree.process_frame - var payload: Variant = worker.wait_to_finish() - _status_workers.erase(worker) - if _status_tearing_down: - return - if not is_instance_valid(connection): - return - if not payload is Dictionary: - payload = ErrorCodes.make( - ErrorCodes.INTERNAL_ERROR, - "Client status worker returned an invalid response.", - ) - elif payload.has("worker_error"): - payload = ErrorCodes.make( - ErrorCodes.INTERNAL_ERROR, - str(payload.get("worker_error", "Client status worker failed.")), - ) - connection.send_deferred_response(request_id, payload) diff --git a/addons/godot_ai/handlers/client_handler.gd.uid b/addons/godot_ai/handlers/client_handler.gd.uid deleted file mode 100644 index 25687f5..0000000 --- a/addons/godot_ai/handlers/client_handler.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bmo4foc5fq75c diff --git a/addons/godot_ai/handlers/control_draw_recipe_handler.gd b/addons/godot_ai/handlers/control_draw_recipe_handler.gd deleted file mode 100644 index 29e65be..0000000 --- a/addons/godot_ai/handlers/control_draw_recipe_handler.gd +++ /dev/null @@ -1,318 +0,0 @@ -@tool -extends RefCounted - -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 deleted file mode 100644 index da0aaf9..0000000 --- a/addons/godot_ai/handlers/control_draw_recipe_handler.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://buat1mt0fjlqb diff --git a/addons/godot_ai/handlers/csg_handler.gd b/addons/godot_ai/handlers/csg_handler.gd deleted file mode 100644 index 6380a9a..0000000 --- a/addons/godot_ai/handlers/csg_handler.gd +++ /dev/null @@ -1,118 +0,0 @@ -@tool -extends RefCounted - -## 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 deleted file mode 100644 index 0f7b976..0000000 --- a/addons/godot_ai/handlers/csg_handler.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://c4mb0tptu52x2 diff --git a/addons/godot_ai/handlers/curve_handler.gd b/addons/godot_ai/handlers/curve_handler.gd deleted file mode 100644 index e21c47a..0000000 --- a/addons/godot_ai/handlers/curve_handler.gd +++ /dev/null @@ -1,243 +0,0 @@ -@tool -extends RefCounted - -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 deleted file mode 100644 index 8eb5b25..0000000 --- a/addons/godot_ai/handlers/curve_handler.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dboqr06a1fvqx diff --git a/addons/godot_ai/handlers/editor_handler.gd b/addons/godot_ai/handlers/editor_handler.gd deleted file mode 100644 index 8516eed..0000000 --- a/addons/godot_ai/handlers/editor_handler.gd +++ /dev/null @@ -1,1060 +0,0 @@ -@tool -extends RefCounted - -const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") -const Telemetry := preload("res://addons/godot_ai/telemetry.gd") -const VisionRoutingScript := preload("res://addons/godot_ai/vision_routing.gd") - -## Handles editor state, selection, log, screenshot, and performance commands. - -const UpdateMixedState := preload("res://addons/godot_ai/utils/update_mixed_state.gd") - -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)), - } - ## Half-installed addon tree from a failed self-update rollback. When - ## non-empty, the agent / dock paint the operator-facing recovery copy - ## from `update_mixed_state.gd::diagnose`. Field omitted when the - ## addons tree is clean so editor_state's normal payload stays small. - ## See issue #354 / audit-v2 #10. - var mixed_state := UpdateMixedState.diagnose() - if not mixed_state.is_empty(): - data["mixed_state"] = mixed_state - 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 and gated on Godot 4.5+; on older - ## engines the buffer can 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: - _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() - 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. -# `static` is load-bearing: the deferred coroutine captures no `self`, so -# it survives even if the EditorHandler RefCounted is freed mid-await — -# which is exactly what reload does to this handler's owner. An instance -# coroutine here resumes on a freed object under reload churn. -static func _do_reload_plugin() -> void: - var fs := EditorInterface.get_resource_filesystem() - fs.scan() - var tree := Engine.get_main_loop() as SceneTree - # Cap the wait so a long scan (huge project) doesn't hang reload. - var deadline_ms := Time.get_ticks_msec() + 5000 - while fs.is_scanning() and Time.get_ticks_msec() < deadline_ms: - await tree.process_frame - EditorInterface.set_plugin_enabled("res://addons/godot_ai/plugin.cfg", false) - EditorInterface.set_plugin_enabled("res://addons/godot_ai/plugin.cfg", true) - - -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_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 deleted file mode 100644 index 16d785f..0000000 --- a/addons/godot_ai/handlers/editor_handler.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dcro7yc8bor6v diff --git a/addons/godot_ai/handlers/environment_handler.gd b/addons/godot_ai/handlers/environment_handler.gd deleted file mode 100644 index 6729f1e..0000000 --- a/addons/godot_ai/handlers/environment_handler.gd +++ /dev/null @@ -1,181 +0,0 @@ -@tool -extends RefCounted - -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 deleted file mode 100644 index f495f8f..0000000 --- a/addons/godot_ai/handlers/environment_handler.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://b1k7jldwjp5jt diff --git a/addons/godot_ai/handlers/filesystem_handler.gd b/addons/godot_ai/handlers/filesystem_handler.gd deleted file mode 100644 index b17ccc9..0000000 --- a/addons/godot_ai/handlers/filesystem_handler.gd +++ /dev/null @@ -1,312 +0,0 @@ -@tool -extends RefCounted - -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: - 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 deleted file mode 100644 index 94135a4..0000000 --- a/addons/godot_ai/handlers/filesystem_handler.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://c7ovtpdiumtju diff --git a/addons/godot_ai/handlers/gridmap_handler.gd b/addons/godot_ai/handlers/gridmap_handler.gd deleted file mode 100644 index 75016e4..0000000 --- a/addons/godot_ai/handlers/gridmap_handler.gd +++ /dev/null @@ -1,192 +0,0 @@ -@tool -extends RefCounted - -## 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 deleted file mode 100644 index 35bb1d0..0000000 --- a/addons/godot_ai/handlers/gridmap_handler.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cdv1yrjeyo5es diff --git a/addons/godot_ai/handlers/input_handler.gd b/addons/godot_ai/handlers/input_handler.gd deleted file mode 100644 index 78ddd22..0000000 --- a/addons/godot_ai/handlers/input_handler.gd +++ /dev/null @@ -1,462 +0,0 @@ -@tool -extends RefCounted - -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 deleted file mode 100644 index 7d509f4..0000000 --- a/addons/godot_ai/handlers/input_handler.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://buk68rbwssqwp diff --git a/addons/godot_ai/handlers/material_handler.gd b/addons/godot_ai/handlers/material_handler.gd deleted file mode 100644 index d8a4981..0000000 --- a/addons/godot_ai/handlers/material_handler.gd +++ /dev/null @@ -1,809 +0,0 @@ -@tool -extends RefCounted - -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 deleted file mode 100644 index fdf789a..0000000 --- a/addons/godot_ai/handlers/material_handler.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://blh4norn3rjga diff --git a/addons/godot_ai/handlers/material_presets.gd b/addons/godot_ai/handlers/material_presets.gd deleted file mode 100644 index db48036..0000000 --- a/addons/godot_ai/handlers/material_presets.gd +++ /dev/null @@ -1,92 +0,0 @@ -@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 deleted file mode 100644 index 2f46051..0000000 --- a/addons/godot_ai/handlers/material_presets.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bnuwye1r8ow7g diff --git a/addons/godot_ai/handlers/material_values.gd b/addons/godot_ai/handlers/material_values.gd deleted file mode 100644 index d6a7f1d..0000000 --- a/addons/godot_ai/handlers/material_values.gd +++ /dev/null @@ -1,194 +0,0 @@ -@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: - if value is float: - return {"ok": true, "value": value} - if value is int: - return {"ok": true, "value": float(value)} - 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 deleted file mode 100644 index 7f8bbe9..0000000 --- a/addons/godot_ai/handlers/material_values.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://daqgjkflia8nk diff --git a/addons/godot_ai/handlers/node_handler.gd b/addons/godot_ai/handlers/node_handler.gd deleted file mode 100644 index 6d7e1ff..0000000 --- a/addons/godot_ai/handlers/node_handler.gd +++ /dev/null @@ -1,1390 +0,0 @@ -@tool -extends RefCounted - -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() - - _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) - _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) - _undo_redo.add_undo_reference(node) - _undo_redo.commit_action() - - # Re-set owner for all descendants (reparent can break ownership chain) - _set_owner_recursive(node, scene_root) - - 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 - - _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) - _undo_redo.add_do_reference(dup) - _undo_redo.add_undo_method(parent, "remove_child", dup) - _undo_redo.commit_action() - - # Set owner for all descendants of the duplicate - _set_owner_recursive(dup, scene_root) - - 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", - } - } - - -func _set_owner_recursive(node: Node, owner: Node) -> void: - for child in node.get_children(): - child.set_owner(owner) - _set_owner_recursive(child, owner) - - -## 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) - 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 deleted file mode 100644 index 86149b4..0000000 --- a/addons/godot_ai/handlers/node_handler.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://qhhd5mm5awym diff --git a/addons/godot_ai/handlers/particle_handler.gd b/addons/godot_ai/handlers/particle_handler.gd deleted file mode 100644 index 5604355..0000000 --- a/addons/godot_ai/handlers/particle_handler.gd +++ /dev/null @@ -1,860 +0,0 @@ -@tool -extends RefCounted - -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 deleted file mode 100644 index ead3722..0000000 --- a/addons/godot_ai/handlers/particle_handler.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://byfc0pnyb5qww diff --git a/addons/godot_ai/handlers/particle_presets.gd b/addons/godot_ai/handlers/particle_presets.gd deleted file mode 100644 index 3bbf1f4..0000000 --- a/addons/godot_ai/handlers/particle_presets.gd +++ /dev/null @@ -1,293 +0,0 @@ -@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 deleted file mode 100644 index 7c1aac9..0000000 --- a/addons/godot_ai/handlers/particle_presets.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bss2ccpmsxo4p diff --git a/addons/godot_ai/handlers/particle_values.gd b/addons/godot_ai/handlers/particle_values.gd deleted file mode 100644 index 66782f6..0000000 --- a/addons/godot_ai/handlers/particle_values.gd +++ /dev/null @@ -1,246 +0,0 @@ -@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 deleted file mode 100644 index 71f6adc..0000000 --- a/addons/godot_ai/handlers/particle_values.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bnnnjq06dmclc diff --git a/addons/godot_ai/handlers/physics_shape_handler.gd b/addons/godot_ai/handlers/physics_shape_handler.gd deleted file mode 100644 index dc5156c..0000000 --- a/addons/godot_ai/handlers/physics_shape_handler.gd +++ /dev/null @@ -1,338 +0,0 @@ -@tool -extends RefCounted - -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 - - -func _init(undo_redo: EditorUndoRedoManager) -> void: - _undo_redo = undo_redo - - -const _SHAPE_3D_CLASSES := { - "box": "BoxShape3D", - "sphere": "SphereShape3D", - "capsule": "CapsuleShape3D", - "cylinder": "CylinderShape3D", -} - -const _SHAPE_2D_CLASSES := { - "rectangle": "RectangleShape2D", - "circle": "CircleShape2D", - "capsule": "CapsuleShape2D", -} - - -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 shape_type: String = params.get("shape_type", "box" if is_3d else "rectangle") - var type_map := _SHAPE_3D_CLASSES if is_3d else _SHAPE_2D_CLASSES - # 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. - if not type_map.has(shape_type): - for short_form in type_map: - if type_map[short_form] == shape_type: - shape_type = short_form - break - if not type_map.has(shape_type): - 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" % [shape_type, 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; pre-multiply by the source's scale - # so the collider tracks what you actually see in the viewport. - var scale_3d: Vector3 = (source as Node3D).transform.basis.get_scale() - aabb.position = aabb.position * scale_3d - aabb.size = aabb.size * scale_3d - return {"aabb": 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 deleted file mode 100644 index 09acb4a..0000000 --- a/addons/godot_ai/handlers/physics_shape_handler.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cdg8kthqla1cj diff --git a/addons/godot_ai/handlers/project_handler.gd b/addons/godot_ai/handlers/project_handler.gd deleted file mode 100644 index eb7d36f..0000000 --- a/addons/godot_ai/handlers/project_handler.gd +++ /dev/null @@ -1,572 +0,0 @@ -@tool -extends RefCounted - -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. Autoloads have a validated route of their -## own (`autoload_handler.add_autoload`, which runs the path through -## `McpPathValidator`); the rest are refused outright because there is no -## legitimate agent workflow for repointing the engine's startup execution. -## -## 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 -] - - -## 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: - return ( - "Refusing to set '%s' — this key controls what the engine loads " % key - + "or executes at project startup." - ) - 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", - } - } - - -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 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 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 deleted file mode 100644 index ec2b5d7..0000000 --- a/addons/godot_ai/handlers/project_handler.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://brf8u32hvha68 diff --git a/addons/godot_ai/handlers/resource_handler.gd b/addons/godot_ai/handlers/resource_handler.gd deleted file mode 100644 index 553ed9b..0000000 --- a/addons/godot_ai/handlers/resource_handler.gd +++ /dev/null @@ -1,591 +0,0 @@ -@tool -extends RefCounted - -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 deleted file mode 100644 index d79d3cf..0000000 --- a/addons/godot_ai/handlers/resource_handler.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dwwd0n3c56ir diff --git a/addons/godot_ai/handlers/scene_handler.gd b/addons/godot_ai/handlers/scene_handler.gd deleted file mode 100644 index 1c1f2b4..0000000 --- a/addons/godot_ai/handlers/scene_handler.gd +++ /dev/null @@ -1,420 +0,0 @@ -@tool -extends RefCounted - -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: - 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 deleted file mode 100644 index af3711f..0000000 --- a/addons/godot_ai/handlers/scene_handler.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://7ms40gm6t2r4 diff --git a/addons/godot_ai/handlers/script_handler.gd b/addons/godot_ai/handlers/script_handler.gd deleted file mode 100644 index 698b462..0000000 --- a/addons/godot_ai/handlers/script_handler.gd +++ /dev/null @@ -1,501 +0,0 @@ -@tool -extends RefCounted - -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." - ) - - # 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. - var efs := EditorInterface.get_resource_filesystem() - if efs != null: - efs.update_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 - - -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) - - # Single-file register, not a full scan() — see create_script (dsarno/godot#6). - var efs := EditorInterface.get_resource_filesystem() - if efs != null: - efs.update_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 deleted file mode 100644 index 6be1ac4..0000000 --- a/addons/godot_ai/handlers/script_handler.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dhub87454jxb3 diff --git a/addons/godot_ai/handlers/signal_handler.gd b/addons/godot_ai/handlers/signal_handler.gd deleted file mode 100644 index ad85c7d..0000000 --- a/addons/godot_ai/handlers/signal_handler.gd +++ /dev/null @@ -1,274 +0,0 @@ -@tool -extends RefCounted - -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 deleted file mode 100644 index d95e1d3..0000000 --- a/addons/godot_ai/handlers/signal_handler.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://b4n8byjeqeddm diff --git a/addons/godot_ai/handlers/test_handler.gd b/addons/godot_ai/handlers/test_handler.gd deleted file mode 100644 index 2c0f4ea..0000000 --- a/addons/godot_ai/handlers/test_handler.gd +++ /dev/null @@ -1,349 +0,0 @@ -@tool -extends RefCounted - -## 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") - -## 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} - ## 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), - } - 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: - 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, - "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) - return {"data": _runner.get_results(verbose)} - - -## 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: - errors.append("%s (cannot instantiate — abstract or broken)" % file_name) - 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 deleted file mode 100644 index e56fce1..0000000 --- a/addons/godot_ai/handlers/test_handler.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bfg3c6iinhwmx diff --git a/addons/godot_ai/handlers/texture_handler.gd b/addons/godot_ai/handlers/texture_handler.gd deleted file mode 100644 index 4e90d18..0000000 --- a/addons/godot_ai/handlers/texture_handler.gd +++ /dev/null @@ -1,199 +0,0 @@ -@tool -extends RefCounted - -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 deleted file mode 100644 index a0a0f73..0000000 --- a/addons/godot_ai/handlers/texture_handler.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cmloikhre8lhe diff --git a/addons/godot_ai/handlers/theme_handler.gd b/addons/godot_ai/handlers/theme_handler.gd deleted file mode 100644 index a71c745..0000000 --- a/addons/godot_ai/handlers/theme_handler.gd +++ /dev/null @@ -1,476 +0,0 @@ -@tool -extends RefCounted - -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 deleted file mode 100644 index b77af13..0000000 --- a/addons/godot_ai/handlers/theme_handler.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://gjyldaddj7mu diff --git a/addons/godot_ai/handlers/tilemap_handler.gd b/addons/godot_ai/handlers/tilemap_handler.gd deleted file mode 100644 index ac68e34..0000000 --- a/addons/godot_ai/handlers/tilemap_handler.gd +++ /dev/null @@ -1,163 +0,0 @@ -@tool -extends RefCounted - -## 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 deleted file mode 100644 index 8cbed53..0000000 --- a/addons/godot_ai/handlers/tilemap_handler.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cm8s7a0ey2q6k diff --git a/addons/godot_ai/handlers/tileset_handler.gd b/addons/godot_ai/handlers/tileset_handler.gd deleted file mode 100644 index a443b01..0000000 --- a/addons/godot_ai/handlers/tileset_handler.gd +++ /dev/null @@ -1,178 +0,0 @@ -@tool -extends RefCounted - -## 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 deleted file mode 100644 index 643cfd3..0000000 --- a/addons/godot_ai/handlers/tileset_handler.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://de3v4m1pnk7tr diff --git a/addons/godot_ai/handlers/ui_handler.gd b/addons/godot_ai/handlers/ui_handler.gd deleted file mode 100644 index 9795097..0000000 --- a/addons/godot_ai/handlers/ui_handler.gd +++ /dev/null @@ -1,525 +0,0 @@ -@tool -extends RefCounted - -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 deleted file mode 100644 index f48f841..0000000 --- a/addons/godot_ai/handlers/ui_handler.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://ckm6f1objpgvw diff --git a/addons/godot_ai/mcp_dock.gd b/addons/godot_ai/mcp_dock.gd deleted file mode 100644 index f598837..0000000 --- a/addons/godot_ai/mcp_dock.gd +++ /dev/null @@ -1,3559 +0,0 @@ -@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. -## -## A future refactor probably wants extract-by-concern instead — e.g. -## `utils/mcp_async_refresh_state_machine.gd` owning the IDLE → RUNNING → -## RUNNING_TIMED_OUT → DEFERRED_FOR_FILESYSTEM → SHUTTING_DOWN transitions -## and pending-flag triplet, `utils/mcp_client_action_dispatcher.gd` owning -## the per-row Configure/Remove worker pool. The dock would keep UI -## construction and lose the state-machine ownership. See issue #360. - -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 Telemetry := preload("res://addons/godot_ai/telemetry.gd") -const UpdateManagerScript := preload("res://addons/godot_ai/utils/update_manager.gd") -const UpdateMixedStateScript := preload("res://addons/godot_ai/utils/update_mixed_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 JsonStrategy := preload("res://addons/godot_ai/clients/_json_strategy.gd") -const TomlStrategy := preload("res://addons/godot_ai/clients/_toml_strategy.gd") -const CliStrategy := preload("res://addons/godot_ai/clients/_cli_strategy.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" -## 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/" -const CLIENT_STATUS_REFRESH_COOLDOWN_MSEC := 15 * 1000 -const CLIENT_STATUS_REFRESH_TIMEOUT_MSEC := 30 * 1000 -const CLIENT_ACTION_TIMEOUT_MSEC := 30 * 1000 -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) - -var _connection -var _log_buffer -var _plugin: EditorPlugin - -# 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 -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` 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` 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] = [] -## One-shot post-self-update auto-repin (armed by plugin.gd via -## `notify_self_update_success`). After an update, every client configured -## through this plugin still pins the OLD server version — the drift banner -## names them, but until they're rewritten each attach-bridge (re)start -## launches the outdated backend. The first completed status sweep with a -## healthy server consumes this flag and runs the same reconfigure the -## banner button would, so the user isn't left owing a manual click for a -## state the update itself created. Sweeps that land while the server is -## still INCOMPATIBLE (stale-occupant recovery in flight) keep it pending — -## their rows read ERROR, not CONFIGURED_MISMATCH. -var _pending_post_update_repin: bool = false -## The version the completed update replaced — consumed with the flag -## above; the pin-only gate renders each entry against it. -var _post_update_from_version: String = "" -var _client_status_refresh_thread: Thread -## Single source of truth for the refresh-sweep state machine. See -## `ClientRefreshStateScript` for the transition table. Replaces the -## previously scattered booleans (`_in_flight`, `_timed_out`, -## `_deferred_until_filesystem_ready`, `_shutdown_requested`). -var _refresh_state: int = ClientRefreshStateScript.IDLE -## Pending-request flags. Kept separate from `_refresh_state` because -## they're "what should the next refresh look like" — not state of -## any current refresh. A pending request is queued when a refresh -## arrives during RUNNING / RUNNING_TIMED_OUT and consumed by -## `_apply_client_status_refresh_results` once the in-flight worker -## drains. `_pending_force` also captures forced retries deferred via -## DEFERRED_FOR_FILESYSTEM so a pending user click survives the wait. -var _client_status_refresh_pending: bool = false -var _client_status_refresh_pending_force: bool = false -var _client_status_refresh_pending_initial: bool = false -var _last_client_status_refresh_completed_msec: int = 0 -var _client_status_refresh_started_msec: int = 0 -var _client_status_refresh_generation: int = 0 -## Owns the self-update slice: GitHub Releases poll, ZIP download, install -## orchestration, and the install-in-flight gate. Dock keeps banner UI -## only and consults the gate via `_is_self_update_in_progress()`. -var _update_manager -static var _orphaned_client_status_refresh_threads: Array[Thread] = [] - -## Per-row worker state for Configure / Remove. Issue #239: shelling out -## to a hung CLI on main hangs the editor. We dispatch each click to its -## own thread (one slot per client), then `_process` reaps completed workers -## and applies returned payloads on main. The buttons stay disabled while -## the slot is busy so the user can't queue a re-click on the same row. -## -## Per-client (not single-slot) so Configure-all can fan out — the -## workers are independent, only the row UI is shared, and McpCliExec -## bounds the wall-clock for each. -## -## A watchdog can abandon a slot when a worker fails to report completion. -## The thread object is retained in `_orphaned_client_action_threads` until -## it finishes so GDScript does not destroy a live Thread object. -var _client_action_threads: Dictionary = {} -var _client_action_generations: Dictionary = {} -var _client_action_started_msec: Dictionary = {} -var _client_action_names: Dictionary = {} -## Timed-out Configure/Remove workers are abandoned but retained here until -## they finish, so GDScript does not destroy a live Thread object. -static var _orphaned_client_action_threads: Array[Thread] = [] - -# Dev-mode only -var _dev_section: VBoxContainer -var _server_label: Label -var _reload_btn: Button -var _setup_section: VBoxContainer -var _setup_container: VBoxContainer -## Primary dev-section button — always (re)starts a `--reload` dev server. -## Same-version Python edits get adopted as compatible by the lifecycle, so -## neither the drift nor the crash Restart button surfaces; this is the -## unconditional kick contributors need to pick up source changes without -## a version bump. -var _dev_primary_btn: Button -## Small "✕" affordance next to the primary — stops the dev server without -## spawning a replacement. Disabled when no dev server is running. -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. Releases polling, ZIP download, and -# the install pipeline live on `_update_manager`. -var _update_banner: VBoxContainer -var _update_label: Label -var _update_btn: Button - -# Mixed-state banner — surfaces when `addons/godot_ai/` contains -# `*.update_backup` files left by a self-update whose rollback failed -# (`UpdateReloadRunner.InstallStatus.FAILED_MIXED`). Without this banner -# the user sees "plugin won't start" with no actionable context, re-runs -# the update, and compounds the mismatch (issue #354 / audit-v2 #10). -var _mixed_state_banner: VBoxContainer -var _mixed_state_label: Label -var _mixed_state_files: RichTextLabel -var _mixed_state_rescan_btn: Button - - -func setup(connection: McpConnection, log_buffer: McpLogBuffer, plugin: EditorPlugin) -> void: - _connection = connection - _log_buffer = log_buffer - _plugin = plugin - _startup_grace_until_msec = Time.get_ticks_msec() + STARTUP_GRACE_MSEC - - -func _ready() -> void: - _build_ui() - - -func _process(_delta: float) -> void: - _prune_orphaned_client_status_refresh_threads() - _prune_orphaned_client_action_threads() - _poll_completed_client_status_refresh_thread() - _poll_completed_client_action_threads() - _check_client_status_refresh_timeout() - _check_client_action_timeouts() - if _connection == null: - return - _retry_deferred_client_status_refresh() - _update_status() - if _log_viewer != null and _log_viewer.visible: - _log_viewer.tick() - - -func _exit_tree() -> void: - ## Block on any in-flight refresh worker before letting the dock leave the - ## tree. The plugin disable path (editor_reload_plugin, Project Settings - ## toggle) reloads the McpDock script class — which wipes the static - ## `_orphaned_client_status_refresh_threads`, GCs the Thread objects mid- - ## execution, and triggers `~Thread … destroyed without its completion - ## having been realized` plus GDScript VM corruption (Opcode: 0, IP-bounds - ## errors, intermittent SIGSEGV). Probes finish in well under a second - ## under normal conditions; if a CLI probe genuinely hung, the runtime - ## timeout path (`_abandon_client_status_refresh_thread`) has already - ## moved that thread into the orphan list, so we drain it here too. - ## - ## `wait_to_finish` is unbounded by design: GDScript's Thread API has no - ## timeout, and a polling/abandon fallback would just re-introduce the - ## GC-mid-execution crash this fix exists to prevent. Blocking the editor - ## briefly on plugin-reload is strictly better than the SIGSEGV. - _refresh_state = ClientRefreshStateScript.SHUTTING_DOWN - _drain_client_status_refresh_workers() - _drain_client_action_workers() - - -## Public drain entry consulted by `McpUpdateManager._install_zip` before -## any disk write. Pairs both worker pools so the manager doesn't reach -## into private dock methods. `_exit_tree` still calls the two underlying -## drains directly because it has additional state-machine work -## (SHUTTING_DOWN sticky-set) that the install-time path must NOT inherit. -func prepare_for_self_update_drain() -> void: - _poll_completed_client_status_refresh_thread() - _poll_completed_client_action_threads() - _drain_client_status_refresh_workers() - _drain_client_action_workers() - - -func _drain_client_status_refresh_workers() -> void: - ## Block until any in-flight refresh worker (and any orphaned workers from - ## a prior timeout) finish, then clear refresh state. Same blocking - ## semantics as the `_exit_tree` drain — see #232. Used by `_exit_tree` - ## (dock teardown) and `McpUpdateManager._install_zip` (before extract - ## overwrites plugin scripts on disk). - _client_status_refresh_generation += 1 - if _client_status_refresh_thread != null: - _client_status_refresh_thread.wait_to_finish() - _client_status_refresh_thread = null - for thread in _orphaned_client_status_refresh_threads: - if thread != null: - thread.wait_to_finish() - _orphaned_client_status_refresh_threads.clear() - ## Don't transition out of SHUTTING_DOWN — the drain is called from - ## `_exit_tree` (sticky shutdown) and from - ## `McpUpdateManager._install_zip`'s post-drain reset, which writes - ## the state explicitly. - if _refresh_state != ClientRefreshStateScript.SHUTTING_DOWN: - _refresh_state = ClientRefreshStateScript.IDLE - _client_status_refresh_pending = false - _client_status_refresh_pending_force = false - _client_status_refresh_pending_initial = false - - -func _drain_client_action_workers() -> void: - ## Same drain semantics as the refresh worker (see comment above): the - ## plugin disable / install-update path reloads our script class, so any - ## live Thread must finish before its slot is GC'd or we hit - ## `~Thread … destroyed without its completion having been realized` → - ## VM corruption. Normal UI recovery is handled by the per-row watchdog; - ## teardown still blocks because GDScript's Thread API has no kill/timeout - ## primitive and destroying a live Thread corrupts the VM. - ## - ## Generation-bumped per-row so any result from a worker that finished - ## after we started draining detects the generation mismatch and - ## short-circuits without touching freed UI state. - ## - ## After draining, restore the row UI for any in-flight rows: bare - ## `_client_action_threads.clear()` would leave the dock stuck showing - ## "Configuring…" / "Removing…" with disabled buttons forever — a - ## user-visible failure mode for the install-update bail-out branch - ## (zip extract failure on the manager clears `_install_in_flight` and - ## the dock stays alive). - for client_id in _client_action_threads.keys(): - var t: Thread = _client_action_threads[client_id] - if t != null: - t.wait_to_finish() - _client_action_generations[client_id] = int(_client_action_generations.get(client_id, 0)) + 1 - _client_action_started_msec.erase(client_id) - _client_action_names.erase(client_id) - _finalize_action_buttons(String(client_id)) - var row: Dictionary = _client_rows.get(String(client_id), {}) - if not row.is_empty(): - _apply_row_status( - String(client_id), - row.get("status", Client.Status.NOT_CONFIGURED), - "" - ) - _client_action_threads.clear() - for thread in _orphaned_client_action_threads: - if thread != null: - thread.wait_to_finish() - _orphaned_client_action_threads.clear() - _client_action_started_msec.clear() - _client_action_names.clear() - - -func _check_client_action_timeouts() -> void: - var now := Time.get_ticks_msec() - for client_id in _client_action_threads.keys(): - if not _client_action_started_msec.has(client_id): - continue - var started := int(_client_action_started_msec.get(client_id, 0)) - if now - started >= CLIENT_ACTION_TIMEOUT_MSEC: - _abandon_client_action_thread(String(client_id)) - - -func _abandon_client_action_thread(client_id: String) -> void: - if not _client_action_threads.has(client_id): - return - var thread: Thread = _client_action_threads[client_id] - var elapsed := Time.get_ticks_msec() - int(_client_action_started_msec.get(client_id, Time.get_ticks_msec())) - var worker_alive := thread != null and thread.is_alive() - if thread != null: - _orphaned_client_action_threads.append(thread) - _client_action_threads.erase(client_id) - _client_action_started_msec.erase(client_id) - var action := str(_client_action_names.get(client_id, "configure")) - _client_action_names.erase(client_id) - _client_action_generations[client_id] = int(_client_action_generations.get(client_id, 0)) + 1 - _finalize_action_buttons(client_id) - print("MCP | client action timed out: client=%s action=%s elapsed_ms=%d worker_alive=%s" % [ - client_id, - action, - elapsed, - str(worker_alive), - ]) - var label := "Remove" if action == "remove" else "Configure" - _apply_row_status( - client_id, - Client.Status.ERROR, - "%s did not report completion in time; refreshing current status." % label - ) - _refresh_clients_summary() - if is_inside_tree(): - _request_client_status_refresh(true) - - -func _prune_orphaned_client_action_threads() -> void: - var completed_orphan := false - for i in range(_orphaned_client_action_threads.size() - 1, -1, -1): - var thread := _orphaned_client_action_threads[i] - if thread == null: - _orphaned_client_action_threads.remove_at(i) - elif not thread.is_alive(): - thread.wait_to_finish() - _orphaned_client_action_threads.remove_at(i) - completed_orphan = true - if completed_orphan and is_inside_tree(): - _request_client_action_completion_refresh() - - -func _request_client_action_completion_refresh() -> void: - _request_client_status_refresh(true) - - -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() - - -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 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) - - _build_mixed_state_banner() - _refresh_mixed_state_banner() - - # --- 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", Color(1.0, 0.85, 0.3)) - ## 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) - - var update_btn_row := HBoxContainer.new() - update_btn_row.add_theme_constant_override("separation", 6) - - _update_btn = Button.new() - _update_btn.text = "Update" - _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(UpdateManagerScript.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) - - if _update_manager == null: - _update_manager = UpdateManagerScript.new() - _update_manager.setup(_plugin, self) - _update_manager.update_check_completed.connect(_on_update_check_result) - _update_manager.install_state_changed.connect(_on_install_state_changed) - _body.add_child(_update_manager) - _update_manager.check_for_updates.call_deferred() - - # --- 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_buffer) - _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 - ## #838/#816 step 11: say which transport Configure will write — the - ## client-owned attach bridge or the client's native URL mode. - 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." - if transport_tag.text == "attach" - else "Configure writes this client's native URL entry." - ) - ## 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: - var connected: bool = _connection != null and _connection.is_connected - ## Pull the connection's transport snapshot on this existing refresh tick. - ## `has_method` preserves the plugin self-update seam while an older - ## Connection instance is still alive under a hot-reloaded dock script. - var transport_status: Dictionary = ( - _connection.get_transport_status() - if _connection != null and _connection.has_method("get_transport_status") - else {} - ) - ## During plugin self-update there's a brief window where this dock - ## script is already the new version (Godot hot-reloads scripts on - ## file change) but `_plugin` is still the old `EditorPlugin` instance - ## (only `set_plugin_enabled(false, true)` re-instantiates that). When - ## the new dock calls a method the old plugin doesn't have, `_process` - ## errors every frame until `McpUpdateManager._reload_after_update` - ## lands. Guard every `_plugin.()` call with `has_method` - ## so that window stays silent. See #168. - var server_status: Dictionary = ( - _plugin.get_server_status() - if _plugin != null and _plugin.has_method("get_server_status") - else {} - ) - var state: int = int(server_status.get("state", ServerStateScript.UNINITIALIZED)) - if ServerStateScript.blocks_client_health(state): - connected = 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 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 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)) - ) - - ## #647: the quick picker only moves `godot_ai/http_port`, so hide it - ## when the diagnosed conflict is on the WebSocket port — the crash - ## body already points at `godot_ai/ws_port` in Editor Settings. - var conflict_port := int(server_status.get("conflict_port", 0)) - var http_conflict := conflict_port <= 0 or conflict_port == ClientConfigurator.http_port() - var port_picker_visible := ( - state == ServerStateScript.PORT_EXCLUDED - or (state == ServerStateScript.FOREIGN_PORT and http_conflict) - ) - _port_picker_panel.visible = port_picker_visible - if port_picker_visible: - ## Seed the spinbox with a suggested non-reserved port each time the - ## panel surfaces. Idempotent when the user already has a good - ## candidate queued up. - _port_picker_panel.seed_suggested_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). Only the http port -## reaches client configs; the ws port is server↔plugin, hence the wording. -## 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 "Ports %d (HTTP) and %d (WS) are free — set `godot_ai/http_port` and `godot_ai/ws_port` in Editor Settings, then update your client config with the new HTTP port (How to change the port, below)." % [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] - - -## Build the mixed-state banner. Hidden until `_refresh_mixed_state_banner` -## confirms `*.update_backup` files exist in the addons tree. Mirrors the -## issue #354 fix shape: structured, agent-readable diagnostic that survives -## a normal editor restart so the user can act on it instead of re-running -## the update. -func _build_mixed_state_banner() -> void: - _mixed_state_banner = VBoxContainer.new() - _mixed_state_banner.add_theme_constant_override("separation", 4) - _mixed_state_banner.visible = false - - _mixed_state_label = Label.new() - _mixed_state_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - _mixed_state_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL - _mixed_state_label.add_theme_color_override("font_color", Color.RED) - _mixed_state_banner.add_child(_mixed_state_label) - - _mixed_state_files = RichTextLabel.new() - _mixed_state_files.bbcode_enabled = false - _mixed_state_files.fit_content = true - _mixed_state_files.autowrap_mode = TextServer.AUTOWRAP_OFF - _mixed_state_files.selection_enabled = true - _mixed_state_files.scroll_active = true - _mixed_state_files.custom_minimum_size = Vector2(0, 90) - _mixed_state_files.add_theme_color_override("default_color", COLOR_AMBER) - _mixed_state_banner.add_child(_mixed_state_files) - - _mixed_state_rescan_btn = Button.new() - _mixed_state_rescan_btn.text = "Re-scan" - _mixed_state_rescan_btn.tooltip_text = ( - "Scan addons/godot_ai/ for *.update_backup files again." - + " Click after restoring the addon manually to dismiss this banner." - ) - _mixed_state_rescan_btn.pressed.connect(func(): _refresh_mixed_state_banner(true)) - _mixed_state_banner.add_child(_mixed_state_rescan_btn) - - _mixed_state_banner.add_child(HSeparator.new()) - _body.add_child(_mixed_state_banner) - - -func _refresh_mixed_state_banner(force: bool = false) -> void: - ## Re-scan button passes `force=true` to bypass the scanner's TTL - ## cache so a manual fix is reflected immediately. - _apply_mixed_state_banner_diagnostic(UpdateMixedStateScript.diagnose( - UpdateMixedStateScript.ADDON_DIR, force - )) - - -## Render seam exposed for testing — the GDScript test suite drives this -## directly with synthetic diagnostics so dock banner contracts can be -## pinned without polluting the real `addons/godot_ai/` tree with backup -## files. Callers from production go through `_refresh_mixed_state_banner`. -func _apply_mixed_state_banner_diagnostic(diag: Dictionary) -> void: - if _mixed_state_banner == null: - return - if diag.is_empty(): - _mixed_state_banner.visible = false - return - _mixed_state_banner.visible = true - ## `Dictionary.get(...)` returns Variant; Label.text is typed String. - ## Explicit cast keeps the type contract honest and dodges some Godot - ## 4.x point-release quirks around Variant→typed-property assignment. - _mixed_state_label.text = String(diag.get("message", "")) - _mixed_state_files.clear() - for path in diag.get("backup_files", []): - _mixed_state_files.add_text(String(path)) - _mixed_state_files.newline() - if bool(diag.get("truncated", false)): - _mixed_state_files.add_text( - "… (list truncated at %d entries)" % UpdateMixedStateScript.MAX_BACKUP_RESULTS - ) - _mixed_state_files.newline() - - -## 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: - if _connection and _connection.dispatcher: - _connection.dispatcher.mcp_logging = enabled - if _log_buffer != null: - _log_buffer.enabled = enabled - - -## Signal handler for the extracted PortPickerPanel — the panel range-validates -## the spinbox value before emitting, so we just write the EditorSetting and -## reload the plugin here. -func _on_port_apply_requested(new_port: int) -> void: - var es := EditorInterface.get_editor_settings() - if es != null: - es.set_setting(McpSettings.SETTING_HTTP_PORT, new_port) - ## Every saved client config now points at the old port. Re-sweep so the - ## drift banner appears in the same frame the user committed the change — - ## the plugin reload below will run a second sweep on its own first paint, - ## but we want the banner up immediately rather than after the reload - ## handshake races to completion. See #166. - _refresh_all_client_statuses() - ## Reload after the setting is committed so `_start_server` reads the new - ## port on the re-enabled plugin instance. - _on_reload_plugin() - - -func _refresh_server_label(server_status: Dictionary = {}) -> void: - if _server_label == null: - return - var ws_port := ClientConfigurator.ws_port() - if _plugin != null and _plugin.has_method("get_resolved_ws_port"): - ws_port = int(_plugin.get_resolved_ws_port()) - var text := "WS: %d HTTP: %d" % [ws_port, ClientConfigurator.http_port()] - if server_status.is_empty() and _plugin != null and _plugin.has_method("get_server_status"): - server_status = _plugin.get_server_status() - if _plugin != null and _plugin.has_method("get_server_pid"): - var ownership := _server_ownership_tag( - int(server_status.get("state", ServerStateScript.UNINITIALIZED)), - int(_plugin.get_server_pid()), - ) - 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" - - -## "attach" when Configure writes a client-owned launch command for this -## client, "URL" when it writes the client's native URL entry. Derived from -## descriptor data so the tag can never disagree with what Configure does. -static func _client_transport_tag(client_id: String) -> String: - var client := ClientRegistry.get_by_id(client_id) - if client == null: - return "" - return "URL" if client.command_shape == Client.CommandShape.NONE else "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 es := EditorInterface.get_editor_settings() - var env_disabled = _is_telemetry_disabled_via_env() - - var enabled: bool - if env_disabled != null: - ## Env var present: resolve and save to EditorSettings so future sessions without - ## the env var honour the last-set value. - enabled = not bool(env_disabled) - if es != null: - es.set_setting(McpSettings.SETTING_TELEMETRY_ENABLED, enabled) - else: - ## No env var: read (or create) the EditorSettings key. - if es != null and es.has_setting(McpSettings.SETTING_TELEMETRY_ENABLED): - enabled = bool(es.get_setting(McpSettings.SETTING_TELEMETRY_ENABLED)) - else: - enabled = true - if es != null: - es.set_setting(McpSettings.SETTING_TELEMETRY_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 = "" - - -func _on_telemetry_toggled(pressed: bool) -> void: - _telemetry_pending_enabled = pressed - _refresh_tools_ui_state() - - -# --- 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` - # 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` 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 server_status: Dictionary = ( - _plugin.get_server_status() - if _plugin != null and _plugin.has_method("get_server_status") - else {} - ) - var state: int = int(server_status.get("state", ServerStateScript.UNINITIALIZED)) - if ServerStateScript.is_terminal_diagnosis(state): - return false - return Time.get_ticks_msec() < _startup_grace_until_msec - - -# --- Button handlers --- - - -func _do_plugin_reload() -> void: - EditorInterface.set_plugin_enabled("res://addons/godot_ai/plugin.cfg", false) - EditorInterface.set_plugin_enabled("res://addons/godot_ai/plugin.cfg", true) - - -func _on_reload_plugin() -> void: - # Persist a pending plugin_reload telemetry event *before* the - # disable kills the live WebSocket — the new plugin's _enter_tree - # flushes it via `_telemetry.flush_pending_plugin_reload()`. - Telemetry.record_pending_plugin_reload("dock_button") - # Defer the toggle so any in-flight input event finishes propagating - # before the dock (and its Window children) leave the tree. Calling - # set_plugin_enabled synchronously from a button press frees the - # viewport mid-dispatch. - _do_plugin_reload.call_deferred() - - -## 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(): - ## Re-fetch only when called outside `_update_status`'s frame - ## (e.g. from `_apply_new_port`, `_on_restart_*`). Inside the - ## per-frame loop, the caller threads its cached snapshot through - ## so we don't allocate a fresh Dictionary every frame. - server_status = ( - _plugin.get_server_status() - if _plugin != null and _plugin.has_method("get_server_status") - else {} - ) - var server_ver: String = _connection.server_version if _connection != null else "" - 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 = ( - _plugin != null - and _plugin.has_method("can_restart_managed_server") - and _plugin.can_restart_managed_server() - ) - 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 _plugin == null or _server_restart_in_progress: - return - _server_restart_in_progress = true - _last_rendered_server_text = "" - _refresh_server_version_label() - if not is_inside_tree(): - await _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 await _dispatch_stale_server_restart(): - _server_restart_in_progress = false - _last_rendered_server_text = "" - _refresh_server_version_label() - - -func _dispatch_stale_server_restart() -> bool: - if _plugin == null: - return false - var status: Dictionary = ( - _plugin.get_server_status() - if _plugin.has_method("get_server_status") - else {} - ) - if int(status.get("state", ServerStateScript.UNINITIALIZED)) == ServerStateScript.INCOMPATIBLE: - if _plugin.has_method("recover_incompatible_server"): - ## Coroutine in production (#678): recovery reports success only - ## after the respawn walk completes and the connection unblocks. - return bool(await _plugin.recover_incompatible_server()) - elif _plugin.has_method("force_restart_server"): - _plugin.force_restart_server() - return true - return false - - -# --- 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 Dev 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 dev server without spawning a replacement." - _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 helper for the primary "Restart Dev Server" button. Always enabled -## (clicking with nothing running just spawns fresh); tooltip adapts to -## whether a kill+respawn or fresh spawn is what'll happen. -static func _dev_primary_btn_state(has_managed: bool, dev_running: bool) -> Dictionary: - var port := ClientConfigurator.http_port() - if has_managed or dev_running: - return { - "text": "Restart Dev Server", - "tooltip": ( - "Kill the server on port %d and start a fresh --reload dev server. " - + "Use this to pick up Python source changes that don't bump the version." - ) % port, - } - return { - "text": "Start Dev Server", - "tooltip": "Spawn a --reload dev server on port %d. Auto-restarts when you edit Python sources." % port, - } - - -## Pure helper for the small "✕" stop button — only enabled when a dev -## server is actually running. Stops without respawning; intentionally -## never targets a managed server (that's the lifecycle's responsibility). -static func _dev_stop_btn_state(dev_running: bool) -> Dictionary: - if dev_running: - return {"enabled": true, "tooltip": "Stop the dev server without spawning a replacement."} - return {"enabled": false, "tooltip": "No --reload dev server to stop."} - - -func _on_dev_primary_pressed() -> void: - if _plugin == null or _server_restart_in_progress: - return - if not _plugin.has_method("force_restart_or_start_dev_server"): - return - if _plugin.has_method("record_dev_server_toggle"): - _plugin.record_dev_server_toggle("start") - _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`. - _plugin.force_restart_or_start_dev_server() - _server_restart_in_progress = false - return - call_deferred("_perform_dev_restart_after_feedback") - - -func _on_dev_stop_pressed() -> void: - if _plugin == null: - return - if _plugin.has_method("stop_dev_server"): - _plugin.stop_dev_server() - if _plugin.has_method("record_dev_server_toggle"): - _plugin.record_dev_server_toggle("stop") - _update_dev_section_buttons.call_deferred() - - -func _perform_dev_restart_after_feedback() -> void: - ## Brief paint cycle so the user sees "Restarting..." before the - ## blocking _wait_for_port_free freezes the editor for up to 5s. - await get_tree().create_timer(0.15).timeout - ## Re-check has_method post-await — a self-update mixed-state window - ## could swap _plugin's script class while we were sleeping, leaving - ## the old reference pointing at a class that no longer carries the - ## new method. Same #168 guard pattern as _update_dev_section_buttons. - if _plugin != null and _plugin.has_method("force_restart_or_start_dev_server"): - _plugin.force_restart_or_start_dev_server() - ## start_dev_server's spawn happens via a 0.5s SceneTree timer; give - ## it time to land plus a buffer for the WS reconnect before clearing - ## the busy state. The unconditional clear matches sibling restart - ## buttons — overshoot is fine because subsequent _update_status calls - ## refresh the button against live plugin state. - await get_tree().create_timer(2.0).timeout - _server_restart_in_progress = false - _update_dev_section_buttons() - - -## Single-scan refresh of every dev-section button state. Both buttons -## key off the same `has_managed_server` / `is_dev_server_running` pair, -## and the latter scrapes lsof/ps — so doing the discovery once and -## applying to both avoids the duplicate subprocess fork on every -## connection-state transition. -func _update_dev_section_buttons() -> void: - if _plugin == null: - return - if not (_plugin.has_method("has_managed_server") and _plugin.has_method("is_dev_server_running")): - return - var has_managed: bool = _plugin.has_managed_server() - var dev_running: bool = _plugin.is_dev_server_running() - 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 = "Killing the current server and respawning..." - else: - var primary_state := _dev_primary_btn_state(has_managed, dev_running) - _dev_primary_btn.disabled = false - _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(dev_running) - _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 _last_client_status_refresh_completed_msec > 0 - - -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: - if _server_blocks_client_health(): - _apply_row_status(client_id, Client.Status.ERROR, _server_blocked_client_message()) - _refresh_clients_summary() - return - _dispatch_client_action(client_id, "configure") - - -func _on_remove_client(client_id: String) -> void: - _dispatch_client_action(client_id, "remove") - - -## Spawn a worker thread for Configure / Remove so a hung CLI can't lock -## the editor (issue #239). The action verbs are: "configure" → calls -## `ClientConfigurator.configure`; "remove" → calls -## `ClientConfigurator.remove`. Both routes shell out to the per-client -## CLI via `McpCliExec.run`, which is wall-clock-bounded. -## -## Per-row in-flight rules: -## - One worker at a time per client (the row's slot). -## - Both buttons disabled while the slot is busy — prevents a -## double-click queueing a stale Configure on top of a still-running -## Remove. -## - The dot turns amber and the row label gets a "Configuring…" / -## "Removing…" suffix so the user can see the click was registered. -func _dispatch_client_action(client_id: String, action: String) -> void: - if _is_self_update_in_progress(): - ## Same gate as the refresh worker — the install window overwrites - ## plugin scripts on disk, and a worker mid-call into them would - ## SIGABRT in `GDScriptFunction::call`. See `_update_manager`. - return - if _client_action_threads.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) - ## Snapshot `server_url` on main: `http_url()` reads - ## `EditorInterface.get_editor_settings()`, which is main-thread-only. - ## The status-refresh worker uses the same pattern — see - ## `_perform_initial_client_status_refresh` and - ## `_request_client_status_refresh`. - var launch_context := ClientConfigurator.capture_launch_context() - var server_url := ClientConfigurator.server_url_from(launch_context) - ## #691: refresh the env snapshot on main before this worker starts — - ## configure/remove resolve CLI + config paths off-thread and must not - ## race a concurrent spawn window's setenv/unsetenv. - ClientConfigurator.warm_env_snapshot() - var generation := int(_client_action_generations.get(client_id, 0)) + 1 - _client_action_generations[client_id] = generation - var thread := Thread.new() - _client_action_threads[client_id] = thread - _client_action_started_msec[client_id] = Time.get_ticks_msec() - _client_action_names[client_id] = action - var err := thread.start( - Callable(self, "_run_client_action_worker").bind( - client_id, action, server_url, launch_context, generation - ) - ) - if err != OK: - _client_action_threads.erase(client_id) - _client_action_started_msec.erase(client_id) - _client_action_names.erase(client_id) - _finalize_action_buttons(client_id) - _apply_row_status(client_id, Client.Status.ERROR, "couldn't start worker thread") - _refresh_clients_summary() - - -func _run_client_action_worker( - client_id: String, - action: String, - server_url: String, - launch_context: Dictionary, - generation: int, -) -> Dictionary: - var result: Dictionary - if action == "remove": - result = ClientConfigurator.remove(client_id, server_url, launch_context) - else: - result = ClientConfigurator.configure(client_id, server_url, launch_context) - return { - "client_id": client_id, - "action": action, - "result": result, - "generation": generation, - } - - -func _poll_completed_client_action_threads() -> void: - for client_id in _client_action_threads.keys(): - var thread: Thread = _client_action_threads[client_id] - if thread == null or thread.is_alive(): - continue - var payload: Variant = thread.wait_to_finish() - _client_action_threads[client_id] = null - if payload is Dictionary: - var data := payload as Dictionary - var result: Dictionary = data.get("result", {}) - _apply_client_action_result( - String(data.get("client_id", client_id)), - String(data.get("action", _client_action_names.get(client_id, "configure"))), - result, - int(data.get("generation", _client_action_generations.get(client_id, 0))) - ) - else: - _apply_client_action_result( - String(client_id), - String(_client_action_names.get(client_id, "configure")), - {"status": "error", "message": "worker returned no result"}, - int(_client_action_generations.get(client_id, 0)) - ) - - -func _apply_client_action_result(client_id: String, action: String, result: Dictionary, generation: int) -> void: - if int(_client_action_generations.get(client_id, 0)) != generation: - if _client_action_threads.get(client_id, null) == null: - _client_action_threads.erase(client_id) - _client_action_started_msec.erase(client_id) - _client_action_names.erase(client_id) - return - if _refresh_state == ClientRefreshStateScript.SHUTTING_DOWN: - return - if _client_action_threads.has(client_id): - var t: Thread = _client_action_threads[client_id] - if t != null: - t.wait_to_finish() - _client_action_threads.erase(client_id) - _client_action_started_msec.erase(client_id) - _client_action_names.erase(client_id) - _finalize_action_buttons(client_id) - if _server_blocks_client_health(): - _apply_row_status(client_id, Client.Status.ERROR, _server_blocked_client_message()) - _refresh_clients_summary() - return - - 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() - - -## 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. -## `_apply_row_status` sets `configure_btn.text` per the resulting -## Status (Configure / Reconfigure / Retry), so we only need to reset -## `remove_btn.text` here — its sibling visibility toggle already -## handles whether to show it at all. -func _finalize_action_buttons(client_id: String) -> void: - var row: Dictionary = _client_rows.get(client_id, {}) - if row.is_empty(): - return - (row["configure_btn"] as Button).disabled = false - 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: - if _server_blocks_client_health(): - for client_id in _client_rows: - _apply_row_status(String(client_id), Client.Status.ERROR, _server_blocked_client_message()) - _refresh_clients_summary() - return - if ClientRefreshStateScript.should_disable_client_actions(_refresh_state): - 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) - - _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) - var es := EditorInterface.get_editor_settings() - if es != null: - es.set_setting(McpSettings.SETTING_EXCLUDED_DOMAINS, canonical_excluded) - es.set_setting(McpSettings.SETTING_TELEMETRY_ENABLED, _telemetry_pending_enabled) - _tools_saved_excluded = _tools_pending_excluded.duplicate() - _telemetry_saved_enabled = _telemetry_pending_enabled - _refresh_tools_ui_state() - ## Plugin reload respawns the server with the new `--exclude-domains` flag - ## (see `plugin.gd::_build_server_flags`) and telemetry option. Mirrors the - ## port-change Apply flow. - _on_reload_plugin() - - -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 - var es := EditorInterface.get_editor_settings() - if es != null: - es.set_setting(McpSettings.SETTING_ALLOW_HOSTS, normalized) - _allow_hosts_saved = normalized - _allow_hosts_edit.text = normalized - _refresh_allow_hosts_ui_state() - ## Plugin reload respawns the server with the new `--allow-host` flag - ## (see `plugin.gd::_build_server_flags`). Mirrors the Tools-tab Apply - ## and port-change flows. - _on_reload_plugin() - - -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() - 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) - 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: - ## Compatibility wrapper for older explicit call sites. Treat this as a manual - ## refresh: it bypasses focus-in cooldown but still runs probes off the editor - ## main thread. - if _server_blocks_client_health(): - for client_id in _client_rows: - _apply_row_status(String(client_id), Client.Status.ERROR, _server_blocked_client_message()) - _refresh_clients_summary() - return - _request_client_status_refresh(true) - - -func _is_client_status_refresh_in_cooldown() -> bool: - if _last_client_status_refresh_completed_msec <= 0: - return false - return Time.get_ticks_msec() - _last_client_status_refresh_completed_msec < CLIENT_STATUS_REFRESH_COOLDOWN_MSEC - - -func _has_client_status_refresh_timed_out() -> bool: - if not ClientRefreshStateScript.has_worker_alive(_refresh_state): - return false - if _client_status_refresh_started_msec <= 0: - return false - return Time.get_ticks_msec() - _client_status_refresh_started_msec >= CLIENT_STATUS_REFRESH_TIMEOUT_MSEC - - -func _check_client_status_refresh_timeout() -> void: - if not _has_client_status_refresh_timed_out(): - return - if _refresh_state == ClientRefreshStateScript.RUNNING_TIMED_OUT: - return - _refresh_state = ClientRefreshStateScript.RUNNING_TIMED_OUT - _refresh_clients_summary() - - -func _abandon_client_status_refresh_thread() -> void: - ## GDScript cannot interrupt a blocking `OS.execute(..., true)` call in a - ## worker. If a CLI probe hangs, orphan this run, bump the generation so any - ## late result becomes a no-op, and let a forced/manual refresh start a fresh - ## probe slot. Completed orphan threads are pruned from `_process`. - _client_status_refresh_generation += 1 - if _client_status_refresh_thread != null: - _orphaned_client_status_refresh_threads.append(_client_status_refresh_thread) - _client_status_refresh_thread = null - if _refresh_state != ClientRefreshStateScript.SHUTTING_DOWN: - _refresh_state = ClientRefreshStateScript.IDLE - ## Reset the full pending-request triplet, not just the - ## focus-in / cooldown half. A timed-out worker has already - ## warmed bytecode, so any stale `_pending_initial` from an - ## earlier deferred-during-busy startup is no longer load-bearing - ## — leaving it set would cause `_retry_deferred_*` to dispatch - ## `_perform_initial_*` a second time after this abandon - ## (which would then no-op because no fresh worker is needed - ## but still re-warm bytecode and walk the row set redundantly). - _client_status_refresh_pending = false - _client_status_refresh_pending_force = false - _client_status_refresh_pending_initial = false - _client_status_refresh_started_msec = 0 - _refresh_clients_summary() - - -func _prune_orphaned_client_status_refresh_threads() -> void: - for i in range(_orphaned_client_status_refresh_threads.size() - 1, -1, -1): - var thread := _orphaned_client_status_refresh_threads[i] - if thread == null: - _orphaned_client_status_refresh_threads.remove_at(i) - elif not thread.is_alive(): - thread.wait_to_finish() - _orphaned_client_status_refresh_threads.remove_at(i) - - -func _perform_initial_client_status_refresh() -> void: - ## Pre-warm strategy bytecode on main, then hand every client probe - ## (JSON / TOML / CLI alike) to the worker. - ## - ## Godot's GDScript hot-reload of overwritten plugin files is lazy: the - ## bytecode swap happens on first dereference, not at `set_plugin_enabled` - ## time. A worker thread spawned from a fresh `_build_ui` walks into - ## `_json_strategy.*` / `_cli_strategy.*` / `client_configurator.*` while - ## bytecode pages are mid-swap → SIGABRT. Dereferencing those scripts on - ## main first forces the swap to complete here; the worker then finds - ## stable bytecode. Filesystem signals don't bracket the swap window - ## (they fire before bytecode replacement), and FOCUS_IN doesn't fire on - ## in-place plugin reload because the editor stays focused — so neither - ## works as a gate. See #233 / #235. - ## - ## Phase 1 (sync, on main): a single explicit `_warm_strategy_bytecode` - ## call invokes a pure-memory helper on each strategy script — - ## `_json_strategy.gd`, `_toml_strategy.gd`, `_cli_strategy.gd`, plus - ## `client_configurator.gd` via `client_ids()` / `get_by_id`. No disk, - ## no `OS.execute`, no JSON parse on main. `client_status_probe_snapshot` - ## per client adds the `installed` flag and (for CLI clients) a cached - ## CLI path to each probe. - ## - ## Phase 2 (worker): every probe — JSON, TOML, CLI — runs through the - ## same `_run_client_status_refresh_worker` pipeline. Disk reads + JSON - ## parses for the ~17 non-CLI clients now happen off the main thread, - ## so the dock paints immediately on cold open instead of stalling - ## behind ~16 sync `FileAccess.open` + `JSON.parse_string` calls. - ## - ## No-op outside the tree — GDScript tests instantiate via `new()`. - if not is_inside_tree(): - return - if _client_rows.is_empty(): - return - if ClientRefreshStateScript.is_blocked_for_spawn(_refresh_state): - return - if _is_self_update_in_progress(): - return - if _is_editor_filesystem_busy(): - _defer_initial_client_status_refresh_until_filesystem_ready() - return - if ClientRefreshStateScript.has_worker_alive(_refresh_state): - return - - if _server_blocks_client_health(): - for client_id in _client_rows: - _apply_row_status(String(client_id), Client.Status.ERROR, _server_blocked_client_message()) - _refresh_clients_summary() - return - - _warm_strategy_bytecode() - - var generation := _begin_client_status_refresh_run() - var launch_context := ClientConfigurator.capture_launch_context() - var server_url := ClientConfigurator.server_url_from(launch_context) - var all_probes: Array[Dictionary] = [] - - for client_id in _client_rows: - var probe := ClientConfigurator.client_status_probe_snapshot(String(client_id)) - if probe.is_empty(): - continue - all_probes.append(probe) - _refresh_clients_summary() - - if all_probes.is_empty(): - _finalize_completed_refresh() - return - - _client_status_refresh_thread = Thread.new() - var err := _client_status_refresh_thread.start( - Callable(self, "_run_client_status_refresh_worker").bind( - all_probes, server_url, launch_context, generation - ) - ) - if err != OK: - _refresh_state = ClientRefreshStateScript.IDLE - _client_status_refresh_thread = null - _refresh_clients_summary() - - -## Force GDScript's lazy bytecode swap to complete for every script the -## worker thread will reach into. Each call is pure-memory — no disk, no -## network, no `OS.execute` — so it only costs the bytecode dereference -## itself. See `_perform_initial_client_status_refresh` for context and -## #233 / #235 for the SIGABRT this exists to prevent. -func _warm_strategy_bytecode() -> void: - var ids := ClientConfigurator.client_ids() - if ids.is_empty(): - return - var any_client := ClientRegistry.get_by_id(String(ids[0])) - if any_client != null: - JsonStrategy.verify_entry(any_client, {}, "") - TomlStrategy.format_body(PackedStringArray(), "") - CliStrategy.format_args(PackedStringArray(), "", "") - ## #691: refresh the env snapshot on main before the worker starts, so - ## its config-path expansions read the snapshot instead of racing a - ## concurrent spawn window's setenv/unsetenv. - ClientConfigurator.warm_env_snapshot() - - -func _begin_client_status_refresh_run() -> int: - ## Marks a refresh as starting and returns the new generation token. - ## Generation is bumped here (not at completion) so that a worker result - ## reaped after `_abandon_client_status_refresh_thread` or `_exit_tree` - ## fires can be detected as stale via generation mismatch. - _refresh_state = ClientRefreshStateScript.RUNNING - _client_status_refresh_pending = false - _client_status_refresh_pending_force = false - _client_status_refresh_started_msec = Time.get_ticks_msec() - _client_status_refresh_generation += 1 - _refresh_clients_summary() - return _client_status_refresh_generation - - -func _finalize_completed_refresh() -> void: - ## Stamps cooldown and clears in-flight state. Called at the end of every - ## refresh that successfully applied results — the worker reaping path - ## and the no-CLI fast path in `_perform_initial_client_status_refresh`. - _last_client_status_refresh_completed_msec = Time.get_ticks_msec() - if _refresh_state != ClientRefreshStateScript.SHUTTING_DOWN: - _refresh_state = ClientRefreshStateScript.IDLE - _refresh_clients_summary() - ## After the summary pass so `_last_mismatched_ids` reflects the sweep - ## that just completed. - _maybe_auto_repin_after_update() - - -func _request_client_status_refresh(force: bool = false) -> bool: - ## Stale-while-refreshing: do not clear dots, summary, or the drift banner - ## when a refresh is requested. The existing UI remains visible until the - ## background worker's result is applied on the main thread. - if _server_blocks_client_health(): - for client_id in _client_rows: - _apply_row_status(String(client_id), Client.Status.ERROR, _server_blocked_client_message()) - _refresh_clients_summary() - return false - if _is_self_update_in_progress(): - ## Self-update is overwriting plugin scripts on disk; spawning a worker - ## now would crash it inside `GDScriptFunction::call` once the bytecode - ## swap reaches a script the worker is mid-call into. Focus-in / - ## manual button / cooldown timer all funnel through here, so one - ## gate covers every spawn path during the install window. The flag - ## lives on `_update_manager` and dies with the dock instance during - ## `set_plugin_enabled(false)`. - return false - if ClientRefreshStateScript.has_worker_alive(_refresh_state): - if force and _has_client_status_refresh_timed_out(): - _abandon_client_status_refresh_thread() - else: - _client_status_refresh_pending = true - _client_status_refresh_pending_force = _client_status_refresh_pending_force or force - _refresh_clients_summary() - return false - if ClientRefreshStateScript.is_blocked_for_spawn(_refresh_state): - return false - if not force and _is_client_status_refresh_in_cooldown(): - return false - if _client_rows.is_empty(): - return false - if _is_editor_filesystem_busy(): - if force: - _defer_client_status_refresh_until_filesystem_ready(force) - return false - - ## Manual refresh (any `force=true` path: button click, popup open, - ## external API caller) implies "may have installed a CLI since the - ## last sweep" — flush CliFinder so freshly-installed binaries get - ## re-detected. Focus-in (`force=false`) stays cached so the cheap - ## case stays cheap. Per-CLI invalidation - ## (`invalidate_uvx_cli_cache`) still pairs with specific events - ## like `_on_install_uv` where the binary name is known. - if force: - ClientConfigurator.invalidate_cli_cache() - - ## Force the bytecode swap on the same scripts the worker will reach - ## into — same #233/#235 guard `_perform_initial_*` already had. - ## Without this, a manual refresh dispatched before the initial sweep - ## has run (e.g. user clicks Refresh during the deferred-initial - ## window after `_defer_client_status_refresh_until_filesystem_ready` - ## cleared `_pending_initial`) walks into mid-swap bytecode and - ## SIGABRTs. - _warm_strategy_bytecode() - - var client_probes: Array[Dictionary] = [] - for client_id in _client_rows: - client_probes.append(ClientConfigurator.client_status_probe_snapshot(String(client_id))) - var launch_context := ClientConfigurator.capture_launch_context() - var server_url := ClientConfigurator.server_url_from(launch_context) - - var generation := _begin_client_status_refresh_run() - _client_status_refresh_thread = Thread.new() - var err := _client_status_refresh_thread.start( - Callable(self, "_run_client_status_refresh_worker").bind( - client_probes, server_url, launch_context, generation - ) - ) - if err != OK: - _refresh_state = ClientRefreshStateScript.IDLE - _client_status_refresh_thread = null - _refresh_clients_summary() - return false - return true - - -func _is_editor_filesystem_busy() -> bool: - var fs := EditorInterface.get_resource_filesystem() - return fs != null and fs.is_scanning() - - -func _defer_initial_client_status_refresh_until_filesystem_ready() -> void: - _refresh_state = ClientRefreshStateScript.DEFERRED_FOR_FILESYSTEM - _client_status_refresh_pending_initial = true - - -func _defer_client_status_refresh_until_filesystem_ready(force: bool) -> void: - ## Godot can still be reparsing/reloading plugin scripts while the editor - ## filesystem is busy. Do not spawn a worker into that window: the worker - ## can call plugin GDScript while the main thread is reloading it, which - ## crashes in `GDScriptFunction::call`. - ## - ## A manual refresh request is more recent intent than any earlier - ## deferred-initial sweep, so we clear `_pending_initial` here. - ## `_request_client_status_refresh` warms strategy bytecode itself - ## now (see #233/#235), so the safety net the initial path provided - ## still applies to the replayed manual refresh. - _refresh_state = ClientRefreshStateScript.DEFERRED_FOR_FILESYSTEM - _client_status_refresh_pending_force = _client_status_refresh_pending_force or force - _client_status_refresh_pending_initial = false - - -func _retry_deferred_client_status_refresh() -> void: - if _refresh_state != ClientRefreshStateScript.DEFERRED_FOR_FILESYSTEM: - return - if _is_self_update_in_progress(): - return - if _is_editor_filesystem_busy(): - return - - var initial := _client_status_refresh_pending_initial - var force := _client_status_refresh_pending_force - _refresh_state = ClientRefreshStateScript.IDLE - _client_status_refresh_pending_force = false - _client_status_refresh_pending_initial = false - if initial: - _perform_initial_client_status_refresh() - else: - _request_client_status_refresh(force) - - -func _run_client_status_refresh_worker( - client_probes: Array[Dictionary], - server_url: String, - launch_context: Dictionary, - generation: int, -) -> Dictionary: - var results: Dictionary = {} - # Command-shaped clients share one attach launch. Discovery can be the - # dominant cold-cache cost, so resolve it once per refresh worker rather - # than once for Claude Desktop and again for Codex. - var resolved_launch := ClientConfigurator.resolve_attach_launch(launch_context) - for probe in client_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", "")), - launch_context, - resolved_launch, - ) - var installed := bool(probe.get("installed", false)) - results[client_id] = { - "status": details.get("status", Client.Status.NOT_CONFIGURED), - "installed": installed, - "error_msg": details.get("error_msg", ""), - } - return {"results": results, "generation": generation} - - -func _poll_completed_client_status_refresh_thread() -> void: - if _client_status_refresh_thread == null: - return - if _client_status_refresh_thread.is_alive(): - return - var payload: Variant = _client_status_refresh_thread.wait_to_finish() - _client_status_refresh_thread = null - if payload is Dictionary: - var data := payload as Dictionary - var results: Dictionary = data.get("results", {}) - _apply_client_status_refresh_results( - results, - int(data.get("generation", _client_status_refresh_generation)) - ) - else: - _apply_client_status_refresh_results({}, _client_status_refresh_generation) - - -func _apply_client_status_refresh_results(results: Dictionary, generation: int) -> void: - if generation != _client_status_refresh_generation or _refresh_state == ClientRefreshStateScript.SHUTTING_DOWN: - return - if _client_status_refresh_thread != null: - _client_status_refresh_thread.wait_to_finish() - _client_status_refresh_thread = null - if _server_blocks_client_health(): - for client_id in _client_rows: - _apply_row_status(String(client_id), Client.Status.ERROR, _server_blocked_client_message()) - _finalize_completed_refresh() - return - - for client_id in results: - ## Skip rows whose Configure / Remove worker is still running so the - ## status refresh doesn't overwrite the "Configuring…" / "Removing…" - ## badge with a stale dot color. The action's own completion handler - ## will repaint the row when it lands. - if _client_action_threads.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) - ) - _finalize_completed_refresh() - - if _client_status_refresh_pending: - var pending_force := _client_status_refresh_pending_force - _client_status_refresh_pending = false - _client_status_refresh_pending_force = false - _request_client_status_refresh(pending_force) - - -func _server_blocks_client_health() -> bool: - if _plugin == null or not _plugin.has_method("get_server_status"): - return false - var status: Dictionary = _plugin.get_server_status() - return ServerStateScript.blocks_client_health( - int(status.get("state", ServerStateScript.UNINITIALIZED)) - ) - - -func _server_blocked_client_message() -> String: - if _plugin == null or not _plugin.has_method("get_server_status"): - return "server incompatible" - var status: Dictionary = _plugin.get_server_status() - var message := str(status.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() - - -## Arm the one-shot post-update repin (see `_pending_post_update_repin`). -## Called by plugin.gd when it drains a `status == "success"` self-update -## marker, i.e. exactly once per completed update. `from_version` is the -## plugin version the update replaced — required by the pin-only gate -## below; an empty value (marker from a pre-gate runner) arms nothing, so -## those updates keep the manual drift-banner path. -func notify_self_update_success(from_version: String = "") -> void: - if from_version.strip_edges().is_empty(): - return - _post_update_from_version = from_version.strip_edges() - _pending_post_update_repin = true - - -## Consume `_pending_post_update_repin` on the first completed status sweep -## that could actually observe drift. Runs from `_finalize_completed_refresh` -## AFTER `_refresh_clients_summary()` has rebuilt `_last_mismatched_ids` -## from the sweep that just landed. -## -## Blast-radius gate: only entries whose SOLE drift is the old version pin -## are repinned (`entry_drift_is_version_pin_only`). A mismatched entry can -## also mean "points at a different editor's ports" — this dock ran inside -## a side project with custom ports, or the user hand-tuned the entry — and -## auto-rewriting those hijacks every AI client on the machine to THIS -## editor's ports (observed live: the self-update smoke fixture on port -## 18000 repinned 21 real client configs). Those stay on the drift banner's -## human click. -func _maybe_auto_repin_after_update() -> void: - if not _pending_post_update_repin: - return - ## While the server is INCOMPATIBLE (post-update stale-occupant recovery - ## still in flight) every row reads ERROR, not CONFIGURED_MISMATCH — a - ## consume here would see an empty mismatch list and drop the repin on - ## the floor. Stay pending for the sweep that lands after recovery. - if _server_blocks_client_health(): - return - if ClientRefreshStateScript.should_disable_client_actions(_refresh_state): - return - _pending_post_update_repin = false - var from_version := _post_update_from_version - _post_update_from_version = "" - if _last_mismatched_ids.is_empty(): - return - var launch_context := ClientConfigurator.capture_launch_context() - var pin_only: Array[String] = [] - for client_id in _last_mismatched_ids: - if _entry_drift_is_version_pin_only(String(client_id), from_version, launch_context): - pin_only.append(String(client_id)) - if pin_only.is_empty(): - print( - "MCP | self-update complete — no client entry drifts by version pin alone; leaving %d drifted config(s) to the Reconfigure banner" - % _last_mismatched_ids.size() - ) - return - print( - "MCP | self-update complete — repinning %d client config(s) from v%s to v%s" - % [pin_only.size(), from_version, ClientConfigurator.get_plugin_version()] - ) - for client_id in pin_only: - if _client_rows.has(client_id): - _on_configure_client(client_id) - _refresh_all_client_statuses() - - -## Instance seam over the static gate so the dock test suite can fake the -## per-client verdict without real config files on disk. -func _entry_drift_is_version_pin_only( - client_id: String, from_version: String, launch_context: Dictionary -) -> bool: - return ClientConfigurator.entry_drift_is_version_pin_only( - client_id, from_version, launch_context - ) - - -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_manager != null and bool(_update_manager.is_install_in_flight()) - - -func _on_update_pressed() -> void: - if _update_manager != null: - _update_manager.start_install() - - -func _on_update_check_result(result: Dictionary) -> void: - _update_label.text = String(result.get("label_text", "")) - _update_banner.visible = true - - -## Apply only the keys present so the manager can ship partial updates -## (e.g. button-text-only during the download phase) without clobbering -## banner state. -func _on_install_state_changed(state: Dictionary) -> void: - 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("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 String(state.get("outcome", "")) == "success" and _update_label != null: - ## Visual confirmation for successful terminal update states. - _update_label.add_theme_color_override("font_color", Color.GREEN) diff --git a/addons/godot_ai/mcp_dock.gd.uid b/addons/godot_ai/mcp_dock.gd.uid deleted file mode 100644 index 5868719..0000000 --- a/addons/godot_ai/mcp_dock.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://b8yknttdjanm5 diff --git a/addons/godot_ai/migration_bridge.gd b/addons/godot_ai/migration_bridge.gd new file mode 100644 index 0000000..50874db --- /dev/null +++ b/addons/godot_ai/migration_bridge.gd @@ -0,0 +1,212 @@ +@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 := "" + + +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 diff --git a/addons/godot_ai/migration_bridge.gd.uid b/addons/godot_ai/migration_bridge.gd.uid new file mode 100644 index 0000000..d53f987 --- /dev/null +++ b/addons/godot_ai/migration_bridge.gd.uid @@ -0,0 +1 @@ +uid://e3r4fem8egus diff --git a/addons/godot_ai/migration_coordinator.gd b/addons/godot_ai/migration_coordinator.gd new file mode 100644 index 0000000..ab2ab35 --- /dev/null +++ b/addons/godot_ai/migration_coordinator.gd @@ -0,0 +1,213 @@ +@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" +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") + 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: + settings.set_setting(status_setting(), JSON.stringify({"error": error})) + + +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_coordinator.gd.uid b/addons/godot_ai/migration_coordinator.gd.uid new file mode 100644 index 0000000..7b3ce25 --- /dev/null +++ b/addons/godot_ai/migration_coordinator.gd.uid @@ -0,0 +1 @@ +uid://qqsn55jmog6v diff --git a/addons/godot_ai/migration_fallback.gd b/addons/godot_ai/migration_fallback.gd new file mode 100644 index 0000000..5030a4d --- /dev/null +++ b/addons/godot_ai/migration_fallback.gd @@ -0,0 +1,217 @@ +@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", +] + + +static func available() -> bool: + return FileAccess.file_exists(FALLBACK_ZIP) + + +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_fallback.gd.uid b/addons/godot_ai/migration_fallback.gd.uid new file mode 100644 index 0000000..0851f5c --- /dev/null +++ b/addons/godot_ai/migration_fallback.gd.uid @@ -0,0 +1 @@ +uid://bym2nenea1nvd diff --git a/addons/godot_ai/migration_payload/godot-ai-v3-plugin.zip b/addons/godot_ai/migration_payload/godot-ai-v3-plugin.zip new file mode 100644 index 0000000..be3f088 Binary files /dev/null and b/addons/godot_ai/migration_payload/godot-ai-v3-plugin.zip 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 new file mode 100644 index 0000000..0f5e238 --- /dev/null +++ b/addons/godot_ai/migration_payload/godot-ai-v4-plugin.manifest.json @@ -0,0 +1 @@ +{"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 new file mode 100644 index 0000000..d7a34b8 Binary files /dev/null and b/addons/godot_ai/migration_payload/godot-ai-v4-plugin.manifest.sig 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 new file mode 100644 index 0000000..bf2ee8f Binary files /dev/null and b/addons/godot_ai/migration_payload/godot-ai-v4-plugin.zip differ diff --git a/addons/godot_ai/plugin.cfg b/addons/godot_ai/plugin.cfg index fcb1a55..75c2188 100644 --- a/addons/godot_ai/plugin.cfg +++ b/addons/godot_ai/plugin.cfg @@ -1,7 +1,7 @@ [plugin] -name="Godot AI" -description="MCP server and AI tools for Godot" +name="Godot AI v4 Migration" +description="One-click signed migration from Godot AI v3 to v4" author="Godot AI" -version="3.2.1" +version="4.1.0" script="plugin.gd" diff --git a/addons/godot_ai/plugin.gd b/addons/godot_ai/plugin.gd index 094ca21..dfe0fe4 100644 --- a/addons/godot_ai/plugin.gd +++ b/addons/godot_ai/plugin.gd @@ -1,764 +1,177 @@ @tool extends EditorPlugin -const GAME_HELPER_AUTOLOAD_NAME := "_mcp_game_helper" -const GAME_HELPER_AUTOLOAD_PATH := "res://addons/godot_ai/runtime/game_helper.gd" +## 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. -## 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") +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." +) -## EditorSettings keys used to remember which server process the plugin -## spawned — survives editor restarts, lets a later editor session adopt -## and manage a server it didn't spawn itself. See #135. -const MANAGED_SERVER_PID_SETTING := "godot_ai/managed_server_pid" -const MANAGED_SERVER_VERSION_SETTING := "godot_ai/managed_server_version" -const MANAGED_SERVER_WS_PORT_SETTING := "godot_ai/managed_server_ws_port" -## Per-launch WS handshake auth token (#690), generated at spawn and handed -## to the server via the GODOT_AI_WS_TOKEN spawn env. Persisted alongside -## the managed-server record so a reloaded plugin instance adopting the -## same server keeps authenticating; cleared with the rest of the record. -const MANAGED_SERVER_WS_TOKEN_SETTING := "godot_ai/managed_server_ws_token" -## keep_server_on_exit (#800): records whether the managed server was -## launched with the keep-alive env opt-outs, so a later session adopting -## the survivor routes its own editor exit through detach too. The live -## setting can't answer that — it may have changed since the spawn. -const MANAGED_SERVER_KEEP_ALIVE_SETTING := "godot_ai/managed_server_keep_alive" -const UPDATE_RELOAD_RUNNER_SCRIPT := preload("res://addons/godot_ai/update_reload_runner.gd") - -## Server lifecycle + port discovery extracted from this file (#297 PR 5). -## State enums + version-check seam extracted in PR 6 (#297). Plugin.gd -## keeps thin shims so the dock and characterization tests see an -## unchanged public surface; spawn-machinery state now lives in the -## lifecycle manager. -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") - -## 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; #398 was stale Script-object -## content from a mixed old/new snapshot, fixed by the runner's single-phase -## write-before-scan model. -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 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/" - -## The Python server writes its own PID here on startup (passed as -## `--pid-file`) and unlinks on clean exit. Deterministic replacement -## for scraping `netstat -ano` to find the port owner — especially on -## Windows where `OS.kill` on the uvx launcher doesn't take the Python -## child with it, and the scrape was the only path to the real PID. -## See issue for #154-era Windows update friction. -## Re-export of PortResolver.SERVER_PID_FILE so the spawn flags, the -## resolver, and characterization tests share one source of truth. -const SERVER_PID_FILE := PortResolver.SERVER_PID_FILE - -## How long we watch the spawned server for early exit. If the process is -## still alive when this expires, we stop watching. Mid-session crashes -## after this point get caught by the WebSocket disconnect flow. -const SERVER_WATCH_MS := 30 * 1000 -## Python's import graph (FastMCP + Rich + uvicorn) plus the pid-file write -## take a beat on cold starts, especially on Windows. Hold off on declaring -## a spawn a crash until this window elapses so the watch loop has time to -## observe either the pid-file (dev venv) or the port listening (uvx). -const SPAWN_GRACE_MS := 5 * 1000 -## Windows only (#797). A uv-created venv launches the real server under a -## different PID than the one `OS.create_process` returns, and that watched PID -## has been seen dying on a healthy boot while the server was still starting -## and had not written its pid-file yet. Past SPAWN_GRACE_MS that reads as -## "server exited" and only the crash-survivor adoption path rescues the -## session. While no pid-file has appeared we keep watching until this longer -## window closes, rather than calling a handoff an exit. Sized to cover a cold -## uvx resolve on top of the launcher hop, and kept well under SERVER_WATCH_MS -## so a genuinely dead Windows spawn is still diagnosed inside the watch rather -## than falling off the end of it. -const SPAWN_HANDOFF_MS := 15 * 1000 -const SERVER_STATUS_PATH := "/godot-ai/status" -const SERVER_STATUS_PROBE_TIMEOUT_MS := 800 -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 under the single-phase runner. The old syntactic -## lint counted bare `Mcp*.MEMBER` references, but #398 was caused by the -## runner scanning a mixed old/new snapshot and reusing stale Script-object -## content. Bare names and preload aliases can both be parsed against stale -## content under an old two-phase runner; from the fixed runner onward the -## full v(N+1) snapshot is written before the scan. In short: preload aliases -## are not the self-update safety metric. -## -## `tests/unit/test_plugin_self_update_safety.py` locks this wording in. -## -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 -## Spawn / stop / adopt orchestration plus state machine; allocated in -## `_init` so test fixtures (which never enter the tree) can drive -## `_start_server`. Owns `_server_pid`, `_server_state`, the version- -## check seam, and the adoption-confirmation deadline — see -## `utils/server_lifecycle.gd`. -var _lifecycle -static var _server_started_this_session := false # guard against re-entrant spawns -static var _resolved_ws_port := ClientConfigurator.DEFAULT_WS_PORT -## True once a startup walk has published a port via `_set_resolved_ws_port` -## this editor session. Gates the `_enter_tree` pre-resolution seed: a fresh -## session seeds `_resolved_ws_port` from the configured EditorSettings -## value, but a plugin reload must keep the prior instance's published -## resolution, which can legitimately differ from the configured value -## (Windows-reservation remap, adopted-server record). -static var _ws_port_resolution_published := false -## Per-launch WS handshake auth token (#690). Static for the same reason as -## _resolved_ws_port: a plugin reload in the same editor session adopts the -## server the previous instance spawned, and must keep its token. Empty -## when this editor never spawned a token-carrying server (dev servers, -## fresh installs) — the handshake then omits the field. -static var _ws_auth_token := "" - -## Server-watch timer lives on the plugin because it's a Node — the -## manager is RefCounted and can't host children. -var _server_watch_timer: Timer = null -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 - - -func _init() -> void: - _lifecycle = ServerLifecycleManager.new(self) +var _dock: VBoxContainer +var _label: Label +var _retry: Button +var _bridge +var _coordinator func _enter_tree() -> void: - _startup_trace_begin() - - ## `_process` is only used by the adoption-confirmation watcher; keep - ## it off until `_watch_for_adoption_confirmation` arms it, so the - ## plugin has zero per-frame cost in the common case. - set_process(false) - - ## #740: register the export plugin BEFORE the headless guard so - ## `godot --headless --export-*` runs strip the game-helper autoload - ## from exported packs too — CI export pipelines are headless. The - ## export plugin is inert outside exports: no server, no sockets. - _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") + _build_dock() + var prior := _bridge_status() + if not prior.is_empty(): + _present_error(str(prior.get("error", "The previous migration attempt did not finish."))) return - - ## Self-update extracts over the live addon and doesn't prune files that - ## disappeared from the new ZIP. Remove obsolete Logger-loader quarantine - ## files/folders once so upgraders match a fresh install. - _cleanup_legacy_logger_scripts() - - ## 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") - - ## With the startup walk's blocking port resolution deferred to a worker - ## (#678), the Connection below dials before `_set_resolved_ws_port` - ## publishes. Seed the pre-resolution port from the configured - ## EditorSettings value (a cheap main-thread read, not a blocking probe) - ## so the first dial honors a `godot_ai/ws_port` override — without this - ## it targeted the compile-time default and the override only took - ## effect on the 1s retry. - _resolved_ws_port = _startup_ws_port_seed( - _ws_port_resolution_published, - _resolved_ws_port, - ClientConfigurator.ws_port(), - ) - - ## #691: pre-warm the env snapshot on the main thread before any worker - ## exists, so worker-thread env reads (dock refresh/action workers, the - ## #678 startup walk's discovery worker) serve from the snapshot and can - ## never race the spawn window's setenv/unsetenv around - ## OS.create_process. - ClientConfigurator.warm_env_snapshot() - - _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() - ## #678: in the real editor, run the startup path's blocking probes and - ## kill-drain waits off the main thread so a contended port can't freeze - ## plugin init/reload. Set here (not _init) so test fixtures — which - ## extend this plugin but never enter the tree — keep the synchronous - ## default and can call-then-assert. - _lifecycle.defer_blocking_work = true - ## A completed self-update means the user's Update click already - ## authorized replacing the previous-version backend. Armed BEFORE the - ## startup walk (a peek, not a drain — `_flush_pending_self_update_telemetry` - ## below still owns the read-and-clear) so the walk can weak-proof-kill - ## the stale server an attach bridge kept alive, and bounded retries - ## absorb the bridge-respawn port race, instead of latching INCOMPATIBLE - ## and waiting for a manual recovery click. - if _pending_self_update_succeeded(): - _lifecycle.authorize_stale_recovery() - _start_server() - _startup_trace_phase("server_start") - - _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 - ## Restore the token before the first connect: after an editor restart - ## the static is empty but the managed-server record still names the - ## token the running server was spawned with (#690). A fresh spawn later - ## overwrites both via _set_ws_auth_token. - if _ws_auth_token.is_empty(): - _ws_auth_token = str(_read_managed_server_record().get("ws_token", "")) - _connection.auth_token = _ws_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", "") - if ( - not _lifecycle.is_connection_blocked() - and not ServerStateScript.is_terminal_diagnosis(_lifecycle.get_state()) - ): - _arm_server_version_check() - ## 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 - _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", - [_connection, ClientConfigurator.capture_launch_context()], - ) - _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]) - _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("get_project_setting", "project", &"get_project_setting") - _dispatcher.register_lazy("set_project_setting", "project", &"set_project_setting") - _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("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.setup(_connection, _log_buffer, self) - add_control_to_dock(DOCK_SLOT_RIGHT_BL, _dock) - _startup_trace_phase("dock_attached") - - _log_buffer.log("plugin loaded") - if _telemetry != null: - _telemetry.record_dock_startup() - _flush_pending_self_update_telemetry() - _telemetry.flush_pending_plugin_reload() - ## The startup-trace 'done' line is stamped by _start_server after the - ## (possibly suspended) walk completes — not here (#682 review). - - -## Public wrapper around the dev-server-toggle telemetry emit. Lets the -## dock (or any other caller) record without reaching into ``_telemetry`` -## directly — keeps the plugin's internal field encapsulated. The dev -## server is a Python subprocess unrelated to the plugin's own -## lifecycle, so emission can be synchronous (no EditorSettings persist -## dance like ``plugin_reload`` / ``self_update``). -func record_dev_server_toggle(action: String) -> void: - if _telemetry == null: + 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) return - _telemetry.record_dev_server_toggle(action) - - -## Non-draining read of the runner's pending self-update marker. Peeked -## before `_start_server` (the drain in `_flush_pending_self_update_telemetry` -## runs later, after the dock attaches) so the startup walk knows an update -## just completed. Only a `status == "success"` marker counts: a failed -## install left the OLD plugin version enabled, and killing a same-version -## backend is not this path's business. -func _pending_self_update_succeeded() -> bool: - var settings := EditorInterface.get_editor_settings() - if settings == null: - return false - var key := UPDATE_RELOAD_RUNNER_SCRIPT.PENDING_SELF_UPDATE_TELEMETRY_KEY - if not settings.has_setting(key): - return false - var raw := str(settings.get_setting(key)) - if raw.is_empty(): - return false - var parsed = JSON.parse_string(raw) - if typeof(parsed) != TYPE_DICTIONARY: - return false - return str(parsed.get("status", "")) == "success" - - -## Drain any self_update event written by `update_reload_runner` during the -## previous disable -> enable window. -func _flush_pending_self_update_telemetry() -> void: - var key := UPDATE_RELOAD_RUNNER_SCRIPT.PENDING_SELF_UPDATE_TELEMETRY_KEY - var parsed = Telemetry._drain_editor_setting_dict(key) - if parsed == null: + 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() + return + _present_state(FLOOR_MESSAGE, true, false) return - var status := str(parsed.get("status", "unknown")) - var error := str(parsed.get("error", "")) - ## Positional args: GDScript doesn't support keyword args in calls - ## (unlike Python). from/to versions ride the marker since the repin - ## gate landed; markers written by older runners simply lack them. - var from_version := str(parsed.get("from_version", "")) - var to_version := str(parsed.get("to_version", "")) - _telemetry.record_self_update(status, from_version, to_version, error) - ## After a successful update, previously-configured client entries still - ## pin the old server version; arm the dock's one-shot auto-repin so its - ## first healthy status sweep rewrites them without the user having to - ## click through the drift banner. The dock repins ONLY entries whose - ## sole drift is the old version pin — it needs `from_version` to render - ## that comparison, so a marker from an older runner (no version fields) - ## arms nothing and the drift banner stays the manual path. `has_method` - ## (not a typed call): the dock script is one of the files the update - ## just overwrote, and the untyped-reference convention applies across - ## that boundary. - if status == "success" and _dock != null and _dock.has_method("notify_self_update_success"): - _dock.notify_self_update_success(from_version) - - + _start_migration.call_deferred() func _exit_tree() -> void: - ## 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: - _server_started_this_session = false - _headless_disabled = false - return - - 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() - - # Drop the dispatcher's Callables AND its lazily-constructed handler - # instances (#736: handlers live in the dispatcher's cache now). Handler - # destructors run here, while their scripts are still loaded. - if _dispatcher: - _dispatcher.clear() - if _vision_routing: - _vision_routing.shutdown() - _vision_routing = null - - if _dock: + _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): remove_control_from_docks(_dock) _dock.queue_free() - _dock = 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 - - ## keep_server_on_exit (#800): the manager routes on the spawn-time - ## keep-alive flag (persisted in the managed-server record), NOT the - ## live setting — detach leaves the server for the next session (or a - ## same-session disable/enable cycle) to adopt. Explicit stops (dock - ## Restart, update reload) still kill via _stop_server. - _lifecycle.teardown_for_editor_exit() - ## Symmetric with prepare_for_update_reload: the static guard persists - ## across disable/enable within a single editor session, so the re-enabled - ## plugin instance's _start_server would short-circuit and never respawn. - ## Pre-#159 this was masked — the old kill path usually left Python alive - ## and the new instance adopted it on port 8000. Now that _stop_server is - ## deterministic, nothing is left to adopt and the reload hangs. - _server_started_this_session = false - print("MCP | plugin unloaded") + _dock = 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 _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) -## Remove old Logger-quarantine artifacts left by extract-over-live -## self-update. Idempotent: existence-guarded, so it's a no-op on fresh -## installs and symlinked dev checkouts. -func _cleanup_legacy_logger_scripts() -> void: - var legacy_files := [ - "res://addons/godot_ai/runtime/logger_loader.gd", - "res://addons/godot_ai/runtime/logger_loader.gd.uid", - "res://addons/godot_ai/testing/script_error_capture_loader.gd", - "res://addons/godot_ai/testing/script_error_capture_loader.gd.uid", - ] - for res_path in legacy_files: - if FileAccess.file_exists(res_path): - DirAccess.remove_absolute(ProjectSettings.globalize_path(res_path)) - var legacy_dirs := [ - "res://addons/godot_ai/runtime/loggers", - "res://addons/godot_ai/testing/loggers", - ] - for res_path in legacy_dirs: - var absolute := ProjectSettings.globalize_path(res_path) - if DirAccess.dir_exists_absolute(absolute): - _remove_dir_recursive_absolute(absolute) - - -static func _remove_dir_recursive_absolute(path: String) -> void: - var dir := DirAccess.open(path) - if dir == null: +func _start_migration() -> void: + if _bridge != null or _coordinator != null: return - dir.list_dir_begin() - var name := dir.get_next() - while not name.is_empty(): - var child := path.path_join(name) - if dir.current_is_dir(): - _remove_dir_recursive_absolute(child) - else: - DirAccess.remove_absolute(child) - name = dir.get_next() - dir.list_dir_end() - DirAccess.remove_absolute(path) + _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() -func _detach_editor_logger() -> void: - if _editor_logger != null: - OS.remove_logger(_editor_logger) - _editor_logger = null +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 -## 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(): +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 - _ensure_game_helper_autoload() + if not _editor_available(): + _present_state(EDITOR_REQUIRED, true, false) + return + _start_migration() -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 _free_bridge() -> void: + if _bridge != null and is_instance_valid(_bridge): + _bridge.queue_free() + _bridge = null -static func _mcp_disabled_for_headless(args: PackedStringArray, display_name: String, allow_value: String) -> bool: - if McpSettings.truthy(allow_value): +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": return false - return _args_request_headless(args) or display_name.to_lower() == "headless" + 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" static func _args_request_headless(args: PackedStringArray) -> bool: @@ -773,1356 +186,17 @@ static func _args_request_headless(args: PackedStringArray) -> bool: return false - - -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: - ## Fire-and-forget: the walk is a coroutine in production (#678). Its - ## completion continuation must NOT live in this method — a reload can - ## free this plugin while the walk is suspended, and resuming a freed - ## Node's coroutine errors out. The manager calls - ## `_finish_startup_trace_after_walk` on walk completion instead, - ## guarded by is_instance_valid. - _lifecycle.start_server() - - -## Called by the lifecycle manager when the (possibly suspended) startup -## walk completes — the point where the real startup outcome is known, so -## the trace 'done' line reports the true contended-port path and duration -## instead of a pre-walk placeholder (#682 review). -func _finish_startup_trace_after_walk() -> void: - var startup_path: String = str(_lifecycle.get_startup_path()) - _startup_trace_finish(startup_path if not startup_path.is_empty() else "loaded") - - -## Test-fixture shim — characterization tests in test_plugin_lifecycle -## reach for this instance method directly. Delegates to the manager's -## state-owning copy. -func _set_incompatible_server(live: Dictionary, expected_version: String, port: int) -> void: - _lifecycle._set_incompatible_server(live, expected_version, port) - - -## Static shim — kept on the plugin class because the characterization -## tests assert against `GodotAiPlugin._incompatible_server_message`. -## Implementation moved to ServerLifecycleManager. -static func _incompatible_server_message( - live: Dictionary, - expected_version: String, - port: int, - expected_ws_port: int -) -> String: - return ServerLifecycleManager._incompatible_server_message( - live, expected_version, port, expected_ws_port - ) - - -static func _server_version_compatibility( - actual_version: String, expected_version: String -) -> Dictionary: - return ServerLifecycleManager._server_version_compatibility( - actual_version, expected_version - ) - - -static func _server_status_compatibility( - actual_version: String, - expected_version: String, - actual_ws_port: int, - expected_ws_port: int, -) -> Dictionary: - return ServerLifecycleManager._server_status_compatibility( - actual_version, expected_version, actual_ws_port, expected_ws_port - ) - - -static func _managed_record_has_version_drift(record_version: String, current_version: String) -> bool: - return ServerLifecycleManager._managed_record_has_version_drift(record_version, current_version) - - -static func _probe_live_server_status(port: int, timeout_ms: int = SERVER_STATUS_PROBE_TIMEOUT_MS) -> Dictionary: - var result := { - "reachable": false, - "version": "", - "name": "", - "ws_port": 0, - "status_code": 0, - "error": "", - } - var client := HTTPClient.new() - var err := client.connect_to_host("127.0.0.1", port) - if err != OK: - result["error"] = "connect_%d" % err - return result - var deadline := Time.get_ticks_msec() + timeout_ms - while client.get_status() == HTTPClient.STATUS_RESOLVING or client.get_status() == 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 - err = client.request(HTTPClient.METHOD_GET, SERVER_STATUS_PATH, ["Accept: application/json"]) - if err != OK: - result["error"] = "request_%d" % err - 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 chunk.size() > 0: - 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) - var response_code := client.get_response_code() - result["status_code"] = response_code - if response_code != 200: - result["error"] = "http_%d" % response_code - return result - var parsed = 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 - - -## Project a parsed `/godot-ai/status` body into the probe's result shape. -## -## Extracted from the probe so it can be tested against a real payload. The -## probe is a whitelist — a field the server publishes does not reach callers -## unless it is copied here — and that is silent: the consumer just sees a -## missing key. #824's lease check shipped reading `active_lease_count` while -## this projection dropped it, so the branch was dead on every platform, and -## the tests could not see it because they hand-built the result dict this -## function is supposed to produce. Add new fields here, and cover them with a -## projection test rather than a fabricated `live_status`. -static func _project_status_payload(parsed: Dictionary) -> Dictionary: - var projected := { - "reachable": true, - "name": str(parsed.get("name", "")), - "version": _extract_server_version(parsed), - "ws_port": int(parsed.get("ws_port", 0)), - ## `package_path` was added in v2.4.4 (#416) so the dock's - ## "Incompatible server" banner can name the source of a version - ## skew. Older servers omit it; treat the missing field as "". - "package_path": str(parsed.get("package_path", "")), - } - ## #824: advisory attach-lease count, consumed by teardown to decide - ## detach-vs-kill. Absent stays absent rather than defaulting to 0, so - ## `ServerLifecycleManager.active_lease_count` keeps distinguishing "backend - ## too old to publish this" from "backend reports zero leases" — both stop - ## the server, but only one of them is a compatibility statement. - ## Anything that is not a finite whole number is dropped, for the same - ## reason the value is clamped downstream: a malformed count must not read - ## as occupancy and keep a server alive. Godot parses every JSON number as - ## a float, so the whole-number test is what distinguishes a real count - ## from junk — truncating 1.5 to 1 would manufacture a held lease. - var raw: Variant = parsed.get("active_lease_count") - if raw is int or raw is float: - var numeric := float(raw) - if is_finite(numeric) and numeric == floor(numeric): - projected["active_lease_count"] = int(numeric) - return projected - - -func _probe_live_server_status_for_port(port: int) -> Dictionary: - _startup_trace_count("http_status_probe") - return _probe_live_server_status(port) - - -static func _extract_server_version(payload: Dictionary) -> String: - var version := str(payload.get("server_version", "")) - if version.is_empty(): - version = str(payload.get("version", "")) - return version - - -static func _live_status_identifies_godot_ai(live: Dictionary) -> bool: - return ServerLifecycleManager._live_status_identifies_godot_ai(live) - - -func _verified_status_version(live: Dictionary) -> String: - if not ServerLifecycleManager._live_status_identifies_godot_ai(live): - return "" - return str(live.get("version", "")) - - -func _verified_status_ws_port(live: Dictionary) -> int: - if not ServerLifecycleManager._live_status_identifies_godot_ai(live): - return 0 - return int(live.get("ws_port", 0)) - - -func _refresh_dock_client_statuses() -> bool: - if _dock == null: - return false - if not _dock.has_method("_refresh_all_client_statuses"): - return false - _dock.call("_refresh_all_client_statuses") - return true - - -## Test-fixture shim — characterization tests in test_plugin_lifecycle -## still drive the first-writer-wins terminal-diagnosis behaviour through -## this method. Delegates to the manager's `set_terminal_diagnosis` -## (which preserves the same first-writer-wins contract). -func _set_spawn_state(state: int) -> void: - _lifecycle.set_terminal_diagnosis(state) - - -## Arm the one-shot connection watcher. Called from `_start_server`'s -## FOREIGN_PORT branch: we flagged the diagnostic preemptively assuming -## the port holder doesn't speak MCP, but if it turns out to be another -## editor's server our WebSocket will open and we need to retract the -## diagnostic. -## -## We intentionally poll `_connection.is_connected` from `_process` -## instead of wiring a new signal on McpConnection. A signal added in the -## same release as a new consumer would be another shape-coupled update: -## old two-phase runners can parse the consumer while the McpConnection -## Script object still reflects v(N). Polling only reads `is_connected` -## (present on every shipped McpConnection), so old-runner upgrade windows -## do not depend on a same-release signal addition. -## -## The watch self-disarms after SPAWN_GRACE_MS so per-frame cost drops -## back to zero if it is ever armed by a legacy adoption path. -func _watch_for_adoption_confirmation() -> void: - _lifecycle.arm_adoption_watch() - _update_process_enabled() - - -func _arm_server_version_check() -> void: - ## `arm_version_check` resolves an empty expected via the plugin - ## version, so we can pass the raw field value through. - _lifecycle.arm_version_check(_connection, str(_lifecycle._server_expected_version)) - _update_process_enabled() - - -func _update_process_enabled() -> void: - if _lifecycle == null: - set_process(false) - return - set_process( - _lifecycle.get_adoption_watch_deadline_ms() > 0 - or _lifecycle.is_awaiting_server_version() - ) - - -func _process(_delta: float) -> void: - ## Guard: during script-reload / dual-plugin enable races `_lifecycle` - ## can be null while process is still armed — spam would otherwise flood - ## the Output dock every frame. - if _lifecycle == null: - set_process(false) - return - var now := Time.get_ticks_msec() - var version_check = _lifecycle.get_version_check() - if version_check != null: - version_check.tick(now) - _lifecycle.tick_adoption_watch(now) - _update_process_enabled() - - -## A WebSocket opening only proves the occupant speaks enough of the editor -## protocol to accept a session. Compatibility is decided by the server -## version in `handshake_ack`, so this only arms that check. -func _on_connection_established() -> void: - if _lifecycle.get_state() == ServerStateScript.FOREIGN_PORT: - _arm_server_version_check() - - -## Test-fixture shim — characterization tests poke the verified path -## directly. Delegates to the version-check seam; the manager resolves -## an empty expected version via `_resolve_expected_version`. -func _on_server_version_verified(version: String) -> void: - _lifecycle.handle_server_version_verified( - str(_lifecycle._server_expected_version), version - ) - _update_process_enabled() - - -## Test-fixture shim — same shape as `_on_server_version_verified`. -func _on_server_version_unverified() -> void: - _lifecycle.handle_server_version_unverified( - str(_lifecycle._server_expected_version) - ) - _update_process_enabled() - - -## Start a 1s-tick timer that watches the spawned server for up to -## SERVER_WATCH_MS. If the process dies inside the window we drain the -## captured pipes and mark the server as crashed so the dock can surface -## what went wrong. After the window expires we close the pipes so they -## don't pin file descriptors or fill their kernel buffers. See #146. -func _start_server_watch() -> void: - _stop_server_watch() - _server_watch_timer = Timer.new() - _server_watch_timer.wait_time = 1.0 - _server_watch_timer.one_shot = false - _server_watch_timer.timeout.connect(_check_server_health) - add_child(_server_watch_timer) - _server_watch_timer.start() - - -func _stop_server_watch() -> void: - if _server_watch_timer != null: - _server_watch_timer.stop() - _server_watch_timer.queue_free() - _server_watch_timer = null - - -func _check_server_health() -> void: - _lifecycle.check_server_health() - - -## True when the first spawn looks like a stale-uvx-index failure and we -## haven't already retried. Fail signal: launcher process already declared -## dead by the caller, pid-file was never written (Python never got to -## argparse), and we're on the uvx tier (the only tier where `--refresh` -## means anything). Bug #172 — after a fresh PyPI publish, uvx's local -## index metadata keeps saying the new version doesn't exist for ~10 min, -## which cascaded into an infinite reconnect loop pre-#171. Retry-at-spawn -## catches every entry path (Update, Reload Plugin, Reconnect, editor -## restart, crash recovery) — unlike the older Update-only precheck. -func _should_retry_with_refresh() -> bool: - return _retry_with_refresh_allowed( - _lifecycle._refresh_retried, - ClientConfigurator.get_server_launch_mode(), - _read_pid_file(), - ) - - -## Pure decision helper — environment-state readers stay in the instance -## method above, the logic lives here so tests can drive the three inputs -## directly without spoofing static caches or pid-files on disk. -static func _retry_with_refresh_allowed(already_retried: bool, launch_mode: String, pid_from_file: int) -> bool: - return ( - not already_retried - and launch_mode == "uvx" - and pid_from_file == 0 - ) - - -func _respawn_with_refresh() -> void: - _lifecycle.respawn_with_refresh() - - -## 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's ownership label. Positive = a PID this -## plugin instance spawned (or re-acquired via the managed record); -1 = an -## adopted external/attach-owned backend. Display only — adoption transfers -## end-of-life responsibility, so this value is never kill proof (#669). -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: - _ws_port_resolution_published = true - _resolved_ws_port = port - if _connection != null: - _connection.ws_port = port - - -## Pure decision helper — environment-state reads (the published flag, the -## EditorSettings port) stay in `_enter_tree`; the logic lives here so tests -## can drive the three inputs directly without mutating the shared statics. -static func _startup_ws_port_seed( - resolution_published: bool, - session_ws_port: int, - configured_ws_port: int -) -> int: - return session_ws_port if resolution_published else configured_ws_port - - -func _resolve_ws_port() -> int: - return PortResolver.resolve_ws_port( - ClientConfigurator.ws_port(), - ClientConfigurator.MAX_PORT, - _log_buffer, - ) - - -## Test-compat shim — characterization tests call this static directly. -static func _resolved_ws_port_for_existing_server( - record_ws_port: int, - record_version: String, - current_version: String, - fresh_resolved: int -) -> int: - return PortResolver.resolved_ws_port_for_existing_server( - record_ws_port, - record_version, - current_version, - fresh_resolved, - ) - - -static func _resolve_ws_port_from_output( - configured_port: int, - netsh_output: String, - span: int = 2048 -) -> int: - return PortResolver.resolve_ws_port_from_output( - configured_port, - netsh_output, - ClientConfigurator.MAX_PORT, - span, - ) - - -## Plugin-level shim around the resolver — keeps the startup-trace -## counter wiring and the `_ProofPlugin` override hook on the plugin. -## The scrape takes `_startup_trace_count` directly so the counter names -## track the scraper that actually ran (Windows can fall through netstat -## → PowerShell; the fallback used to hide under the `netstat` count). -func _is_port_in_use(port: int) -> bool: - if PortResolver.can_bind_local_port(port): - ## POSIX can still have an IPv6 wildcard listener on this port - ## even when an IPv4 loopback bind succeeds. Confirm through - ## lsof so startup and kill-path discovery agree. - if OS.get_name() != "Windows": - return PortResolver.is_port_in_use_via_scrape(port, _startup_trace_count) - return false - return PortResolver.is_port_in_use_via_scrape(port, _startup_trace_count) - - -## Pass `_startup_trace_count` so the resolver bumps the right counter -## per scraper that actually ran (Windows can fall through netstat → -## PowerShell — counting both unconditionally would over-report). -func _find_pid_on_port(port: int) -> int: - return PortResolver.find_pid_on_port(port, _startup_trace_count) - - -func _find_all_pids_on_port(port: int) -> Array[int]: - return PortResolver.find_all_pids_on_port(port, _startup_trace_count) - - -static func _execute_windows_powershell(script: String, output: Array) -> int: - return PortResolver.execute_windows_powershell(script, output) - - -static func _windows_listener_pids_from_execute_result(exit_code: int, output: Array) -> Array[int]: - return PortResolver.windows_listener_pids_from_execute_result(exit_code, output) - - -static func _windows_listener_execute_result_in_use(exit_code: int, output: Array) -> bool: - return PortResolver.windows_listener_execute_result_in_use(exit_code, output) - - -static func _parse_lsof_pids(raw: String) -> Array[int]: - return PortResolver.parse_lsof_pids(raw) - - -static func _parse_pid_lines(raw: String) -> Array[int]: - return PortResolver.parse_pid_lines(raw) - - -## Find the managed server PID deterministically: prefer the pid-file -## the Python server writes on startup (see runtime_info.py), fall back -## to scraping `netstat -ano` / `lsof` only when the file is missing or -## stale. This is the replacement for raw port-scraping: on Windows the -## uvx launcher PID doesn't cover the Python child, and netstat parsing -## is fragile. -## -## Returns 0 when no server can be identified. -func _find_managed_pid(port: int) -> int: - var pid := _read_pid_file() - if pid > 0 and _pid_alive(pid): - return pid - return _find_pid_on_port(port) - - -## `live` is the result of a prior `_probe_live_server_status_for_port` -## call that the caller already has on hand. When non-empty it short- -## circuits the internal probe at the bottom of this helper, so a single -## `_start_server` invocation that probes once at the top can thread the -## same snapshot through compatibility check + recovery without paying -## for a second ~500 ms localhost HTTPClient poll loop. Default `{}` -## preserves the historical behavior for callers outside the spawn flow -## (`can_recover_incompatible_server`, the dock's UI buttons), where a -## fresh probe is the right thing. -## `record_override`: a managed-server record snapshot the caller already -## read. Non-empty skips the internal `_read_managed_server_record()` — -## required when this helper runs on a worker thread (#678), because the -## record lives in EditorSettings, which is main-thread-only. `{}` keeps -## the historical read-it-here behavior for synchronous callers -## (`_read_managed_server_record` never returns a bare `{}`, so the -## sentinel is unambiguous). -func _evaluate_strong_port_occupant_proof(port: int, live: Dictionary = {}, record_override: Dictionary = {}) -> Dictionary: - var result := {"proof": "", "pids": []} - var listener_pids := _find_all_pids_on_port(port) - if listener_pids.is_empty(): - return result - - var record: Dictionary = record_override if not record_override.is_empty() else _read_managed_server_record() - var record_pid := int(record.get("pid", 0)) - var record_version := str(record.get("version", "")) - - if record_pid > 1 and record_pid != OS.get_process_id(): - ## Brand-verify the recorded PID before trusting it as a kill target. - ## A recorded PID can outlive the server it named and be recycled by - ## the kernel for an unrelated process that happens to bind the same - ## port — without the cmdline brand gate (the same one the - ## `pidfile_listener` branch enforces) that process could be killed. - ## See #525. - if ( - listener_pids.has(record_pid) - and _pid_alive_for_proof(record_pid) - and _pid_cmdline_is_godot_ai_for_proof(record_pid) - ): - return {"proof": "managed_record", "pids": [record_pid]} - - var legacy_targets := _legacy_pidfile_kill_targets(port, listener_pids) - if not legacy_targets.is_empty(): - return {"proof": "pidfile_listener", "pids": legacy_targets} - - var current_live: Dictionary = live if not live.is_empty() else _probe_live_server_status_for_port(port) - if ( - _live_status_identifies_godot_ai(current_live) - and not record_version.is_empty() - and str(current_live.get("version", "")) == record_version - ): - ## Brand-check every listener before returning it as a kill target - ## (#686): the /godot-ai/status match proves *a* godot-ai server owns - ## the port, but `listener_pids` is a raw scrape that can include an - ## unrelated process sharing the port number (e.g. a ::1-only - ## listener lsof reports alongside our IPv4 one). The other two tiers - ## brand-check every target (#525); this tier feeds the fully - ## automatic start_server drift-kill path, so it must too. - var branded_listeners: Array[int] = [] - for pid in listener_pids: - var listener_pid := int(pid) - if _pid_cmdline_is_godot_ai_for_proof(listener_pid): - branded_listeners.append(listener_pid) - if not branded_listeners.is_empty(): - return {"proof": "status_matches_record", "pids": branded_listeners} - - return result - - -## See `_evaluate_strong_port_occupant_proof` for the `live` and -## `record_override` contracts. Threads both through the strong-proof -## delegate so neither helper probes when the caller already knows the -## port-owner status, and so callers running this on a worker thread -## (#712) can inject the EditorSettings record read on the main thread. -func _evaluate_recovery_port_occupant_proof( - port: int, live: Dictionary = {}, record_override: Dictionary = {} -) -> Dictionary: - var proof := _evaluate_strong_port_occupant_proof(port, live, record_override) - if not str(proof.get("proof", "")).is_empty(): - return proof - - var current_live: Dictionary = live if not live.is_empty() else _probe_live_server_status_for_port(port) - if _live_status_identifies_godot_ai(current_live): - return {"proof": "status_name", "pids": _find_all_pids_on_port(port)} - - return {"proof": "", "pids": []} - - -## Seam over the static pre-warm so lifecycle recovery flows can fire it -## through the host (`_host._prewarm_server_package(...)`) and test stubs -## can record the call instead of spawning a real uvx process. Worker-safe: -## the static touches only CliFinder (mutex-guarded) and OS.create_process. -func _prewarm_server_package(version: String) -> int: - return ClientConfigurator.prewarm_server_package(version) - - -func _recover_strong_port_occupant(port: int, wait_s: float, pre_kill_live: Dictionary = {}) -> bool: - ## `await` because the manager method is a coroutine in production - ## (#678); with `defer_blocking_work` off it completes synchronously - ## and this await is a pass-through. - return await _lifecycle.recover_strong_port_occupant(port, wait_s, pre_kill_live) - - -func _legacy_pidfile_kill_targets(_port: int, listener_pids: Array[int]) -> Array[int]: - var targets: Array[int] = [] - var pidfile_pid := _read_pid_file_for_proof() - if pidfile_pid <= 1 or pidfile_pid == OS.get_process_id(): - return targets - ## An alive, branded pid-file PID is sufficient ownership proof. Under - ## `uvicorn --reload` the reloader writes the pid-file but a child worker - ## binds the port, so `listener_pids` never contains the reloader PID. - ## Requiring `listener_pids.has(pidfile_pid)` here used to silently skip - ## the kill path for the entire reload-shaped server family. The branded - ## listener loop below still does the per-PID brand check so we never - ## kill an unrelated process that happens to share the port. - if not _pid_alive_for_proof(pidfile_pid) or not _pid_cmdline_is_godot_ai_for_proof(pidfile_pid): - return targets - - for pid in listener_pids: - if pid <= 1 or pid == OS.get_process_id(): - continue - ## Reuse the brand result already proven above when this listener is - ## the same PID as the pidfile — saves a parent-chain walk and a - ## shell-out (PowerShell on Windows, /proc on Linux, ps on macOS) per - ## startup proof evaluation. - if pid == pidfile_pid or _pid_cmdline_is_godot_ai_for_proof(pid): - targets.append(pid) - ## Also kill the reloader/launcher itself when it isn't already a listener. - ## Without this, `--reload` workers would be killed but their parent would - ## immediately respawn a replacement and the port would never free. - if not targets.has(pidfile_pid): - targets.append(pidfile_pid) - return targets - - -func _read_pid_file_for_proof() -> int: - return _read_pid_file() - - -func _pid_alive_for_proof(pid: int) -> bool: - return _pid_alive(pid) - - -func _pid_cmdline_is_godot_ai_for_proof(pid: int) -> bool: - return _pid_cmdline_is_godot_ai(pid) - - -static func _parse_windows_netstat_pid(stdout: String, port: int) -> int: - return PortResolver.parse_windows_netstat_pid(stdout, port) - - -static func _parse_windows_netstat_pids(stdout: String, port: int) -> Array[int]: - return PortResolver.parse_windows_netstat_pids(stdout, port) - - -static func _parse_windows_netstat_listening(stdout: String, port: int) -> bool: - return PortResolver.parse_windows_netstat_listening(stdout, port) - - -static func _split_on_whitespace(s: String) -> PackedStringArray: - return PortResolver.split_on_whitespace(s) - - -static func _read_pid_file() -> int: - return PortResolver.read_pid_file() - - -static func _clear_pid_file() -> void: - PortResolver.clear_pid_file() - - -func _stop_server() -> void: - _lifecycle.stop_server() - - - - -## Clear the managed-server record and pid-file only if `port` is free. -## Returns true when state was cleared. Extracted from `_stop_server` so -## the "preserve on failed kill" contract is independently testable. -func _finalize_stop_if_port_free(port: int) -> bool: - if _is_port_in_use(port): - return false - _clear_managed_server_record() - _clear_pid_file() - return true - - -## Shared tail of the server CLI: transport, ports, and `--pid-file`. Both -## the initial spawn in `_start_server` and the `--refresh` retry in -## `_respawn_with_refresh` go through here so a new flag added in one place -## can't silently drop out of the other. -static func _build_server_flags(port: int, ws_port: int) -> Array[String]: - var flags: Array[String] = [] - flags.assign([ - "--transport", "streamable-http", - "--port", str(port), - "--ws-port", str(ws_port), - "--pid-file", ProjectSettings.globalize_path(SERVER_PID_FILE), - ]) - ## Append `--exclude-domains` only when the user has actually picked at - ## least one domain to drop. Skipping the empty case keeps spawns - ## compatible with older (pre-1.4.2) servers that don't know the flag — - ## relevant during staggered plugin/server upgrades in user-mode installs. - var excluded := ClientConfigurator.excluded_domains() - if not excluded.is_empty(): - flags.append("--exclude-domains") - flags.append(excluded) - ## LAN opt-in (#507, server core #421): pass `--allow-host` only when the - ## developer-mode Settings tab named at least one CIDR / bare IP. Skipping - ## the empty case keeps the default spawn byte-for-byte identical and - ## compatible with older servers that don't know the flag — same pattern - ## as `--exclude-domains` above. - var allow_hosts := ClientConfigurator.allow_hosts() - if not allow_hosts.is_empty(): - flags.append("--allow-host") - flags.append(allow_hosts) - return flags - - -## Returns true only when we can prove `pid`'s command line carries the -## `godot-ai` brand AND a server flag (`--pid-file` / `--transport`). Used by -## automatic kill paths (`_legacy_pidfile_kill_targets`) so a stale pidfile -## whose PID has been recycled by an unrelated listener can't hand us a -## kill target. If the OS lookup fails or returns an empty cmdline we -## conservatively return false — better to surface incompatible-server and -## let the user click Restart than to kill the wrong process. -func _pid_cmdline_is_godot_ai(pid: int) -> bool: - ## Walks up the parent chain so a uvicorn `--reload` worker whose - ## cmdline is just `multiprocessing.spawn` still matches when its - ## parent reloader carries the godot_ai brand. Bound the walk so a - ## hypothetical loop or runaway PPID can't stall the editor. - var current := pid - for _i in range(5): - if current <= 1: - return false - var cmd := "" - if OS.get_name() == "Windows": - cmd = _windows_pid_commandline(current) - else: - cmd = _posix_pid_commandline(current) - if _commandline_is_godot_ai_server(cmd): - return true - current = _pid_parent(current) - return false - - -func _pid_parent(pid: int) -> int: - if pid <= 1: - return 0 - if OS.get_name() == "Windows": - var output: Array = [] - var script := ( - "Get-CimInstance Win32_Process -Filter 'ProcessId = %d' | " - + "Select-Object -ExpandProperty ParentProcessId" - ) % pid - _startup_trace_count("powershell") - if _execute_windows_powershell(script, output) != 0 or output.is_empty(): - return 0 - return int(str(output[0]).strip_edges()) - var output_posix: Array = [] - if OS.execute("ps", ["-o", "ppid=", "-p", str(pid)], output_posix, true) != 0 or output_posix.is_empty(): - return 0 - return int(str(output_posix[0]).strip_edges()) - - -static func _commandline_is_godot_ai_server(cmd: String) -> bool: - if cmd.is_empty(): - return false - var lower := cmd.to_lower() - ## The server is invoked with `--pid-file /godot_ai_server.pid`, - ## so the path itself contains "godot_ai". A naive substring brand - ## search would falsely match an unrelated process whose cmdline - ## happens to reference a similarly-named pidfile path. Strip the - ## value (but leave the bare flag for the has_flag check) before - ## brand matching. - var brand_search := _strip_pidfile_value(lower) - var has_brand := brand_search.find("godot-ai") >= 0 or brand_search.find("godot_ai") >= 0 - var has_flag := lower.find("--pid-file") >= 0 or lower.find("--transport") >= 0 - return has_brand and has_flag - - -static func _strip_pidfile_value(cmd: String) -> String: - var rx := RegEx.new() - ## Match `--pid-file=` and `--pid-file `; keep the bare - ## flag so the flag-presence check still succeeds for a real server. - if rx.compile("--pid-file(?:=|\\s+)\\S+") != OK: - return cmd - return rx.sub(cmd, "--pid-file ", true) - - -func _windows_pid_commandline(pid: int) -> String: - var output: Array = [] - var script := ( - "Get-CimInstance Win32_Process -Filter 'ProcessId = %d' | " - + "Select-Object -ExpandProperty CommandLine" - ) % pid - _startup_trace_count("powershell") - var exit_code := _execute_windows_powershell(script, output) - if exit_code != 0 or output.is_empty(): - return "" - return str(output[0]) - - -## POSIX command-line lookup. Linux exposes `/proc//cmdline` as -## NUL-separated argv — read it directly so we avoid a `ps` fork on Linux -## and get the full argv rather than the truncated/quoted form some `ps` -## builds emit. Falls back to `ps -ww -p -o args=` on macOS / *BSD, -## which lack a Linux-style `/proc//cmdline`. Returns "" on failure -## so callers conservatively reject the PID rather than killing it blind. -func _posix_pid_commandline(pid: int) -> String: - var proc_path := "/proc/%d/cmdline" % pid - if FileAccess.file_exists(proc_path): - var f := FileAccess.open(proc_path, FileAccess.READ) - if f != null: - ## procfs pseudo-files report length 0 (the kernel generates - ## content on read). `get_length()` therefore returns 0 and - ## `get_buffer(0)` reads nothing. Read in chunks until EOF - ## instead. Cap at ARG_MAX-class bound so a hypothetically - ## misbehaving file can never stall the editor frame. - var bytes := PackedByteArray() - var max_bytes := 1 << 20 # 1 MiB - while bytes.size() < max_bytes: - var chunk := f.get_buffer(4096) - if chunk.is_empty(): - break - bytes.append_array(chunk) - if f.eof_reached(): - break - f.close() - ## /proc cmdline is NUL-separated argv; convert NULs to spaces - ## so the substring fingerprint matches the same way it does on - ## the Windows path. Empty (kernel threads, exited processes) - ## bubbles up as "" via the strip below. - for i in range(bytes.size()): - if bytes[i] == 0: - bytes[i] = 0x20 - return bytes.get_string_from_utf8().strip_edges() - ## `-ww` removes ps's column-width truncation so trailing flags like - ## --pid-file / --transport aren't dropped from the args= field. - ## Both procps (Linux) and BSD ps (macOS / *BSD) accept the - ## double-w form. - var output: Array = [] - var exit_code := OS.execute("ps", ["-ww", "-p", str(pid), "-o", "args="], output, true) - if exit_code != 0 or output.is_empty(): - return "" - return str(output[0]).strip_edges() - - -## True if the given PID corresponds to a live (non-zombie) process. -## POSIX uses `ps -o stat=` (see inline comment for the zombie rationale); -## Windows uses `tasklist`. Called by `_start_server` to distinguish a live -## managed server that outlived its editor from a stale EditorSettings -## record, and by `_check_server_health` to detect a fast-failing launcher. -static func _pid_alive(pid: int) -> bool: - return PortResolver.pid_alive(pid) - - -## Calls `_is_port_in_use` (not `PortResolver.wait_for_port_free`) so -## `_ProofPlugin` overrides keep driving the loop. -func _wait_for_port_free(port: int, timeout_s: float) -> void: - var deadline := Time.get_ticks_msec() + int(timeout_s * 1000.0) - while _is_port_in_use(port): - if Time.get_ticks_msec() >= deadline: - push_warning("MCP | port %d still in use after %.1fs — proceeding anyway" % [port, timeout_s]) - return - OS.delay_msec(100) - - -func _read_managed_server_record() -> Dictionary: - var es := EditorInterface.get_editor_settings() - if es == null: - return {"pid": 0, "version": "", "ws_port": 0, "ws_token": "", "keep_alive": false} - var pid: int = 0 - if es.has_setting(MANAGED_SERVER_PID_SETTING): - pid = int(es.get_setting(MANAGED_SERVER_PID_SETTING)) - var version: String = "" - if es.has_setting(MANAGED_SERVER_VERSION_SETTING): - version = str(es.get_setting(MANAGED_SERVER_VERSION_SETTING)) - var ws_port: int = 0 - if es.has_setting(MANAGED_SERVER_WS_PORT_SETTING): - ws_port = int(es.get_setting(MANAGED_SERVER_WS_PORT_SETTING)) - var ws_token: String = "" - if es.has_setting(MANAGED_SERVER_WS_TOKEN_SETTING): - ws_token = str(es.get_setting(MANAGED_SERVER_WS_TOKEN_SETTING)) - var keep_alive := false - if es.has_setting(MANAGED_SERVER_KEEP_ALIVE_SETTING): - keep_alive = bool(es.get_setting(MANAGED_SERVER_KEEP_ALIVE_SETTING)) - return { - "pid": pid, - "version": version, - "ws_port": ws_port, - "ws_token": ws_token, - "keep_alive": keep_alive, - } - - -func _write_managed_server_record(pid: int, version: String, keep_alive: bool = false) -> void: - var es := EditorInterface.get_editor_settings() - if es == null: - return - es.set_setting(MANAGED_SERVER_PID_SETTING, pid) - es.set_setting(MANAGED_SERVER_VERSION_SETTING, version) - es.set_setting(MANAGED_SERVER_WS_PORT_SETTING, _resolved_ws_port) - es.set_setting(MANAGED_SERVER_WS_TOKEN_SETTING, _ws_auth_token) - es.set_setting(MANAGED_SERVER_KEEP_ALIVE_SETTING, keep_alive) - - -## Keep the in-memory token, the connection's handshake field, and (via the -## next _write_managed_server_record) the persisted record in one place so -## the three can't drift. Empty token = "send no auth_token field". -func _set_ws_auth_token(token: String) -> void: - _ws_auth_token = token - if _connection != null: - _connection.auth_token = token - - -func _clear_managed_server_record() -> void: - ## Drop the in-memory token together with the persisted one: a cleared - ## record means "no managed server", and a surviving static would make - ## the next handshake send a stale token — the exact present-but-wrong - ## shape a newer spawned server rejects with 4003. (Runs before the - ## es == null early return on purpose: the in-memory scrub must not - ## depend on EditorSettings being available.) - _set_ws_auth_token("") - var es := EditorInterface.get_editor_settings() - if es == null: - return - if es.has_setting(MANAGED_SERVER_PID_SETTING): - es.set_setting(MANAGED_SERVER_PID_SETTING, 0) - if es.has_setting(MANAGED_SERVER_VERSION_SETTING): - es.set_setting(MANAGED_SERVER_VERSION_SETTING, "") - if es.has_setting(MANAGED_SERVER_WS_PORT_SETTING): - es.set_setting(MANAGED_SERVER_WS_PORT_SETTING, 0) - if es.has_setting(MANAGED_SERVER_WS_TOKEN_SETTING): - es.set_setting(MANAGED_SERVER_WS_TOKEN_SETTING, "") - if es.has_setting(MANAGED_SERVER_KEEP_ALIVE_SETTING): - es.set_setting(MANAGED_SERVER_KEEP_ALIVE_SETTING, false) - - -func prepare_for_update_reload() -> void: - if _dispatcher != null: - # Stop accepting handler work and hand any live status worker to its - # frame-polled teardown coroutine. _exit_tree() calls clear() again; the - # second call is intentionally inert because the caches are empty. - _dispatcher.clear() - _lifecycle.prepare_for_update_reload() - - -func _adopt_compatible_server( - record_version: String, - current_version: String, - owner: int, - record_owns_listener: bool = false -) -> String: - return _lifecycle.adopt_compatible_server( - record_version, - current_version, - owner, - record_owns_listener - ) - - -static func _compatible_adoption_log_message( - owner_label: String, - owned_pid: int, - observed_owner_pid: int, - live_version: String, - live_ws_port: int, - current_version: String -) -> String: - if owner_label == "managed": - return "MCP | adopted managed server (PID %d, live v%s, WS %d, plugin v%s)" % [ - owned_pid, - live_version, - live_ws_port, - current_version - ] - return "MCP | adopted external server owner_pid=%d (live v%s, WS %d, plugin v%s)" % [ - observed_owner_pid, - live_version, - live_ws_port, - current_version - ] - - -## Hand the self-update over to a tiny runner that is not owned by this -## EditorPlugin. The runner keeps the editor process alive, but disables this -## plugin before extracting/scanning the new scripts so every plugin-owned -## instance tears down on pre-update bytecode and pre-update field storage. -func install_downloaded_update(zip_path: String, temp_dir: String, source_dock: Control) -> void: - prepare_for_update_reload() - - var detached_dock = null - if _dock != null and is_instance_valid(_dock): - detached_dock = _dock - remove_control_from_docks(_dock) - _dock = null - elif source_dock != null and is_instance_valid(source_dock): - detached_dock = source_dock - remove_control_from_docks(source_dock) - - var runner = UPDATE_RELOAD_RUNNER_SCRIPT.new() - var parent: Node = EditorInterface.get_base_control() - if parent == null: - parent = get_tree().root - parent.add_child(runner) - runner.start(zip_path, temp_dir, detached_dock) - - -func can_recover_incompatible_server() -> bool: - return _lifecycle.can_recover_incompatible_server() - - -func _resume_connection_after_recovery() -> void: - if _connection == null: - return - var state: int = _lifecycle.get_state() - if ( - _lifecycle.is_connection_blocked() - or ( - state != ServerStateScript.SPAWNING - and state != ServerStateScript.READY - ) - ): - return - _connection.connect_blocked = false - _connection.connect_block_reason = "" - _connection.server_version = "" - _connection.set_process(true) - _arm_server_version_check() - - -func recover_incompatible_server(user_initiated: bool = true) -> bool: - ## A user's click (the dock's Restart) authorizes the bounded - ## stale-occupant retry for this episode, so a bridge respawning the old - ## version and winning the post-kill bind race gets re-killed - ## automatically instead of dead-ending the click in a terminal state. - ## The automatic triggers (post-update handshake mismatch, fast-exit - ## re-walk) call with `user_initiated=false` and only SPEND from the - ## already-authorized budget — re-arming there would unbound the - ## kill/respawn loop against a persistent respawner. - if user_initiated: - _lifecycle.authorize_stale_recovery() - ## `await` because the manager's recovery is a coroutine in production - ## (#678): `_resume_connection_after_recovery` gates on the post-walk - ## state, so it must not run until the respawn walk has completed. With - ## `defer_blocking_work` off this completes synchronously. - if not await _lifecycle.recover_incompatible_server(): - return false - _resume_connection_after_recovery() - return true - - -## Kill whichever process is holding `http_port()` right now — by resolving -## the port-owning PID via pid-file / netstat / lsof, independent of whether -## we ever set the manager's `_server_pid` — then clear ownership state -## and respawn via the lifecycle manager. The dock's version-mismatch -## banner wires here when the plugin adopted a foreign server whose -## `server_version` drifts from the current plugin version. -func force_restart_server() -> void: - _lifecycle.force_restart_server() - - -## Single entry point for the dock's primary "Restart Dev Server" button. -## The user clicking Restart is explicit consent to take over the HTTP port, -## so this is aggressive: any PID holding the port gets killed (managed, -## branded-dev, or orphan multiprocessing.spawn workers whose parent died -## so brand detection misses them). After the port frees we spawn a fresh -## --reload dev server. Returns true if a kill happened, false if the port -## was already free and we just spawned. -func force_restart_or_start_dev_server() -> bool: - var port := ClientConfigurator.http_port() - var killed := false - if has_managed_server(): - _lifecycle.reset_for_force_restart() - if _is_port_in_use(port): - _kill_processes_and_windows_spawn_children(_find_all_pids_on_port(port)) - killed = true - if killed: - ## OS.kill returns synchronously but uvicorn's listener can take - ## longer to release the port. Without this wait, start_dev_server's - ## fixed 500ms timer races the old shutdown and the new --reload - ## spawn fails to bind. - _wait_for_port_free(port, 5.0) - start_dev_server() - return killed - - -func start_dev_server() -> void: - ## Start a dev server with --reload that survives plugin reloads. - ## Kills any managed server first, waits for the port to free, then spawns. - ## - ## PYTHONPATH handling: when `res://` sits inside a checkout that owns a - ## `src/godot_ai/` (root repo or a git worktree), prepend that `src/` to - ## PYTHONPATH so `import godot_ai` and uvicorn's `reload_dirs` both pick - ## up *this* tree's source rather than the root repo's editable install. - ## On the root repo the path matches the installed package, so this is a - ## no-op; in a worktree it's what makes `--reload` actually watch the - ## worktree's Python. See #84. - _stop_server() - get_tree().create_timer(0.5).timeout.connect(func(): - var server_cmd := ClientConfigurator.get_server_command() - if server_cmd.is_empty(): - push_warning("MCP | could not find server command for dev server") - return - - var cmd: String = server_cmd[0] - _set_resolved_ws_port(_resolve_ws_port()) - var inner_args: Array[String] = [] - inner_args.assign(server_cmd.slice(1)) - inner_args.append_array([ - "--transport", "streamable-http", - "--port", str(ClientConfigurator.http_port()), - "--ws-port", str(_resolved_ws_port), - "--reload", - ]) - - var worktree_src := ClientConfigurator.find_worktree_src_dir(ProjectSettings.globalize_path("res://")) - var prev_pythonpath := OS.get_environment("PYTHONPATH") - if not worktree_src.is_empty(): - var sep := ";" if OS.get_name() == "Windows" else ":" - var new_pp := worktree_src if prev_pythonpath.is_empty() else worktree_src + sep + prev_pythonpath - OS.set_environment("PYTHONPATH", new_pp) - - var injected_telemetry: bool = _lifecycle._inject_telemetry_env() - var pid := OS.create_process(cmd, inner_args) - if injected_telemetry: - OS.unset_environment("GODOT_AI_DISABLE_TELEMETRY") - - ## Restore PYTHONPATH immediately — the spawned child has already - ## copied the env, so the editor's own process state returns to - ## baseline. Leaving it set would leak to any later OS.create_process - ## from unrelated paths. - if not worktree_src.is_empty(): - if prev_pythonpath.is_empty(): - OS.unset_environment("PYTHONPATH") - else: - OS.set_environment("PYTHONPATH", prev_pythonpath) - - if pid > 0: - ## Match `server_lifecycle.gd::start_server`'s log wording — - ## "prefix" since we prepended to any pre-existing PYTHONPATH, - ## not replaced it. See #429 review. - var suffix := " (PYTHONPATH prefix=%s)" % worktree_src if not worktree_src.is_empty() else "" - print("MCP | started dev server with --reload (PID %d): %s %s%s" % [pid, cmd, " ".join(inner_args), suffix]) - else: - push_warning("MCP | failed to start dev server") - ) - - -func stop_dev_server() -> void: - ## Stop any server running on the HTTP port (by port, not PID). - ## Used for dev servers whose PID we don't track across reloads. - if _lifecycle.get_server_pid() > 0: - # We have a managed server — use normal stop - _stop_server() - return - ## A suspended startup walk holds pre-kill probe results; without this - ## it can resume against the listener we are about to kill and adopt a - ## dead server. - _lifecycle._invalidate_async_startup() - var port := ClientConfigurator.http_port() - var candidates: Array[int] = [] - for pid in _find_all_pids_on_port(port): - var candidate := int(pid) - if _pid_cmdline_is_godot_ai(candidate): - candidates.append(candidate) - var killed := _kill_processes_and_windows_spawn_children(candidates) - if not killed.is_empty(): - print("MCP | stopped dev server on port %d" % port) - - -## `verify_brand`: re-check `pid_alive` + the godot-ai cmdline brand -## immediately before the kill (#686). Pass true when the proof that -## nominated `pids` was evaluated in an earlier scheduling window (e.g. -## `recover_strong_port_occupant`'s proof runs in one `_run_blocking` task -## and the kill in a second, with main-thread frames in between) — a branded -## target that exits in that gap can have its PID recycled to an innocent -## process. Default false preserves the intentionally-unbranded call sites -## (the dock's explicit-consent Restart button, orphan spawn workers whose -## parent died so brand detection misses them). -func _kill_processes_and_windows_spawn_children(pids: Array[int], verify_brand: bool = false) -> Array[int]: - var unique: Array[int] = [] - for pid in pids: - if pid <= 0 or unique.has(pid): - continue - if verify_brand and not (_pid_alive_for_proof(pid) and _pid_cmdline_is_godot_ai_for_proof(pid)): - continue - unique.append(pid) - if OS.get_name() == "Windows": - for child_pid in _find_windows_spawn_children(unique): - if not unique.has(child_pid): - unique.append(child_pid) - var killed: Array[int] = [] - for pid in unique: - if OS.get_name() == "Windows": - var output: Array = [] - var exit_code := OS.execute("taskkill", ["/PID", str(pid), "/T", "/F"], output, true) - if exit_code == 0 or not _pid_alive(pid): - killed.append(pid) - else: - ## Mirror the Windows branch: only report the PID as killed if - ## the kill succeeded or the process is verifiably gone. - if OS.kill(pid) == OK or not _pid_alive(pid): - killed.append(pid) - return killed - - -func _find_windows_spawn_children(parent_pids: Array[int]) -> Array[int]: - if parent_pids.is_empty(): - var empty: Array[int] = [] - return empty - var found: Array[int] = [] - for parent_pid in parent_pids: - var output: Array = [] - var script := ( - "Get-CimInstance Win32_Process | " - + "Where-Object { $_.CommandLine -like '*spawn_main(parent_pid=%d*' } | " - + "ForEach-Object { $_.ProcessId }" - ) % parent_pid - _startup_trace_count("powershell") - var exit_code := _execute_windows_powershell(script, output) - if exit_code != 0 or output.is_empty(): - continue - for pid in _parse_pid_lines(str(output[0])): - if not found.has(pid): - found.append(pid) - return found - - -func is_dev_server_running() -> bool: - ## Returns true if a branded dev server is running on the HTTP port - ## that we didn't start as managed. - if _lifecycle.get_server_pid() > 0: - return false - for pid in _find_all_pids_on_port(ClientConfigurator.http_port()): - if _pid_cmdline_is_godot_ai(int(pid)): - return true - return false - - -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 is allowed only when we have ownership proof. A live PID - ## means this plugin spawned/adopted a managed server; a non-empty - ## managed record is the cross-session proof used by the drift branch. - return _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 and _custom_tool_registry != null: - _on_custom_tools_changed() +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) diff --git a/addons/godot_ai/plugin.gd.uid b/addons/godot_ai/plugin.gd.uid index 4c550dd..33cf809 100644 --- a/addons/godot_ai/plugin.gd.uid +++ b/addons/godot_ai/plugin.gd.uid @@ -1 +1 @@ -uid://d3ui3yx6vdigl +uid://b5w7nw8pfnjww diff --git a/addons/godot_ai/runtime/draw_recipe.gd b/addons/godot_ai/runtime/draw_recipe.gd deleted file mode 100644 index 8204bd7..0000000 --- a/addons/godot_ai/runtime/draw_recipe.gd +++ /dev/null @@ -1,86 +0,0 @@ -@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 deleted file mode 100644 index 5de2df2..0000000 --- a/addons/godot_ai/runtime/draw_recipe.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://da3fqfqv6gtgm diff --git a/addons/godot_ai/runtime/editor_logger.gd b/addons/godot_ai/runtime/editor_logger.gd deleted file mode 100644 index 2d19daa..0000000 --- a/addons/godot_ai/runtime/editor_logger.gd +++ /dev/null @@ -1,139 +0,0 @@ -@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 deleted file mode 100644 index 9db6035..0000000 --- a/addons/godot_ai/runtime/editor_logger.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cxfregddqj5b8 diff --git a/addons/godot_ai/runtime/game_helper.gd b/addons/godot_ai/runtime/game_helper.gd deleted file mode 100644 index ff4fb4e..0000000 --- a/addons/godot_ai/runtime/game_helper.gd +++ /dev/null @@ -1,1317 +0,0 @@ -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 -## 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: - ## #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 - "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 _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 deleted file mode 100644 index 7224a50..0000000 --- a/addons/godot_ai/runtime/game_helper.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://gfybkdtsclti diff --git a/addons/godot_ai/runtime/game_logger.gd b/addons/godot_ai/runtime/game_logger.gd deleted file mode 100644 index 6384960..0000000 --- a/addons/godot_ai/runtime/game_logger.gd +++ /dev/null @@ -1,142 +0,0 @@ -@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 deleted file mode 100644 index d4e263e..0000000 --- a/addons/godot_ai/runtime/game_logger.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dwcs6d1y7vhqi diff --git a/addons/godot_ai/runtime/validation_logger.gd b/addons/godot_ai/runtime/validation_logger.gd deleted file mode 100644 index 672b11f..0000000 --- a/addons/godot_ai/runtime/validation_logger.gd +++ /dev/null @@ -1,43 +0,0 @@ -@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 deleted file mode 100644 index 4e693b4..0000000 --- a/addons/godot_ai/runtime/validation_logger.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://b2ff3aot6t2l4 diff --git a/addons/godot_ai/telemetry.gd b/addons/godot_ai/telemetry.gd deleted file mode 100644 index dc90bf9..0000000 --- a/addons/godot_ai/telemetry.gd +++ /dev/null @@ -1,198 +0,0 @@ -## 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", -] - -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: - _flush() - -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 deleted file mode 100644 index 6f46be6..0000000 --- a/addons/godot_ai/telemetry.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dlul2gculiy1p diff --git a/addons/godot_ai/testing/script_error_capture.gd b/addons/godot_ai/testing/script_error_capture.gd deleted file mode 100644 index 92c53ed..0000000 --- a/addons/godot_ai/testing/script_error_capture.gd +++ /dev/null @@ -1,49 +0,0 @@ -@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 deleted file mode 100644 index 6b2d712..0000000 --- a/addons/godot_ai/testing/script_error_capture.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://crk8w2nei087v diff --git a/addons/godot_ai/testing/test_runner.gd b/addons/godot_ai/testing/test_runner.gd deleted file mode 100644 index 1aa0041..0000000 --- a/addons/godot_ai/testing/test_runner.gd +++ /dev/null @@ -1,441 +0,0 @@ -@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 deleted file mode 100644 index c0befb0..0000000 --- a/addons/godot_ai/testing/test_runner.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://367b77qh5grt diff --git a/addons/godot_ai/testing/test_suite.gd b/addons/godot_ai/testing/test_suite.gd deleted file mode 100644 index 57b3bef..0000000 --- a/addons/godot_ai/testing/test_suite.gd +++ /dev/null @@ -1,330 +0,0 @@ -@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 - - -## Mark the current test as skipped when the running Godot is older than -## `min_version` (a "major.minor" string like "4.6"). Use for tests that -## exercise an engine API or behavior that only exists on newer Godot. -## Returns true when the test was skipped, so callers can `return` from -## the test body. -## -## Example: -## func test_uses_46_only_api() -> void: -## if skip_on_godot_lt("4.6", "example API requires Godot 4.6+"): -## return -## ... -func skip_on_godot_lt(min_version: String, reason: String = "") -> bool: - var v := Engine.get_version_info() - var current_major := int(v.get("major", 0)) - var current_minor := int(v.get("minor", 0)) - var parts := min_version.split(".") - var want_major := int(parts[0]) if parts.size() > 0 else 0 - var want_minor := int(parts[1]) if parts.size() > 1 else 0 - if ( - current_major < want_major - or (current_major == want_major and current_minor < want_minor) - ): - var msg := reason if not reason.is_empty() else "requires Godot %s+" % min_version - skip(msg + " (running %d.%d)" % [current_major, current_minor]) - return true - return false - - -## 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 deleted file mode 100644 index b75e726..0000000 --- a/addons/godot_ai/testing/test_suite.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dlrq2s7jhp71s diff --git a/addons/godot_ai/tool_catalog.gd b/addons/godot_ai/tool_catalog.gd deleted file mode 100644 index d1e8b4a..0000000 --- a/addons/godot_ai/tool_catalog.gd +++ /dev/null @@ -1,110 +0,0 @@ -@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 deleted file mode 100644 index c0c179b..0000000 --- a/addons/godot_ai/tool_catalog.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://d1vqyt4uyo378 diff --git a/addons/godot_ai/update_reload_runner.gd b/addons/godot_ai/update_reload_runner.gd deleted file mode 100644 index 7761e40..0000000 --- a/addons/godot_ai/update_reload_runner.gd +++ /dev/null @@ -1,590 +0,0 @@ -@tool -extends Node - -## EditorSetting key used to defer a self_update telemetry event across the -## disable -> enable boundary. The runner runs while the plugin is disabled, -## so it can't send WebSocket events directly; it writes the outcome here -## and the re-enabled plugin's `_enter_tree` flushes it. See -## `plugin.gd::_flush_pending_self_update_telemetry`. -const PENDING_SELF_UPDATE_TELEMETRY_KEY := "godot_ai/pending_self_update_event" - -## Self-update runner. Owns the install-and-reload sequence from -## `start(zip_path, temp_dir, detached_dock)` onward: extract files into -## `addons/godot_ai/` with rollback bookkeeping, scan the filesystem, -## re-enable the plugin, and clean up the detached dock. -## -## Single-phase install: writes the full `_new_file_paths + -## _existing_file_paths` set before issuing exactly one -## `EditorFileSystem.scan()`. Godot's scan-time reparse pass then sees one -## consistent v(N+1) snapshot, so new files and existing files can resolve -## each other's same-release API changes regardless of parse order. -## -## Not owned here: HTTP download (in `utils/update_manager.gd`), banner UI -## (in `mcp_dock.gd`), or server stop prep (called by -## `plugin.gd::install_downloaded_update` before this runner starts via -## `_lifecycle.prepare_for_update_reload()`). -## -## This node is deliberately tiny and not parented under the EditorPlugin: -## it survives `set_plugin_enabled(false)`, extracts the downloaded release, -## waits for Godot's filesystem scan, then enables the plugin again. The old -## dock is detached before this runner starts, kept alive while deferred -## Callables drain, and freed only after the new plugin instance is loaded. - -const PLUGIN_CFG_PATH := "res://addons/godot_ai/plugin.cfg" -const PRE_DISABLE_DRAIN_FRAMES := 8 -const POST_DISABLE_DRAIN_FRAMES := 2 -const POST_ENABLE_FREE_FRAMES := 8 -const INSTALL_BASE_PATH := "res://" -const ZIP_ADDON_PREFIX := "addons/godot_ai/" -const TEMP_FILE_SUFFIX := ".godot_ai_update_tmp" -const INSTALL_BACKUP_SUFFIX := ".update_backup" - -## Outcome of `_install_zip_paths`. `OK` means all listed files were replaced. -## `FAILED_CLEAN` means a write/rename failed mid-batch but every previously -## written file was rolled back to its vN content (or removed, if the file -## was new in vN+1). `FAILED_MIXED` means rollback itself failed: the addons -## tree contains a mix of vN and vN+1 files. The runner MUST NOT re-enable -## the plugin in the MIXED case — see issue #297 finding #9 for the data-loss -## scenario this guards against. -enum InstallStatus { OK, FAILED_CLEAN, FAILED_MIXED } - -var _zip_path := "" -## Old plugin.cfg version, read from disk in `start()` before the extract -## replaces it. Rides the pending self-update marker. -var _from_version := "" -var _temp_dir := "" -var _detached_dock = null -var _started := false -var _next_step := "" -var _frames_remaining := 0 -var _waiting_for_scan := false -var _scan_next_step := "" -## Watchdog for `_start_filesystem_scan`: if Godot's `filesystem_changed` -## signal never fires (slow disk, NFS, AV holding the just-extracted addon -## files open), the runner used to hang in `_waiting_for_scan = true` -## forever and the dock stayed disabled. After this timeout we disconnect -## the signal and proceed anyway — worst case the new files aren't visible -## on the first frame, but they get picked up on the next scan. See -## audit-v2 finding #9 (issue #353). Untyped to match the codebase's -## defensive pattern for state that survives `fs.scan()` during update. -const SCAN_WATCHDOG_SECS := 30.0 -var _scan_watchdog_timer = null -## Sticky flag set by `_on_scan_watchdog_timeout`. Subsequent -## `_start_filesystem_scan` calls in the same update bypass connect+scan -## so a delayed `filesystem_changed` emission from the timed-out scan -## can't fire on a freshly-armed listener for the next scan and falsely -## settle it before that scan actually completed. See PR #381 review for -## the cross-scan race this guards against. -var _scan_timed_out := false -## Keep Array fields untyped: this runner survives fs.scan() during update, -## and typed Variant storage is part of the hot-reload crash class. -var _new_file_paths = [] -var _existing_file_paths = [] -## Per-file install records accumulated during install so a later failure -## can roll back files already replaced earlier in the same update. -## Each entry is an untyped Dictionary with target_path / backup_path / -## had_original keys. Cleared by `_finalize_install_success` on full success -## and by `_rollback_paths_written` on failure. -var _paths_written = [] -## Set true if `_install_zip_file`'s inner restore-from-backup couldn't -## complete (backup gone, copy failed). The failed file is NOT recorded in -## `_paths_written` because the function bails at that point — without this -## flag, `_rollback_paths_written` would walk only the prior records, all -## restore cleanly, and report FAILED_CLEAN even though the current target -## is missing or stale on disk. Surfaces FAILED_MIXED so the runner refuses -## to re-enable the plugin against a half-installed tree. -var _restore_failed := false -## Test-only opt-out for the scan-watchdog `push_warning` lines. The -## watchdog unit tests in `test_update_reload_runner.gd` invoke -## `_on_scan_watchdog_timeout()` and the post-timeout -## `_start_filesystem_scan` bypass branch directly to pin their behavior -## — but those code paths' `push_warning` calls then appear as yellow -## console noise in every `test_run`, training reviewers to ignore the -## runner's real production warnings. Tests set this true; production -## leaves it false so genuine scan timeouts during a real self-update -## still surface loudly. See issue #413. -var _suppress_scan_warnings := false - - -func start(zip_path: String, temp_dir: String, detached_dock) -> void: - if _started: - return - _started = true - _zip_path = zip_path - _temp_dir = temp_dir - _detached_dock = detached_dock - ## Captured before the extract replaces plugin.cfg: the success marker - ## carries from/to versions so the re-enabled plugin can (a) report a - ## complete self_update telemetry event and (b) scope the post-update - ## client repin to entries whose ONLY drift is the old version pin. Pure - ## file IO on purpose — the runner must not call into plugin scripts - ## during the disable window. - _from_version = _read_plugin_cfg_version() - _wait_frames(PRE_DISABLE_DRAIN_FRAMES, "_disable_old_plugin") - - -## Read `version="X.Y.Z"` from the on-disk plugin.cfg. Returns "" when the -## file or key is missing — consumers treat an empty version as "unknown" -## and degrade (telemetry sends empty fields, the repin gate skips). -static func _read_plugin_cfg_version() -> String: - var file := FileAccess.open(PLUGIN_CFG_PATH, FileAccess.READ) - if file == null: - return "" - var text := file.get_as_text() - file.close() - var re := RegEx.new() - if re.compile("(?m)^version=\"([^\"]+)\"") != OK: - return "" - var found := re.search(text) - return found.get_string(1) if found != null else "" - - -func _process(_delta: float) -> void: - if _frames_remaining <= 0: - set_process(false) - return - - _frames_remaining -= 1 - if _frames_remaining <= 0: - var step := _next_step - _next_step = "" - set_process(false) - call(step) - - -func _wait_frames(frame_count: int, next_step: String) -> void: - _next_step = next_step - _frames_remaining = max(1, frame_count) - set_process(true) - - -func _disable_old_plugin() -> void: - ## Disable before writing or scanning new scripts. This avoids both the - ## Dict/Array field-storage hot-reload crash (#245) and cached handler - ## constructor shape mismatches (#247) for plugin-owned instances. - print("MCP | update runner disabling old plugin") - EditorInterface.set_plugin_enabled(PLUGIN_CFG_PATH, false) - _wait_frames(POST_DISABLE_DRAIN_FRAMES, "_extract_and_scan") - - -func _extract_and_scan() -> void: - if not _read_update_manifest(): - EditorInterface.set_plugin_enabled(PLUGIN_CFG_PATH, true) - _wait_frames(POST_ENABLE_FREE_FRAMES, "_cleanup_and_finish") - return - - var install_paths := [] - install_paths.append_array(_new_file_paths) - install_paths.append_array(_existing_file_paths) - - var status := _install_zip_paths(install_paths) - if status != InstallStatus.OK: - _handle_install_failure(status) - return - - _finalize_install_success() - _cleanup_update_temp() - ## One scan covers both dependency directions: plugin.gd's preloads of - ## new files resolve because those files are already present, and new - ## files' references to new members or static-ness changes on existing - ## load-surface scripts resolve because those existing files are also - ## already at v(N+1). The goal is a consistent snapshot before scan, not - ## a tree-atomic install; per-file writes still use `.tmp` + rename and - ## rollback on failure. - _start_filesystem_scan("_enable_new_plugin") - - -func _start_filesystem_scan(next_step: String = "_enable_new_plugin") -> void: - var fs := EditorInterface.get_resource_filesystem() - var deferred_step := next_step if not next_step.is_empty() else "_enable_new_plugin" - if fs == null: - call_deferred(deferred_step) - return - - ## Bypass: a previous scan in this update already watchdog'd, so the - ## editor's filesystem is unresponsive. Re-arming a `filesystem_changed` - ## listener now would race with a delayed emission from the timed-out - ## scan: that single emission would fire whichever listener is currently - ## connected to the shared signal, falsely settling this scan before it - ## actually completed. Skip the wait; Godot's normal background scan - ## catches up after the plugin re-enables. See PR #381 review. - if _scan_timed_out: - if not _suppress_scan_warnings: - push_warning( - "MCP | skipping filesystem_changed wait after previous timeout (next_step=%s)" - % deferred_step - ) - call_deferred(deferred_step) - return - - _waiting_for_scan = true - _scan_next_step = deferred_step - if not fs.filesystem_changed.is_connected(_on_filesystem_changed): - fs.filesystem_changed.connect(_on_filesystem_changed, CONNECT_ONE_SHOT) - _arm_scan_watchdog() - fs.scan() - - -func _arm_scan_watchdog() -> void: - if _scan_watchdog_timer == null: - _scan_watchdog_timer = Timer.new() - _scan_watchdog_timer.one_shot = true - _scan_watchdog_timer.timeout.connect(_on_scan_watchdog_timeout) - add_child(_scan_watchdog_timer) - _scan_watchdog_timer.start(SCAN_WATCHDOG_SECS) - - -func _stop_scan_watchdog() -> void: - if _scan_watchdog_timer != null: - _scan_watchdog_timer.stop() - - -func _on_scan_watchdog_timeout() -> void: - ## Signal didn't fire within SCAN_WATCHDOG_SECS — most likely the - ## filesystem scan is blocked behind a slow disk / NFS / AV scanner - ## still reading the just-extracted addon files. - ## Set the sticky `_scan_timed_out` flag so any subsequent - ## `_start_filesystem_scan` in this update skips its connect+scan - ## (otherwise a delayed emission from this scan would falsely settle - ## the next scan's listener — see PR #381 review). - ## Disconnect the current listener too, so this scan's listener can't - ## double-call `_finish_scan_wait` if the signal arrives quickly after - ## the timeout fires. `_finish_scan_wait` is idempotent on - ## `_waiting_for_scan == false`. - if not _waiting_for_scan: - return - if not _suppress_scan_warnings: - push_warning( - "MCP | filesystem_changed didn't fire within %ds; proceeding without scan confirmation" - % int(SCAN_WATCHDOG_SECS) - ) - _scan_timed_out = true - var fs := EditorInterface.get_resource_filesystem() - if fs != null and fs.filesystem_changed.is_connected(_on_filesystem_changed): - fs.filesystem_changed.disconnect(_on_filesystem_changed) - _finish_scan_wait() - - -func _read_update_manifest() -> bool: - var zip_path := ProjectSettings.globalize_path(_zip_path) - var install_base := ProjectSettings.globalize_path(INSTALL_BASE_PATH) - - var reader := ZIPReader.new() - if reader.open(zip_path) != OK: - print("MCP | update extract failed: could not open %s" % zip_path) - return false - - _new_file_paths.clear() - _existing_file_paths.clear() - var has_plugin_cfg := false - var has_plugin_script := false - var files := reader.get_files() - for file_path in files: - if not file_path.begins_with(ZIP_ADDON_PREFIX): - continue - var rel_path := file_path.trim_prefix(ZIP_ADDON_PREFIX) - ## Many zip builders (`zip -r` without `-D`, AssetLib uploads, hand- - ## built archives) emit zero-byte directory entries like - ## `addons/godot_ai/`. Skip those before the safety check; the - ## empty-segment guard in `_is_safe_zip_addon_file` would otherwise - ## flag the bare prefix as unsafe and abort the extract. Current - ## release.yml passes `-D` to strip them, but installed runners must - ## still tolerate older or manually built zips. - if rel_path.is_empty() or file_path.ends_with("/"): - continue - if not _is_safe_zip_addon_file(file_path): - print("MCP | update extract failed: unsafe zip path %s" % file_path) - reader.close() - return false - if rel_path == "plugin.cfg": - has_plugin_cfg = true - elif rel_path == "plugin.gd": - has_plugin_script = true - var target_path := install_base.path_join(file_path) - if FileAccess.file_exists(target_path): - _existing_file_paths.append(file_path) - else: - _new_file_paths.append(file_path) - reader.close() - if not has_plugin_cfg: - print("MCP | update extract failed: zip is missing plugin.cfg") - return false - if not has_plugin_script: - print("MCP | update extract failed: zip is missing plugin.gd") - return false - return true - - -func _handle_install_failure(status: int) -> void: - _record_pending_self_update({ - "status": "failed_mixed" if status == InstallStatus.FAILED_MIXED else "failed_clean", - }) - if status == InstallStatus.FAILED_MIXED: - ## Half-installed addon tree on disk: re-enabling the plugin would - ## load a mix of vN and vN+1 files. Print a load-bearing diagnostic - ## and bail without re-enabling — user must restore manually. See - ## issue #297 finding #9 for the data-loss scenario. - push_error( - "MCP | self-update failed mid-install AND rollback could not" - + " restore the previous addons/godot_ai/ contents. The plugin" - + " is left disabled. Inspect addons/godot_ai/ for" - + " *.update_backup / *.godot_ai_update_tmp files and restore" - + " manually before re-enabling the plugin." - ) - print( - "MCP | self-update aborted: addons/godot_ai/ is in a mixed state;" - + " plugin left disabled (manual intervention required)." - ) - _wait_frames(POST_ENABLE_FREE_FRAMES, "_cleanup_and_finish") - return - ## FAILED_CLEAN: rollback restored every previously-written file. Safe - ## to re-enable the previous plugin version. - print("MCP | self-update rolled back; re-enabling previous plugin version") - EditorInterface.set_plugin_enabled(PLUGIN_CFG_PATH, true) - _wait_frames(POST_ENABLE_FREE_FRAMES, "_cleanup_and_finish") - - -func _is_safe_zip_addon_file(file_path: String) -> bool: - if file_path.is_absolute_path() or file_path.contains("\\"): - return false - if not file_path.begins_with(ZIP_ADDON_PREFIX): - return false - var rel_path := file_path.trim_prefix(ZIP_ADDON_PREFIX) - if rel_path.is_empty() or rel_path.ends_with("/"): - return false - ## Reserved install-machinery suffixes (#713): an entry named like the - ## runner's own staging/backup files would collide with the temp file - ## `_install_zip_file` writes, or overwrite / later be deleted with the - ## rollback snapshots `_finalize_install_success` cleans up — corrupting - ## the very rollback set protecting this install. - if rel_path.ends_with(TEMP_FILE_SUFFIX) or rel_path.ends_with(INSTALL_BACKUP_SUFFIX): - return false - for segment in rel_path.split("/", true): - if segment.is_empty() or segment == "." or segment == "..": - return false - return true - - -func _install_zip_paths(paths: Array) -> int: - if paths.is_empty(): - return InstallStatus.OK - - var zip_path := ProjectSettings.globalize_path(_zip_path) - var reader := ZIPReader.new() - if reader.open(zip_path) != OK: - print("MCP | update extract failed: could not reopen %s" % zip_path) - ## Nothing else can be written, but earlier files from this update - ## may have landed on disk; roll those back too. - return _rollback_paths_written() - - var install_base := ProjectSettings.globalize_path(INSTALL_BASE_PATH) - for file_path in paths: - var record := _install_zip_file(reader, String(file_path), install_base) - if record.is_empty(): - reader.close() - return _rollback_paths_written() - _paths_written.append(record) - reader.close() - return InstallStatus.OK - - -func _install_zip_file( - reader: ZIPReader, file_path: String, install_base: String -) -> Dictionary: - var target_path := install_base.path_join(file_path) - var dir := target_path.get_base_dir() - if DirAccess.make_dir_recursive_absolute(dir) != OK: - print("MCP | update extract failed: could not create %s" % dir) - return {} - - var temp_path := target_path + TEMP_FILE_SUFFIX - DirAccess.remove_absolute(temp_path) - var content := reader.read_file(file_path) - var f := FileAccess.open(temp_path, FileAccess.WRITE) - if f == null: - print("MCP | update extract failed: could not write %s (error %d)" % [ - temp_path, - FileAccess.get_open_error(), - ]) - return {} - ## `store_buffer` reports a short write only via its return value — it - ## does NOT set the last-error state `get_error()` reads — and the write - ## is stdio-buffered, so disk-full surfaces at flush/close, not here. - ## Capture the return and re-verify the on-disk size after close() so a - ## disk-full "succeeds" write can't rename a truncated file over the - ## live target (#687). - var stored := f.store_buffer(content) - f.flush() - var write_error := f.get_error() - f.close() - ## get_length() on a read handle, not get_file_as_bytes().size() — the - ## latter re-reads the whole file into memory per extracted entry just - ## to learn its length. - var written_size := -1 - var verify := FileAccess.open(temp_path, FileAccess.READ) - if verify != null: - written_size = verify.get_length() - verify.close() - if not stored or write_error != OK or written_size != content.size(): - print("MCP | update extract failed: write validation failed (error %d) for %s (stored=%s size=%d expected=%d)" % [ - write_error, - temp_path, - stored, - written_size, - content.size(), - ]) - DirAccess.remove_absolute(temp_path) - return {} - - ## Back up the original via COPY (not rename) so the source of truth - ## stays in place if a later step fails. Rolled back via - ## `_rollback_paths_written` if a subsequent file in this batch — or a - ## later batch — can't be installed. - var had_original := FileAccess.file_exists(target_path) - var backup_path := target_path + INSTALL_BACKUP_SUFFIX - if had_original: - DirAccess.remove_absolute(backup_path) - if DirAccess.copy_absolute(target_path, backup_path) != OK: - DirAccess.remove_absolute(temp_path) - print("MCP | update extract failed: could not back up %s" % target_path) - return {} - - if DirAccess.rename_absolute(temp_path, target_path) != OK: - ## POSIX and APFS replace atomically. Some filesystems reject - ## rename-over-existing; keep a fallback so the update can still - ## proceed, but the common path never exposes a truncated target. - DirAccess.remove_absolute(target_path) - if DirAccess.rename_absolute(temp_path, target_path) != OK: - DirAccess.remove_absolute(temp_path) - ## Target was removed above; restore from the COPY backup so the - ## addons dir is left in its vN state before we surface failure. - ## Only delete the backup if the restore copy actually succeeded - ## — if it didn't, target_path is missing, and `_restore_failed` - ## tells `_rollback_paths_written` to surface FAILED_MIXED so the - ## runner refuses to re-enable the plugin. Leaving the backup on - ## disk also gives the user a manual recovery path. Without this - ## guard the failed file isn't tracked anywhere (we return `{}`, - ## not appended to `_paths_written`) and the caller would - ## erroneously see FAILED_CLEAN. - if had_original: - if ( - FileAccess.file_exists(backup_path) - and DirAccess.copy_absolute(backup_path, target_path) == OK - ): - DirAccess.remove_absolute(backup_path) - else: - _restore_failed = true - print("MCP | update extract failed: could not replace %s" % target_path) - return {} - return { - "target_path": target_path, - "backup_path": backup_path, - "had_original": had_original, - } - - -## Restore (or remove) every file already touched in this update. Safe to -## call after a partial install — entries are processed in reverse so a -## given target is restored before the next earlier write of the same path -## could resurrect a stale value. Returns FAILED_CLEAN if every entry was -## restored AND no in-flight `_install_zip_file` left a target stranded -## (`_restore_failed`); FAILED_MIXED otherwise. The caller MUST NOT -## re-enable the plugin in the MIXED case. -func _rollback_paths_written() -> int: - var any_failed := false - var i := _paths_written.size() - 1 - while i >= 0: - var record = _paths_written[i] - var target := String(record.get("target_path", "")) - var backup := String(record.get("backup_path", "")) - var had_original := bool(record.get("had_original", false)) - if had_original: - if not FileAccess.file_exists(backup): - print("MCP | update rollback failed: backup missing for %s" % target) - any_failed = true - else: - DirAccess.remove_absolute(target) - if DirAccess.copy_absolute(backup, target) != OK: - print("MCP | update rollback failed: could not restore %s" % target) - any_failed = true - else: - DirAccess.remove_absolute(backup) - else: - if FileAccess.file_exists(target): - if DirAccess.remove_absolute(target) != OK: - print( - "MCP | update rollback failed: could not delete %s" % target - ) - any_failed = true - i -= 1 - _paths_written.clear() - if any_failed or _restore_failed: - return InstallStatus.FAILED_MIXED - return InstallStatus.FAILED_CLEAN - - -## Discard accumulated backups after the combined install succeeds. Backups -## are best-effort: a failure here doesn't compromise the new install, just -## leaves stray *.update_backup files for the user to clean up. -func _finalize_install_success() -> void: - for record in _paths_written: - if record.get("had_original", false): - DirAccess.remove_absolute(String(record.get("backup_path", ""))) - _paths_written.clear() - ## plugin.cfg on disk is the NEW version by now — read it back rather - ## than trusting any in-memory value, so the marker describes what was - ## actually installed. - _record_pending_self_update({ - "status": "success", - "from_version": _from_version, - "to_version": _read_plugin_cfg_version(), - }) - - -## Persist a self_update event description so the re-enabled plugin can -## emit it once its WebSocket is connected. Survives the disable -> enable -## window where the runner cannot send anything itself. -func _record_pending_self_update(data: Dictionary) -> void: - var settings := EditorInterface.get_editor_settings() - if settings == null: - return - settings.set_setting(PENDING_SELF_UPDATE_TELEMETRY_KEY, JSON.stringify(data)) - - -func _cleanup_update_temp() -> void: - DirAccess.remove_absolute(ProjectSettings.globalize_path(_zip_path)) - DirAccess.remove_absolute(ProjectSettings.globalize_path(_temp_dir)) - - -func _on_filesystem_changed() -> void: - _finish_scan_wait() - - -func _finish_scan_wait() -> void: - if not _waiting_for_scan: - return - _waiting_for_scan = false - _stop_scan_watchdog() - var next_step := _scan_next_step - _scan_next_step = "" - set_process(false) - if next_step.is_empty(): - next_step = "_enable_new_plugin" - call_deferred(next_step) - - -func _enable_new_plugin() -> void: - print("MCP | update runner enabling new plugin") - EditorInterface.set_plugin_enabled(PLUGIN_CFG_PATH, true) - _wait_frames(POST_ENABLE_FREE_FRAMES, "_cleanup_and_finish") - - -func _cleanup_and_finish() -> void: - _cleanup_detached_dock() - queue_free() - - -func _cleanup_detached_dock() -> void: - if _detached_dock != null and is_instance_valid(_detached_dock): - _detached_dock.queue_free() - _detached_dock = null diff --git a/addons/godot_ai/update_reload_runner.gd.uid b/addons/godot_ai/update_reload_runner.gd.uid deleted file mode 100644 index 9cc6615..0000000 --- a/addons/godot_ai/update_reload_runner.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cu6c75n3x2pik diff --git a/addons/godot_ai/utils/allow_hosts.gd b/addons/godot_ai/utils/allow_hosts.gd deleted file mode 100644 index a34c964..0000000 --- a/addons/godot_ai/utils/allow_hosts.gd +++ /dev/null @@ -1,170 +0,0 @@ -@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 deleted file mode 100644 index 02fecd1..0000000 --- a/addons/godot_ai/utils/allow_hosts.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://b7qk3vw2nxr4d diff --git a/addons/godot_ai/utils/class_introspection.gd b/addons/godot_ai/utils/class_introspection.gd deleted file mode 100644 index 3076740..0000000 --- a/addons/godot_ai/utils/class_introspection.gd +++ /dev/null @@ -1,259 +0,0 @@ -@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 deleted file mode 100644 index 3eda5ca..0000000 --- a/addons/godot_ai/utils/class_introspection.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://caedbsmsl6fk4 diff --git a/addons/godot_ai/utils/diagnostics_capture.gd b/addons/godot_ai/utils/diagnostics_capture.gd deleted file mode 100644 index 22441ad..0000000 --- a/addons/godot_ai/utils/diagnostics_capture.gd +++ /dev/null @@ -1,66 +0,0 @@ -@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 deleted file mode 100644 index 95973c9..0000000 --- a/addons/godot_ai/utils/diagnostics_capture.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://b3npxxpuobbc2 diff --git a/addons/godot_ai/utils/editor_log_buffer.gd b/addons/godot_ai/utils/editor_log_buffer.gd deleted file mode 100644 index 0ea44e7..0000000 --- a/addons/godot_ai/utils/editor_log_buffer.gd +++ /dev/null @@ -1,127 +0,0 @@ -@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 deleted file mode 100644 index 8c8d823..0000000 --- a/addons/godot_ai/utils/editor_log_buffer.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://b6ynms0856hhq diff --git a/addons/godot_ai/utils/error_codes.gd b/addons/godot_ai/utils/error_codes.gd deleted file mode 100644 index 5dc3466..0000000 --- a/addons/godot_ai/utils/error_codes.gd +++ /dev/null @@ -1,163 +0,0 @@ -@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 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 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 deleted file mode 100644 index fcc1e82..0000000 --- a/addons/godot_ai/utils/error_codes.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://d2klnglf5p861 diff --git a/addons/godot_ai/utils/fuzzy_suggestions.gd b/addons/godot_ai/utils/fuzzy_suggestions.gd deleted file mode 100644 index de7cc19..0000000 --- a/addons/godot_ai/utils/fuzzy_suggestions.gd +++ /dev/null @@ -1,39 +0,0 @@ -@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 deleted file mode 100644 index c663d01..0000000 --- a/addons/godot_ai/utils/fuzzy_suggestions.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bxwaws6w0xw60 diff --git a/addons/godot_ai/utils/game_log_buffer.gd b/addons/godot_ai/utils/game_log_buffer.gd deleted file mode 100644 index 2f1b3b0..0000000 --- a/addons/godot_ai/utils/game_log_buffer.gd +++ /dev/null @@ -1,105 +0,0 @@ -@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 deleted file mode 100644 index 6c7ce7d..0000000 --- a/addons/godot_ai/utils/game_log_buffer.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://biojw0xl64haw diff --git a/addons/godot_ai/utils/json_values.gd b/addons/godot_ai/utils/json_values.gd deleted file mode 100644 index d745d02..0000000 --- a/addons/godot_ai/utils/json_values.gd +++ /dev/null @@ -1,92 +0,0 @@ -@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 - - -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 deleted file mode 100644 index b3eeba9..0000000 --- a/addons/godot_ai/utils/json_values.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://4wbf83hwckms diff --git a/addons/godot_ai/utils/log_backtrace.gd b/addons/godot_ai/utils/log_backtrace.gd deleted file mode 100644 index 3fdf838..0000000 --- a/addons/godot_ai/utils/log_backtrace.gd +++ /dev/null @@ -1,113 +0,0 @@ -@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 deleted file mode 100644 index 4845ad8..0000000 --- a/addons/godot_ai/utils/log_backtrace.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://b8t9kznr2pqxa diff --git a/addons/godot_ai/utils/log_buffer.gd b/addons/godot_ai/utils/log_buffer.gd deleted file mode 100644 index 34bd2b5..0000000 --- a/addons/godot_ai/utils/log_buffer.gd +++ /dev/null @@ -1,67 +0,0 @@ -@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 deleted file mode 100644 index 58341ca..0000000 --- a/addons/godot_ai/utils/log_buffer.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://ddkslse7511e6 diff --git a/addons/godot_ai/utils/mcp_adoption_label.gd b/addons/godot_ai/utils/mcp_adoption_label.gd deleted file mode 100644 index 3114037..0000000 --- a/addons/godot_ai/utils/mcp_adoption_label.gd +++ /dev/null @@ -1,23 +0,0 @@ -@tool -class_name McpAdoptionLabel -extends RefCounted - -## Outcome flag for `McpServerLifecycleManager.adopt_compatible_server`. -## Distinguishes a same-version managed adoption (we own the PID, can -## restart it) from an external compatible adoption (some other plugin -## instance / dev server owns the process; we just rendezvoused with it). -## -## Was a free-form string in PR 5; promoted to constants here because -## the seam now spans `server_lifecycle.gd`, `plugin.gd`'s log helper, -## the dock's restart-button gating, and the test suite. Stable strings -## keep log scrapes and characterization fixtures unaffected. - -## We have a PID we spawned (or re-acquired by reading the managed -## record + verifying liveness). `force_restart_server` and -## `prepare_for_update_reload` may target this PID. -const MANAGED := "managed" - -## A compatible godot-ai server is on the port but we don't own its -## PID — likely another plugin instance's spawn, or a developer-run -## `godot-ai --reload` server. We reuse it but won't kill it on stop. -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 deleted file mode 100644 index aee58fe..0000000 --- a/addons/godot_ai/utils/mcp_adoption_label.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://klhsu1cuhcue diff --git a/addons/godot_ai/utils/mcp_client_refresh_state.gd b/addons/godot_ai/utils/mcp_client_refresh_state.gd deleted file mode 100644 index fbc0b84..0000000 --- a/addons/godot_ai/utils/mcp_client_refresh_state.gd +++ /dev/null @@ -1,107 +0,0 @@ -@tool -class_name McpClientRefreshState -extends RefCounted - -## State machine for the dock's client-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 dock parks the request and retries on the next `_process` after -## the scan settles. Held alongside two flags (force / initial) for -## what kind of refresh to retry; those live next to the state, not -## inside it, because they're requests not state. -const DEFERRED_FOR_FILESYSTEM := 1 -## Worker thread is alive and probing client status off-main. The -## dock paints "(checking...)" in the clients summary and 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)" and a forced refresh is -## allowed to abandon the worker into the orphan list and start a new -## sweep. The state stays RUNNING after a forced abandon-and-restart. -const RUNNING_TIMED_OUT := 3 -## `_exit_tree` / `_install_update` is draining workers. New refresh -## requests are rejected outright. Set once and not cleared (the dock -## instance is being torn down). -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 a worker running, but -## the timed-out flavor allows a force-refresh to abandon it. -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 -## dock's two refresh-spawn guards (the deferred refresh entrypoint and -## the 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. Forced respawn after orphan abandon - ## stays in RUNNING (covered by from == to above). - return to == IDLE or to == RUNNING_TIMED_OUT - RUNNING_TIMED_OUT: - ## Late-arriving worker result drops back to IDLE; forced - ## abandon-and-respawn drops back to RUNNING. - return to == IDLE or to == RUNNING - 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 deleted file mode 100644 index 9e4129c..0000000 --- a/addons/godot_ai/utils/mcp_client_refresh_state.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dv4tukg6eioww diff --git a/addons/godot_ai/utils/mcp_server_state.gd b/addons/godot_ai/utils/mcp_server_state.gd deleted file mode 100644 index 24597f4..0000000 --- a/addons/godot_ai/utils/mcp_server_state.gd +++ /dev/null @@ -1,182 +0,0 @@ -@tool -class_name McpServerState -extends RefCounted - -## State machine for the plugin's server-spawn / adopt / version-verify -## lifecycle. Single source of truth — supersedes the boolean-flag thicket -## (`_server_started_this_session`, `_awaiting_server_version`, -## `_server_version_deadline_ms`, `_connection_blocked`, -## `_can_recover_incompatible`, `_refresh_retried`, -## `_adoption_watch_deadline_ms`) and the older terminal-only -## McpSpawnState string union. -## -## The integer values matter — they're what `get_server_status()` -## surfaces, what the dock pattern-matches on, and what the test suites -## assert against. Reordering the enum is a breaking change. -## -## The transitions are documented in `can_transition()`. The lifecycle -## manager calls `set_state()` which: -## 1. Validates the transition (logs a warning + no-ops on illegal). -## 2. Preserves first-writer-wins among terminal diagnoses so a late -## CRASHED from the watch loop can't clobber an earlier -## PORT_EXCLUDED from the proactive Windows reservation check. - -## Fresh plugin instance, `_start_server` has not run yet. Default state. -const UNINITIALIZED := 0 -## Process spawned via OS.create_process; watch loop is observing the -## SPAWN_GRACE_MS window. Transitions directly to READY (handshake_ack -## verifies a compatible version), CRASHED (process died early), or -## INCOMPATIBLE (handshake reported a mismatch). -const SPAWNING := 1 -## (slot 2 reserved — keep wire-compat for clients pattern-matching -## numeric `editor_state.state` values; do not reuse.) -## Server is healthy and version-verified. Happy path. Includes both -## "spawned fresh" and "adopted compatible existing server" flavors — -## adoption flavor is recorded separately via `McpAdoptionLabel`. -const READY := 3 -## Live server on the HTTP port returned a version that doesn't match -## what this plugin expects, OR returned no `handshake_ack` inside the -## timeout. Connection is blocked; recovery requires a kill+respawn -## click via `recover_incompatible_server`. -const INCOMPATIBLE := 4 -## Spawned process exited inside the SPAWN_GRACE_MS window. Python -## traceback went to Godot's output log. Terminal — reload the plugin -## or restart the editor to retry. -const CRASHED := 5 -## No server command resolved: no `.venv` Python, no `uvx` on PATH, no -## system `godot-ai`. Terminal — install guidance shown in dock. -const NO_COMMAND := 6 -## Windows reserved the HTTP port via Hyper-V / WSL2 / Docker exclusion -## range. Caught proactively before bind. Terminal — port picker shown. -const PORT_EXCLUDED := 7 -## HTTP port held by a process we didn't spawn (no matching managed -## record). Plugin armed an adoption-confirmation watcher; if the foreign -## occupant turns out to be a compatible godot-ai server, -## `handle_server_version_verified` transitions to READY. If the -## adoption deadline expires without a connection, the watcher self- -## disarms but the state stays at FOREIGN_PORT — the dock keeps showing -## "port held by another process" until the user reloads. The version- -## check seam (separate from the adoption deadline) is what fires -## INCOMPATIBLE on a positive-but-mismatched handshake. -const FOREIGN_PORT := 8 -## Static re-entrancy guard fired (`_server_started_this_session` was -## already true). The plugin is being re-enabled within the same editor -## session; the previous instance still owns the spawn. Terminal — does -## NOT block READY paths, just records that this enable cycle no-op'd. -const GUARDED := 9 -## stop_server / prepare_for_update_reload in progress. Transitional — -## next state is STOPPED. -const STOPPING := 10 -## stop_server completed; `_server_pid` reset to -1, port may or may -## not be free. From here a fresh `start_server` call moves back through -## SPAWNING / READY. -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", - GUARDED: "guarded", - 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 consider the server unsuitable for client -## health checks (incompatible tool surface). 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 - - -## Transition validation table. Returns true when `from -> to` is a -## legal transition the lifecycle manager should accept. Illegal -## transitions are silently no-op'd at the call site (with a -## `push_warning` log) — this preserves the first-writer-wins contract -## that prevents a late CRASHED from the watch loop overwriting an -## earlier PORT_EXCLUDED diagnosis. -static func can_transition(from: int, to: int) -> bool: - if from == to: - return true - ## Stop is always legal — teardown / install reload short-circuits - ## any in-flight state. - if to == STOPPING: - return true - if to == STOPPED and from == STOPPING: - return true - ## STOPPED can also be reached directly when `_server_pid <= 0` and - ## stop_server early-returns; treat it as legal from any state to - ## keep the teardown path forgiving. - if to == STOPPED: - return true - ## STOPPED -> any (re-arm via restart paths). - if from == STOPPED: - return true - ## GUARDED is sticky for the rest of this enable cycle; only stop is - ## legal out of it. Already covered by the stop checks above. - if from == GUARDED: - return false - ## Terminal diagnoses freeze further forward transitions. Recovery - ## goes through STOPPING (covered above), so any other target is - ## rejected — this is the first-writer-wins contract. - if ( - from == CRASHED - or from == NO_COMMAND - or from == PORT_EXCLUDED - or from == INCOMPATIBLE - ): - return false - ## UNINITIALIZED is the boot state — any target except STOPPING is - ## reachable directly (start_server's early branches set - ## terminal states without going through SPAWNING). - if from == UNINITIALIZED: - return true - ## In-flight forward transitions. - match from: - SPAWNING: - return ( - to == READY - or to == CRASHED - or to == FOREIGN_PORT - or to == INCOMPATIBLE - ) - FOREIGN_PORT: - return to == READY or to == INCOMPATIBLE - READY: - ## Late incompatibility detection (e.g. version verifier - ## re-arms after a foreign-port reconnect that turns out - ## to be incompatible after all). - return to == INCOMPATIBLE or to == CRASHED - STOPPING: - ## Recovery rollback: kill-then-respawn paths that fail to - ## free the port re-latch INCOMPATIBLE (so the dock keeps - ## the diagnostic UI) or fall back to UNINITIALIZED (clean - ## baseline for a follow-up `_set_incompatible_server`). - ## STOPPING -> STOPPED is handled by the early checks above. - return to == INCOMPATIBLE or to == UNINITIALIZED - return false diff --git a/addons/godot_ai/utils/mcp_server_state.gd.uid b/addons/godot_ai/utils/mcp_server_state.gd.uid deleted file mode 100644 index dc5bef9..0000000 --- a/addons/godot_ai/utils/mcp_server_state.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://d3ial4erjonlq diff --git a/addons/godot_ai/utils/mcp_startup_path.gd b/addons/godot_ai/utils/mcp_startup_path.gd deleted file mode 100644 index 130b8e1..0000000 --- a/addons/godot_ai/utils/mcp_startup_path.gd +++ /dev/null @@ -1,34 +0,0 @@ -@tool -class_name McpStartupPath -extends RefCounted - -## Branch-tag enum for `McpServerLifecycleManager.start_server`. Records -## which arm of the spawn / adopt / drift / recover decision tree the -## current `_enter_tree` walked. Surfaced via the startup trace log so -## a Windows port-reservation issue or a stale-record kill can be -## reconstructed from the editor output. -## -## Single-file constants, not an int enum, because the values land in -## startup-trace text and the strings are stable across releases (the -## CLAUDE.md "tool surface" entry references them by name). - -const UNSET := "" -## Re-entrancy guard fired; this enable cycle did not spawn or adopt. -const GUARDED := "guarded" -## Adopted a compatible existing server (managed or external). -const ADOPTED := "adopted" -## Spawned a fresh server process. -const SPAWNED := "spawned" -## OS.create_process returned -1 or proactive Windows reservation -## detected. Either way the spawn never produced a live process. -const CRASHED := "crashed" -## Windows port-exclusion check fired — port is blocked at the OS layer. -const RESERVED := "reserved" -## Server-command discovery returned an empty list — no .venv, no uvx, -## no system godot-ai. -const NO_COMMAND := "no_command" -## Drift-recovery kill fell through; we set INCOMPATIBLE and stayed. -const INCOMPATIBLE := "incompatible" -## Port was free at start; this is the prelude to SPAWNED but kept as -## a distinct path so adopt-vs-spawn is unambiguous in the trace. -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 deleted file mode 100644 index fd01066..0000000 --- a/addons/godot_ai/utils/mcp_startup_path.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cikdvq2x4vs4x diff --git a/addons/godot_ai/utils/path_validator.gd b/addons/godot_ai/utils/path_validator.gd deleted file mode 100644 index a199dc5..0000000 --- a/addons/godot_ai/utils/path_validator.gd +++ /dev/null @@ -1,178 +0,0 @@ -@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 - - -## 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" - ## Guard the sentinel: on builds where String.chr(0) yields "" (some engines - ## normalize embedded nulls away, e.g. 4.3), contains("") would be true and - ## reject every path. A String that can't hold a null can't smuggle one. - var nul := String.chr(0) - if not nul.is_empty() and path.contains(nul): - return "Path must not contain null bytes" - 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" - ## Guard the sentinel: on builds where String.chr(0) yields "" (some engines - ## normalize embedded nulls away, e.g. 4.3), contains("") would be true and - ## reject every path. A String that can't hold a null can't smuggle one. - var nul := String.chr(0) - if not nul.is_empty() and path.contains(nul): - return "Path must not contain null bytes" - 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: - var file_lower := path.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 := path.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 deleted file mode 100644 index 8ed4acf..0000000 --- a/addons/godot_ai/utils/path_validator.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://blxntmd65ljyu diff --git a/addons/godot_ai/utils/port_resolver.gd b/addons/godot_ai/utils/port_resolver.gd index 789cfc6..9ef32e9 100644 --- a/addons/godot_ai/utils/port_resolver.gd +++ b/addons/godot_ai/utils/port_resolver.gd @@ -9,7 +9,37 @@ extends RefCounted ## Canonical pid-file path. plugin.gd::SERVER_PID_FILE re-exports this so ## external readers and tests can use either name. const SERVER_PID_FILE := "user://godot_ai_server.pid" +## Where a plugin-spawned server reports a failure that happens before it +## publishes its capability record (`--startup-report`). Removed before every +## spawn, so a report that exists afterwards belongs to that launch. +const SERVER_STARTUP_REPORT := "user://godot_ai_server_startup.json" const WindowsPortReservation := preload("res://addons/godot_ai/utils/windows_port_reservation.gd") +static var _process_spawn_mutex := Mutex.new() + + +## Serialize plugin-owned subprocess creation across the brief process-global +## environment override used for server capabilities. Callers must keep this +## window to environment mutation + one spawn and release it immediately. +static func lock_process_spawn() -> void: + _process_spawn_mutex.lock() + + +static func unlock_process_spawn() -> void: + _process_spawn_mutex.unlock() + + +## A managed Linux server needs a listener PID tool to prove ownership. +## Query each launch so installing the missing tool makes Retry work. +static func listener_tools_problem() -> String: + if OS.get_name() != "Linux": + return "" + var output: Array = [] + var available := OS.execute("/bin/sh", ["-c", + "command -v lsof >/dev/null 2>&1 || command -v ss >/dev/null 2>&1" + ], output, true) + if available == 0: + return "" + return "Cannot verify Linux listener ownership: neither lsof nor ss is available on the editor's PATH. Install lsof or iproute2 (which provides ss), then retry starting the server." static func can_bind_local_port(port: int) -> bool: @@ -60,7 +90,16 @@ static func is_port_in_use_via_scrape(port: int, trace: Callable = Callable()) - return not find_listener_pids_windows(port).is_empty() _trace(trace, "lsof") var exit_code := OS.execute("lsof", ["-ti:%d" % port, "-sTCP:LISTEN"], output, true) - return exit_code == 0 and output.size() > 0 and not output[0].strip_edges().is_empty() + if exit_code == 0 and output.size() > 0 and not output[0].strip_edges().is_empty(): + return true + if OS.get_name() != "Linux": + return false + _trace(trace, "ss") + output.clear() + return ( + OS.execute("ss", ["-H", "-ltnp"], output, true) == 0 + and not parse_linux_ss_pids(str(output[0]) if not output.is_empty() else "", port).is_empty() + ) ## Return the PID currently listening on the given TCP port, or 0 if @@ -76,7 +115,7 @@ static func find_pid_on_port(port: int, trace: Callable = Callable()) -> int: ## both bind the same port. ## ## `trace` is an optional Callable that fires once per OS invocation with -## a counter name (`"netstat"` / `"powershell"` / `"lsof"`) so the plugin +## a counter name (`"netstat"` / `"powershell"` / `"lsof"` / `"ss"`) so the plugin ## can keep its cold-start trace accurate. The Windows path may fall ## through netstat → PowerShell, and a wrapping caller can't see which ## scraper actually ran without the hook. @@ -105,10 +144,19 @@ static func find_all_pids_on_port(port: int, trace: Callable = Callable()) -> Ar var output: Array = [] _trace(trace, "lsof") var exit_code := OS.execute("lsof", ["-ti:%d" % port, "-sTCP:LISTEN"], output, true) - if exit_code != 0 or output.is_empty(): + if exit_code == 0 and not output.is_empty(): + var lsof_pids := parse_lsof_pids(str(output[0])) + if not lsof_pids.is_empty() or OS.get_name() != "Linux": + return lsof_pids + elif OS.get_name() != "Linux": var empty: Array[int] = [] return empty - return parse_lsof_pids(str(output[0])) + _trace(trace, "ss") + output.clear() + if OS.execute("ss", ["-H", "-ltnp"], output, true) != 0 or output.is_empty(): + var empty: Array[int] = [] + return empty + return parse_linux_ss_pids(str(output[0]), port) static func _trace(trace: Callable, counter: String) -> void: @@ -139,6 +187,14 @@ static func execute_windows_powershell(script: String, output: Array) -> int: static func windows_powershell_candidates() -> Array[String]: var candidates: Array[String] = [] + ## Prefer an already-installed PowerShell 7 at its fixed machine path. + ## Missing or failing installations retain every existing fallback below. + for variable in ["ProgramW6432", "ProgramFiles"]: + var program_files := OS.get_environment(variable).replace("\\", "/").trim_suffix("/") + if not program_files.is_empty(): + var pwsh := program_files.path_join("PowerShell/7/pwsh.exe") + if FileAccess.file_exists(pwsh) and not candidates.has(pwsh): + candidates.append(pwsh) var system_root := OS.get_environment("SystemRoot") if system_root.is_empty(): system_root = "C:/Windows" @@ -161,16 +217,32 @@ static func windows_listener_execute_result_in_use(exit_code: int, output: Array ## Pure parser for `lsof -ti` output — newline-separated decimal PIDs. -## Empty lines and non-numeric tokens are dropped. Duplicates pass -## through (uvicorn reloader + worker can produce the same PID twice -## across runs but typically two distinct PIDs). +## Empty lines, non-positive values, non-numeric tokens, and duplicate +## IPv4/IPv6 rows for the same listener are dropped. static func parse_lsof_pids(raw: String) -> Array[int]: - var pids: Array[int] = [] - for line in raw.strip_edges().split("\n", false): - var stripped := line.strip_edges() - if stripped.is_valid_int(): - pids.append(int(stripped)) - return pids + return parse_pid_lines(raw) + + +## Parse Linux `ss -H -ltnp` without trusting process names or localized +## state text. The local endpoint is the fourth whitespace-delimited field; +## every `pid=` value on an exact-port row is retained for later brand, +## lineage, and fingerprint checks. +static func parse_linux_ss_pids(raw: String, port: int) -> Array[int]: + var result: Array[int] = [] + if port < 1 or port > 65535: + return result + var expression := RegEx.new() + if expression.compile("(?:^|,)pid=([0-9]+)(?:,|\\))") != OK: + return result + for line in raw.split("\n", false): + var fields := line.split(" ", false) + if fields.size() < 4 or not str(fields[3]).ends_with(":%d" % port): + continue + for matched in expression.search_all(line): + var pid := int(matched.get_string(1)) + if pid > 0 and not result.has(pid): + result.append(pid) + return result static func parse_pid_lines(raw: String) -> Array[int]: @@ -262,10 +334,10 @@ static func split_on_whitespace(s: String) -> PackedStringArray: return out -static func read_pid_file() -> int: - if not FileAccess.file_exists(SERVER_PID_FILE): +static func read_pid_file(path := SERVER_PID_FILE) -> int: + if not FileAccess.file_exists(path): return 0 - var f := FileAccess.open(SERVER_PID_FILE, FileAccess.READ) + var f := FileAccess.open(path, FileAccess.READ) if f == null: return 0 var content := f.get_as_text().strip_edges() @@ -286,9 +358,11 @@ static func clear_pid_file() -> void: ## uvx launcher lingers as a zombie forever and `kill -0` would block ## the spawn-failure branch in check_server_health from firing. Use ## `ps -o stat=` instead. State codes: R/S/D/I/T (live), Z (zombie). #172. -static func pid_alive(pid: int) -> bool: +static func pid_alive(pid: int, snapshot: Variant = null) -> bool: if pid <= 0: return false + if snapshot != null: + return not _process_snapshot_row(snapshot, pid).is_empty() if OS.get_name() == "Windows": var output: Array = [] var exit_code := OS.execute("tasklist", ["/FI", "PID eq %d" % pid, "/NH", "/FO", "CSV"], output, true) @@ -306,11 +380,353 @@ static func pid_alive(pid: int) -> bool: return not stat.is_empty() and not stat.begins_with("Z") +## Read-only process identity helpers. Lifecycle authority is granted only +## after capturing a fingerprint and every later stop re-reads the fingerprint +## before acting, so PID reuse fails closed. +## One Windows query captures the target and at most fifteen ancestors. The +## dictionary belongs to one proof boundary, never a cache: callers must take +## a fresh snapshot when closing the capture window or authorizing a kill. +## Non-Windows callers receive null and keep the existing live-query path. +## The process enumeration stays inside PowerShell; only the bounded target +## ancestry crosses back into Godot, without logging command-line metadata. +static func capture_process_snapshot(pid: int) -> Variant: + if OS.get_name() != "Windows": + return null + if pid <= 1: + return {"capture_error": true} + var script := _windows_process_snapshot_script(pid) + var output: Array = [] + if execute_windows_powershell(script, output) != 0 or output.is_empty(): + return {"capture_error": true} + return parse_process_snapshot(str(output[0]), pid) + + +static func _windows_process_snapshot_script(pid: int) -> String: + return ( + "$rows = @(); $current = %d; $seen = @{}; $capture_failed = $false; " + + "$processes = @{}; try { Get-CimInstance Win32_Process -ErrorAction Stop | " + + "ForEach-Object { $processes[[int]$_.ProcessId] = $_ } } catch {}; " + + "for ($depth = 0; $depth -lt 16 -and $current -gt 1; $depth++) { " + + "if ($seen.ContainsKey($current)) { break }; $seen[$current] = $true; " + + "$p = $processes[$current]; " + + "if ($null -eq $p) { " + + "try { $identity = [string](Get-Process -Id $current -ErrorAction Stop).StartTime.ToFileTimeUtc(); " + + "$rows += @{pid=$current;parent_pid=0;identity=$identity;commandline=''} } catch { if ($_.CategoryInfo.Category -ne 'ObjectNotFound') { $capture_failed = $true } }; break }; " + + "$rows += @{pid=[int]$p.ProcessId;parent_pid=[int]$p.ParentProcessId; " + + "identity=([string]$p.CreationDate + '|' + [string]$p.CommandLine);commandline=[string]$p.CommandLine}; " + + "$current = [int]$p.ParentProcessId }; " + + "if ($capture_failed) { 'null' } else { ConvertTo-Json -InputObject @($rows) -Compress -Depth 3 }" + ) % pid + + +## Independent CIM maps in separate scriptblock scopes are +## captured within one shell invocation. No snapshot crosses a grant call. +static func _capture_windows_process_snapshot_pair(pid: int) -> Array: + var query := _windows_process_snapshot_script(pid) + var script := ( + "$first = & { %s }; $final = & { %s }; " + + "ConvertTo-Json -InputObject @([string]$first,[string]$final) -Compress" + ) % [query, query] + var output: Array = [] + if execute_windows_powershell(script, output) != 0 or output.is_empty(): + return [{"capture_error": true}, {"capture_error": true}] + return _parse_process_snapshot_pair(str(output[0]), pid) + + +static func _parse_process_snapshot_pair(raw: String, pid: int) -> Array: + if raw.length() > 4 * 1024 * 1024 + 16: + return [{"capture_error": true}, {"capture_error": true}] + var json := JSON.new() + if json.parse(raw) != OK: + return [{"capture_error": true}, {"capture_error": true}] + var pair: Variant = json.data + if not (pair is Array) or pair.size() != 2 or not (pair[0] is String and pair[1] is String): + return [{"capture_error": true}, {"capture_error": true}] + return [parse_process_snapshot(pair[0], pid), parse_process_snapshot(pair[1], pid)] + + +static func parse_process_snapshot(raw: String, expected_pid: int) -> Dictionary: + if raw.length() > 1024 * 1024: + return {"capture_error": true} + var json := JSON.new() + if json.parse(raw) != OK: + return {"capture_error": true} + var rows: Variant = json.data + if not (rows is Array) or rows.size() > 16: + return {"capture_error": true} + var snapshot := {} + var next_pid := expected_pid + for row in rows: + if not (row is Dictionary): + return {"capture_error": true} + var value: Variant = row.get("pid") + if ( + not (value is int or value is float) or not is_finite(float(value)) + or float(value) <= 1.0 or float(value) > 4294967295.0 + or float(value) != float(int(value)) + ): + return {"capture_error": true} + var pid := int(value) + if pid <= 1 or pid != next_pid or snapshot.has(pid): + return {"capture_error": true} + snapshot[pid] = row + if _process_snapshot_row(snapshot, pid).is_empty(): + return {"capture_error": true} + next_pid = int(row.parent_pid) + if snapshot.has(next_pid): + return {"capture_error": true} + return snapshot + + +## An unavailable capture is not evidence that its PID has exited. +static func capture_failed(snapshot: Variant) -> bool: + return snapshot is Dictionary and bool(snapshot.get("capture_error", false)) + + +static func _process_snapshot_row(snapshot: Variant, pid: int) -> Dictionary: + if not (snapshot is Dictionary) or capture_failed(snapshot) or pid <= 1: + return {} + var row: Variant = snapshot.get(pid) + if not (row is Dictionary) or row.size() != 4: + return {} + var parent: Variant = row.get("parent_pid") + if ( + row.get("pid") != pid + or not (parent is int or parent is float) + or not is_finite(float(parent)) or float(parent) < 0.0 or float(parent) > 4294967295.0 + or float(parent) != float(int(parent)) + or not (row.get("identity") is String) or str(row.identity).strip_edges().is_empty() + or not (row.get("commandline") is String) + ): + return {} + var identity := str(row.identity) + var commandline := str(row.commandline) + var separator := identity.find("|") + if separator >= 0: + if separator == 0 or identity.substr(separator + 1) != commandline: + return {} + elif not identity.is_valid_int() or not commandline.is_empty() or int(parent) != 0: + return {} + return row + + +static func process_commandline(pid: int, snapshot: Variant = null) -> String: + if pid <= 1: + return "" + if snapshot != null: + return str(_process_snapshot_row(snapshot, pid).get("commandline", "")) + if OS.get_name() == "Windows": + var output: Array = [] + var script := ( + "Get-CimInstance Win32_Process -Filter 'ProcessId = %d' | " + + "Select-Object -ExpandProperty CommandLine" + ) % pid + if execute_windows_powershell(script, output) != 0 or output.is_empty(): + return "" + return str(output[0]).strip_edges() + var proc_path := "/proc/%d/cmdline" % pid + if FileAccess.file_exists(proc_path): + var file := FileAccess.open(proc_path, FileAccess.READ) + if file != null: + var bytes := PackedByteArray() + while bytes.size() < (1 << 20): + var chunk := file.get_buffer(4096) + if chunk.is_empty(): + break + bytes.append_array(chunk) + if file.eof_reached(): + break + file.close() + for index in range(bytes.size()): + if bytes[index] == 0: + bytes[index] = 0x20 + return bytes.get_string_from_utf8().strip_edges() + var output: Array = [] + if OS.execute("ps", ["-ww", "-p", str(pid), "-o", "args="], output, true) != 0: + return "" + return str(output[0]).strip_edges() if not output.is_empty() else "" + + +static func process_parent(pid: int, snapshot: Variant = null) -> int: + if pid <= 1: + return 0 + if snapshot != null: + return int(_process_snapshot_row(snapshot, pid).get("parent_pid", 0)) + var output: Array = [] + if OS.get_name() == "Windows": + var script := ( + "Get-CimInstance Win32_Process -Filter 'ProcessId = %d' | " + + "Select-Object -ExpandProperty ParentProcessId" + ) % pid + if execute_windows_powershell(script, output) != 0 or output.is_empty(): + return 0 + else: + if OS.execute("ps", ["-o", "ppid=", "-p", str(pid)], output, true) != 0 or output.is_empty(): + return 0 + var raw := str(output[0]).strip_edges() + return int(raw) if raw.is_valid_int() else 0 + + +static func process_descends_from(pid: int, ancestor_pid: int, snapshot: Variant = null) -> bool: + if pid <= 1 or ancestor_pid <= 1: + return false + if snapshot != null and _process_snapshot_row(snapshot, pid).is_empty(): + return false + var current := pid + for _depth in range(16): + if snapshot != null and _process_snapshot_row(snapshot, current).is_empty(): + return false + if current == ancestor_pid: + return true + var parent := process_parent(current, snapshot) + if parent <= 1 or parent == current: + return false + current = parent + return false + + +static func commandline_is_godot_ai_server(commandline: String) -> bool: + if commandline.is_empty(): + return false + var lower := commandline.to_lower() + var expression := RegEx.new() + var brand_search := lower + ## Paths we choose ourselves must not satisfy the brand: strip the pid-file + ## and startup-report values (they live under user:// and carry our name) + ## before searching for it. + if expression.compile("--(pid-file|startup-report)(?:=|\\s+)\\S+") == OK: + brand_search = expression.sub(lower, "--$1 ", true) + var branded := brand_search.contains("godot-ai") or brand_search.contains("godot_ai") + return branded and (lower.contains("--pid-file") or lower.contains("--transport")) + + +static func pid_cmdline_is_godot_ai(pid: int, snapshot: Variant = null) -> bool: + var current := pid + for _depth in range(5): + if current <= 1: + return false + if commandline_is_godot_ai_server(process_commandline(current, snapshot)): + return true + current = process_parent(current, snapshot) + return false + + +static func process_fingerprint(pid: int, snapshot: Variant = null) -> String: + if not pid_alive(pid, snapshot): + return "" + var output: Array = [] + var identity := "" + if snapshot != null: + identity = str(_process_snapshot_row(snapshot, pid).get("identity", "")).strip_edges() + elif OS.get_name() == "Windows": + var script := ( + "Get-CimInstance Win32_Process -Filter 'ProcessId = %d' | " + + "ForEach-Object { [string]$_.CreationDate + '|' + [string]$_.CommandLine }" + ) % pid + if execute_windows_powershell(script, output) == 0 and not output.is_empty(): + identity = str(output[0]).strip_edges() + if identity.is_empty(): + ## Some restricted Windows hosts expose process start time through + ## Get-Process while CIM returns no Win32_Process projection. PID + + ## creation time is the reuse-resistant identity; command-line brand + ## is checked independently before destructive authority is minted. + output.clear() + script = ( + "(Get-Process -Id %d -ErrorAction Stop).StartTime.ToFileTimeUtc()" + ) % pid + if execute_windows_powershell(script, output) == 0 and not output.is_empty(): + identity = str(output[0]).strip_edges() + else: + if OS.execute( + "ps", ["-ww", "-p", str(pid), "-o", "lstart=", "-o", "args="], output, true + ) == 0 and not output.is_empty(): + identity = str(output[0]).strip_edges() + if identity.is_empty(): + return "" + var context := HashingContext.new() + context.start(HashingContext.HASH_SHA256) + context.update((str(pid) + "|" + identity).to_utf8_buffer()) + return context.finish().hex_encode() + + +## Capture the process identity that a later destructive call must present. +## A PID or command-line brand alone is never kill authority: either can refer +## to a different process by the time a worker reaches the effect boundary. +## `diagnostics`, when given, receives one word naming the check that +## refused the grant. It costs no extra probe and never changes the result. +static func capture_process_kill_grant( + pid: int, require_brand := false, diagnostics: Array = [] +) -> Dictionary: + if pid <= 1 or pid == OS.get_process_id(): + diagnostics.append("invalid_pid") + return {} + var pair: Array = _capture_windows_process_snapshot_pair(pid) if OS.get_name() == "Windows" else [] + var first: Variant = pair[0] if not pair.is_empty() else capture_process_snapshot(pid) + if capture_failed(first): + diagnostics.append("identity_unavailable") + return {} + if not pid_alive(pid, first): + diagnostics.append("not_alive") + return {} + if require_brand and not pid_cmdline_is_godot_ai(pid, first): + diagnostics.append("unbranded") + return {} + var fingerprint := process_fingerprint(pid, first) + if fingerprint.is_empty(): + diagnostics.append("fingerprint_unavailable") + return {} + ## Close the capture window: both identity and optional lineage/brand must + ## still describe the same process in the independently collected final snapshot. + var final: Variant = pair[1] if not pair.is_empty() else capture_process_snapshot(pid) + if capture_failed(final): + diagnostics.append("identity_unavailable") + return {} + if process_fingerprint(pid, final) != fingerprint: + diagnostics.append("fingerprint_changed") + return {} + if require_brand and not pid_cmdline_is_godot_ai(pid, final): + diagnostics.append("brand_changed") + return {} + return {"pid": pid, "fingerprint": fingerprint} + + +## Kill only exact process grants and re-read identity immediately before the +## OS call. On Windows, taskkill /T already handles real descendants; do not +## append command-line lookalikes such as spoofed multiprocessing children. +static func kill_exact_processes( + grants: Array[Dictionary], require_brand := false, require_tree_proof := false +) -> Array[int]: + var killed: Array[int] = [] + var seen: Array[int] = [] + for grant in grants: + if grant.keys().size() != 2 or not grant.has("pid") or not grant.has("fingerprint"): + continue + var pid := int(grant.get("pid", 0)) + var fingerprint := str(grant.get("fingerprint", "")) + if pid <= 1 or pid == OS.get_process_id() or seen.has(pid) or fingerprint.is_empty(): + continue + var snapshot: Variant = capture_process_snapshot(pid) + if ( + process_fingerprint(pid, snapshot) != fingerprint + or (require_brand and not pid_cmdline_is_godot_ai(pid, snapshot)) + ): + continue + seen.append(pid) + if OS.get_name() == "Windows": + var output: Array = [] + var code := OS.execute("taskkill", ["/PID", str(pid), "/T", "/F"], output, true) + ## A vanished direct PID is enough for ordinary exact-process callers. + ## Timeout safety needs stronger evidence: only taskkill /T success + ## proves that Windows accepted termination of the whole child tree. + if code == 0 or (not require_tree_proof and not pid_alive(pid)): + killed.append(pid) + elif OS.kill(pid) == OK or not pid_alive(pid): + killed.append(pid) + return killed + + ## Poll until the given port is no longer bound, or the timeout elapses. ## Used after `OS.kill` so we don't race the port-in-use check on rebind. -## NOTE: plugin.gd::_wait_for_port_free (and _is_port_in_use) is a -## deliberate line-for-line fork of this pair kept for _ProofPlugin -## isolation — keep the two in sync when editing either. static func wait_for_port_free(port: int, timeout_s: float) -> void: var deadline := Time.get_ticks_msec() + int(timeout_s * 1000.0) while is_port_in_use(port): @@ -338,24 +754,6 @@ static func resolve_ws_port(configured: int, max_port: int, log_buffer = null) - return resolved -## Trust the cached ws_port from the managed record only when the record -## is current ownership proof — i.e. record version matches the installed -## plugin. Otherwise a stale record from an older install (e.g. a 9500 -## value pre-Windows-reservation collision) would mislead the -## compatibility check into killing an unrelated external process. #259. -static func resolved_ws_port_for_existing_server( - record_ws_port: int, - record_version: String, - current_version: String, - fresh_resolved: int -) -> int: - if record_ws_port <= 0: - return fresh_resolved - if current_version.is_empty() or record_version != current_version: - return fresh_resolved - return record_ws_port - - static func resolve_ws_port_from_output( configured_port: int, netsh_output: String, diff --git a/addons/godot_ai/utils/release_verifier.gd b/addons/godot_ai/utils/release_verifier.gd new file mode 100644 index 0000000..77ce158 --- /dev/null +++ b/addons/godot_ai/utils/release_verifier.gd @@ -0,0 +1,876 @@ +@tool +class_name McpReleaseVerifier +extends RefCounted + +## Pure, static verification of a signed v4 release: the canonical manifest, +## its RSA signature, the archive, and the tree hash the installer compares +## after extraction and again after restart. +## +## Every rule here mirrors `src/godot_ai/release_verify.py`. The two must stay +## byte-for-byte compatible: the release pipeline produces the manifest with +## Python's `_canonical()` and the installed-tree hash with Python's +## `hash_tree()`, and this file re-derives both from raw bytes. Every function +## fails closed with a specific error string and never raises. + +const REPOSITORY := "hi-godot/godot-ai" +const CHANNEL := "stable" +const ASSET_NAME := "godot-ai-v4-plugin.zip" +const PLUGIN_PREFIX := "addons/godot_ai/" +const PLUGIN_CONFIG := "addons/godot_ai/plugin.cfg" +const SCHEMA_VERSION := 1 +const MAX_FILES := 4096 +const MAX_FILE_SIZE := 64 * 1024 * 1024 +const MAX_ARCHIVE_SIZE := 64 * 1024 * 1024 +const MAX_TREE_SIZE := MAX_ARCHIVE_SIZE +const MAX_MANIFEST_SIZE := 1024 * 1024 +## Signature length for the production RSA-4096 key. The check itself is +## derived from the supplied key's modulus so a smaller injected test key +## still verifies its own (shorter) signatures. +const SIGNATURE_SIZE := 512 +const MIN_RSA_MODULUS_BYTES := 256 +## Largest integer JSON can round-trip through Godot's double-based parser. +const MAX_SAFE_INTEGER := 9007199254740992 +const READ_CHUNK := 1024 * 1024 + +const ROOT_KEYS := [ + "asset", "channel", "inventory", "repository", "schema_version", "source_commit", "tag", "version", +] +const ASSET_KEYS := ["name", "sha256", "size"] +const ROW_KEYS := ["path", "sha256", "size"] +const RESERVED_NAMES := [ + "CON", "PRN", "AUX", "NUL", "CONIN$", "CONOUT$", + "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", + "COM¹", "COM²", "COM³", + "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", + "LPT¹", "LPT²", "LPT³", +] +const _UNSAFE_PATH_CHARS := "<>:\"|?*" +## DER-encoded AlgorithmIdentifier for rsaEncryption with NULL parameters. +const _RSA_ALGORITHM := [0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01, 0x05, 0x00] + + +# ----- public API ------------------------------------------------------- + + +## Authenticate raw manifest bytes against a detached signature and the +## caller's explicit expectations. `expected` carries `repository` and +## `channel`, optionally `version` (must equal the manifest's), and optionally +## `current_version` (the manifest must be strictly newer). Returns +## `{"ok": bool, "error": String, "manifest": Dictionary}`; the returned +## manifest has integer sizes normalised to `int`. +static func verify_manifest( + manifest_bytes: PackedByteArray, + signature: PackedByteArray, + expected: Dictionary, + public_key_pem: String, +) -> Dictionary: + var result := {"ok": false, "error": "", "manifest": {}} + if manifest_bytes.is_empty() or manifest_bytes.size() > MAX_MANIFEST_SIZE: + result.error = "manifest: expected 1..%d bytes, got %d" % [MAX_MANIFEST_SIZE, manifest_bytes.size()] + return result + if not is_valid_utf8(manifest_bytes): + result.error = "manifest: not valid UTF-8" + return result + var json := JSON.new() + var parse_error := json.parse(manifest_bytes.get_string_from_utf8()) + if parse_error != OK: + result.error = "manifest: invalid JSON: %s (line %d)" % [json.get_error_message(), json.get_error_line()] + return result + var canonical := canonical_json(json.data) + if not bool(canonical.ok): + result.error = "manifest: encoding is not canonical JSON: %s" % str(canonical.error) + return result + if canonical.bytes != manifest_bytes: + result.error = "manifest: encoding is not canonical JSON" + return result + if not json.data is Dictionary: + result.error = "manifest: expected a JSON object" + return result + var validated := validate_manifest_dict(json.data) + if not bool(validated.ok): + result.error = str(validated.error) + return result + var manifest: Dictionary = validated.manifest + var identity_error := _check_identity(manifest, expected) + if not identity_error.is_empty(): + result.error = identity_error + return result + var signature_error := verify_signature(manifest_bytes, signature, public_key_pem) + if not signature_error.is_empty(): + result.error = signature_error + return result + result.ok = true + result.manifest = manifest + return result + + +## Check a zip on disk against an already-verified manifest: whole-file size +## and SHA-256 against `manifest.asset`, then the entry list against the +## inventory (exact names, exact order, no directory entries), then every +## entry's size and SHA-256 against its row. Returns `{"ok", "error"}`. +static func verify_archive(zip_path: String, manifest: Dictionary) -> Dictionary: + var result := {"ok": false, "error": ""} + var validated := validate_manifest_dict(manifest) + if not bool(validated.ok): + result.error = str(validated.error) + return result + var normalised: Dictionary = validated.manifest + var asset: Dictionary = normalised["asset"] + var hashed := sha256_file(zip_path) + if not bool(hashed.ok): + result.error = "archive: %s" % str(hashed.error) + return result + if int(hashed["size"]) != int(asset["size"]): + result.error = "archive: size %d differs from signed manifest size %d" % [int(hashed["size"]), int(asset["size"])] + return result + if str(hashed.sha256) != str(asset["sha256"]): + result.error = "archive: SHA-256 differs from signed manifest" + return result + var reader := ZIPReader.new() + var open_error := reader.open(_absolute(zip_path)) + if open_error != OK: + result.error = "archive: cannot open zip: %s" % error_string(open_error) + return result + var entries := reader.get_files() + var rows: Array = normalised["inventory"] + var expected_paths: Array[String] = [] + var expected_rows := {} + for row in rows: + expected_paths.append(str(row["path"])) + expected_rows[str(row["path"])] = row + var listing_error := _check_entry_names(entries, expected_paths, expected_rows) + if not listing_error.is_empty(): + reader.close() + result.error = listing_error + return result + var total := 0 + var config := PackedByteArray() + for entry in entries: + var row: Dictionary = expected_rows[entry] + var data := reader.read_file(entry) + if data.size() != int(row["size"]): + reader.close() + result.error = "archive entry %s: size %d differs from inventory size %d" % [entry, data.size(), int(row["size"])] + return result + if sha256_bytes(data) != str(row["sha256"]): + reader.close() + result.error = "archive entry %s: content differs from inventory" % entry + return result + total += data.size() + if total > MAX_TREE_SIZE: + reader.close() + result.error = "archive: expanded tree exceeds %d-byte bound" % MAX_TREE_SIZE + return result + if entry == PLUGIN_CONFIG: + config = data + reader.close() + var config_version := plugin_cfg_version(config) + if config_version != str(normalised["version"]): + result.error = "archive: plugin.cfg version %s differs from signed manifest version %s" % [ + config_version if not config_version.is_empty() else "(unreadable)", str(normalised["version"]), + ] + return result + result.ok = true + return result + + +## Hash every regular file under `root` recursively. Returns +## `{"ok", "error", "files": {relative_path: {"size", "sha256"}}, "tree_sha256"}`. +## +## Tree-hash algorithm (shared verbatim with Python `release_verify.hash_tree`): +## relative POSIX paths sorted by UTF-8 byte order; for each file one line +## `sha256 + " " + str(size) + " " + path + "\n"`; `tree_sha256` is the hex +## SHA-256 of those lines concatenated. Symlinks anywhere in the tree fail. +static func hash_tree(root: String) -> Dictionary: + var result := {"ok": false, "error": "", "files": {}, "tree_sha256": ""} + var root_abs := _absolute(root) + if not DirAccess.dir_exists_absolute(root_abs): + result.error = "tree: directory does not exist: %s" % root_abs + return result + var files := {} + var pending: Array[String] = [""] + var total := 0 + while not pending.is_empty(): + var relative: String = pending.pop_back() + var directory_abs := root_abs if relative.is_empty() else root_abs.path_join(relative) + var directory := DirAccess.open(directory_abs) + if directory == null: + result.error = "tree: cannot open %s: %s" % [directory_abs, error_string(DirAccess.get_open_error())] + return result + directory.include_hidden = true + directory.include_navigational = false + for name in directory.get_directories(): + var child_abs := directory_abs.path_join(name) + if directory.is_link(child_abs): + result.error = "tree: link is not allowed: %s" % child_abs + return result + pending.append(name if relative.is_empty() else relative + "/" + name) + for name in directory.get_files(): + var child_abs := directory_abs.path_join(name) + if directory.is_link(child_abs): + result.error = "tree: link is not allowed: %s" % child_abs + return result + var hashed := sha256_file(child_abs) + if not bool(hashed.ok): + result.error = "tree: %s" % str(hashed.error) + return result + total += int(hashed["size"]) + if files.size() >= MAX_FILES or total > MAX_TREE_SIZE: + result.error = "tree: exceeds %d files or %d bytes" % [MAX_FILES, MAX_TREE_SIZE] + return result + var relative_path := name if relative.is_empty() else relative + "/" + name + files[relative_path] = {"size": int(hashed["size"]), "sha256": str(hashed.sha256)} + var sorted_paths: Array[String] = [] + for path in files: + sorted_paths.append(str(path)) + sorted_paths.sort() + var ordered := {} + for path in sorted_paths: + ordered[path] = files[path] + result.files = ordered + result.tree_sha256 = tree_hash_from_files(ordered) + result.ok = true + return result + + +## The tree hash a correct extraction of `manifest.inventory` must produce: +## the same line algorithm as `hash_tree` over the inventory rows with the +## `addons/godot_ai/` prefix stripped. Only `inventory` is read, and row +## order does not matter. Returns "" for a malformed inventory or a row +## outside `addons/godot_ai/`, which can never equal a real hash. +static func inventory_tree_hash(manifest: Dictionary) -> String: + if not manifest.has("inventory") or not manifest["inventory"] is Array: + return "" + var files := {} + for value in manifest["inventory"]: + if not value is Dictionary: + return "" + var row: Dictionary = value + if not _check_keys(row, ROW_KEYS, "row").is_empty(): + return "" + var size := _integer(row["size"], "row.size", MAX_FILE_SIZE) + if not row["path"] is String or not bool(size.ok) or not row["sha256"] is String: + return "" + var path := str(row["path"]) + var digest := str(row["sha256"]) + if not path.begins_with(PLUGIN_PREFIX) or path.length() == PLUGIN_PREFIX.length() or not _is_hex(digest, 64): + return "" + var relative := path.substr(PLUGIN_PREFIX.length()) + if files.has(relative): + return "" + files[relative] = {"size": int(size.value), "sha256": digest} + if files.is_empty(): + return "" + return tree_hash_from_files(files) + + +## Apply the tree-hash line algorithm to a `{relative_path: {size, sha256}}` +## dictionary (the `files` shape `hash_tree` returns). +static func tree_hash_from_files(files: Dictionary) -> String: + var paths: Array[String] = [] + for path in files: + paths.append(str(path)) + paths.sort() + var context := HashingContext.new() + context.start(HashingContext.HASH_SHA256) + for path in paths: + var entry: Dictionary = files[path] + var line := "%s %d %s\n" % [str(entry["sha256"]), int(entry["size"]), path] + context.update(line.to_utf8_buffer()) + return context.finish().hex_encode() + + +## Hex SHA-256 of a byte buffer. +static func sha256_bytes(bytes: PackedByteArray) -> String: + var context := HashingContext.new() + context.start(HashingContext.HASH_SHA256) + if not bytes.is_empty(): + context.update(bytes) + return context.finish().hex_encode() + + +## Chunked SHA-256 of a file. Returns `{"ok", "error", "size", "sha256"}`; +## files beyond MAX_FILE_SIZE fail without being read to the end. +static func sha256_file(path: String) -> Dictionary: + var result := {"ok": false, "error": "", "size": 0, "sha256": ""} + var absolute := _absolute(path) + var file := FileAccess.open(absolute, FileAccess.READ) + if file == null: + result.error = "cannot read %s: %s" % [absolute, error_string(FileAccess.get_open_error())] + return result + var context := HashingContext.new() + context.start(HashingContext.HASH_SHA256) + var size := 0 + while true: + var chunk := file.get_buffer(READ_CHUNK) + if chunk.is_empty(): + break + size += chunk.size() + if size > MAX_FILE_SIZE: + file.close() + result.error = "%s exceeds %d-byte bound" % [absolute, MAX_FILE_SIZE] + return result + context.update(chunk) + var read_error := file.get_error() + file.close() + if read_error != OK and read_error != ERR_FILE_EOF: + result.error = "cannot read %s: %s" % [absolute, error_string(read_error)] + return result + result.size = size + result.sha256 = context.finish().hex_encode() + result.ok = true + return result + + +## Serialise `value` exactly as Python's +## `json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + "\n"` +## encodes it: sorted keys, no whitespace, integers (including integral +## floats, which is what Godot's JSON parser yields for them) as integers, +## non-ASCII unescaped. Returns `{"ok", "error", "bytes"}`. Non-integral +## numbers fail: the manifest schema has none and Godot's float formatting +## is not guaranteed to match Python's. +static func canonical_json(value: Variant) -> Dictionary: + var result := {"ok": false, "error": "", "bytes": PackedByteArray()} + var text := _canonical_value(value, result) + if not str(result.error).is_empty(): + return result + result.bytes = (text + "\n").to_utf8_buffer() + result.ok = true + return result + + +## Strict UTF-8 validation: rejects overlong forms, surrogates, and code +## points beyond U+10FFFF, the same input Python's `bytes.decode` rejects. +static func is_valid_utf8(bytes: PackedByteArray) -> bool: + var index := 0 + var size := bytes.size() + while index < size: + var lead := bytes[index] + if lead < 0x80: + index += 1 + continue + var need := 0 + var code := 0 + if lead >= 0xC2 and lead <= 0xDF: + need = 1 + code = lead & 0x1F + elif lead >= 0xE0 and lead <= 0xEF: + need = 2 + code = lead & 0x0F + elif lead >= 0xF0 and lead <= 0xF4: + need = 3 + code = lead & 0x07 + else: + return false + if index + need >= size: + return false + for offset in range(1, need + 1): + var continuation := bytes[index + offset] + if (continuation & 0xC0) != 0x80: + return false + code = (code << 6) | (continuation & 0x3F) + if need == 2 and code < 0x800: + return false + if need == 3 and (code < 0x10000 or code > 0x10FFFF): + return false + if code >= 0xD800 and code <= 0xDFFF: + return false + index += need + 1 + return true + + +## Validate a parsed manifest's schema, bounds, and inventory path safety +## without checking identity or the signature. Returns +## `{"ok", "error", "manifest"}` where `manifest` is a normalised copy with +## integer fields as `int`. +static func validate_manifest_dict(root: Dictionary) -> Dictionary: + var result := {"ok": false, "error": "", "manifest": {}} + var root_error := _check_keys(root, ROOT_KEYS, "manifest") + if not root_error.is_empty(): + result.error = root_error + return result + var schema := _integer(root["schema_version"], "manifest.schema_version", SCHEMA_VERSION) + if not bool(schema.ok): + result.error = str(schema.error) + return result + if int(schema.value) != SCHEMA_VERSION: + result.error = "manifest.schema_version: expected %d" % SCHEMA_VERSION + return result + var manifest := {"schema_version": SCHEMA_VERSION} + for key in ["repository", "channel", "tag", "version", "source_commit"]: + var text := _string(root[key], "manifest.%s" % key) + if not bool(text.ok): + result.error = str(text.error) + return result + manifest[key] = str(text.value) + if not root["asset"] is Dictionary: + result.error = "manifest.asset: expected object" + return result + var asset: Dictionary = root["asset"] + var asset_error := _check_keys(asset, ASSET_KEYS, "manifest.asset") + if not asset_error.is_empty(): + result.error = asset_error + return result + if not asset["name"] is String or str(asset["name"]) != ASSET_NAME: + result.error = "manifest.asset.name: expected %s" % ASSET_NAME + return result + var asset_size := _integer(asset["size"], "manifest.asset.size", MAX_ARCHIVE_SIZE) + if not bool(asset_size.ok): + result.error = str(asset_size.error) + return result + if not asset["sha256"] is String or not _is_hex(str(asset["sha256"]), 64): + result.error = "manifest.asset.sha256: expected 64 lowercase hex characters" + return result + manifest["asset"] = {"name": ASSET_NAME, "size": int(asset_size.value), "sha256": str(asset["sha256"])} + if not root["inventory"] is Array: + result.error = "manifest.inventory: expected array" + return result + var rows: Array = root["inventory"] + var inventory: Array = [] + var paths: Array[String] = [] + var total := 0 + for index in rows.size(): + var context := "manifest.inventory[%d]" % index + if not rows[index] is Dictionary: + result.error = "%s: expected object" % context + return result + var row: Dictionary = rows[index] + var row_error := _check_keys(row, ROW_KEYS, context) + if not row_error.is_empty(): + result.error = row_error + return result + var path := _string(row["path"], context + ".path") + if not bool(path.ok): + result.error = str(path.error) + return result + var size := _integer(row["size"], context + ".size", MAX_FILE_SIZE) + if not bool(size.ok): + result.error = str(size.error) + return result + if not row["sha256"] is String or not _is_hex(str(row["sha256"]), 64): + result.error = "%s.sha256: expected 64 lowercase hex characters" % context + return result + total += int(size.value) + paths.append(str(path.value)) + inventory.append({"path": str(path.value), "size": int(size.value), "sha256": str(row["sha256"])}) + var paths_error := _validate_paths(paths, "manifest.inventory") + if not paths_error.is_empty(): + result.error = paths_error + return result + if total > MAX_TREE_SIZE: + result.error = "manifest.inventory: expanded tree exceeds %d-byte bound" % MAX_TREE_SIZE + return result + if not paths.has(PLUGIN_CONFIG): + result.error = "manifest.inventory: missing %s" % PLUGIN_CONFIG + return result + manifest["inventory"] = inventory + result.manifest = manifest + result.ok = true + return result + + +## Verify a PKCS#1 v1.5 SHA-256 signature over `message` with a PEM +## SubjectPublicKeyInfo. Returns "" on success, otherwise the reason. +static func verify_signature(message: PackedByteArray, signature: PackedByteArray, public_key_pem: String) -> String: + var modulus_size := rsa_modulus_size(public_key_pem) + if modulus_size < MIN_RSA_MODULUS_BYTES: + return "public key: expected a PEM RSA SubjectPublicKeyInfo of at least %d bits" % (MIN_RSA_MODULUS_BYTES * 8) + if signature.size() != modulus_size: + return "manifest signature: expected exactly %d bytes, got %d" % [modulus_size, signature.size()] + var key := CryptoKey.new() + if key.load_from_string(public_key_pem, true) != OK: + return "public key: could not be loaded" + var crypto := Crypto.new() + var digest := PackedByteArray() + var context := HashingContext.new() + context.start(HashingContext.HASH_SHA256) + context.update(message) + digest = context.finish() + if not crypto.verify(HashingContext.HASH_SHA256, digest, signature, key): + return "manifest signature: RSA PKCS#1 v1.5 SHA-256 verification failed" + return "" + + +## Byte length of the RSA modulus inside a PEM SubjectPublicKeyInfo, or 0 +## when the PEM is not a well-formed rsaEncryption public key. +static func rsa_modulus_size(public_key_pem: String) -> int: + var lines := public_key_pem.replace("\r\n", "\n").strip_edges().split("\n") + if ( + lines.size() < 3 + or lines[0].strip_edges() != "-----BEGIN PUBLIC KEY-----" + or lines[lines.size() - 1].strip_edges() != "-----END PUBLIC KEY-----" + ): + return 0 + var encoded := "" + for index in range(1, lines.size() - 1): + encoded += lines[index].strip_edges() + if encoded.is_empty() or not _is_base64(encoded): + return 0 + var der := Marshalls.base64_to_raw(encoded) + if der.is_empty(): + return 0 + var outer := _der_item(der, 0, 0x30) + if not bool(outer.ok) or int(outer.end) != der.size(): + return 0 + var body: PackedByteArray = outer.value + var algorithm := _der_item(body, 0, 0x30) + if not bool(algorithm.ok) or algorithm.value != PackedByteArray(_RSA_ALGORITHM): + return 0 + var bits := _der_item(body, int(algorithm.end), 0x03) + if not bool(bits.ok) or int(bits.end) != body.size(): + return 0 + var bit_string: PackedByteArray = bits.value + if bit_string.is_empty() or bit_string[0] != 0: + return 0 + var key := _der_item(bit_string, 1, 0x30) + if not bool(key.ok) or int(key.end) != bit_string.size(): + return 0 + var key_body: PackedByteArray = key.value + var modulus := _der_item(key_body, 0, 0x02) + if not bool(modulus.ok): + return 0 + var raw: PackedByteArray = modulus.value + if raw.is_empty() or raw[0] & 0x80: + return 0 + if raw.size() > 1 and raw[0] == 0: + if not (raw[1] & 0x80): + return 0 + raw = raw.slice(1) + var exponent := _der_item(key_body, int(modulus.end), 0x02) + if not bool(exponent.ok) or int(exponent.end) != key_body.size() or exponent.value.is_empty(): + return 0 + return raw.size() + + +## The single quoted `version="..."` line of a plugin.cfg, or "" when the +## file does not carry exactly one. +static func plugin_cfg_version(data: PackedByteArray) -> String: + if not is_valid_utf8(data): + return "" + var expression := RegEx.new() + if expression.compile("(?m)^\\s*version\\s*=\\s*\"([^\"]+)\"\\s*$") != OK: + return "" + var matches := expression.search_all(data.get_string_from_utf8()) + if matches.size() != 1: + return "" + return matches[0].get_string(1) + + +## Parse a dotted `major.minor.patch` version into three ints, or [] when +## it is not exactly three unsigned decimal components. +static func version_parts(version: String) -> Array[int]: + var parts := version.split(".") + if parts.size() != 3: + return [] + var numbers: Array[int] = [] + for part in parts: + if not _all_digits(part): + return [] + numbers.append(int(part)) + return numbers + + +## True when `candidate` is strictly newer than `current` by tuple order. +static func is_newer_version(candidate: String, current: String) -> bool: + var left := version_parts(candidate) + var right := version_parts(current) + if left.is_empty() or right.is_empty(): + return false + for index in 3: + if left[index] != right[index]: + return left[index] > right[index] + return false + + +# ----- identity --------------------------------------------------------- + + +static func _check_identity(manifest: Dictionary, expected: Dictionary) -> String: + var repository := str(manifest["repository"]) + if repository != REPOSITORY or repository != str(expected.get("repository", REPOSITORY)): + return "manifest.repository: expected %s" % REPOSITORY + var channel := str(manifest["channel"]) + if channel != CHANNEL or channel != str(expected.get("channel", CHANNEL)): + return "manifest.channel: v4 releases are stable-only" + var version := str(manifest["version"]) + var parts := version_parts(version) + if parts.is_empty() or parts[0] != 4: + return "manifest.version: expected a 4.x.y version" + if str(manifest["tag"]) != "v" + version: + return "manifest.tag: expected v%s" % version + if not _is_hex(str(manifest["source_commit"]), 40): + return "manifest.source_commit: expected 40 lowercase hex characters" + var expected_version := str(expected.get("version", "")) + if not expected_version.is_empty() and expected_version != version: + return "manifest.version: %s does not match the expected release %s" % [version, expected_version] + var expected_tag := str(expected.get("tag", "")) + if not expected_tag.is_empty() and expected_tag != str(manifest["tag"]): + return "manifest.tag: %s does not match the expected tag %s" % [str(manifest["tag"]), expected_tag] + var expected_commit := str(expected.get("source_commit", "")) + if not expected_commit.is_empty() and expected_commit != str(manifest["source_commit"]): + return "manifest.source_commit: does not match the expected commit" + var current_version := str(expected.get("current_version", "")) + if not current_version.is_empty(): + if version_parts(current_version).is_empty(): + return "current_version: %s is not a dotted major.minor.patch version" % current_version + if not is_newer_version(version, current_version): + return "manifest.version: %s is not newer than the installed %s" % [version, current_version] + return "" + + +# ----- archive listing -------------------------------------------------- + + +static func _check_entry_names( + entries: PackedStringArray, expected_paths: Array[String], expected_rows: Dictionary +) -> String: + var seen := {} + for entry in entries: + if entry.ends_with("/"): + return "archive: directory entry is not allowed: %s" % entry + if not expected_rows.has(entry): + return "archive: entry is not in the signed inventory: %s" % entry + if seen.has(entry): + return "archive: duplicate entry: %s" % entry + seen[entry] = true + for path in expected_paths: + if not seen.has(path): + return "archive: inventory file is missing from the zip: %s" % path + if Array(entries) != Array(expected_paths): + return "archive: entries are not in the sorted inventory order" + return "" + + +# ----- path safety ------------------------------------------------------ + + +static func _validate_paths(paths: Array[String], context: String) -> String: + if paths.is_empty() or paths.size() > MAX_FILES: + return "%s: expected 1..%d paths" % [context, MAX_FILES] + var sorted := paths.duplicate() + sorted.sort() + var exact := {} + var identities := {} + for index in paths.size(): + var path := paths[index] + var path_error := _check_path(path, "%s[%d]" % [context, index]) + if not path_error.is_empty(): + return path_error + if exact.has(path): + return "%s: duplicate path %s" % [context, path] + exact[path] = true + var identity := path.to_lower() + if identities.has(identity): + return "%s: case-colliding paths %s and %s" % [context, str(identities[identity]), path] + identities[identity] = path + if paths != sorted: + return "%s: paths are not in bytewise order" % context + for path in paths: + var parent := path.get_base_dir() + while parent.contains("/"): + if exact.has(parent): + return "%s: file/ancestor collision at %s" % [context, parent] + parent = parent.get_base_dir() + return "" + + +static func _check_path(path: String, context: String) -> String: + if not path.begins_with(PLUGIN_PREFIX) or path.ends_with("/") or path.contains("\\"): + return "%s: path must be a file beneath %s: %s" % [context, PLUGIN_PREFIX, path] + for part in path.split("/"): + if part.is_empty() or part == "." or part == ".." or part.ends_with(" ") or part.ends_with("."): + return "%s: unsafe path component in %s" % [context, path] + for index in part.length(): + var code := part.unicode_at(index) + if code < 32 or _UNSAFE_PATH_CHARS.contains(char(code)): + return "%s: unsafe path character in %s" % [context, path] + var stem := part.split(".", true, 1)[0] + if RESERVED_NAMES.has(stem.to_upper()): + return "%s: reserved path component in %s" % [context, path] + return "" + + +# ----- schema helpers --------------------------------------------------- + + +static func _check_keys(value: Dictionary, expected_keys: Array, context: String) -> String: + var keys := value.keys() + for key in keys: + if not key is String: + return "%s: expected object with exactly keys %s" % [context, str(expected_keys)] + keys.sort() + if keys != expected_keys: + return "%s: expected object with exactly keys %s" % [context, str(expected_keys)] + return "" + + +static func _string(value: Variant, context: String) -> Dictionary: + if not value is String or str(value).is_empty(): + return {"ok": false, "error": "%s: expected non-empty string" % context, "value": ""} + return {"ok": true, "error": "", "value": str(value)} + + +## Accept `int`, or the integral `float` Godot's JSON parser yields, within +## `[0, maximum]`. Booleans are rejected. +static func _integer(value: Variant, context: String, maximum: int) -> Dictionary: + var failure := {"ok": false, "error": "%s: expected integer in [0, %d]" % [context, maximum], "value": 0} + var number := 0 + if value is int: + number = value + elif value is float: + var real: float = value + if real != floor(real) or absf(real) > MAX_SAFE_INTEGER: + return failure + number = int(real) + else: + return failure + if number < 0 or number > maximum: + return failure + return {"ok": true, "error": "", "value": number} + + +static func _is_hex(text: String, length: int) -> bool: + if text.length() != length: + return false + for index in text.length(): + var code := text.unicode_at(index) + if not ((code >= 48 and code <= 57) or (code >= 97 and code <= 102)): + return false + return true + + +static func _all_digits(text: String) -> bool: + if text.is_empty(): + return false + for index in text.length(): + var code := text.unicode_at(index) + if code < 48 or code > 57: + return false + return true + + +static func _is_base64(text: String) -> bool: + for index in text.length(): + var code := text.unicode_at(index) + var ok := ( + (code >= 65 and code <= 90) + or (code >= 97 and code <= 122) + or (code >= 48 and code <= 57) + or code == 43 or code == 47 or code == 61 + ) + if not ok: + return false + return true + + +# ----- canonical JSON --------------------------------------------------- + + +static func _canonical_value(value: Variant, result: Dictionary) -> String: + match typeof(value): + TYPE_NIL: + return "null" + TYPE_BOOL: + return "true" if value else "false" + TYPE_INT: + return String.num_int64(value) + TYPE_FLOAT: + var real: float = value + if real != floor(real) or absf(real) > MAX_SAFE_INTEGER: + result.error = "non-integer number %s" % str(real) + return "" + return String.num_int64(int(real)) + TYPE_STRING, TYPE_STRING_NAME: + return _escape_json_string(str(value)) + TYPE_ARRAY: + var items: Array[String] = [] + for item in value: + items.append(_canonical_value(item, result)) + if not str(result.error).is_empty(): + return "" + return "[" + ",".join(items) + "]" + TYPE_DICTIONARY: + var dictionary: Dictionary = value + var keys: Array = dictionary.keys() + for key in keys: + if not key is String: + result.error = "non-string object key" + return "" + keys.sort() + var members: Array[String] = [] + for key in keys: + var member := _canonical_value(dictionary[key], result) + if not str(result.error).is_empty(): + return "" + members.append(_escape_json_string(str(key)) + ":" + member) + return "{" + ",".join(members) + "}" + _: + result.error = "unsupported JSON value type %s" % type_string(typeof(value)) + return "" + + +static func _escape_json_string(text: String) -> String: + var out := "\"" + for index in text.length(): + var code := text.unicode_at(index) + match code: + 0x22: + out += "\\\"" + 0x5C: + out += "\\\\" + 0x0A: + out += "\\n" + 0x0D: + out += "\\r" + 0x09: + out += "\\t" + 0x08: + out += "\\b" + 0x0C: + out += "\\f" + _: + if code < 0x20: + out += "\\u%04x" % code + else: + out += char(code) + return out + "\"" + + +# ----- DER -------------------------------------------------------------- + + +static func _der_item(data: PackedByteArray, offset: int, tag: int) -> Dictionary: + var failure := {"ok": false, "value": PackedByteArray(), "end": 0} + var size := data.size() + if offset >= size or data[offset] != tag: + return failure + offset += 1 + if offset >= size: + return failure + var first := data[offset] + offset += 1 + var length := 0 + if first < 0x80: + length = first + else: + var count := first & 0x7F + if count < 1 or count > 4 or offset + count > size or data[offset] == 0: + return failure + for index in count: + length = (length << 8) | data[offset + index] + offset += count + if length < 0x80: + return failure + var end := offset + length + if end > size: + return failure + return {"ok": true, "value": data.slice(offset, end), "end": end} + + +# ----- paths ------------------------------------------------------------ + + +static func _absolute(path: String) -> String: + if path.begins_with("res://") or path.begins_with("user://"): + return ProjectSettings.globalize_path(path) + return path diff --git a/addons/godot_ai/utils/release_verifier.gd.uid b/addons/godot_ai/utils/release_verifier.gd.uid new file mode 100644 index 0000000..34abbfe --- /dev/null +++ b/addons/godot_ai/utils/release_verifier.gd.uid @@ -0,0 +1 @@ +uid://cvjv1388502ne diff --git a/addons/godot_ai/utils/resource_io.gd b/addons/godot_ai/utils/resource_io.gd deleted file mode 100644 index cfac90f..0000000 --- a/addons/godot_ai/utils/resource_io.gd +++ /dev/null @@ -1,277 +0,0 @@ -@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: - 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 deleted file mode 100644 index 94da4c9..0000000 --- a/addons/godot_ai/utils/resource_io.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://de2rwdoa4wabf diff --git a/addons/godot_ai/utils/scene_path.gd b/addons/godot_ai/utils/scene_path.gd deleted file mode 100644 index 782aea1..0000000 --- a/addons/godot_ai/utils/scene_path.gd +++ /dev/null @@ -1,155 +0,0 @@ -@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 deleted file mode 100644 index 85795a9..0000000 --- a/addons/godot_ai/utils/scene_path.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://c1irdrss0amex diff --git a/addons/godot_ai/utils/screenshot_encode.gd b/addons/godot_ai/utils/screenshot_encode.gd deleted file mode 100644 index e3b0210..0000000 --- a/addons/godot_ai/utils/screenshot_encode.gd +++ /dev/null @@ -1,38 +0,0 @@ -@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 deleted file mode 100644 index 0bcdac2..0000000 --- a/addons/godot_ai/utils/screenshot_encode.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cl0xhoxwsbmow diff --git a/addons/godot_ai/utils/server_lifecycle.gd b/addons/godot_ai/utils/server_lifecycle.gd deleted file mode 100644 index ca7bdb0..0000000 --- a/addons/godot_ai/utils/server_lifecycle.gd +++ /dev/null @@ -1,2061 +0,0 @@ -@tool -class_name McpServerLifecycleManager -extends RefCounted - -## Server spawn / stop / respawn / adopt / recover orchestration plus the -## update-reload handoff. Owns the server-state machine -## (`McpServerState`), version-check seam (`McpServerVersionCheck`), -## adoption metadata, and connection-blocked / dev-mismatch flags. -## -## State previously lived on plugin.gd; PR 6 (#297) moved it here so -## PR 7 (UpdateManager extraction) can absorb the same encapsulation -## pattern. The plugin still owns the physical editor surfaces -## (Connection, Dock, Timer, EditorSettings I/O) and exposes them via -## `_host.()` shims; the test fixtures override those shims to -## drive the manager without touching the editor. -## -## `_host` is untyped to honor the self-update field-storage policy -## plugin.gd calls out near `_connection`. -var _host - -const UvCacheCleanup := preload("res://addons/godot_ai/utils/uv_cache_cleanup.gd") -const ClientConfigurator := preload("res://addons/godot_ai/client_configurator.gd") -const PortResolver := preload("res://addons/godot_ai/utils/port_resolver.gd") -const WindowsPortReservation := preload("res://addons/godot_ai/utils/windows_port_reservation.gd") -const McpServerStateScript := preload("res://addons/godot_ai/utils/mcp_server_state.gd") -const McpStartupPathScript := preload("res://addons/godot_ai/utils/mcp_startup_path.gd") -const McpAdoptionLabelScript := preload("res://addons/godot_ai/utils/mcp_adoption_label.gd") -const McpServerVersionCheckScript := preload("res://addons/godot_ai/utils/server_version_check.gd") - -# ---- State (owned here, was on plugin.gd through PR 5) --------------- - -## Single source of truth for the server-spawn/adopt/version lifecycle. -## See `McpServerState` for the transition table. -var _server_state: int = McpServerStateScript.UNINITIALIZED - -## OS-level state populated only when WE spawned the process. -var _server_pid: int = -1 -## keep_server_on_exit (#800): whether the RUNNING server was launched with -## the keep-alive env opt-outs (no owner pid, NO_IDLE_EXIT staged). Editor -## teardown routes on this, never on the live setting — a server spawned -## without the opt-outs must die with the editor even if the user enabled -## the setting mid-session, or the owner-PID watchdog reaps it seconds -## later and the preserved record goes stale (the #774 scenario). Set at -## spawn, recovered from the managed-server record on adoption. -var _server_keep_alive := false -var _server_spawn_ms: int = 0 -var _server_exit_ms: int = 0 -## Elapsed-since-spawn at the first watch tick that saw the spawn PID dead, or -## 0 when it is alive / has been healed onto the real PID. Only meaningful -## while a Windows trampoline handoff is being waited out (#797): it preserves -## the true exit time so a diagnosis raised after the wait still reports when -## the process actually died. Reset per spawn alongside `_server_spawn_ms`. -var _spawn_dead_since_ms: int = 0 - -## Version metadata. `expected_version` is what the plugin shipped with; -## `actual_version` is what the live server reported via handshake_ack. -var _server_expected_version: String = "" -var _server_actual_version: String = "" -var _server_actual_name: String = "" - -## Diagnostic + recovery flags surfaced to the dock via `get_status()`. -var _server_status_message: String = "" -## #647: when a post-crash probe pins the failure on a specific port held -## by a foreign process, this names that port (HTTP or WS) so the dock's -## status line and port-picker gating don't blame the wrong one. Zero when -## no conflict was diagnosed. -var _conflict_port: int = 0 -var _can_recover_incompatible: bool = false -var _connection_blocked: bool = false - -## One-shot guard for the stale-uvx-index recovery (#172). Reset at the -## top of `start_server` so each fresh spawn attempt gets its own -## refresh budget. -var _refresh_retried: bool = false - -## One-shot guard for the spawn-lost-port-race re-adoption (see -## `_diagnose_spawn_fast_exit`). #805: the budget is per RECOVERY, not per -## walk — a walk the re-adopt arm itself triggered must NOT refresh it -## (`_readopt_walk_pending` skips the top-of-walk reset), or a flapping -## godot-ai occupant sustains spawn → fast-exit → re-walk forever. The -## budget refreshes on the paths that prove recovery: a fresh -## user/plugin-initiated walk, a successful `adopt_compatible_server`, -## or a spawn that survives to publish its pid-file. -var _readopt_after_spawn_exit_retried: bool = false - -## #805: set by the re-adopt arm just before it re-runs `start_server`, -## consumed by the top of `_start_server_impl` to skip that walk's -## `_readopt_after_spawn_exit_retried` reset. Never true outside that -## one triggered walk. -var _readopt_walk_pending: bool = false - -## Rounds of weak-proof (`status_name`-tier) recovery available against a -## brand-verified stale-VERSION godot-ai occupant. Zero by default: the -## startup walk may only kill via strong ownership proof. Armed by an -## explicit user action that already authorizes replacing the old backend — -## the post-self-update startup (`authorize_stale_recovery` from plugin.gd, -## consented by the Update click) and the dock's recovery click. This is -## what lets the walk finish an update against an attach-bridge-owned old -## server: bridges pinned to the previous version respawn it the moment it -## is killed, and each respawn is an external server with no strong proof, -## so without this budget every lost port race dead-ends in INCOMPATIBLE -## and the user is left toggling the plugin by hand. Never spent on a -## same-version or foreign occupant; each spent round re-proves brand at -## kill time. Reset on the proven recoveries — adoption and the verified -## compatible handshake — so leftover rounds can't authorize a kill in a -## later, unrelated episode. Pid-file publication deliberately does NOT -## reset it: the server writes that file before binding either port, so a -## bridge respawn can still steal the bind afterwards and the fast-exit -## needs its remaining rounds (#890 review P1). -var _stale_recovery_budget: int = 0 - -## Bounded deadline for the foreign-port adoption-confirmation watcher. -## Zero when disarmed. -var _adoption_watch_deadline_ms: int = 0 - -## Branch-tag from the most recent `start_server` walk. See -## `McpStartupPath`. Drives the startup-trace log. -var _startup_path: String = McpStartupPathScript.UNSET - -## Version-check seam. Lazily constructed on `arm_version_check` so -## tests that exercise the manager without a connection don't have to -## stub it out. -var _version_check - -## #678: when true, the blocking primitives on the startup path (port -## scrapes, per-PID brand shells, the HTTP status probe, kill + port-drain -## waits) run on a WorkerThreadPool thread while the main thread keeps -## pumping frames — the editor stays responsive during plugin init/reload -## on a contended port; the dock panel just arrives a beat later. The -## plugin enables this in production. Default false: unit tests (and any -## legacy caller) keep the historical fully-synchronous behavior, where -## the startup coroutines never actually suspend and call-then-assert -## still works. -var defer_blocking_work: bool = false - -## Cancellation for in-flight async startup work: bumped by `stop_server` -## (and therefore by `_exit_tree` and update-reload prep), checked after -## every await so a suspended `start_server` can't resurrect state — or -## spawn a server — after teardown started. -var _async_generation: int = 0 - -## Re-entrancy guard: with startup a coroutine, a second `start_server` -## call (respawn watch, dock button) can land mid-flight. -var _start_in_flight: bool = false - - -func _init(host) -> void: - _host = host - - -## The worker thread of the walk's current `_run_blocking` call, while it -## runs. `_invalidate_async_startup` JOINS it (bounded by the blocking -## op's own timeout) so no worker can still be executing a plugin method -## when `_exit_tree` frees the plugin — a mid-call free is use-after-free -## on the worker, which wedged the editor on macOS during rapid reload -## churn (main CI, post-#682). Null when no blocking work is in flight. -var _active_blocking_thread: Thread = null - - -## Run `work` off the main thread and suspend until it completes (#678). -## Falls back to inline execution when `defer_blocking_work` is off, or -## when no SceneTree is available to pump frames against. -## -## Uses a dedicated Thread (the dock's #238/#239 worker pattern) rather -## than WorkerThreadPool: `wait_to_finish()` hands the return value back -## without a shared mutable container, and this plugin has already seen -## WorkerThreadPool tasks SIGABRT under concurrency (see the notes in -## script_handler.gd / filesystem_handler.gd). `wait_to_finish` after -## `is_alive()` goes false joins an already-dead thread, so it never -## blocks the main thread. -## -## Returns null (without joining) when `_invalidate_async_startup` took -## ownership of the thread mid-flight — the walk is stale at that point -## and must bail. Callers therefore assign the result to an untyped -## local and bail on `_async_stale(...) or result == null` BEFORE any -## typed use — a typed assignment (or a bool()/int() constructor, both -## of which have no Nil form) trips on the null first. The null check is -## not redundant with the generation check: a caller that loses the slot -## without a generation bump — an invariant violation, but exactly what -## a concurrent fire-and-forget `_run_blocking` user produces — must -## still unwind instead of crashing on the Nil. -func _run_blocking(work: Callable) -> Variant: - if not defer_blocking_work: - return work.call() - var tree := Engine.get_main_loop() - if not (tree is SceneTree): - return work.call() - var thread := Thread.new() - if thread.start(work) != OK: - return work.call() - _active_blocking_thread = thread - while thread.is_alive(): - await (tree as SceneTree).process_frame - if _active_blocking_thread != thread: - ## Teardown/invalidation already joined this thread; the - ## result belongs to a cancelled walk. All resumes and joins - ## happen on the main thread, so this check cannot race. - return null - if _active_blocking_thread != thread: - return null - _active_blocking_thread = null - return thread.wait_to_finish() - - -func _async_stale(generation: int) -> bool: - return generation != _async_generation - - -## Cancel any in-flight async startup walk AND release the re-entrancy -## guard so the very next `start_server()` call walks fresh (#682 review). -## Every one-shot kill-and-restart path must call this before its -## follow-up start: without the generation bump the suspended walk -## resumes against post-kill reality (stale live-status snapshots), and -## without releasing the guard the follow-up start is silently swallowed. -## The cancelled walk unwinds via its post-await staleness checks and -## must NOT clear the guard itself — a newer walk may already own it -## (see the generation check in `start_server`). -## -## Also JOINS the walk's in-flight worker thread (bounded by that op's -## own timeout: lsof/netstat scrape, ≤800ms status probe, or kill + -## port-drain wait). `stop_server` runs this from `_exit_tree`, so once -## it returns no worker thread can still be executing a method of the -## plugin that is about to be freed — the macOS reload-churn wedge. -func _invalidate_async_startup() -> void: - _async_generation += 1 - _start_in_flight = false - var thread := _active_blocking_thread - _active_blocking_thread = null - if thread != null: - thread.wait_to_finish() - - -# ---- Public state accessors -------------------------------------------- - -func get_state() -> int: - return _server_state - - -func get_status_dict() -> Dictionary: - return { - "state": _server_state, - "exit_ms": _server_exit_ms, - "actual_name": _server_actual_name, - "actual_version": _server_actual_version, - "expected_version": _server_expected_version, - "message": _server_status_message, - "can_recover_incompatible": _can_recover_incompatible, - "connection_blocked": _connection_blocked, - "conflict_port": _conflict_port, - "keep_alive": _server_keep_alive, - "stale_recovery_budget": _stale_recovery_budget, - } - - -## Arm the bounded stale-occupant recovery (see `_stale_recovery_budget`). -## Called before the startup walk when a completed self-update is detected -## (plugin.gd) and by the dock recovery click — both are user actions that -## already authorize replacing the previous-version backend. `max()` so a -## click landing on an already-armed post-update episode never shrinks the -## remaining rounds. -func authorize_stale_recovery(rounds: int = 2) -> void: - _stale_recovery_budget = maxi(_stale_recovery_budget, rounds) - - -## True when a spent round of stale-occupant recovery may target the live -## occupant: budget remains and the occupant is a VERIFIED godot-ai whose -## version differs from ours. An empty version (foreign or unverified -## occupant) never qualifies — those stay on the strong-proof-only path. -func _stale_recovery_available(live_version: String, expected_version: String) -> bool: - if _stale_recovery_budget <= 0: - return false - if live_version.is_empty(): - return false - return live_version != expected_version - - -func get_server_pid() -> int: - return _server_pid - - -func get_startup_path() -> String: - return _startup_path - - -func get_adoption_watch_deadline_ms() -> int: - return _adoption_watch_deadline_ms - - -func is_awaiting_server_version() -> bool: - return _version_check != null and _version_check.is_active() - - -func is_connection_blocked() -> bool: - return _connection_blocked - - -# ---- State-machine entry points --------------------------------------- - -## Validated transition. Returns true on success; false (and logs a -## warning) when the transition is illegal under `McpServerState`'s -## table. Callers that need first-writer-wins among terminal diagnoses -## use `set_terminal_diagnosis` instead — that helper silently no-ops -## without warning when the diagnosis would be a regression. -func transition_state(target: int) -> bool: - if _server_state == target: - return true - if not McpServerStateScript.can_transition(_server_state, target): - push_warning( - "MCP | rejected illegal state transition %s -> %s" - % [ - McpServerStateScript.name_of(_server_state), - McpServerStateScript.name_of(target), - ] - ) - return false - _server_state = target - return true - - -## First-writer-wins mutator for terminal diagnoses (CRASHED, -## NO_COMMAND, PORT_EXCLUDED, INCOMPATIBLE, FOREIGN_PORT). Used during -## spawn to make sure a late watch-loop CRASHED doesn't clobber an -## earlier proactive PORT_EXCLUDED. Silent no-op when the current state -## is already a terminal diagnosis — the existing diagnosis is kept. -func set_terminal_diagnosis(target: int) -> bool: - if not McpServerStateScript.is_terminal_diagnosis(target): - push_warning( - "MCP | set_terminal_diagnosis called with non-terminal %s" - % McpServerStateScript.name_of(target) - ) - return false - if McpServerStateScript.is_terminal_diagnosis(_server_state): - return false - _server_state = target - return true - - -# ---- Adoption confirmation watcher ------------------------------------- - -## Arm the FOREIGN_PORT adoption-confirmation watcher. SPAWN_GRACE_MS -## ahead of `now`; `tick_adoption_watch` self-disarms after this expires -## so per-frame cost drops back to zero on a permanent foreign occupant. -func arm_adoption_watch() -> void: - _adoption_watch_deadline_ms = ( - Time.get_ticks_msec() + int(_host.SPAWN_GRACE_MS) - ) - - -func tick_adoption_watch(now_msec: int) -> void: - if _adoption_watch_deadline_ms > 0 and now_msec >= _adoption_watch_deadline_ms: - _adoption_watch_deadline_ms = 0 - - -# ---- Server version-check seam ---------------------------------------- - -func arm_version_check(connection, expected_version: String) -> void: - if _version_check == null: - _version_check = McpServerVersionCheckScript.new(self) - var expected := _resolve_expected_version(expected_version) - _server_expected_version = expected - _version_check.arm(connection, expected) - - -func disarm_version_check() -> void: - if _version_check != null: - _version_check.disarm() - - -func get_version_check(): - return _version_check - - -## Resolves a possibly-empty expected version to the plugin's shipping -## version. Manager methods that are called via test fixtures may -## receive an empty string when the test never seeded -## `_server_expected_version`, so this is the one place that fallback -## lives. -func _resolve_expected_version(supplied: String) -> String: - if not supplied.is_empty(): - return supplied - return _expected_server_version() - - -func _expected_server_version() -> String: - return ClientConfigurator.get_plugin_version() - - -## Called by McpServerVersionCheck when handshake_ack carries a version -## string. Decides compatible vs incompatible and transitions the state. -func handle_server_version_verified(expected_version: String, version: String) -> void: - _server_actual_name = "godot-ai" - _server_actual_version = version - var expected := _resolve_expected_version(expected_version) - _server_expected_version = expected - var compatibility := _server_version_compatibility(version, expected) - if compatibility.get("compatible", false): - _can_recover_incompatible = false - ## A verified compatible handshake ends any stale-recovery episode — - ## leftover authorized rounds must not survive into a later walk. - _stale_recovery_budget = 0 - ## Foreign-port and post-spawn handshakes both clear to READY - ## on a successful handshake. Late re-arms from READY also land - ## here and self-confirm. - transition_state(McpServerStateScript.READY) - _host._update_process_enabled() - return - var live := {"version": version, "status_code": 200, "name": "godot-ai"} - ## Connection propagation + version-check disarm + process re-evaluation - ## all live inside _set_incompatible_server now (#691) so the startup-walk - ## recovery-failure and force-restart-failure paths get them too. - _set_incompatible_server(live, expected, ClientConfigurator.http_port()) - ## Post-update the WebSocket usually reaches the OLD backend before the - ## startup walk finishes its probes — this verdict then cancels the walk - ## (the `_invalidate_async_startup` in `_set_incompatible_server`'s - ## fire-and-forget arm) and used to strand the update at INCOMPATIBLE - ## even though the Update click already authorized replacing that - ## backend. With authorized rounds left and a VERIFIED stale version, - ## spend one and fire the same recovery flow the dock's Restart click - ## runs — via the plugin wrapper so the connection unblocks on success, - ## with `user_initiated=false` so the trigger can only spend, never - ## re-arm. The INCOMPATIBLE latch it gates on is synchronous: the - ## `_set_incompatible_server` head above runs before its first await. - if _stale_recovery_available(version, expected) and _host.has_method("recover_incompatible_server"): - _stale_recovery_budget -= 1 - print( - "MCP | stale godot-ai v%s answered the WS handshake — replacing it (update/recovery authorized, %d retry round(s) left)" - % [version, _stale_recovery_budget] - ) - ## Fire-and-forget coroutine: this verdict lands on the main thread - ## from `_process`, and the recovery owns the flow from here - ## (mirroring the recovery click). - _host.recover_incompatible_server(false) - - -func handle_server_version_unverified(expected_version: String) -> void: - var expected := _resolve_expected_version(expected_version) - _server_expected_version = expected - var live := {"version": "", "status_code": 0, "error": "missing_handshake_ack"} - _set_incompatible_server(live, expected, ClientConfigurator.http_port()) - - -# ---- Compatibility / version helpers (pure) --------------------------- - -## Plugin and server speak a single, version-coupled protocol — new commands -## and response fields are added together. Treating dev-mode mismatches as -## "compatible" silently adopts a stale server whose code may differ from the -## live source tree (e.g. another worktree on a different branch holding -## port 8000). Strict match in all modes routes mismatches through -## `recover_strong_port_occupant`, which kills the branded port-holder and -## lets `start_server` spawn fresh against the current source. -static func _server_version_compatibility( - actual_version: String, - expected_version: String -) -> Dictionary: - if actual_version.is_empty(): - return {"compatible": false, "reason": "unknown"} - if actual_version == expected_version: - return {"compatible": true, "reason": "exact"} - return {"compatible": false, "reason": "version_mismatch"} - - -static func _server_status_compatibility( - actual_version: String, - expected_version: String, - actual_ws_port: int, - expected_ws_port: int, -) -> Dictionary: - var version_result := _server_version_compatibility(actual_version, expected_version) - if not bool(version_result.get("compatible", false)): - return version_result - if actual_ws_port != expected_ws_port: - return {"compatible": false, "reason": "ws_port_mismatch"} - return version_result - - -static func _managed_record_has_version_drift(record_version: String, current_version: String) -> bool: - return not record_version.is_empty() and record_version != current_version - - -# ---- Incompatible-server bookkeeping ---------------------------------- - -func _set_incompatible_server( - live: Dictionary, - expected_version: String, - port: int, - caller_owns_worker_slot := false -) -> void: - ## Latches the incompatible diagnosis into manager state and asks - ## the dock to re-sweep client rows so they don't show stale green. - ## Threads the caller's `live` snapshot through the recovery proof - ## helper so we don't double-probe the port (~500ms each). - ## - ## Coroutine (#712): the recovery-proof evaluation (port scrapes + - ## per-PID brand shells) and the free-port bind probes run via - ## `_run_blocking` — these fire in exactly the contended/crashed - ## scenarios #678 de-blocked, so they must not stall the main thread - ## either. Everything user-visible (status message, connection block, - ## version-check disarm) is latched synchronously before the first - ## await; only the recovery verdict and the suggested-port diagnostic - ## arrive with the worker. - ## - ## `_run_blocking` tracks a single active worker, so the tail below - ## needs exclusive ownership of that slot. The startup walk awaits - ## this call with `caller_owns_worker_slot=true` — it already owns the - ## slot and serializes the tail behind its own blocking ops. Sync - ## callers (the handshake verdicts via `handle_server_version_*`, the - ## force-restart failure arm) fire-and-forget the tail and leave the - ## flag false, so the head takes ownership for them: a handshake - ## verdict lands from `_process` while a startup walk can still be - ## suspended in `_run_blocking`, and starting the tail's worker then - ## would steal the slot — the walk's op is orphaned from the - ## `_invalidate_async_startup` join guarantee and its resume gets a - ## null without a generation bump (the Nil-into-Dictionary crash on - ## the incompatible-occupant walk). Cancelling the walk first mirrors - ## the recovery click (#712): the diagnosis in hand supersedes - ## whatever the walk was still probing for. - if not caller_owns_worker_slot: - _invalidate_async_startup() - transition_state(McpServerStateScript.INCOMPATIBLE) - _connection_blocked = true - _server_expected_version = expected_version - _server_actual_name = str(live.get("name", "")) - _server_actual_version = _live_version_for_message(live) - _server_status_message = _incompatible_server_message( - live, expected_version, port, int(_host._resolved_ws_port) - ) - ## Conservative default until the off-thread proof lands: the dock - ## paints "not recoverable" rather than offering a kill we have not - ## yet proven ownership for. - _can_recover_incompatible = false - _host._refresh_dock_client_statuses() - ## Propagate the verdict to the live connection (#691). Pre-#678 the - ## startup walk finished synchronously before `_connection` existed, so - ## plugin.gd captured the INCOMPATIBLE verdict when constructing it. - ## Post-#678 the walk suspends at its first `_run_blocking` and the - ## plugin snapshots the pre-walk defaults (`connect_blocked=false`) — so - ## a verdict landing later (startup-walk recovery failure, handshake - ## mismatch, force-restart failure) must reach the connection here, or - ## it keeps dialing the WS port forever. Also disarm the version check: - ## the diagnosis already landed, so leaving the check armed keeps - ## per-frame `_process` on for the plugin's whole lifetime. - if _host._connection != null: - _host._connection.connect_blocked = true - _host._connection.connect_block_reason = _server_status_message - _host._connection.disconnect_from_server() - disarm_version_check() - _host._update_process_enabled() - - ## Off-thread recovery proof (#712), mirroring recover_strong_port_occupant: - ## the EditorSettings record is read on the main thread up front and - ## injected as record_override — EditorSettings is main-thread-only. - var async_gen := _async_generation - var record: Dictionary = _host._read_managed_server_record() - var proof_result: Variant = await _run_blocking(func() -> Variant: - if not is_instance_valid(_host): - return {"proof": "", "pids": []} - return _host._evaluate_recovery_port_occupant_proof(port, live, record) - ) - if _async_stale(async_gen) or proof_result == null: - return - var proof: Dictionary = proof_result - var proof_name := str(proof.get("proof", "")) - _can_recover_incompatible = not proof_name.is_empty() - print("MCP | proof: %s" % (proof_name if _can_recover_incompatible else "(none)")) - if not _can_recover_incompatible: - ## Non-recoverable: a foreign / unprovable occupant holds the port and - ## we have no ownership proof, so we must NOT kill it — surface a - ## concrete free port the user can switch to instead (the same hint - ## the dock crash body renders). Logging it to the editor output also - ## lets `ci-stale-server-smoke --mode foreign` assert this upstream - ## classification from CI. Reservation-aware on Windows; the bind - ## probes behind suggest_free_port also run off-thread (#712). - var suggested_result: Variant = await _run_blocking(func() -> Variant: - return ClientConfigurator.suggest_free_port(port + 1) - ) - if _async_stale(async_gen) or suggested_result == null: - return - print("MCP | port %d occupant not recoverable (no ownership proof); suggested free port %d (set godot_ai/http_port)" % [port, int(suggested_result)]) - ## Second sweep so the dock's recovery affordance reflects the verdict - ## that just landed. - _host._refresh_dock_client_statuses() - - -static func _incompatible_server_message( - live: Dictionary, - expected_version: String, - port: int, - expected_ws_port: int -) -> String: - var version := _live_version_for_message(live) - var actual_ws_port := _live_ws_port_for_message(live) - ## `package_path` is a v2.4.4+ field — older servers omit it. Suffix - ## the message with "(loaded from )" when present so the user - ## can tell *which* `src/godot_ai/` is serving the port without - ## walking the process tree. See #416. - var package_path := _live_package_path_for_message(live) - var path_suffix := " (loaded from %s)" % package_path if not package_path.is_empty() else "" - ## After a plugin update, the usual occupant is a backend kept alive by - ## AI-client attach bridges still pinned to the previous version (their - ## leases outrank us — #669/#839, we must not kill it). Name that repair - ## first; "stop the old server" alone reads as a dead end when the server - ## respawns the moment the user kills it. - var repair := ( - "If AI-client attach bridges are keeping it alive, run Configure all to " - + "repin them, then restart those client apps — the old server exits on " - + "its own. Otherwise stop it manually or change both HTTP and WS ports." - ) - if not version.is_empty(): - if actual_ws_port > 0 and actual_ws_port != expected_ws_port: - return ( - "Port %d is occupied by godot-ai server v%s using WS port %d%s; " - + "plugin expects v%s with WS port %d. %s" - ) % [port, version, actual_ws_port, path_suffix, expected_version, expected_ws_port, repair] - return ( - "Port %d is occupied by godot-ai server v%s%s; plugin expects v%s. %s" - ) % [port, version, path_suffix, expected_version, repair] - var status_code := int(live.get("status_code", 0)) - if status_code > 0: - return ( - "Port %d is occupied by an unverified server (status endpoint returned HTTP %d); " - + "plugin expects godot-ai v%s. Stop the other server or change both HTTP and WS ports." - ) % [port, status_code, expected_version] - return ( - "Port %d is occupied by another process; plugin expects godot-ai v%s. " - + "Stop the other process or change both HTTP and WS ports." - ) % [port, expected_version] - - -static func _live_status_identifies_godot_ai(live: Dictionary) -> bool: - return str(live.get("name", "")) == "godot-ai" - - -static func _live_version_for_message(live: Dictionary) -> String: - if live.has("name") and str(live.get("name", "")) != "godot-ai": - return "" - return str(live.get("version", "")) - - -static func _live_ws_port_for_message(live: Dictionary) -> int: - if live.has("name") and str(live.get("name", "")) != "godot-ai": - return 0 - return int(live.get("ws_port", 0)) - - -static func _live_package_path_for_message(live: Dictionary) -> String: - ## Only trust the path when the live snapshot confirms a godot-ai - ## server — a probe of some unrelated HTTP service could in theory - ## return a `package_path` JSON field, and we don't want to mislabel - ## that as "godot-ai loaded from …" in the incompatible banner. - if live.has("name") and str(live.get("name", "")) != "godot-ai": - return "" - return str(live.get("package_path", "")) - - -# ---- start_server / spawn watch / respawn ----------------------------- - - -## Sets GODOT_AI_DISABLE_TELEMETRY in the process environment for the -## upcoming OS.create_process call if: (a) neither GODOT_AI_DISABLE_TELEMETRY -## nor DISABLE_TELEMETRY is already set to a *truthy* value (a falsey "0" does -## NOT count — it must not suppress a dock UI opt-out), and (b) the effective -## McpSettings.telemetry_enabled() is false. Returns true if the var was -## injected so the caller can unset it after spawning. -func _inject_telemetry_env() -> bool: - ## If telemetry is already disabled by a *truthy* env var, leave the env as - ## the user/CI set it — the post-spawn cleanup unsets what we inject, so - ## injecting here would strip their own var from the editor process. A - ## *falsey* value (e.g. DISABLE_TELEMETRY=0) must NOT count as "handled": - ## fall through so a dock UI opt-out still reaches the spawned server. The - ## truthy test mirrors McpSettings.telemetry_enabled() and the Python server. - if McpSettings.env_truthy("GODOT_AI_DISABLE_TELEMETRY") or McpSettings.env_truthy("DISABLE_TELEMETRY"): - return false - if not McpSettings.telemetry_enabled(): - OS.set_environment("GODOT_AI_DISABLE_TELEMETRY", "true") - return true - return false - - -## Set GODOT_AI_OWNER_PID to this editor's PID for the next OS.create_process, -## so the spawned server can self-reap if this editor crashes. Returns true if -## set (caller must unset right after spawning — keep it out of the persistent -## editor env). No-op on Windows, where the server's reaper is disabled. -func _set_owner_pid_env() -> bool: - if OS.get_name() == "Windows": - return false - ## keep_server_on_exit (#800): a server meant to outlive editors must not - ## self-reap when this editor dies — don't hand it an owner pid at all. - if ClientConfigurator.keep_server_on_exit(): - return false - OS.set_environment("GODOT_AI_OWNER_PID", str(OS.get_process_id())) - return true - - -## Mark the next OS.create_process as plugin-spawned so the server arms its -## session-idle self-terminate backstop (#498): with zero editor sessions for -## a grace window, it exits on its own. Unlike the owner-PID reaper this is -## pure session-count on the server side, so it is set on EVERY platform — -## including Windows, where owner-PID is skipped; this marker is what finally -## gives Windows orphan coverage (#497). Same env-channel rationale and same -## tight scoping as _set_owner_pid_env: callers unset it right after spawning -## so a later manually-started dev server can never inherit it and idle-kill -## itself. -func _set_plugin_spawned_env() -> void: - OS.set_environment("GODOT_AI_PLUGIN_SPAWNED", "1") - - -## keep_server_on_exit (#800): opt the spawned server out of the -## session-idle self-terminate backstop (#498) via its existing -## GODOT_AI_NO_IDLE_EXIT escape hatch — a keep-alive server sits at zero -## sessions between editor runs by design, which is exactly what the -## backstop reaps. Returns true if set (same tight scoping as -## _set_owner_pid_env: callers unset right after spawning, and only when -## WE set it, so a user's own NO_IDLE_EXIT env is never stripped). -func _set_keep_alive_env() -> bool: - if not ClientConfigurator.keep_server_on_exit(): - return false - OS.set_environment("GODOT_AI_NO_IDLE_EXIT", "1") - return true - - -## Generate a fresh per-launch WS handshake auth token (#690) and stage it -## in the env for the next OS.create_process, same channel and same tight -## scoping as _set_owner_pid_env (callers unset right after spawning — the -## secret must not linger in the editor env). The caller hands the returned -## token to the host on successful spawn so the connection echoes it in the -## handshake and the managed-server record persists it across reloads. -func _set_ws_token_env() -> String: - var token := Crypto.new().generate_random_bytes(32).hex_encode() - OS.set_environment("GODOT_AI_WS_TOKEN", token) - return token - - -## Branch table (recorded version is the "is this ours?" signal — uvx -## launcher PIDs go stale; #135/#137): -## port free -> spawn fresh, record PID -## port in use, record matches + live ok -> adopt port owner (heals PID) -## port in use, record drifts -> kill owner + respawn -## port in use, no verified live match -> block adoption + warn -## -## #678: this is a coroutine in production (`defer_blocking_work`) — the -## port scrapes, status probes, and kill-drain waits run off the main -## thread and the state machine resumes between frames, so the editor -## stays responsive when the port is contended. With the flag off (unit -## tests) nothing suspends and the call completes synchronously. -func start_server() -> void: - if _start_in_flight: - return - _start_in_flight = true - var gen := _async_generation - await _start_server_impl(gen) - ## Only release the guard if this walk is still the current one — a - ## cancelled (stale) walk unwinding here must not clobber the guard a - ## newer walk armed after `_invalidate_async_startup`. - if gen == _async_generation: - _start_in_flight = false - ## Walk-completion continuation lives HERE — on the RefCounted - ## manager, kept alive by its own suspended state — never on the - ## plugin: resuming a coroutine of a freed Node errors out, and - ## reload churn frees plugin instances while walks are suspended. - if is_instance_valid(_host) and _host.has_method("_finish_startup_trace_after_walk"): - _host._finish_startup_trace_after_walk() - - -func _start_server_impl(async_gen: int) -> void: - if _host._server_started_this_session: - ## Static flag persists across disable/enable cycles in one editor - ## session — re-entrant spawn guard for plugin-reload-during-update. - _startup_path = McpStartupPathScript.GUARDED - transition_state(McpServerStateScript.GUARDED) - return - - _refresh_retried = false - if _readopt_walk_pending: - ## #805: this walk was triggered by the fast-exit re-adopt arm. - ## Keep the spent budget: if this walk ends up spawning and that - ## spawn fast-exits against a live godot-ai again, the occupant is - ## flapping and the diagnosis must latch terminal instead of - ## re-walking forever. Recovery paths (adoption, healthy spawn) - ## refresh the budget explicitly. - _readopt_walk_pending = false - else: - _readopt_after_spawn_exit_retried = false - _conflict_port = 0 - - var port := ClientConfigurator.http_port() - var ws_port := ClientConfigurator.ws_port() - var current_version := _expected_server_version() - _server_expected_version = current_version - - ## The worker closures re-check the host: the plugin can be freed while - ## a bounded shell probe is still running, and the generation check only - ## protects state after resume, not calls inside the task (#682 review). - var port_in_use_result: Variant = await _run_blocking(func() -> Variant: - return is_instance_valid(_host) and _host._is_port_in_use(port) - ) - if _async_stale(async_gen) or port_in_use_result == null: - return - var port_in_use := bool(port_in_use_result) - if not port_in_use: - ## #745: after an editor crash (or under multi-editor churn) the - ## managed server keeps running, yet the bind probe can still say - ## "free" (Windows lets a SO_REUSEADDR bind succeed over a live - ## listener; the scrape fallback can fail transiently). The HTTP - ## status probe is the authoritative tie-breaker and runs - ## UNCONDITIONALLY: the pid-file evidence gate that used to guard it - ## goes stale exactly when it's needed most — same-named test - ## projects share one app_userdata dir, so another editor's walk can - ## clear or overwrite the pid-file, and blind-spawning here produced - ## the reproduced duplicate-spawn + 4003 token loop. A live godot-ai - ## answer forces the adopt/recover branch below; an unresponsive - ## port falls through to the normal spawn path at the cost of one - ## fast connection-refused probe (off-thread in production). - var evidence_result: Variant = await _run_blocking(func() -> Variant: - if not is_instance_valid(_host): - return {} - return _host._probe_live_server_status_for_port(port) - ) - if _async_stale(async_gen) or evidence_result == null: - return - var evidence: Dictionary = evidence_result - if _live_status_identifies_godot_ai(evidence): - port_in_use = true - if port_in_use: - var record: Dictionary = _host._read_managed_server_record() - var record_version := str(record.get("version", "")) - var record_ws_port := int(record.get("ws_port", 0)) - _host._set_resolved_ws_port(PortResolver.resolved_ws_port_for_existing_server( - record_ws_port, - record_version, - current_version, - int(_host._resolve_ws_port()) - )) - ws_port = int(_host._resolved_ws_port) - ## Untyped first: a cancelled walk gets null back (see _run_blocking) - ## and must reach the staleness check before any typed cast. - var live_result: Variant = await _run_blocking(func() -> Variant: - if not is_instance_valid(_host): - return {} - return _host._probe_live_server_status_for_port(port) - ) - if _async_stale(async_gen) or live_result == null: - return - var live: Dictionary = live_result - var live_version := str(_host._verified_status_version(live)) - var live_ws_port := int(_host._verified_status_ws_port(live)) - var compatibility: Dictionary = _server_status_compatibility( - live_version, - current_version, - live_ws_port, - ws_port, - ) - if compatibility.get("compatible", false): - _server_actual_name = "godot-ai" - _server_actual_version = live_version - _can_recover_incompatible = false - ## A matching version is compatibility evidence, not ownership - ## evidence (#759/#764). A stale EditorSettings record can name a - ## dead PID while an unrelated compatible server owns the port. - ## Retain managed ownership only when the recorded PID is itself - ## the live, branded listener. - var adoption_proof_result: Variant = await _run_blocking(func() -> Variant: - if not is_instance_valid(_host): - return {"proof": "", "pids": []} - return _host._evaluate_strong_port_occupant_proof(port, live, record) - ) - if _async_stale(async_gen) or adoption_proof_result == null: - return - var adoption_proof: Dictionary = adoption_proof_result - var proof_pids: Array[int] = [] - proof_pids.assign(adoption_proof.get("pids", [])) - var owner := int(proof_pids[0]) if not proof_pids.is_empty() else 0 - var record_owns_listener := str(adoption_proof.get("proof", "")) == "managed_record" - var owner_label := adopt_compatible_server( - record_version, - current_version, - owner, - record_owns_listener - ) - _host._server_started_this_session = true - _startup_path = McpStartupPathScript.ADOPTED - transition_state(McpServerStateScript.READY) - print(_compatible_adoption_log_message( - owner_label, - int(_server_pid), - owner, - str(_server_actual_version), - live_ws_port, - current_version - )) - return - if bool(_managed_record_has_version_drift(record_version, current_version)): - print("MCP | managed server v%s does not match plugin v%s, restarting" - % [record_version, current_version]) - ## Forward `live` so the recovery proof helper reuses our snapshot. - ## The kill invalidates it, so the failure arm re-probes below. - var recovered: bool = await recover_strong_port_occupant(port, 3.0, live) - if _async_stale(async_gen): - return - if not recovered and _stale_recovery_available(live_version, current_version): - ## Post-update / recovery-click episode: the occupant is a verified - ## stale-VERSION godot-ai (typically the previous release kept alive - ## — or just respawned — by attach bridges still pinned to it) and a - ## user action already authorized replacing it. Strong proof cannot - ## exist for that server (external adoption dropped record and - ## pid-file by design), so spend one bounded round of weak-proof - ## recovery instead of latching INCOMPATIBLE and asking the user to - ## click through the replacement by hand. - _stale_recovery_budget -= 1 - print( - "MCP | stale godot-ai v%s holds port %d — replacing it (update/recovery authorized, %d retry round(s) left)" - % [live_version, port, _stale_recovery_budget] - ) - recovered = await recover_stale_port_occupant(port, 3.0) - if _async_stale(async_gen): - return - if not recovered: - _host._server_started_this_session = true - var post_recovery_result: Variant = await _run_blocking(func() -> Variant: - if not is_instance_valid(_host): - return {} - return _host._probe_live_server_status_for_port(port) - ) - if _async_stale(async_gen) or post_recovery_result == null: - return - var post_recovery_live: Dictionary = post_recovery_result - ## Awaited with caller_owns_worker_slot=true (#712): the - ## diagnosis tail runs its own _run_blocking proof, and the walk - ## stays the single owner of the active-worker slot by - ## serializing that tail behind this await instead of letting it - ## re-take the slot. The status message is latched before the - ## tail's first await, so the push_warning below reads the final - ## text either way. - await _set_incompatible_server(post_recovery_live, current_version, port, true) - if _async_stale(async_gen): - return - _startup_path = McpStartupPathScript.INCOMPATIBLE - push_warning(str(_server_status_message)) - return - else: - _startup_path = McpStartupPathScript.FREE - - _host._set_resolved_ws_port(_host._resolve_ws_port()) - ws_port = _host._resolved_ws_port - - _host._startup_trace_count("server_command_discovery") - ## CLI-finder discovery shells out (which/where, login shell) on cache - ## misses — the same #238/#239 family the dock already runs off-thread. - var server_cmd_result: Variant = await _run_blocking(func() -> Variant: - return ClientConfigurator.get_server_command() - ) - if _async_stale(async_gen) or server_cmd_result == null: - return - var server_cmd: Array = server_cmd_result - if server_cmd.is_empty(): - set_terminal_diagnosis(McpServerStateScript.NO_COMMAND) - _startup_path = McpStartupPathScript.NO_COMMAND - push_warning("MCP | could not find server command") - return - - var cmd: String = server_cmd[0] - var args: Array[String] = [] - args.assign(server_cmd.slice(1)) - args.append_array(_host._build_server_flags(port, ws_port)) - - ## Wipe any stale pid-file so a failed launch can't leave last - ## session's PID for `_find_managed_pid` to read. - _host._clear_pid_file() - - ## Proactive Windows port-reservation check (#146) — bind would - ## fail silently with WinError 10013 inside a Hyper-V / WSL2 / - ## Docker exclusion range; netstat shows nothing. - if WindowsPortReservation.is_port_excluded(port): - _host._server_started_this_session = true - set_terminal_diagnosis(McpServerStateScript.PORT_EXCLUDED) - _startup_path = McpStartupPathScript.RESERVED - push_warning("MCP | port %d is reserved by Windows (Hyper-V / WSL2 / Docker)" % port) - return - - ## ---- Spawn-time env-mutation window (#691) ------------------------- - ## From here to the post-spawn unsets below, the editor's process-global - ## environment is mutated around OS.create_process (which has no - ## per-child env parameter). Two invariants keep this safe: - ## 1. The window is SYNCHRONOUS main-thread code — no `await` between - ## the first setenv and the last unsetenv — and worker dispatch also - ## only happens on the main thread, so no new worker can start inside - ## the window. - ## 2. Already-running workers never call OS.get_environment: every env - ## read reachable from a worker (path templates, config_home_override, - ## CLI finder, mode_override/startup-trace) routes through - ## McpPathTemplate.env_lookup, which serves worker threads from a - ## main-thread-warmed snapshot. A concurrent glibc getenv during - ## setenv can return a freed pointer — process-fatal. - ## Residual (accepted): a worker's own OS.execute child (CLI status - ## probe) launched while this window is open inherits the temp vars — - ## rare, and tame next to the crash class above. - var injected_telemetry_env := _inject_telemetry_env() - - ## PYTHONPATH handling for dev checkouts: when the editor is launched - ## against a worktree whose `src/godot_ai/__version__` differs from the - ## root repo's editable install, the dev-venv python's `sitecustomize` - ## adds the *root repo's* `src/` to `sys.path`. The spawned server then - ## reports the root repo's version, the plugin's compatibility check - ## flags it as incompatible, and the user gets a Restart-Server loop - ## with no exit. `start_dev_server` already prepends the worktree's - ## `src/` for its --reload spawn; mirror that here for the auto-spawn - ## path so the same worktree-vs-root version skew is impossible. Gated - ## on `is_dev_checkout()` so production user installs (no nearby `src/`) - ## are untouched. See #418. - var worktree_src := "" - var prev_pythonpath := "" - var pythonpath_set := false - if ClientConfigurator.is_dev_checkout(): - worktree_src = ClientConfigurator.find_worktree_src_dir( - ProjectSettings.globalize_path("res://") - ) - if not worktree_src.is_empty(): - prev_pythonpath = OS.get_environment("PYTHONPATH") - var sep := ";" if OS.get_name() == "Windows" else ":" - var new_pp := ( - worktree_src - if prev_pythonpath.is_empty() - else worktree_src + sep + prev_pythonpath - ) - OS.set_environment("PYTHONPATH", new_pp) - pythonpath_set = true - - ## Tell the spawned server which editor owns it so it can self-reap if we - ## die without a clean stop_server (crash / hard-kill). Passed via env, not - ## a CLI flag, so an older server (staggered user-mode upgrade) silently - ## ignores an unknown var instead of failing argparse. Scoped tightly around - ## create_process and unset right after (like PYTHONPATH below): the child - ## inherits it, but it must NOT linger in the editor env, or a later - ## non-reload `godot-ai` subprocess (dev server, future spawn) would inherit - ## it and wrongly arm a reaper keyed to this editor. - ## Skipped on Windows: the server's reaper is POSIX-only for now (Windows - ## process-liveness/self-shutdown isn't live-validated yet). The server - ## gates on this too. - var owner_env_set := _set_owner_pid_env() - _set_plugin_spawned_env() - var keep_alive_env_set := _set_keep_alive_env() - var ws_token := _set_ws_token_env() - - _server_pid = OS.create_process(cmd, args) - var spawned_pid := int(_server_pid) - - if owner_env_set: - OS.unset_environment("GODOT_AI_OWNER_PID") - OS.unset_environment("GODOT_AI_PLUGIN_SPAWNED") - if keep_alive_env_set: - OS.unset_environment("GODOT_AI_NO_IDLE_EXIT") - OS.unset_environment("GODOT_AI_WS_TOKEN") - - ## Restore PYTHONPATH immediately — the spawned child has already - ## copied the env, so the editor's own process state returns to - ## baseline. Leaving it set would leak to any later OS.create_process - ## from unrelated paths. - if pythonpath_set: - if prev_pythonpath.is_empty(): - OS.unset_environment("PYTHONPATH") - else: - OS.set_environment("PYTHONPATH", prev_pythonpath) - - if injected_telemetry_env: - OS.unset_environment("GODOT_AI_DISABLE_TELEMETRY") - - if spawned_pid > 0: - _server_spawn_ms = Time.get_ticks_msec() - _server_exit_ms = 0 - _spawn_dead_since_ms = 0 - _server_keep_alive = keep_alive_env_set - _host._server_started_this_session = true - transition_state(McpServerStateScript.SPAWNING) - ## The child copied the env, so this token is what the server will - ## verify handshakes against — adopt it BEFORE writing the record - ## (the record write persists _ws_auth_token). - _host._set_ws_auth_token(ws_token) - ## Record the launcher PID so same-session - ## prepare_for_update_reload has something to kill. The next - ## editor start's adopt branch heals it to the real port owner. - _host._write_managed_server_record(spawned_pid, current_version, _server_keep_alive) - _startup_path = McpStartupPathScript.SPAWNED - ## Log "PYTHONPATH prefix=" rather than "PYTHONPATH=" so the line - ## isn't misleading when an existing PYTHONPATH was present — - ## we prepended `worktree_src`, not replaced. Keeps the log - ## compact (worktree_src is the actionable piece; the full - ## prev_pythonpath can be 5+ entries long on dev machines). - var suffix := " (PYTHONPATH prefix=%s)" % worktree_src if not worktree_src.is_empty() else "" - print("MCP | started server (PID %d, v%s): %s %s%s" % [spawned_pid, current_version, cmd, " ".join(args), suffix]) - _host._start_server_watch() - else: - _server_status_message = "" - set_terminal_diagnosis(McpServerStateScript.CRASHED) - _startup_path = McpStartupPathScript.CRASHED - push_warning("MCP | failed to start server") - - -## Is the watched spawn PID's death still explainable as a launcher handoff -## rather than a server exit? (#797) -## -## Observed on Windows 11 with a uv-created venv: one boot in four logged -## "server exited after 5146ms" while the real server kept running and was -## then adopted. The watched PID had died on a healthy boot, and because the -## server had not yet written its pid-file there was nothing to heal onto, so -## the watch crossed SPAWN_GRACE_MS and reported an exit — rescued only by the -## crash-survivor adoption path. -## -## A uv venv's `python.exe` is a shim rather than the interpreter, and the real -## server does run under a *different* PID than the one `OS.create_process` -## hands back. But the original report's suspected mechanism — that the shim -## exits once its child is up — is **disproven**, not merely unconfirmed. A -## 12-boot run on Windows 11 with a uv venv found the spawned trampoline alive -## on every boot, with the child owning both the pid-file and the listener; a -## CI runner showed the same. The shim is a live parent for the process's whole -## life, so it is not what kills the watched PID. -## -## Two consequences worth keeping straight. First, this gate is keyed to the -## observable condition — watched PID dead, no pid-file yet — not to any theory -## of why it died, so it stays correct whatever the cause. Second, and less -## comfortable: in that same 12-boot run the false "server exited" line never -## appeared AND the watched PID never died, so the guard never fired. Those -## clean boots are evidence the symptom did not reproduce, NOT evidence this -## guard fixes it. The true cause of the original 1-in-4 report is still -## unknown; if it resurfaces, start from that rather than from the trampoline. -## -## `real_pid <= 0` means no pid-file exists yet, and that reliably means "this -## server has not published one" rather than "stale leftover": `start_server` -## wipes the pid-file immediately before every spawn. So an absent pid-file -## plus a dead spawn PID inside the window is the handoff signature. -## -## Deliberately gated to Windows. POSIX uv venvs exec rather than trampoline, -## so a dead spawn PID there really is a dead server, and delaying its -## diagnosis would only slow down honest crash reporting on the platforms -## where this cannot happen. `os_name` is a parameter rather than an -## `OS.get_name()` call so the Windows path is exercisable from any host. -static func is_spawn_handoff_pending( - os_name: String, real_pid: int, elapsed_ms: int, window_ms: int -) -> bool: - if os_name != "Windows": - return false - if real_pid > 0: - return false - return elapsed_ms < window_ms - - -## First-write-wins stamp for the elapsed time at which the spawn PID was first -## observed dead (#797). -## -## A diagnosis raised after waiting out a handoff must still report when the -## process actually exited, not when the wait gave up — the point of #797 is an -## honest log line. Returns the existing stamp once one is set, so later ticks -## in the same wait cannot overwrite it; `<= 0` means "not yet stamped", -## matching how the field is cleared per spawn. -static func first_death_stamp(current_stamp_ms: int, elapsed_ms: int) -> int: - return current_stamp_ms if current_stamp_ms > 0 else elapsed_ms - - -## One-line forensic snapshot taken the moment a spawn is judged to have -## fast-exited (#797). -## -## #797 reported `server exited after 5146ms` on a healthy Windows boot, once -## in four. It is still unexplained: a 12-boot run on the reported -## configuration reproduced neither the symptom nor its suspected mechanism — -## the uv trampoline was alive on every boot, with the child owning the -## pid-file and the listener, so the shim's exit is ruled out as the cause. -## What killed that watched PID is unknown, and the log line at the time -## carried no evidence to answer it with. -## -## So capture the state at the moment of judgement rather than asking the next -## person to reproduce a 1-in-4 bug under observation. Everything here is read -## through seams the surrounding diagnosis already uses, on a path that only -## runs when a spawn is being declared dead, so it costs nothing in the -## healthy case. -## Deliberately does NOT scrape the port for listener PIDs. This runs from the -## 1 Hz watch loop, on a live frame, so a `_find_all_pids_on_port` subprocess -## here would stall the editor for a diagnostic. Deferring it via -## `_run_blocking` was the alternative and is worse: that helper is -## `await`-based, so it would turn this, `_diagnose_spawn_fast_exit` and -## `check_server_health` into coroutines — making the watch callback resume -## across arbitrary frames while its branches set terminal state and trigger -## re-adoption walks. That is the teardown-ordering hazard -## `_invalidate_async_startup` exists to contain, and it is not worth taking -## on for a log line. -## -## Little is lost: the probe on the very next line already establishes whether -## a godot-ai server answers on the port, and `_diagnose_spawn_port_conflict` -## names a foreign occupant when there is one. If you are tempted to add the -## PID list back, put it behind that existing conflict path rather than here. -func _log_spawn_exit_forensics() -> void: - var spawn_pid := int(_server_pid) - var pid_file_pid := int(_host._read_pid_file_for_proof()) - ## Computed here rather than accepted as a parameter. The caller's - ## `elapsed` IS `_spawn_dead_since_ms` — #837 passes the true death time so - ## the user-facing "server exited after Nms" line stays honest — so taking - ## it would make these two fields report the same number, collapsing the - ## exact distinction they exist to record. - var diagnosed_at_ms := 0 - if int(_server_spawn_ms) > 0: - diagnosed_at_ms = Time.get_ticks_msec() - int(_server_spawn_ms) - _host._log_buffer.log(format_spawn_exit_forensics({ - "os": OS.get_name(), - "launch_mode": ClientConfigurator.get_server_launch_mode(), - "elapsed_ms": diagnosed_at_ms, - ## Differs from elapsed_ms when a Windows handoff window was waited out - ## (#824/#837): the true death time versus when we gave up on it. - "first_dead_ms": int(_spawn_dead_since_ms), - "spawn_pid": spawn_pid, - ## Re-read rather than trusted from the watch tick: if the spawn PID is - ## alive HERE, the death that triggered this was transient, which is a - ## different bug from a process that really exited. - "spawn_alive": spawn_pid > 0 and bool(_host._pid_alive_for_proof(spawn_pid)), - "pid_file_pid": pid_file_pid, - "pid_file_alive": pid_file_pid > 0 and bool(_host._pid_alive_for_proof(pid_file_pid)), - })) - - -## Render the forensic snapshot. Pure so the format is testable without a live -## editor, and kept to one line so it survives log truncation in a bug report. -static func format_spawn_exit_forensics(facts: Dictionary) -> String: - var spawn_pid := int(facts.get("spawn_pid", 0)) - var pid_file_pid := int(facts.get("pid_file_pid", 0)) - ## The single most diagnostic bit, stated rather than left to be inferred: - ## a live pid-file process while the watched one is gone is the launcher - ## handoff shape; both gone is a real crash. - var shape := "unknown" - var spawn_alive := bool(facts.get("spawn_alive", false)) - var file_alive := bool(facts.get("pid_file_alive", false)) - if spawn_alive: - shape = "watched_pid_still_alive" - elif file_alive and pid_file_pid != spawn_pid: - shape = "handoff_child_alive" - elif not file_alive and pid_file_pid <= 0: - shape = "no_pid_file_published" - else: - shape = "all_dead" - return ( - "#797 spawn-exit forensics: shape=%s os=%s launch=%s elapsed=%dms " - + "first_dead=%dms spawn_pid=%d(alive=%s) pid_file_pid=%d(alive=%s)" - ) % [ - shape, - str(facts.get("os", "")), - str(facts.get("launch_mode", "")), - int(facts.get("elapsed_ms", 0)), - int(facts.get("first_dead_ms", 0)), - spawn_pid, - str(spawn_alive), - pid_file_pid, - str(file_alive), - ] - - -## Watch-loop callback (1 Hz, capped by SERVER_WATCH_MS). -## `--pid-file` is the source of truth on Windows / uvx where the -## launcher PID dies quickly after spawning the real interpreter. -func check_server_health() -> void: - if int(_server_pid) <= 0: - _host._stop_server_watch() - return - var elapsed := Time.get_ticks_msec() - int(_server_spawn_ms) - var real_pid := PortResolver.read_pid_file() - var spawn_pid := int(_server_pid) - if real_pid > 0 and real_pid != spawn_pid and PortResolver.pid_alive(real_pid): - _spawn_dead_since_ms = 0 - _server_pid = real_pid - ## The spawn record initially contains the launcher PID so same-session - ## teardown can kill it. Heal it as soon as the server publishes its - ## authoritative PID; future adoption requires the recorded PID to be - ## the actual live listener (#759). - _host._write_managed_server_record(real_pid, _expected_server_version(), _server_keep_alive) - ## #805: the spawn survived to publish its pid-file — proven - ## recovery, so the fast-exit re-adopt budget refreshes. - ## - ## The stale-recovery budget is deliberately NOT reset here (#890 - ## review P1): the Python server writes its pid-file during import, - ## BEFORE uvicorn binds HTTP or the WebSocket listener starts - ## (src/godot_ai/__init__.py) — publication proves the child reached - ## that line, not that it owns either port. A bridge respawn can - ## still steal the bind after the pid-file lands, and zeroing here - ## left that fast-exit with no recovery round. The budget resets on - ## the genuinely proven recoveries: adoption and the verified - ## compatible handshake. - _readopt_after_spawn_exit_retried = false - elif not PortResolver.pid_alive(spawn_pid): - _spawn_dead_since_ms = first_death_stamp(_spawn_dead_since_ms, elapsed) - if is_spawn_handoff_pending( - OS.get_name(), real_pid, elapsed, int(_host.SPAWN_HANDOFF_MS) - ): - return - if elapsed >= int(_host.SPAWN_GRACE_MS) and not McpServerStateScript.is_terminal_diagnosis(_server_state): - _diagnose_spawn_fast_exit(_spawn_dead_since_ms) - return - if elapsed >= int(_host.SERVER_WATCH_MS): - ## Survived startup — mid-session crashes surface via WebSocket disconnect. - _host._stop_server_watch() - - -## The spawned server died inside the SPAWN_GRACE_MS window. Decide what -## that means, in order: -## 1. A live godot-ai server answers on the HTTP port -> our spawn lost -## a port race the bind probe never saw (#745 bind-trap: the walk -## thought the port was free, the duplicate exited unable to bind, -## and the token it staged in the record is now stale). Re-run the -## startup walk so the adopt/recover branch handles the survivor — -## latching CRASHED here left the connection redialing forever with -## a token the surviving server rejects (close code 4003). One -## re-adopt per recovery via `_readopt_after_spawn_exit_retried` -## (#805): the triggered walk preserves the spent budget, so a -## flapping occupant (alive at each fast-exit probe, gone by each -## walk's probes — sustained multi-editor churn) latches a specific -## CRASHED diagnosis on the second round instead of re-walking -## forever. Exception: a verified stale-VERSION occupant with -## `_stale_recovery_budget` rounds left keeps re-walking (each walk -## spends one round in its recovery arm), so a bridge-respawn race -## after a self-update converges instead of latching. -## 2. #647: foreign process on the HTTP or WS port -> FOREIGN_PORT with -## an actionable message (we can't read the child's "port already in -## use" stderr). Checked before the --refresh retry: respawning -## against an occupied port can only fail the same way. -## 3. #172: stale uvx index -> one `--refresh` respawn. -## 4. Otherwise -> CRASHED, pointing at the Godot output log. -func _diagnose_spawn_fast_exit(elapsed: int) -> void: - _log_spawn_exit_forensics() - var live: Dictionary = _host._probe_live_server_status_for_port( - ClientConfigurator.http_port() - ) - if _live_status_identifies_godot_ai(live): - ## Stale-version occupant with authorized recovery rounds left: allow - ## another re-adopt walk even after the #805 one-shot is spent. This is - ## the attach-bridge respawn race — a bridge pinned to the previous - ## version respawns its cached backend the instant the old server is - ## killed, and can beat our (possibly cold) spawn to the bind. Each - ## extra walk spends one `_stale_recovery_budget` round in its recovery - ## arm, so this stays bounded; the #805 flapping diagnosis below still - ## latches for same-version/unverified occupants and once the budget is - ## exhausted. - var stale_retry := _stale_recovery_available( - str(_host._verified_status_version(live)), _expected_server_version() - ) - if not _readopt_after_spawn_exit_retried or stale_retry: - if _readopt_after_spawn_exit_retried: - ## Second and later rounds ride the stale budget — spend HERE, - ## not only in the walk's recovery arm: an occupant that flaps - ## out of existence before each walk's probe (the #805 shape) - ## would otherwise re-walk without ever spending, and the - ## terminal latch below would never be reached. - _stale_recovery_budget -= 1 - _readopt_after_spawn_exit_retried = true - _readopt_walk_pending = true - _host._log_buffer.log( - "server exited after %dms but a live godot-ai server answers on port %d — re-running adoption" - % [elapsed, ClientConfigurator.http_port()] - ) - _host._stop_server_watch() - _server_pid = -1 - ## Clear the spawn guard so the re-walk isn't GUARDED away. The - ## walk's adopt arm re-sets it and fixes the stale token/record - ## (external adoption drops both; managed adoption re-records). - _host._server_started_this_session = false - ## Fire-and-forget (mirrors force_restart_server): the walk is a - ## coroutine in production; its continuation lives on the manager. - start_server() - return - ## #805: the re-adopt budget is spent and a live godot-ai still - ## answers while our spawns keep dying — a flapping occupant - ## (another editor's server starting/stopping under it). Re-walking - ## or respawning can only repeat the cycle; latch a terminal - ## diagnosis that names the actual conflict. Reload Plugin (a fresh - ## walk) refreshes the budget for a deliberate retry. - _server_exit_ms = elapsed - _server_status_message = ( - "The spawned server keeps exiting while another godot-ai server " - + "answers on port %d, and re-adoption was already attempted. " - + "Another editor may be repeatedly starting/stopping a server on " - + "this port. Stop the other process or pick a different port, " - + "then click Reload Plugin." - ) % ClientConfigurator.http_port() - set_terminal_diagnosis(McpServerStateScript.CRASHED) - disarm_version_check() - _host._update_process_enabled() - _host._log_buffer.log(str(_server_status_message)) - push_warning("MCP | %s" % _server_status_message) - _host._stop_server_watch() - return - var conflict := _diagnose_spawn_port_conflict(live) - if not conflict.is_empty(): - _server_exit_ms = elapsed - _server_status_message = str(conflict.get("message", "")) - _conflict_port = int(conflict.get("port", 0)) - set_terminal_diagnosis(McpServerStateScript.FOREIGN_PORT) - disarm_version_check() - _host._update_process_enabled() - _host._log_buffer.log(str(_server_status_message)) - push_warning("MCP | %s" % _server_status_message) - _host._stop_server_watch() - return - if bool(_host._should_retry_with_refresh()): - _refresh_retried = true - respawn_with_refresh() - return - _server_exit_ms = elapsed - ## Generic crash: clear any stale per-state message so the dock's - ## CRASHED body falls back to its launch-mode copy instead of text - ## from an earlier diagnosis. - _server_status_message = "" - set_terminal_diagnosis(McpServerStateScript.CRASHED) - disarm_version_check() - _host._update_process_enabled() - _host._log_buffer.log("server exited after %dms — see Godot output log" % int(_server_exit_ms)) - _host._stop_server_watch() - - -## #647: post-crash port-conflict probe. Returns `{}` when no foreign -## conflict is detected (fall through to the CRASHED / retry path), or -## `{"message": String, "port": int}` when the HTTP or WS port is held by -## a process we can't identify as godot-ai. An occupant that *does* -## identify as godot-ai is deliberately not diagnosed here — that's the -## stale-server / adoption territory handled by `_diagnose_spawn_fast_exit`'s -## re-adopt arm (or the next `start_server` walk), not a foreign conflict. -## `pre_probed_live`: an HTTP status snapshot the caller already has on -## hand; non-empty skips the internal ~500ms probe (the probe helper never -## returns a bare `{}`, so the sentinel is unambiguous). -func _diagnose_spawn_port_conflict(pre_probed_live: Dictionary = {}) -> Dictionary: - var http_port := ClientConfigurator.http_port() - if bool(_host._is_port_in_use(http_port)): - var live: Dictionary = ( - pre_probed_live - if not pre_probed_live.is_empty() - else _host._probe_live_server_status_for_port(http_port) - ) - if _live_status_identifies_godot_ai(live): - return {} - return { - "message": ( - "Port %d is in use by another application. Stop it or change " - + "the port in Editor Settings (godot_ai/http_port)." - ) % http_port, - "port": http_port, - } - var ws_port := int(_host._resolved_ws_port) - if ws_port > 0 and bool(_host._is_port_in_use(ws_port)): - return { - "message": ( - "WebSocket port %d is in use by another application. Stop it " - + "or change the port in Editor Settings (godot_ai/ws_port)." - ) % ws_port, - "port": ws_port, - } - return {} - - -## Retry the spawn with uvx `--refresh` prepended (PyPI index can lag a -## fresh publish ~10 min — #172). One-shot per session via _refresh_retried. -func respawn_with_refresh() -> void: - _host._startup_trace_count("server_command_discovery") - var server_cmd := ClientConfigurator.get_server_command(true) - if server_cmd.is_empty(): - return - var cmd: String = server_cmd[0] - var args: Array[String] = [] - args.assign(server_cmd.slice(1)) - args.append_array(_host._build_server_flags(ClientConfigurator.http_port(), int(_host._resolved_ws_port))) - _host._clear_pid_file() - _host._log_buffer.log("retrying with --refresh (PyPI index may be stale)") - var injected_telemetry_env := _inject_telemetry_env() - ## Set owner PID for THIS spawn too (don't rely on it lingering from - ## start_server) — and unset right after, same scoping as start_server. - var owner_env_set := _set_owner_pid_env() - _set_plugin_spawned_env() - var keep_alive_env_set := _set_keep_alive_env() - var ws_token := _set_ws_token_env() - _server_pid = OS.create_process(cmd, args) - if owner_env_set: - OS.unset_environment("GODOT_AI_OWNER_PID") - OS.unset_environment("GODOT_AI_PLUGIN_SPAWNED") - if keep_alive_env_set: - OS.unset_environment("GODOT_AI_NO_IDLE_EXIT") - OS.unset_environment("GODOT_AI_WS_TOKEN") - if injected_telemetry_env: - OS.unset_environment("GODOT_AI_DISABLE_TELEMETRY") - var spawn_pid := int(_server_pid) - if spawn_pid > 0: - _server_spawn_ms = Time.get_ticks_msec() - _server_exit_ms = 0 - _spawn_dead_since_ms = 0 - _server_keep_alive = keep_alive_env_set - var current_version := _expected_server_version() - _host._set_ws_auth_token(ws_token) - _host._write_managed_server_record(spawn_pid, current_version, _server_keep_alive) - print("MCP | retried server (PID %d, v%s): %s %s" % [spawn_pid, current_version, cmd, " ".join(args)]) - else: - ## OS.create_process returned -1 on the retry — surface CRASHED - ## rather than loop. `_refresh_retried` is already true. - _server_status_message = "" - set_terminal_diagnosis(McpServerStateScript.CRASHED) - disarm_version_check() - _host._update_process_enabled() - _host._log_buffer.log("refresh retry failed to spawn — see Godot output log") - _host._stop_server_watch() - - -func adopt_compatible_server( - record_version: String, - current_version: String, - owner: int, - record_owns_listener: bool = false -) -> String: - _server_actual_name = "godot-ai" - _can_recover_incompatible = false - ## #805: adoption (managed or external) is a proven recovery — the - ## session now has a live compatible server. Refresh the fast-exit - ## re-adopt budget so a later, unrelated port race can heal again. - _readopt_after_spawn_exit_retried = false - ## The stale-recovery episode (if any) is over: leftover authorized - ## rounds must not carry into a later, unrelated walk where they could - ## weak-proof-kill a server the user deliberately started. - _stale_recovery_budget = 0 - if record_version == current_version and owner > 0 and record_owns_listener: - ## Managed adoption keeps the record's token (loaded into - ## _ws_auth_token at plugin startup) — the running server was - ## spawned with it and still verifies against it (#690). Version - ## equality alone is deliberately insufficient: the record must also - ## identify the live branded listener (#759/#764). - _server_pid = owner - ## Recover the keep-alive launch flag from the record the spawning - ## session persisted — a keep-alive survivor adopted here must - ## detach again on THIS session's exit, and only the record knows - ## how the process was actually launched. - _server_keep_alive = bool(_host._read_managed_server_record().get("keep_alive", false)) - _host._write_managed_server_record(owner, current_version, _server_keep_alive) - return McpAdoptionLabelScript.MANAGED - _server_pid = -1 - _server_keep_alive = false - ## External server: we didn't spawn it and don't know its token (it - ## most likely has none — dev servers aren't launched with one). Drop - ## ours so the handshake omits the field instead of sending a stale - ## token the server would reject. - _host._set_ws_auth_token("") - _host._clear_managed_server_record() - _host._clear_pid_file() - return McpAdoptionLabelScript.EXTERNAL - - -static func _compatible_adoption_log_message( - owner_label: String, - owned_pid: int, - observed_owner_pid: int, - live_version: String, - live_ws_port: int, - current_version: String -) -> String: - if owner_label == McpAdoptionLabelScript.MANAGED: - return "MCP | adopted managed server (PID %d, live v%s, WS %d, plugin v%s)" % [ - owned_pid, - live_version, - live_ws_port, - current_version - ] - return "MCP | adopted external server owner_pid=%d (live v%s, WS %d, plugin v%s)" % [ - observed_owner_pid, - live_version, - live_ws_port, - current_version - ] - - -## `pre_kill_live` is forwarded into the proof helper so it doesn't -## re-probe a port the caller already probed. The kill invalidates the -## snapshot — callers MUST re-probe before consuming live-status data -## after this returns. -## -## #678: coroutine in production — the proof evaluation (port scrapes + -## per-PID brand shells) and the kill + port-drain wait run off the main -## thread. The EditorSettings record is read on the main thread up front -## and injected into the proof helper; record/pid-file clears stay on the -## main thread after the awaits. -func recover_strong_port_occupant(port: int, wait_s: float, pre_kill_live: Dictionary = {}) -> bool: - var async_gen := _async_generation - var record: Dictionary = _host._read_managed_server_record() - var proof_result: Variant = await _run_blocking(func() -> Variant: - if not is_instance_valid(_host): - return {"proof": "", "pids": []} - return _host._evaluate_strong_port_occupant_proof(port, pre_kill_live, record) - ) - if _async_stale(async_gen) or proof_result == null: - return false - var proof: Dictionary = proof_result - var targets: Array[int] = [] - targets.assign(proof.get("pids", [])) - if targets.is_empty(): - return false - - print("MCP | strong proof: %s" % str(proof.get("proof", ""))) - var freed_result: Variant = await _run_blocking(func() -> Variant: - if not is_instance_valid(_host): - return false - ## verify_brand=true: the proof above ran in a separate _run_blocking - ## task with main-thread frames in between — re-check each target at - ## kill time so a PID recycled inside that gap isn't killed (#686). - var killed: Array = _host._kill_processes_and_windows_spawn_children(targets, true) - if not killed.is_empty(): - print("MCP | killed pids %s on port %d" % [str(killed), port]) - _host._wait_for_port_free(port, wait_s) - return not bool(_host._is_port_in_use(port)) - ) - if _async_stale(async_gen) or freed_result == null: - return false - if not bool(freed_result): - return false - - _host._clear_managed_server_record() - _host._clear_pid_file() - return true - - -## Weak-proof sibling of `recover_strong_port_occupant`, reserved for the -## bounded stale-version episode (`_stale_recovery_budget` — callers gate on -## `_stale_recovery_available` and spend a round BEFORE calling). Uses the -## recovery-tier proof (`status_name` allowed) because the target is an -## external previous-version server with no managed record or pid-file to -## prove strong ownership with — the same authority the dock's recovery -## click exercises, here spent under an equally explicit user action (the -## Update click / a recovery click's retry). The kill still brand-verifies -## every PID at kill time (#686), so a recycled or unrelated PID on the -## port is never killed. No pre-probed `live` parameter on purpose: a prior -## failed strong recovery may have partially changed the port's state, so -## the worker re-probes — and re-verifies STALENESS on that fresh snapshot -## (#890 CodeRabbit): the caller's version gate ran on an earlier probe, -## and a same-version server that took the port in the interval must abort -## this recovery, not be killed by it. -func recover_stale_port_occupant(port: int, wait_s: float) -> bool: - var async_gen := _async_generation - var record: Dictionary = _host._read_managed_server_record() - ## Resolved on the main thread: the worker must not touch EditorSettings. - var expected_version := _expected_server_version() - var proof_result: Variant = await _run_blocking(func() -> Variant: - if not is_instance_valid(_host): - return {"proof": "", "pids": []} - var live: Dictionary = _host._probe_live_server_status_for_port(port) - var live_version := str(_host._verified_status_version(live)) - if live_version.is_empty() or live_version == expected_version: - return {"proof": "", "pids": []} - return _host._evaluate_recovery_port_occupant_proof(port, live, record) - ) - if _async_stale(async_gen) or proof_result == null: - return false - var proof: Dictionary = proof_result - var targets: Array[int] = [] - targets.assign(proof.get("pids", [])) - if targets.is_empty(): - return false - - print("MCP | stale-occupant proof: %s" % str(proof.get("proof", ""))) - var freed_result: Variant = await _run_blocking(func() -> Variant: - if not is_instance_valid(_host): - return false - ## Warm the current-version uv env in parallel with the kill + port - ## drain below (#890 review P1): on the release that first ships this - ## code the OLD updater ran the update, so no click-time pre-warm - ## happened and the respawn would resolve cold — exactly the race - ## this episode exists to win. Detached fire-and-forget via the host - ## seam (stubbed in tests); worker-safe (CliFinder is mutex-guarded, - ## no EditorSettings access). - _host._prewarm_server_package(expected_version) - var killed: Array = _host._kill_processes_and_windows_spawn_children(targets, true) - if not killed.is_empty(): - print("MCP | killed pids %s on port %d" % [str(killed), port]) - _host._wait_for_port_free(port, wait_s) - return not bool(_host._is_port_in_use(port)) - ) - if _async_stale(async_gen) or freed_result == null: - return false - if not bool(freed_result): - return false - - _host._clear_managed_server_record() - _host._clear_pid_file() - return true - - -## Editor-exit teardown chooser (#800): detach only when the RUNNING -## server was launched keep-alive (_server_keep_alive, set at spawn / -## recovered on adoption) — never on the live setting, which may have -## been toggled after spawn. Flag clear → stop_server kills as always, -## so enabling the setting mid-session takes effect on the next server -## start instead of leaving a record that points at a soon-reaped PID. -func teardown_for_editor_exit() -> void: - if _server_keep_alive: - detach_server() - return - ## #824: a backend we spawned may be keeping one or more MCP clients alive - ## through their `godot-ai attach` bridges. Killing it because *this* editor - ## is closing takes the server out from under them: an in-flight call can - ## become TRANSPORT_OUTCOME_UNKNOWN, and every bridge has to establish a new - ## backend before the next editor can reconnect. A live lease means the - ## backend has consumers beyond this editor, so hand it over instead. - var leased := active_lease_count_at_exit() - if leased > 0: - ## Give up kill authority along with the process: dropping the managed - ## record means the next editor adopts it through the external branch - ## rather than as a managed server it may kill. The server's own - ## pid-file is deliberately left in place — it is the backend's - ## publication, not our claim on it, and adoption reads it. - ## - ## The Python side remains the reaper of record: a plugin-spawned - ## backend keeps its idle backstop armed (only keep_server_on_exit - ## disarms it) and that backstop is lease-aware, so this defers the - ## stop to "no editors AND no leases AND grace elapsed" rather than - ## leaking the process. - _host._clear_managed_server_record() - detach_server( - "detaching server: %d attach lease(s) still held, leaving it to the " - % leased - + "server's own idle reaper" - ) - return - stop_server() - - -## Active attach-bridge leases on the backend this editor manages, or 0 when -## there is nothing to consult (#824). -## -## Returns 0 — preserving the historical kill-on-exit behavior — for every -## uncertain case: no managed PID, a probe that fails or times out, a server -## that does not identify as godot-ai, or one too old to publish the field. -## That direction is deliberate. A false 0 costs what today already costs -## (the backend is stopped and bridges reconnect); a false positive would -## leave a process running on a guess. -## -## Bounded by the status probe's own timeout (SERVER_STATUS_PROBE_TIMEOUT_MS), -## which is what keeps editor exit from hanging on a wedged HTTP server. -func active_lease_count_at_exit() -> int: - var pid := int(_server_pid) - if pid <= 0: - return 0 - ## Only a process we can still prove is our godot-ai server earns the - ## benefit of the doubt. The lease count comes from whoever answers on the - ## port, which is not by itself proof that it IS the process we are about - ## to stop — another editor's backend, or an attach-owned one, could hold - ## the port after ours died. Requiring the same alive+branded proof - ## `stop_server` uses before its kill closes that gap: without it, a - ## stranger's leases could talk this editor out of stopping its own server. - ## - ## Failing this check is harmless either way. A dead PID has nothing to - ## kill, and a recycled-but-unbranded PID is rejected by stop_server's own - ## gate (#686) — both land on the historical path. - if not _host._pid_alive_for_proof(pid): - return 0 - if not _host._pid_cmdline_is_godot_ai_for_proof(pid): - return 0 - return active_lease_count( - _host._probe_live_server_status_for_port(ClientConfigurator.http_port()) - ) - - -## Read the advisory lease count out of a `/godot-ai/status` payload. -## -## Gated on the payload identifying as godot-ai, so an unrelated process -## answering on the port cannot talk this editor out of a clean stop. A -## missing field means an older backend that predates #824; it reads as 0, -## which keeps that pairing on today's behavior. -static func active_lease_count(live: Dictionary) -> int: - if not _live_status_identifies_godot_ai(live): - return 0 - var raw: Variant = live.get("active_lease_count") - if raw == null: - return 0 - return maxi(0, int(raw)) - - -## keep_server_on_exit (#800): editor teardown that leaves the server -## running. Mirrors stop_server's bookkeeping — cancel in-flight async -## startup, stop the watch, settle on STOPPED — but kills nothing and -## PRESERVES the managed-server record + pid-file, so the next editor -## session's start_server walk adopts the survivor through the existing -## record-matches branch (#758/#774). Explicit stops (dock Restart, -## update reload) still route through stop_server and kill as before. -## `log_reason` names why the server is being left alive; the default is the -## keep_server_on_exit wording this function was written for. #824 reuses the -## same bookkeeping for the active-lease handover, and a shared log line would -## have reported the wrong cause for it. -func detach_server( - log_reason: String = "keep_server_on_exit: leaving server running" -) -> void: - _invalidate_async_startup() - _host._stop_server_watch() - var detached_pid := int(_server_pid) - _server_pid = -1 - transition_state(McpServerStateScript.STOPPED) - if detached_pid > 0: - print("MCP | %s (PID %d)" % [log_reason, detached_pid]) - - -func stop_server() -> void: - ## Cancel any in-flight async startup (#678): a suspended start_server - ## resuming after teardown must not resurrect state or spawn a server. - _invalidate_async_startup() - _host._stop_server_watch() - if int(_server_pid) <= 0: - transition_state(McpServerStateScript.STOPPED) - return - transition_state(McpServerStateScript.STOPPING) - ## Kill the tracked PID AND the real Python PID — they differ for the - ## uvx tier (the launcher exits before its child) and on Windows - ## `OS.kill` is `TerminateProcess` which doesn't walk the child tree. - var port := ClientConfigurator.http_port() - var killed: Array = [] - var candidates: Array[int] = [] - ## Re-verify the tracked PID at kill time (#686): nothing clears - ## `_server_pid` when the server dies mid-session (`check_server_health` - ## stops watching after SERVER_WATCH_MS), so hours later the kernel may - ## have recycled this PID to an unrelated process. Every other candidate - ## in this function is brand-gated; the tracked seed must be too. A false - ## negative is fail-safe: the port stays held and the record is preserved, - ## so the next start_server's drift branch retries the kill. - var tracked_pid := int(_server_pid) - if ( - tracked_pid > 0 - and _host._pid_alive_for_proof(tracked_pid) - and _host._pid_cmdline_is_godot_ai_for_proof(tracked_pid) - ): - candidates.append(tracked_pid) - var real_pid := int(_host._find_managed_pid(port)) - ## Add the real Python PID only if it isn't already tracked and proves out - ## as ours — re-appending an already-present PID just produces a duplicate - ## kill candidate. - if real_pid > 0 and not candidates.has(real_pid) and _host._pid_cmdline_is_godot_ai_for_proof(real_pid): - candidates.append(real_pid) - var listener_pids: Array = _host._find_all_pids_on_port(port) - for pid in listener_pids: - var listener_pid := int(pid) - if candidates.has(listener_pid): - continue - if _host._pid_cmdline_is_godot_ai_for_proof(listener_pid): - candidates.append(listener_pid) - killed = _host._kill_processes_and_windows_spawn_children(candidates) - if not killed.is_empty(): - print("MCP | stopped server (PID %s)" % str(killed)) - _server_pid = -1 - _server_keep_alive = false - _host._wait_for_port_free(port, 2.0) - ## Preserve record/pid-file when port is still held — the drift - ## branch on the next start_server retries the kill (#159 follow-up). - _host._finalize_stop_if_port_free(port) - transition_state(McpServerStateScript.STOPPED) - - ## Server's `_pydantic_core.pyd` hard-link is now released — sweep - ## stale uvx builds before they trip the next attach launcher. - UvCacheCleanup.purge_stale_builds() - - -## Kill the server, reset the re-entrancy guard so the re-enabled plugin -## spawns fresh (#132). User-mode only kills via strong proof. -func prepare_for_update_reload() -> void: - stop_server() - _host._server_started_this_session = false - if ClientConfigurator.is_dev_checkout(): - return - - var port := ClientConfigurator.http_port() - if not bool(_host._is_port_in_use(port)): - return - - var proof: Dictionary = _host._evaluate_strong_port_occupant_proof(port) - var targets: Array[int] = [] - targets.assign(proof.get("pids", [])) - if targets.is_empty(): - return - - _host._kill_processes_and_windows_spawn_children(targets) - _host._wait_for_port_free(port, 3.0) - if not bool(_host._is_port_in_use(port)): - _host._clear_managed_server_record() - _host._clear_pid_file() - - -# ---- Recovery click ---------------------------------------------------- - -## Returns true when a pure-state probe says recovery is allowed: -## current state is INCOMPATIBLE, the port is still held, and the -## incompatible diagnosis latched an ownership proof. Pure-state in the -## sense that nothing is killed — that's `recover_incompatible_server`. -## -## Consults the `_can_recover_incompatible` verdict that -## `_set_incompatible_server` computed off-thread instead of re-running -## the proof's port scrapes + per-PID brand shells on the main thread -## (#712): the dock polls this on refresh, and -## `recover_incompatible_server` re-proves at kill time anyway, so a -## stale latch can never kill an unproven occupant — worst case is a -## recovery click that comes back false. The port liveness re-check is -## a single local bind probe, cheap enough to stay synchronous. -func can_recover_incompatible_server() -> bool: - if _server_state != McpServerStateScript.INCOMPATIBLE: - return false - if not _can_recover_incompatible: - return false - return bool(_host._is_port_in_use(ClientConfigurator.http_port())) - - -func recover_incompatible_server() -> bool: - if _server_state != McpServerStateScript.INCOMPATIBLE: - return false - - var port := ClientConfigurator.http_port() - ## Cancel any suspended contended-port walk BEFORE the off-thread proof - ## (#712): `_run_blocking` tracks a single active worker for the - ## teardown join, so starting ours while another walk's worker is alive - ## would orphan that thread from the join guarantee. This also releases - ## the guard so the respawn at the bottom isn't silently swallowed - ## (#682 review). The user's recovery click owns the flow from here. - _invalidate_async_startup() - var async_gen := _async_generation - ## EditorSettings record read on the main thread, injected so the - ## worker never touches EditorSettings (#712, mirroring - ## recover_strong_port_occupant). - var record: Dictionary = _host._read_managed_server_record() - var proof_result: Variant = await _run_blocking(func() -> Variant: - if not is_instance_valid(_host): - return {"proof": "", "pids": []} - return _host._evaluate_recovery_port_occupant_proof(port, {}, record) - ) - if _async_stale(async_gen) or proof_result == null: - return false - var proof: Dictionary = proof_result - var targets: Array[int] = [] - targets.assign(proof.get("pids", [])) - if targets.is_empty(): - return false - print("MCP | proof: %s" % str(proof.get("proof", ""))) - - ## Move into STOPPING so the post-kill respawn passes the - ## first-writer-wins guards. - transition_state(McpServerStateScript.STOPPING) - ## Main-thread read for the worker below (EditorSettings-backed). - var recovery_expected_version := _expected_server_version() - var freed_result: Variant = await _run_blocking(func() -> Variant: - if not is_instance_valid(_host): - return false - ## Warm the current-version uv env in parallel with the kill + drain - ## so the respawn below wins the bind race even when no click-time - ## pre-warm ran (#890 review P1 — the transition release's update runs - ## the OLD updater). Same rationale and seam as - ## recover_stale_port_occupant. - _host._prewarm_server_package(recovery_expected_version) - ## verify_brand=true: the proof above ran in a separate - ## _run_blocking task with main-thread frames in between — re-check - ## each target at kill time so a PID recycled inside that gap isn't - ## killed (#686, mirroring recover_strong_port_occupant). - var killed: Array = _host._kill_processes_and_windows_spawn_children(targets, true) - if not killed.is_empty(): - print("MCP | killed pids %s on port %d" % [str(killed), port]) - _host._wait_for_port_free(port, 5.0) - return not bool(_host._is_port_in_use(port)) - ) - if _async_stale(async_gen) or freed_result == null: - return false - if not bool(freed_result): - ## Kill failed; re-latch INCOMPATIBLE so the dock keeps the - ## diagnostic UI. - transition_state(McpServerStateScript.INCOMPATIBLE) - return false - - UvCacheCleanup.purge_stale_builds() - _host._clear_managed_server_record() - _host._clear_pid_file() - transition_state(McpServerStateScript.STOPPED) - _connection_blocked = false - _server_status_message = "" - _conflict_port = 0 - _server_actual_version = "" - _server_actual_name = "" - _can_recover_incompatible = false - _host._server_started_this_session = false - _server_pid = -1 - ## Await the respawn walk: the plugin gates its connection unblock on - ## the post-walk state (SPAWNING/READY), so returning true while the - ## walk is still suspended would leave the connection blocked forever - ## after a successful recovery click (#682 review). - ## NOTE: arming the stale-recovery budget for the click lives in the - ## plugin wrapper (`plugin.gd::recover_incompatible_server`), NOT here — - ## the automatic handshake-mismatch trigger reuses this manager flow and - ## must only SPEND budget; re-arming inside it would unbound the - ## kill/respawn loop against a persistent bridge respawner. - await start_server() - return true - - -## Restart authorisation — a live PID means we spawned/adopted, a -## non-empty managed record is the cross-session proof used by the -## drift branch. -func can_restart_managed_server() -> bool: - if _server_pid > 0: - return true - var record: Dictionary = _host._read_managed_server_record() - return not str(record.get("version", "")).is_empty() - - -func has_managed_server() -> bool: - return _server_pid > 0 - - -## Reset state for a force-restart. Drops the managed record, clears -## the pid-file, and resets the spawn guard so the follow-up -## `start_server()` walks the spawn arm. -func reset_for_force_restart() -> void: - ## The user's explicit restart takes over: cancel any suspended - ## contended-port walk and release the re-entrancy guard so the - ## follow-up start isn't silently swallowed (#682 review). - _invalidate_async_startup() - _host._clear_managed_server_record() - _host._clear_pid_file() - _host._server_started_this_session = false - _server_pid = -1 - transition_state(McpServerStateScript.UNINITIALIZED) - - -## Ownership-checked kill of the port occupant + respawn. Driven from -## the dock's "Restart Server" button when the plugin adopted a foreign -## server whose version drifted from the plugin. -func force_restart_server() -> void: - if not can_restart_managed_server(): - push_warning("MCP | refusing to kill server on port %d without managed-server ownership proof" - % ClientConfigurator.http_port()) - return - var port := ClientConfigurator.http_port() - ## Kill every LISTENER on the port, not just the first one. A dev - ## server run via `uvicorn --reload` owns port 8000 through both a - ## reloader parent AND a worker child — killing only one (or zero, - ## if the single-pid parse fell over on multi-line lsof output) leaves - ## the other holding the port past `_wait_for_port_free`'s window. - ## - ## Brand-gate each raw listener PID (#686): `can_restart_managed_server()` - ## only proves we once managed *a* server, not that the port's current - ## occupants are ours — an adopted server that exited on its own can be - ## replaced on the port by an unrelated dev tool before the user clicks - ## Restart. Unbranded PIDs fall through to `_set_incompatible_server` - ## below instead of being killed. - transition_state(McpServerStateScript.STOPPING) - var restart_targets: Array[int] = [] - for pid in _host._find_all_pids_on_port(port): - var listener_pid := int(pid) - if _host._pid_cmdline_is_godot_ai_for_proof(listener_pid): - restart_targets.append(listener_pid) - _host._kill_processes_and_windows_spawn_children(restart_targets) - _host._wait_for_port_free(port, 5.0) - if _host._is_port_in_use(port): - ## Kill failed; clean baseline for the follow-up - ## `_set_incompatible_server`. - transition_state(McpServerStateScript.UNINITIALIZED) - _set_incompatible_server( - _host._probe_live_server_status_for_port(port), - _expected_server_version(), - port - ) - return - ## Same rationale as `stop_server`: the server child python just - ## released its `pydantic_core` mapping, so this is the only window in - ## which the hard-linked copies under `builds-v0\.tmp*` are deletable. - ## Sweep before respawning so the next uvx attach build doesn't - ## inherit the same cleanup-failure path that triggered the restart. - UvCacheCleanup.purge_stale_builds() - reset_for_force_restart() - start_server() diff --git a/addons/godot_ai/utils/server_lifecycle.gd.uid b/addons/godot_ai/utils/server_lifecycle.gd.uid deleted file mode 100644 index 8e62667..0000000 --- a/addons/godot_ai/utils/server_lifecycle.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bwfx8b0w2mgf6 diff --git a/addons/godot_ai/utils/server_version_check.gd b/addons/godot_ai/utils/server_version_check.gd deleted file mode 100644 index 4bb1371..0000000 --- a/addons/godot_ai/utils/server_version_check.gd +++ /dev/null @@ -1,126 +0,0 @@ -@tool -class_name McpServerVersionCheck -extends RefCounted - -## Standalone polling seam for the post-connection server-version -## handshake gate. Extracted from `plugin.gd` so the lifecycle manager -## stays focused on spawn/adopt/stop and the version-verify dance has -## its own home. -## -## The seam itself does NOT transition `McpServerState` on arm/disarm — -## the version check runs concurrently with whatever spawn-state the -## caller had latched (typically FOREIGN_PORT during adoption -## confirmation, or no-op directly to READY for a fresh spawn). Result -## transitions land on the manager via `handle_server_version_verified` -## (READY / INCOMPATIBLE) or `handle_server_version_unverified` -## (INCOMPATIBLE on deadline expiry); arm() leaves the state alone so a -## FOREIGN_PORT diagnosis isn't accidentally cleared before the -## handshake actually arrives. -## -## Owns the deadline timer (`_deadline_ms`) and requires the manager to -## feed it `tick(now_msec)` from the plugin's `_process` while -## `is_active()` is true. -## -## Decoupled from the connection's signal surface: `tick()` polls -## `_connection.is_connected` and `_connection.server_version` directly. -## A same-release signal addition plus a new consumer is shape-coupled work -## for old two-phase runners; they can parse the consumer while the -## McpConnection Script object still reflects v(N). We still null-check -## `_connection` because `disarm()` releases it. - -## How long to wait after the WebSocket opens before declaring the -## handshake_ack overdue. This is the sole owner of the 5s budget -## — kept at this layer so the version-check seam is self-contained. -const TIMEOUT_MS := 5 * 1000 - -## Untyped on purpose for the same self-update field-storage reason -## plugin.gd's fields are untyped. `_connection` is the live -## `McpConnection`; `_manager` is `McpServerLifecycleManager`. -## `_connection` is null between disarm() and the next arm() — the -## seam can spend most of the plugin's life dormant and we don't want -## to pin a Node that may be queue_freed in `_exit_tree`. `_manager` is -## set once at construction and held for the seam's lifetime (the -## manager owns this instance, so the cycle is short). -var _connection -var _manager -var _active: bool = false -var _deadline_ms: int = 0 -var _expected_version: String = "" - - -func _init(manager) -> void: - _manager = manager - - -## Arm the version-check. Marks the seam active, (re)attaches the -## connection it should poll, and starts watching for -## `_connection.server_version`. Does NOT transition manager state — -## the version check runs concurrently with whatever spawn-state was -## latched (e.g. FOREIGN_PORT during adoption confirmation, READY for -## a fresh spawn). Result transitions land on the manager via -## `handle_server_version_verified` / `_unverified` once the handshake -## (or its deadline) lands. -## -## The deadline starts the moment the connection actually opens, not at -## arm-time, because uvx cold-starts can take ~30s to bind the -## WebSocket and we don't want to count that against the handshake. -func arm(connection, expected_version: String) -> void: - _active = true - _deadline_ms = 0 - _expected_version = expected_version - _connection = connection - - -## Disarm without firing a verdict. Used when the manager moves on -## (e.g. recovery click → STOPPING). Releases the connection / -## manager references so the seam doesn't pin them past the active -## window — the plugin can spend most of its life with the version -## check disarmed, and `_connection` is a Node that may be queue_free'd -## by `_exit_tree`. Caller has already transitioned state, so we don't -## touch the manager. -func disarm() -> void: - _active = false - _deadline_ms = 0 - _connection = null - - -## True while the version-check needs `_process` ticks. Plugin uses -## this to gate `set_process(true)`. -func is_active() -> bool: - return _active - - -## Per-frame tick from the plugin's `_process`. No-op when disarmed. -## Returns true when the check finished this tick (verified or -## unverified) so the plugin can re-evaluate `set_process` enable. -func tick(now_msec: int) -> bool: - if not _active: - return false - if _connection == null: - return false - if not bool(_connection.is_connected): - return false - if _deadline_ms == 0: - _deadline_ms = now_msec + TIMEOUT_MS - var server_version := str(_connection.server_version) - if not server_version.is_empty(): - _complete_with_version(server_version) - return true - if now_msec >= _deadline_ms: - _complete_unverified() - return true - return false - - -func _complete_with_version(version: String) -> void: - _active = false - _deadline_ms = 0 - if _manager != null: - _manager.handle_server_version_verified(_expected_version, version) - - -func _complete_unverified() -> void: - _active = false - _deadline_ms = 0 - if _manager != null: - _manager.handle_server_version_unverified(_expected_version) diff --git a/addons/godot_ai/utils/server_version_check.gd.uid b/addons/godot_ai/utils/server_version_check.gd.uid deleted file mode 100644 index 7baacfd..0000000 --- a/addons/godot_ai/utils/server_version_check.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://ciqldbuaq8i8u diff --git a/addons/godot_ai/utils/settings.gd b/addons/godot_ai/utils/settings.gd deleted file mode 100644 index 9ce2c68..0000000 --- a/addons/godot_ai/utils/settings.gd +++ /dev/null @@ -1,135 +0,0 @@ -@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 deleted file mode 100644 index b8b1547..0000000 --- a/addons/godot_ai/utils/settings.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://pefrtofs7ijw diff --git a/addons/godot_ai/utils/structured_log_ring.gd b/addons/godot_ai/utils/structured_log_ring.gd deleted file mode 100644 index 3e00b4e..0000000 --- a/addons/godot_ai/utils/structured_log_ring.gd +++ /dev/null @@ -1,156 +0,0 @@ -@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 deleted file mode 100644 index 57012ba..0000000 --- a/addons/godot_ai/utils/structured_log_ring.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://c4yh3jqfn6dwe diff --git a/addons/godot_ai/utils/surfaced_error_tracker.gd b/addons/godot_ai/utils/surfaced_error_tracker.gd deleted file mode 100644 index 5bd3022..0000000 --- a/addons/godot_ai/utils/surfaced_error_tracker.gd +++ /dev/null @@ -1,642 +0,0 @@ -@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 deleted file mode 100644 index 433a191..0000000 --- a/addons/godot_ai/utils/surfaced_error_tracker.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://o0ulahkt83re diff --git a/addons/godot_ai/utils/update_activation_runner.gd b/addons/godot_ai/utils/update_activation_runner.gd new file mode 100644 index 0000000..2f302a2 --- /dev/null +++ b/addons/godot_ai/utils/update_activation_runner.gd @@ -0,0 +1,391 @@ +@tool +extends Node + +## Compile this source into a GDScript with no resource_path before creating +## the node. Its named callbacks must survive replacement of the file itself. +## The caller verifies/stages/quiesces and owns the update lock. No plugin, +## dock, handler, or suspended caller frame crosses the deferred handoff. +const LIVE_ROOT := "res://addons/godot_ai" +const PLUGIN_CFG := LIVE_ROOT + "/plugin.cfg" +const STAGE_ROOT := "res://addons/.godot_ai_update/stage/addons/godot_ai" +const INSTALLER_PATH := LIVE_ROOT + "/utils/update_installer.gd" +const VERIFIER_PATH := LIVE_ROOT + "/utils/release_verifier.gd" +const RECEIPT_PATH := "res://addons/.godot_ai_update/activation.json" +const SCAN_TIMEOUT_MS := 60000 + +enum Phase { IDLE, DRAIN, REQUEST_OLD_SCAN, WAIT_OLD_SCAN, SWAPPING, REQUEST_NEW_SCAN, WAIT_NEW_SCAN, ENABLING, DONE } + +var _phase := Phase.IDLE +var _package: Dictionary = {} +var _installer: Script +var _verifier: Script +var _frames := 0 +var _deadline := 0 +var _filesystem: EditorFileSystem +var _failure := "" +var _outcome := "" +var _backup_root := "" +var _retired_scripts: Array[Script] = [] +var _inspected_objects := {} +var _inspection_budget := 100000 +## Copied by a caller when start() refuses, before the runner is freed. +var refusal_reason := "The independent activation handoff was refused." + + +func start(package: Dictionary) -> bool: + if _phase != Phase.IDLE: + refusal_reason = "The activation runner has already started." + return false + if not is_inside_tree(): + refusal_reason = "The activation runner must belong to the editor scene tree." + return false + if not str(get_script().resource_path).is_empty(): + refusal_reason = "The activation runner must be source-free before replacing its files." + return false # A file-backed runner can be replaced by the scan it starts. + if package.get("stage_root") != STAGE_ROOT: + refusal_reason = "The update stage root does not match the verified activation location." + return false + if not package.get("record") is Dictionary: + refusal_reason = "The update handoff requires an installer record Dictionary." + return false + var record: Dictionary = package.record + for key in ["from_version", "to_version", "manifest_sha256", "expected_tree_sha256", "editor_nonce"]: + if not record.get(key) is String or str(record[key]).is_empty(): + refusal_reason = "The update record requires a nonempty String field: %s." % key + return false + if not record.get("replace_owned_mismatches") is bool: + refusal_reason = "The update record requires a boolean replace_owned_mismatches field." + return false + # Copy only values that belong to the existing installer's swap record. + _package = {"stage_root": STAGE_ROOT, "record": {}} + for key in ["from_version", "to_version", "manifest_sha256", "expected_tree_sha256", "editor_nonce", "replace_owned_mismatches"]: + _package.record[key] = record[key] + _installer = ResourceLoader.load(INSTALLER_PATH, "", ResourceLoader.CACHE_MODE_IGNORE_DEEP) as Script + _verifier = ResourceLoader.load(VERIFIER_PATH, "", ResourceLoader.CACHE_MODE_IGNORE_DEEP) as Script + if _installer == null or _verifier == null: + refusal_reason = "The update installer or release verifier script could not be loaded." + return false + for method in ["swap", "verify_after_restart", "release_lock", "read_lock", "discard_stage"]: + if not _declares(_installer, method): + refusal_reason = "The update installer is missing its required %s method." % method + return false + if not _declares(_verifier, "hash_tree"): + refusal_reason = "The release verifier is missing its required hash_tree method." + return false + var lock: Dictionary = _installer.call("read_lock") + if int(lock.get("pid", 0)) != OS.get_process_id() or str(lock.get("fingerprint", "")).is_empty(): + refusal_reason = "The update lock does not identify this editor with a process fingerprint." + return false + _inspected_objects.clear() + _inspection_budget = 100000 + if not _user_resources_can_keep_paths(): + return false + refusal_reason = "" + process_mode = Node.PROCESS_MODE_ALWAYS + _phase = Phase.DRAIN + set_process(false) + _begin.call_deferred() + return true + + +func _declares(script: Script, method: String) -> bool: + for entry in script.get_script_method_list(): + if str(entry.get("name", "")) == method: + return true + return false + + +func _begin() -> void: + _filesystem = EditorInterface.get_resource_filesystem() + if _filesystem == null: + _stop("The editor filesystem is unavailable; no update swap was attempted.") + return + _filesystem.sources_changed.connect(_scan_completed) + EditorInterface.set_plugin_enabled(PLUGIN_CFG, false) + if EditorInterface.is_plugin_enabled(PLUGIN_CFG): + _stop("Godot could not disable the plugin; the verified stage was retained.") + return + _frames = 2 + set_process(true) + + +func _process(_delta: float) -> void: + if _phase == Phase.DRAIN: + _frames -= 1 + if _frames <= 0: + _phase = Phase.REQUEST_OLD_SCAN + _deadline = Time.get_ticks_msec() + SCAN_TIMEOUT_MS + elif _phase in [Phase.REQUEST_OLD_SCAN, Phase.WAIT_OLD_SCAN, Phase.REQUEST_NEW_SCAN, Phase.WAIT_NEW_SCAN]: + if Time.get_ticks_msec() >= _deadline: + _stop("The filesystem scan did not complete. The plugin remains disabled; the staged update and any backup were retained.") + elif _phase in [Phase.REQUEST_OLD_SCAN, Phase.REQUEST_NEW_SCAN] and not _filesystem.is_scanning(): + _phase = Phase.WAIT_OLD_SCAN if _phase == Phase.REQUEST_OLD_SCAN else Phase.WAIT_NEW_SCAN + # scan() may decline while a finished worker still awaits GUI + # completion. Its sources_changed signal remains a valid barrier. + _filesystem.scan() + + +func _swap() -> void: + set_process(false) + # A cache lookup may resolve dependencies even when the requested script + # is cached. Retain all old references while their complete tree exists; + # never load a canonical path after the swap to recover an old script. + if not _collect_cached_scripts(LIVE_ROOT): + _stop("The previous script cache could not be captured safely; no update swap was attempted.") + return + var before: Dictionary = _verifier.call("hash_tree", LIVE_ROOT) + if not bool(before.get("ok", false)): + _stop("Could not hash the previous tree before activation; no swap was attempted.") + return + var swapped: Dictionary = _installer.call("swap", STAGE_ROOT, LIVE_ROOT, _package.record) + if not bool(swapped.get("ok", false)): + _failure = "Update swap failed: %s" % str(swapped.get("error", "unknown error")) + var restored: Dictionary = _verifier.call("hash_tree", LIVE_ROOT) + if not bool(restored.get("ok", false)) or restored.get("tree_sha256") != before.get("tree_sha256"): + _stop(_failure + "; the original tree could not be proven restored. Explicit repair is required.") + return + _installer.call("discard_stage") + _outcome = "previous_tree_retained" + else: + print("MCP | update to %s swapped in; reloading the plugin in this editor" % str(_package.record.to_version)) + var verified: Dictionary = _installer.call("verify_after_restart", LIVE_ROOT) + _outcome = str(verified.get("status", "")) + if _outcome != "success" and _outcome != "rolled_back": + _stop("The installed tree could not be verified: %s" % str(verified.get("error", "unknown outcome"))) + return + if _outcome == "rolled_back": + _failure = "Update verification failed; the previous tree was restored: %s" % str(verified.get("error", "")) + else: + _backup_root = str(verified.get("backup_root", "")) + var expected_backup := "res://addons/.godot_ai_update/backup/" + str(_package.record.from_version) + if _backup_root != expected_backup: + _stop("The update marker names an unexpected backup. The plugin remains disabled.") + return + var backup: Dictionary = _verifier.call("hash_tree", _backup_root) + if not bool(backup.get("ok", false)) or backup.get("tree_sha256") != before.get("tree_sha256"): + _stop("The retained backup could not be proven to match the previous tree. The plugin remains disabled.") + return + if _outcome == "success" and not _retire_cached_scripts(_backup_root): + _stop("The previous script cache could not be retained safely. The plugin remains disabled.") + return + _release_scripts() + _phase = Phase.REQUEST_NEW_SCAN + _deadline = Time.get_ticks_msec() + SCAN_TIMEOUT_MS + set_process(true) + + +func _scan_completed(_sources_changed: bool) -> void: + if _phase not in [Phase.WAIT_OLD_SCAN, Phase.WAIT_NEW_SCAN]: + return + if _filesystem.is_scanning(): + return # Another completion listener already started a newer scan. + if Time.get_ticks_msec() >= _deadline: + _stop("The filesystem scan completion arrived after the activation deadline. The plugin remains disabled.") + return + set_process(false) + # sources_changed follows scan actions, documentation, and GUI updates. + # filesystem_changed can occur earlier; a deferred completion can become + # stale during a newer GUI scan. Consume this boundary synchronously. + if _phase == Phase.WAIT_OLD_SCAN: + _phase = Phase.SWAPPING + _swap() + else: + _phase = Phase.ENABLING + _filesystem.sources_changed.disconnect(_scan_completed) + _enable() + + +func _enable() -> void: + if not _failure.is_empty() and str(_package.record.from_version).get_slice(".", 0).to_int() < 4: + # A restored capsule must show Retry instead of automatically repeating + # the failed update. This is its existing project-scoped status key. + var settings := EditorInterface.get_editor_settings() + if settings != null: + var key := "godot_ai/v4_migration_bridge_status_" + ProjectSettings.globalize_path("res://").sha256_text() + settings.set_setting(key, JSON.stringify({"error": _failure})) + EditorInterface.set_plugin_enabled(PLUGIN_CFG, true) + if not EditorInterface.is_plugin_enabled(PLUGIN_CFG): + _stop("The scanned plugin could not be enabled. The installed tree and backup were retained.") + return + if _outcome == "success": + var plugin := _find_plugin(get_tree().root) + if plugin == null or str(plugin.get("_loaded_plugin_version")) != str(_package.record.to_version): + EditorInterface.set_plugin_enabled(PLUGIN_CFG, false) + _stop("The enabled plugin did not report the installed version. The update evidence was retained.") + return + var saved := ProjectSettings.save() + if saved != OK: + _stop("The plugin is enabled, but saving its enabled state failed: %s" % error_string(saved)) + return + _phase = Phase.DONE + _record(_outcome, _failure) + if not _failure.is_empty(): + _show_failure(_failure) + return + print("MCP | update activation completed in editor PID %d" % OS.get_process_id()) + queue_free() + + +func _user_resources_can_keep_paths() -> bool: + # Retiring a Script changes resource_path for all its existing users. + # Refuse serializable scene/resource references instead of silently making + # a user's next save point into the updater's retained backup directory. + var roots: Array = [] + if EditorInterface.has_method("get_open_scene_roots"): + roots = EditorInterface.call("get_open_scene_roots") + else: + if EditorInterface.get_open_scenes().size() > 1: + refusal_reason = "This Godot version cannot inspect every open scene safely. Close the other scene tabs before retrying the update." + return false + var current := EditorInterface.get_edited_scene_root() + if current != null: + roots.append(current) + for root in roots: + if not _inspect_user_node(root): + return false + var inspected := EditorInterface.get_inspector().get_edited_object() + if inspected is Resource and not _inspect_user_value(inspected, 0): + return false + return true + + +func _inspect_user_node(node: Node) -> bool: + if not _inspect_user_value(node, 0): + return false + for child in node.get_children(): + if not _inspect_user_node(child): + return false + return true + + +func _inspect_user_value(value: Variant, depth: int) -> bool: + _inspection_budget -= 1 + if _inspection_budget < 0 or depth > 64: + refusal_reason = "Open scene or resource data exceeded the safe inspection limit. Close the affected scenes or resources before retrying the update." + return false + if value is Script: + if value.resource_path.begins_with(LIVE_ROOT + "/"): + refusal_reason = "An open scene or resource references addon script %s. In-editor activation would change its saved script path. Close the scene or resource before retrying; the update was not installed." % value.resource_path + return false + return true + if value is Resource or value is Node: + var identity: int = value.get_instance_id() + if _inspected_objects.has(identity): + return true + _inspected_objects[identity] = true + for property in value.get_property_list(): + if int(property.usage) & PROPERTY_USAGE_STORAGE: + if not _inspect_user_value(value.get(property.name), depth + 1): + return false + elif value is Array: + for entry in value: + if not _inspect_user_value(entry, depth + 1): + return false + elif value is Dictionary: + for key in value: + if not _inspect_user_value(key, depth + 1) or not _inspect_user_value(value[key], depth + 1): + return false + return true + + +func _retire_cached_scripts(backup_root: String) -> bool: + # All references were captured before replacement. Godot moves + # both its resource and GDScript cache entries through take_over_path(). + # Existing UndoRedo callbacks keep the old compiled code and typed state; + # canonical loads create a fresh graph, including fresh static variables. + for script in _retired_scripts: + var previous_path := script.resource_path + var retained_path := backup_root + previous_path.trim_prefix(LIVE_ROOT) + if not previous_path.begins_with(LIVE_ROOT + "/") or not FileAccess.file_exists(retained_path): + return false + if ResourceLoader.has_cached(retained_path): + return false # Never displace a different retained generation. + # A later update in this same editor must not prune source files still + # referenced by an earlier generation's undo callbacks. + get_tree().root.set_meta("godot_ai_retained_update_scripts", true) + for script in _retired_scripts: + var retained_path := backup_root + script.resource_path.trim_prefix(LIVE_ROOT) + script.take_over_path(retained_path) + if script.resource_path != retained_path: + return false + return true + + +func _collect_cached_scripts(folder: String) -> bool: + var directory := DirAccess.open(folder) + if directory == null: + return false + for filename in directory.get_files(): + if not filename.ends_with(".gd"): + continue + var canonical := folder.path_join(filename) + if ResourceLoader.has_cached(canonical): + var script := ResourceLoader.load(canonical) as Script + if script == null or script.resource_path != canonical: + return false + _retired_scripts.append(script) + for child in directory.get_directories(): + if not _collect_cached_scripts(folder.path_join(child)): + return false + return true + + +func _find_plugin(node: Node) -> Node: + var script := node.get_script() as Script + if script != null and script.resource_path == LIVE_ROOT + "/plugin.gd": + return node + for child in node.get_children(): + var found := _find_plugin(child) + if found != null: + return found + return null + + +func _release_scripts() -> void: + if _installer != null: + _installer.call("release_lock") + _installer = null + _verifier = null + + +func _stop(message: String) -> void: + _phase = Phase.DONE + set_process(false) + if _filesystem != null and _filesystem.sources_changed.is_connected(_scan_completed): + _filesystem.sources_changed.disconnect(_scan_completed) + _release_scripts() + _record("activation_failed", message) + _show_failure(message) + + +func _record(status: String, message: String) -> void: + var file := FileAccess.open(RECEIPT_PATH, FileAccess.WRITE) + if file != null: + file.store_string(JSON.stringify({"status": status, "error": message, + "editor_pid": OS.get_process_id(), "from_version": _package.record.from_version, + "to_version": _package.record.to_version, "unix": Time.get_unix_time_from_system()})) + else: + push_error("MCP | could not write update activation evidence: %s" % error_string(FileAccess.get_open_error())) + + +func _show_failure(message: String) -> void: + push_error("MCP | %s" % message) + if DisplayServer.get_name() == "headless": + queue_free() + return + var dialog := AcceptDialog.new() + dialog.title = "Godot AI update needs attention" + dialog.dialog_text = message + "\nUpdate evidence: res://addons/.godot_ai_update/" + add_child(dialog) + dialog.confirmed.connect(_dismiss_failure) + dialog.canceled.connect(_dismiss_failure) + dialog.popup_centered(Vector2i(620, 220)) + + +func _dismiss_failure() -> void: + ## Godot may have left its shared progress dialog under the failure window. + ## Return it before freeing our subtree and leaving the editor's pointer dangling. + if is_inside_tree(): + for dialog in find_children("*", "ProgressDialog", true, false): + dialog.reparent(get_tree().root) + queue_free() diff --git a/addons/godot_ai/utils/update_activation_runner.gd.uid b/addons/godot_ai/utils/update_activation_runner.gd.uid new file mode 100644 index 0000000..fd3597f --- /dev/null +++ b/addons/godot_ai/utils/update_activation_runner.gd.uid @@ -0,0 +1 @@ +uid://mj7jpaq6wt27 diff --git a/addons/godot_ai/utils/update_installer.gd b/addons/godot_ai/utils/update_installer.gd new file mode 100644 index 0000000..a74c893 --- /dev/null +++ b/addons/godot_ai/utils/update_installer.gd @@ -0,0 +1,548 @@ +@tool +class_name McpUpdateInstaller +extends RefCounted + +## The in-editor half of the lean self-updater: stage a verified archive +## beside the live add-on, take the project-wide update lock, swap the two +## trees with two directory renames, and after the restart verify the live +## tree against the signed inventory, rolling back to the retained backup on +## any mismatch. See docs/self-update.md. +## +## Everything lives under `res://addons/.godot_ai_update/` (dot-prefixed so +## Godot's scanner ignores it; a `.gdignore` and a `.gitignore` are written +## on first use) plus the live root the caller passes in. No function writes +## anywhere else, no failure path deletes a backup, and every function fails +## closed with a specific error string instead of raising. + +const UPDATE_ROOT := "res://addons/.godot_ai_update" +const STAGE_DIR := UPDATE_ROOT + "/stage" +const STAGE_PLUGIN_ROOT := STAGE_DIR + "/addons/godot_ai" +const BACKUP_DIR := UPDATE_ROOT + "/backup" +const QUARANTINE_DIR := UPDATE_ROOT + "/quarantine" +const PENDING_FILE := UPDATE_ROOT + "/pending.json" +const LOCK_FILE := UPDATE_ROOT + "/lock.json" +const DEFAULT_LIVE_ROOT := "res://addons/godot_ai" +const ENABLED_PLUGINS_SETTING := "editor_plugins/enabled" + +const STATUS_SWAPPED := "swapped" +const STATUS_SUCCESS := "success" +const STATUS_ROLLED_BACK := "rolled_back" +const STATUS_REPAIR_REQUIRED := "repair_required" + +const PortResolver := preload("res://addons/godot_ai/utils/port_resolver.gd") +const ReleaseVerifier := preload("res://addons/godot_ai/utils/release_verifier.gd") + + +# ----- staging ---------------------------------------------------------- + + +## Extract `zip_path` into a fresh `stage/addons/godot_ai/` and prove the +## extracted tree hashes to exactly the signed inventory. Returns +## `{"ok", "error", "stage_root", "tree_sha256"}`; `stage_root` is the +## `res://` path of the staged plugin directory. +static func stage(zip_path: String, manifest: Dictionary) -> Dictionary: + var result := {"ok": false, "error": "", "stage_root": "", "tree_sha256": ""} + var validated := ReleaseVerifier.validate_manifest_dict(manifest) + if not bool(validated.ok): + result.error = "stage: %s" % str(validated.error) + return result + var normalised: Dictionary = validated.manifest + var rooted := ensure_root() + if not bool(rooted.ok): + result.error = "stage: %s" % str(rooted.error) + return result + var stage_abs := _absolute(STAGE_DIR) + if _exists(stage_abs): + var removed := remove_under_update_root(STAGE_DIR) + if not bool(removed.ok): + result.error = "stage: cannot clear previous stage: %s" % str(removed.error) + return result + var reader := ZIPReader.new() + var open_error := reader.open(_absolute(zip_path)) + if open_error != OK: + result.error = "stage: cannot open archive %s: %s" % [zip_path, error_string(open_error)] + return result + var rows: Array = normalised["inventory"] + var expected: Array = [] + for row in rows: + expected.append(str(row["path"])) + if Array(reader.get_files()) != expected: + reader.close() + result.error = "stage: archive entries do not match the signed inventory (run verify_archive for detail)" + return result + for row in rows: + var path := str(row["path"]) + var target := stage_abs.path_join(path) + var make_error := DirAccess.make_dir_recursive_absolute(target.get_base_dir()) + if make_error != OK: + reader.close() + result.error = "stage: cannot create %s: %s" % [target.get_base_dir(), error_string(make_error)] + return result + var write_error := _write_bytes(target, reader.read_file(path)) + if write_error != OK: + reader.close() + result.error = "stage: cannot write %s: %s" % [target, error_string(write_error)] + return result + reader.close() + var expected_hash := ReleaseVerifier.inventory_tree_hash(normalised) + var hashed := ReleaseVerifier.hash_tree(STAGE_PLUGIN_ROOT) + if not bool(hashed.ok): + remove_under_update_root(STAGE_DIR) + result.error = "stage: %s" % str(hashed.error) + return result + if expected_hash.is_empty() or str(hashed.tree_sha256) != expected_hash: + remove_under_update_root(STAGE_DIR) + result.error = "stage: staged tree hash %s differs from the signed inventory hash %s" % [ + str(hashed.tree_sha256), expected_hash, + ] + return result + result.stage_root = STAGE_PLUGIN_ROOT + result.tree_sha256 = str(hashed.tree_sha256) + result.ok = true + return result + + +## Remove `stage/` if present (for example when quiescence is refused after +## staging). Never touches backups, quarantine, or the live tree. +static func discard_stage() -> void: + if _exists(_absolute(STAGE_DIR)): + remove_under_update_root(STAGE_DIR) + + +# ----- lock ------------------------------------------------------------- + + +## Take `lock.json` for this editor. A lock whose recorded process is alive +## and carries a different fingerprint refuses; a dead or own lock is +## replaced. Returns `{"ok", "error"}`. +static func acquire_lock(pid: int, fingerprint: String) -> Dictionary: + var result := {"ok": false, "error": ""} + if pid <= 0 or fingerprint.is_empty(): + result.error = "lock: a positive pid and a non-empty fingerprint are required" + return result + var rooted := ensure_root() + if not bool(rooted.ok): + result.error = "lock: %s" % str(rooted.error) + return result + var existing := read_lock() + if not existing.is_empty(): + var other_pid := int(existing.get("pid", 0)) + var other_fingerprint := str(existing.get("fingerprint", "")) + if other_pid > 0 and other_fingerprint != fingerprint and PortResolver.pid_alive(other_pid): + result.error = "lock: update lock is held by live process %d (%s) since %d" % [ + other_pid, other_fingerprint, int(existing.get("started_unix", 0)), + ] + return result + var write_error := _write_json(LOCK_FILE, { + "pid": pid, + "fingerprint": fingerprint, + "started_unix": int(Time.get_unix_time_from_system()), + }) + if write_error != OK: + result.error = "lock: cannot write %s: %s" % [LOCK_FILE, error_string(write_error)] + return result + result.ok = true + return result + + +## Drop `lock.json` when it belongs to this process or to a dead one. A +## lock held by another live process is left in place. +static func release_lock() -> void: + var existing := read_lock() + if existing.is_empty(): + if FileAccess.file_exists(_absolute(LOCK_FILE)): + DirAccess.remove_absolute(_absolute(LOCK_FILE)) + return + var pid := int(existing.get("pid", 0)) + if pid == OS.get_process_id() or not PortResolver.pid_alive(pid): + DirAccess.remove_absolute(_absolute(LOCK_FILE)) + + +## The current lock record, or `{}` when there is none or it is unreadable. +static func read_lock() -> Dictionary: + return _read_json(LOCK_FILE) + + +# ----- swap ------------------------------------------------------------- + + +## Replace the live tree with the staged one using two directory renames: +## live -> `backup//`, then stage -> live. If the second rename +## fails the first is undone. On success `pending.json` holds every key of +## `record` verbatim plus `status: "swapped"`, `backup_root`, `live_root`, +## `expected_tree_sha256` and `swapped_unix`. `record` must carry +## `from_version`, `to_version`, `manifest_sha256`, `expected_tree_sha256` +## and `editor_nonce`. Refuses when `backup//` already exists: +## backups are pruned only after a verified success, so a leftover one means +## an unresolved earlier update. +static func swap(stage_root: String, live_root: String, record: Dictionary) -> Dictionary: + var result := {"ok": false, "error": ""} + for key in ["from_version", "to_version", "manifest_sha256", "expected_tree_sha256", "editor_nonce"]: + if not record.has(key) or not record[key] is String or str(record[key]).is_empty(): + result.error = "swap: record is missing %s" % key + return result + var from_version := str(record["from_version"]) + var to_version := str(record["to_version"]) + if not _is_safe_segment(from_version) or not _is_safe_segment(to_version): + result.error = "swap: versions must be plain path segments, got %s -> %s" % [from_version, to_version] + return result + if not _is_hex(str(record["expected_tree_sha256"]), 64): + result.error = "swap: expected_tree_sha256 must be 64 lowercase hex characters" + return result + var stage_abs := _absolute(stage_root) + var live_abs := _absolute(live_root) + if not DirAccess.dir_exists_absolute(stage_abs): + result.error = "swap: stage root does not exist: %s" % stage_abs + return result + if not DirAccess.dir_exists_absolute(live_abs): + result.error = "swap: live root does not exist: %s" % live_abs + return result + if stage_abs.simplify_path() == live_abs.simplify_path(): + result.error = "swap: stage and live roots are the same directory" + return result + var pending := read_pending() + if not pending.is_empty() and str(pending.get("status", "")) == STATUS_SWAPPED: + result.error = "swap: an earlier swap is still pending verification (%s)" % PENDING_FILE + return result + var rooted := ensure_root() + if not bool(rooted.ok): + result.error = "swap: %s" % str(rooted.error) + return result + var backup_root := BACKUP_DIR + "/" + from_version + var backup_abs := _absolute(backup_root) + if _exists(backup_abs): + result.error = "swap: backup already exists at %s; an earlier update was never resolved" % backup_root + return result + var make_error := DirAccess.make_dir_recursive_absolute(_absolute(BACKUP_DIR)) + if make_error != OK: + result.error = "swap: cannot create %s: %s" % [BACKUP_DIR, error_string(make_error)] + return result + var first := DirAccess.rename_absolute(live_abs, backup_abs) + if first != OK: + result.error = "swap: cannot move live tree %s to %s: %s" % [live_abs, backup_abs, error_string(first)] + return result + var second := DirAccess.rename_absolute(stage_abs, live_abs) + if second != OK: + var restore := DirAccess.rename_absolute(backup_abs, live_abs) + result.error = "swap: cannot move stage %s into %s: %s" % [stage_abs, live_abs, error_string(second)] + if restore != OK: + result.error += "; restoring the backup failed too (%s): live tree is at %s" % [ + error_string(restore), backup_abs, + ] + return result + var marker := record.duplicate(true) + marker["status"] = STATUS_SWAPPED + marker["backup_root"] = backup_root + marker["live_root"] = live_root + marker["expected_tree_sha256"] = str(record["expected_tree_sha256"]) + marker["swapped_unix"] = int(Time.get_unix_time_from_system()) + ## The editor that verifies the swap should be this same Godot. On macOS + ## the restart goes through LaunchServices, which has been seen to launch + ## another installed copy of Godot; recording the version lets the next + ## start explain that instead of silently refusing or verifying elsewhere. + marker["godot_version"] = str(Engine.get_version_info().get("string", "")) + var write_error := _write_json(PENDING_FILE, marker) + if write_error != OK: + var undo_stage := DirAccess.rename_absolute(live_abs, stage_abs) + var undo_backup := DirAccess.rename_absolute(backup_abs, live_abs) + result.error = "swap: cannot write %s: %s" % [PENDING_FILE, error_string(write_error)] + if undo_stage != OK or undo_backup != OK: + result.error += "; undoing the swap failed: new tree at %s, old tree at %s" % [ + live_abs if undo_stage != OK else stage_abs, backup_abs if undo_backup != OK else live_abs, + ] + return result + ## The staged tree now lives; only its empty scaffold is left behind. + discard_stage() + result.ok = true + return result + + +# ----- pending marker --------------------------------------------------- + + +## The `pending.json` record, `{}` when there is none. An unreadable marker +## is reported as `status: "repair_required"` rather than ignored. +static func read_pending() -> Dictionary: + var absolute := _absolute(PENDING_FILE) + if not FileAccess.file_exists(absolute): + return {} + var marker := _read_json(PENDING_FILE) + if marker.is_empty(): + return { + "status": STATUS_REPAIR_REQUIRED, + "error": "pending marker %s exists but is not a JSON object; inspect it by hand" % PENDING_FILE, + } + return marker + + +## Record in the success marker that the plugin repinned client configuration, +## so later starts do not repeat the migration. No-op unless the marker +## records success. +static func record_clients_migrated() -> Error: + var marker := read_pending() + if str(marker.get("status", "")) != STATUS_SUCCESS: + return ERR_UNAVAILABLE + marker["clients_migrated"] = true + return _write_json(PENDING_FILE, marker) + + +## Remove `pending.json`. +static func clear_pending() -> void: + var absolute := _absolute(PENDING_FILE) + if FileAccess.file_exists(absolute): + DirAccess.remove_absolute(absolute) + + +## After the restart: settle a `swapped` marker. Hash the live tree and +## compare it to `expected_tree_sha256`. Equal: the marker becomes +## `success`. Different: the live tree is renamed to +## `quarantine//`, the backup is renamed back into place, and the +## marker becomes `rolled_back` with the reason. No backup: the marker becomes +## `repair_required` naming the exact paths and the live tree is left alone. +## Nothing is ever deleted. A marker in any other status is returned +## unchanged, and `{}` means there is nothing pending. Every marker key is +## preserved verbatim. +static func verify_after_restart(live_root: String) -> Dictionary: + var marker := read_pending() + if marker.is_empty(): + return {} + if str(marker.get("status", "")) != STATUS_SWAPPED: + ## A success marker whose client migration already ran is the durable + ## record of the last update, not work for this start. + if str(marker.get("status", "")) == STATUS_SUCCESS and bool(marker.get("clients_migrated", false)): + return {} + return marker + var live_abs := _absolute(live_root) + var expected := str(marker.get("expected_tree_sha256", "")) + var hashed := ReleaseVerifier.hash_tree(live_abs) + if bool(hashed.ok) and _is_hex(expected, 64) and str(hashed.tree_sha256) == expected: + marker["status"] = STATUS_SUCCESS + marker["error"] = "" + marker["verified_unix"] = int(Time.get_unix_time_from_system()) + _write_json(PENDING_FILE, marker) + return marker + var reason := "" + if not bool(hashed.ok): + reason = "live tree could not be hashed: %s" % str(hashed.error) + elif not _is_hex(expected, 64): + reason = "pending marker carries no usable expected_tree_sha256" + else: + reason = "live tree hash %s does not match the expected %s" % [str(hashed.tree_sha256), expected] + var backup_root := str(marker.get("backup_root", "")) + var backup_abs := _absolute(backup_root) if not backup_root.is_empty() else "" + if backup_abs.is_empty() or not DirAccess.dir_exists_absolute(backup_abs): + return _settle_repair(marker, "%s; no backup exists at %s to restore, live tree left at %s" % [ + reason, backup_abs if not backup_abs.is_empty() else "(unrecorded)", live_abs, + ]) + var to_version := str(marker.get("to_version", "")) + var quarantine_root := QUARANTINE_DIR + "/" + (to_version if _is_safe_segment(to_version) else "unknown") + var quarantine_abs := _absolute(quarantine_root) + if _exists(quarantine_abs): + return _settle_repair(marker, "%s; quarantine already exists at %s, live tree left at %s, backup at %s" % [ + reason, quarantine_abs, live_abs, backup_abs, + ]) + if DirAccess.dir_exists_absolute(live_abs): + var make_error := DirAccess.make_dir_recursive_absolute(_absolute(QUARANTINE_DIR)) + if make_error != OK: + return _settle_repair(marker, "%s; cannot create %s (%s), live tree left at %s, backup at %s" % [ + reason, QUARANTINE_DIR, error_string(make_error), live_abs, backup_abs, + ]) + var quarantine_error := DirAccess.rename_absolute(live_abs, quarantine_abs) + if quarantine_error != OK: + return _settle_repair(marker, "%s; cannot move live tree %s to %s (%s), backup at %s" % [ + reason, live_abs, quarantine_abs, error_string(quarantine_error), backup_abs, + ]) + var restore_error := DirAccess.rename_absolute(backup_abs, live_abs) + if restore_error != OK: + var undo := DirAccess.rename_absolute(quarantine_abs, live_abs) + return _settle_repair(marker, "%s; cannot restore backup %s to %s (%s); new tree is at %s" % [ + reason, backup_abs, live_abs, error_string(restore_error), + live_abs if undo == OK else quarantine_abs, + ]) + marker["status"] = STATUS_ROLLED_BACK + marker["error"] = reason + marker["quarantine_root"] = quarantine_root + marker["verified_unix"] = int(Time.get_unix_time_from_system()) + _write_json(PENDING_FILE, marker) + return marker + + +# ----- retention -------------------------------------------------------- + + +## Remove every `backup//` except `keep_version`. Call only after a +## verified success. +static func prune_backups(keep_version: String) -> void: + var backups := DirAccess.open(_absolute(BACKUP_DIR)) + if backups == null: + return + backups.include_hidden = true + backups.include_navigational = false + for name in backups.get_directories(): + if name != keep_version: + remove_under_update_root(BACKUP_DIR + "/" + name) + + +# ----- restart ---------------------------------------------------------- + + +## Add `plugin_cfg` to `editor_plugins/enabled` and save project.godot so the +## next editor start loads the add-on, without enabling it in this process. +static func persist_next_start_enabled(plugin_cfg: String) -> Error: + if plugin_cfg.is_empty(): + return ERR_INVALID_PARAMETER + var enabled := PackedStringArray() + if ProjectSettings.has_setting(ENABLED_PLUGINS_SETTING): + var current: Variant = ProjectSettings.get_setting(ENABLED_PLUGINS_SETTING) + if current is PackedStringArray: + enabled = current + elif current is Array: + for item in current: + enabled.append(str(item)) + if not enabled.has(plugin_cfg): + enabled.append(plugin_cfg) + ProjectSettings.set_setting(ENABLED_PLUGINS_SETTING, enabled) + return ProjectSettings.save() + + +## Restart the editor, saving open scenes first. +static func request_restart() -> void: + if Engine.is_editor_hint(): + EditorInterface.restart_editor(true) + + +# ----- update root ------------------------------------------------------ + + +## Create `res://addons/.godot_ai_update/` with its `.gdignore` and +## `.gitignore` (`*`). Returns `{"ok", "error"}`. +static func ensure_root() -> Dictionary: + var result := {"ok": false, "error": ""} + var root_abs := _absolute(UPDATE_ROOT) + var make_error := DirAccess.make_dir_recursive_absolute(root_abs) + if make_error != OK: + result.error = "cannot create %s: %s" % [UPDATE_ROOT, error_string(make_error)] + return result + for marker in [[".gdignore", ""], [".gitignore", "*\n"]]: + var path := root_abs.path_join(str(marker[0])) + if FileAccess.file_exists(path): + continue + var write_error := _write_bytes(path, str(marker[1]).to_utf8_buffer()) + if write_error != OK: + result.error = "cannot write %s: %s" % [path, error_string(write_error)] + return result + result.ok = true + return result + + +## Recursively remove a file or directory, refusing anything outside the +## update root. Returns `{"ok", "error"}`. +static func remove_under_update_root(path: String) -> Dictionary: + var result := {"ok": false, "error": ""} + var absolute := _absolute(path).simplify_path() + var root_abs := _absolute(UPDATE_ROOT).simplify_path() + if absolute == root_abs or not absolute.begins_with(root_abs + "/"): + result.error = "refusing to remove %s: outside %s" % [absolute, UPDATE_ROOT] + return result + var remove_error := _remove_tree(absolute) + if remove_error != OK: + result.error = "cannot remove %s: %s" % [absolute, error_string(remove_error)] + return result + result.ok = true + return result + + +# ----- helpers ---------------------------------------------------------- + + +static func _settle_repair(marker: Dictionary, error: String) -> Dictionary: + marker["status"] = STATUS_REPAIR_REQUIRED + marker["error"] = error + marker["verified_unix"] = int(Time.get_unix_time_from_system()) + _write_json(PENDING_FILE, marker) + return marker + + +static func _remove_tree(absolute: String) -> Error: + var parent := DirAccess.open(absolute.get_base_dir()) + if parent == null: + return DirAccess.get_open_error() + if not DirAccess.dir_exists_absolute(absolute) or parent.is_link(absolute): + return DirAccess.remove_absolute(absolute) + var directory := DirAccess.open(absolute) + if directory == null: + return DirAccess.get_open_error() + directory.include_hidden = true + directory.include_navigational = false + for name in directory.get_files(): + var file_error := DirAccess.remove_absolute(absolute.path_join(name)) + if file_error != OK: + return file_error + for name in directory.get_directories(): + var child_error := _remove_tree(absolute.path_join(name)) + if child_error != OK: + return child_error + return DirAccess.remove_absolute(absolute) + + +static func _write_bytes(absolute: String, data: PackedByteArray) -> Error: + var file := FileAccess.open(absolute, FileAccess.WRITE) + if file == null: + return FileAccess.get_open_error() + file.store_buffer(data) + var error := file.get_error() + file.close() + return error + + +static func _write_json(path: String, data: Dictionary) -> Error: + return _write_bytes(_absolute(path), (JSON.stringify(data, "\t") + "\n").to_utf8_buffer()) + + +static func _read_json(path: String) -> Dictionary: + var absolute := _absolute(path) + if not FileAccess.file_exists(absolute): + return {} + var bytes := FileAccess.get_file_as_bytes(absolute) + if bytes.is_empty() or not ReleaseVerifier.is_valid_utf8(bytes): + return {} + var parsed: Variant = JSON.parse_string(bytes.get_string_from_utf8()) + if not parsed is Dictionary: + return {} + return parsed + + +static func _exists(absolute: String) -> bool: + return DirAccess.dir_exists_absolute(absolute) or FileAccess.file_exists(absolute) + + +static func _is_safe_segment(name: String) -> bool: + if name.is_empty() or name.length() > 64 or name == "." or name == ".." or name.begins_with("."): + return false + for index in name.length(): + var code := name.unicode_at(index) + var ok := ( + (code >= 48 and code <= 57) + or (code >= 65 and code <= 90) + or (code >= 97 and code <= 122) + or code == 45 or code == 46 or code == 95 + ) + if not ok: + return false + return true + + +static func _is_hex(text: String, length: int) -> bool: + if text.length() != length: + return false + for index in text.length(): + var code := text.unicode_at(index) + if not ((code >= 48 and code <= 57) or (code >= 97 and code <= 102)): + return false + return true + + +static func _absolute(path: String) -> String: + if path.begins_with("res://") or path.begins_with("user://"): + return ProjectSettings.globalize_path(path) + return path diff --git a/addons/godot_ai/utils/update_installer.gd.uid b/addons/godot_ai/utils/update_installer.gd.uid new file mode 100644 index 0000000..337a3af --- /dev/null +++ b/addons/godot_ai/utils/update_installer.gd.uid @@ -0,0 +1 @@ +uid://c30k72xyjmfrp diff --git a/addons/godot_ai/utils/update_manager.gd b/addons/godot_ai/utils/update_manager.gd deleted file mode 100644 index 795d7ac..0000000 --- a/addons/godot_ai/utils/update_manager.gd +++ /dev/null @@ -1,775 +0,0 @@ -@tool -class_name McpUpdateManager -extends Node - -## Self-update manager for pre-runner work. Owns release checks, HTTP ZIP -## download, the install-in-flight gate, and install state signals back to -## the dock. Once `_install_zip()` calls -## `plugin.gd::install_downloaded_update(...)`, ownership transfers to -## `update_reload_runner.gd`, which owns extract, scan, plugin re-enable, -## and detached-dock cleanup. -## -## The dock owns banner rendering and forwards button clicks. The split -## exists because the dock script is one of the files overwritten on disk -## during install — keeping pipeline state on a separate Node lets the dock -## tear down cleanly without losing the in-flight gate that other dock spawn -## paths consult. -## -## `class_name McpUpdateManager` is retained because it shipped in a -## published release. If this class is ever retired, follow CLAUDE.md's -## never-delete-published-class_name shim policy instead of deleting the -## declaration. -## -## `_plugin` and `_dock` are deliberately untyped: the same self-update -## window that overwrites this script also overwrites the dock and plugin -## scripts, and a static-typed reference into a script being hot-reloaded -## crashes inside `GDScriptFunction::call`. `server_lifecycle.gd` follows -## the same convention. - -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 UPDATE_TEMP_DIR := "user://godot_ai_update/" -const UPDATE_TEMP_ZIP := "user://godot_ai_update/update.zip" -const ClientConfigurator := preload("res://addons/godot_ai/client_configurator.gd") - -## RSA-4096 public key for release-signature verification (#687). The paired -## private key exists only in the GitHub Actions secret RELEASE_SIGNING_KEY_PEM -## (plus the maintainer's offline backup) — deliberately outside the repo -## token's scope, because the threat model is release-asset substitution by a -## leaked token or compromised workflow, and a token that can rewrite assets -## still cannot read secrets. Rotation requires shipping a new plugin release -## embedding the new key (and bumping SIGNING_REQUIRED_FROM_VERSION past the -## last release signed with the old one). -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----- -""" - -## Every release at or above this version ships a signed sidecar -## (release.yml hard-fails without the signing secret). At or above it, a -## missing `.sha256.sig` asset is treated as tampering — an attacker who can -## rewrite release assets could otherwise just strip the signature to skip -## verification. Below it (releases published before signing existed), the -## legacy checksum-only path still installs. -const SIGNING_REQUIRED_FROM_VERSION := "2.9.3" - -## Host -> required path prefix for self-update downloads (ZIP and checksum -## sidecar). The URLs are taken verbatim from the GitHub Releases API's -## `browser_download_url`, so before fetching we pin them to https on a -## GitHub-owned host AND to this repo's release-asset path (#599) — a -## tampered or unexpected API response can't point the in-editor updater at -## an arbitrary origin, nor at a release asset of a *different* repo on a -## trusted host. -## -## In practice `browser_download_url` is always the -## `https://github.com/hi-godot/godot-ai/releases/download//` -## shape; HTTPRequest then follows the github.com -> *.githubusercontent.com -## redirect internally (this guard validates the entry point, not each hop). -## The CDN hosts are kept as defense-in-depth should the API ever hand back -## a direct CDN URL — their object keys carry the repo *id*, not the repo -## name, so the tightest checkable prefix there is the release-asset key -## namespace. -const _TRUSTED_DOWNLOAD_PATH_PREFIXES := { - "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-", -} - -## Emitted after `check_for_updates()` resolves a newer remote version. -## Payload mirrors the Dictionary returned by `parse_releases_response`: -## {has_update, version, forced, label_text, download_url} -signal update_check_completed(result: Dictionary) - -## Emitted at every UI-relevant step of the install pipeline. Payload -## keys are all optional and apply on top of the current banner state: -## label_text: String ## banner label override -## button_text: String ## update button text override -## button_disabled: bool ## update button disabled state -## banner_visible: bool ## banner visibility override -## outcome: String ## "success" -> dock paints green -signal install_state_changed(state: Dictionary) - -var _plugin -var _dock - -var _http_request: HTTPRequest -var _download_request: HTTPRequest -var _verify_request: HTTPRequest -var _signature_request: HTTPRequest -var _latest_download_url: String = "" -## URL of the `godot-ai-plugin.zip.sha256` sidecar asset. Used to verify the -## downloaded archive's integrity before extract (#523). Verification is -## mandatory (#599): when a release ships no sidecar this stays empty and -## `_verify_then_install` refuses the install. -var _latest_checksum_url: String = "" -## URL of the `godot-ai-plugin.zip.sha256.sig` signature asset (#687). Empty -## on releases published before signing existed; `_verify_then_install` -## refuses an empty URL once the remote version is inside the signing era -## (see SIGNING_REQUIRED_FROM_VERSION). -var _latest_signature_url: String = "" -## Remote version from the last update check — drives the -## signature-required compat gate in `_verify_then_install`. -var _latest_remote_version: String = "" -## Sidecar bytes + parsed digest held between the checksum download and the -## signature verdict, so the signature is checked against exactly the bytes -## the digest was parsed from. -var _pending_sidecar_body := PackedByteArray() -var _pending_expected_digest: String = "" - -## Set for the duration of `_install_zip` — extract-overwrite of plugin -## scripts on disk would crash any worker mid-`GDScriptFunction::call` -## (confirmed via SIGABRT in the dock's refresh worker). Dock spawn paths -## consult this via `is_install_in_flight()`; in-flight workers are -## drained before any disk write. -var _install_in_flight: bool = false - - -# ---- Setup ------------------------------------------------------------- - -func setup(plugin, dock) -> void: - _plugin = plugin - _dock = dock - - -# ---- Public API --------------------------------------------------------- - -## Kick off the GitHub Releases API check. No-ops in dev checkouts — -## `addons/godot_ai/` is a symlink into canonical `plugin/` source there, -## and an extract would clobber tracked files (#116). `is_dev_checkout()` -## honours the mode override (EditorSetting `godot_ai/mode_override` > -## `GODOT_AI_MODE` env), so -## testers can force `user` to exercise the AssetLib flow from a dev tree; -## `_install_zip` still gates on the physical symlink check so a forced- -## user mode can never clobber source. -func check_for_updates() -> void: - if ClientConfigurator.is_dev_checkout(): - return - if _http_request == null: - _http_request = HTTPRequest.new() - _http_request.request_completed.connect(_on_update_check_completed) - add_child(_http_request) - _http_request.request(RELEASES_URL, ["Accept: application/vnd.github+json"]) - - -## Cancel any in-flight check so a follow-up check_for_updates() can't -## hit ERR_BUSY on the shared HTTPRequest. No current dock caller — the -## mode-override dropdown that used it was removed in #408; kept as -## published API of the update flow. -func cancel_check() -> void: - if _http_request != null: - _http_request.cancel_request() - - -## Reset the cached download/checksum URLs so a fresh check paints over -## a clean banner. No current production caller — the mode-override -## dropdown that used it was removed in #408; kept for tests and any -## future re-check path. -func clear_pending_download() -> void: - _latest_download_url = "" - _latest_checksum_url = "" - _latest_signature_url = "" - _latest_remote_version = "" - _pending_sidecar_body = PackedByteArray() - _pending_expected_digest = "" - - -## True when the running Godot is within the supported self-update floor. -## Godot < 4.5 must not be offered a one-click update to a release whose -## always-loaded scripts depend on 4.5 APIs/classes. -## Guards `major` too so a future Godot 5.x (minor 0) isn't misclassified. -func _can_self_update() -> bool: - var v := Engine.get_version_info() - return _version_can_self_update(int(v.get("major", 0)), int(v.get("minor", 0))) - - -## Pure version predicate, split out so it's testable without faking the -## running engine. In-editor self-update needs Godot >= 4.5. -static func _version_can_self_update(major: int, minor: int) -> bool: - return major > 4 or (major == 4 and minor >= 5) - - -## Banner guidance for engines below the support floor. Shown up-front at -## check time so those users do not install an incompatible latest release. -static func _manual_update_label(version: String) -> String: - var release_noun := "release" - var suffix := "" - if not version.is_empty(): - release_noun = "version" - suffix = " (latest: v%s)" % version - return ( - "This is the last Godot AI %s for this Godot%s. " % [release_noun, suffix] - + "Upgrade to Godot 4.5+ to keep receiving updates." - ) - -## Driven by the dock's Update button. On Godot < 4.5 (see _can_self_update) -## the in-editor install is disabled so users cannot install an incompatible -## latest release. With no resolved download URL, falls back to opening the -## release page. Otherwise kicks off the download -> extract -> reload pipeline. -func start_install() -> void: - if not _can_self_update(): - install_state_changed.emit({ - "button_text": "Upgrade Godot", - "button_disabled": true, - "label_text": _manual_update_label(""), - "banner_visible": true, - }) - return - - if _latest_download_url.is_empty(): - OS.shell_open(RELEASES_PAGE) - return - - ## Pin the resolved asset URL to https on a GitHub host AND to this - ## repo's release-asset path before fetching (#523, #599). Fall back to - ## the release page (a user-driven browser download) rather than pulling - ## an executable plugin payload from an unexpected origin. - if not _is_trusted_download_url(_latest_download_url): - push_error( - "MCP | refusing self-update download from untrusted URL: %s" - % _latest_download_url - ) - OS.shell_open(RELEASES_PAGE) - install_state_changed.emit({ - "button_text": "Update via download page", - "button_disabled": false, - }) - return - - install_state_changed.emit({ - "button_text": "Downloading...", - "button_disabled": true, - }) - - ## Warm the uv cache for the NEW server version while the plugin zip - ## downloads (see prewarm_server_package): the post-update walk must win - ## a port bind race against attach bridges respawning the cached OLD - ## version, and a cold uvx resolve loses that race every time. uvx is - ## almost always cached in the CliFinder by now (the dock's setup probe); - ## a cold lookup is the same bounded main-thread cost as - ## `_reprobe_uv_if_negative`, on the same rare click-driven path. - ClientConfigurator.prewarm_server_package(_latest_remote_version) - - if _download_request != null: - _download_request.queue_free() - _download_request = HTTPRequest.new() - var global_zip := ProjectSettings.globalize_path(UPDATE_TEMP_ZIP) - var global_dir := ProjectSettings.globalize_path(UPDATE_TEMP_DIR) - DirAccess.make_dir_recursive_absolute(global_dir) - _download_request.download_file = global_zip - _download_request.max_redirects = 10 - _download_request.request_completed.connect(_on_download_completed) - add_child(_download_request) - var err := _download_request.request(_latest_download_url) - if err != OK: - ## `request_completed` never fires when `request()` itself errors, - ## so cleanup (queue_free + null + drop the staged zip) has to land - ## inline — otherwise the HTTPRequest stays parented under the - ## manager until the next click. - _download_request.queue_free() - _download_request = null - DirAccess.remove_absolute(global_zip) - install_state_changed.emit({ - "button_text": "Request failed", - "button_disabled": false, - }) - -## Consulted by the dock's spawn paths (focus-in refresh, manual button, -## deferred initial refresh) — true while plugin scripts are being -## overwritten. A worker mid-`GDScriptFunction::call` into a half- -## overwritten script SIGABRTs the editor. -func is_install_in_flight() -> bool: - return _install_in_flight - - -# ---- Releases-API parse (pure, testable) ------------------------------- - -## Parses the GitHub Releases API JSON response. Returns: -## has_update: bool ## true if remote tag > local version -## version: String ## remote tag minus leading "v" -## forced: bool ## mode_override() == "user" (banner-only hint) -## label_text: String ## "Update available: vX.Y.Z" + " (forced)" -## download_url: String ## matching `godot-ai-plugin.zip` asset URL -## checksum_url: String ## `godot-ai-plugin.zip.sha256` asset URL ("" if absent) -## signature_url: String ## `godot-ai-plugin.zip.sha256.sig` asset URL ("" if absent) -## -## Static so tests drive it without instancing the manager. -static func parse_releases_response( - result: int, response_code: int, body: PackedByteArray -) -> Dictionary: - var out := { - "has_update": false, - "version": "", - "forced": false, - "label_text": "", - "download_url": "", - "checksum_url": "", - "signature_url": "", - } - if result != HTTPRequest.RESULT_SUCCESS or response_code != 200: - return out - var parsed = JSON.parse_string(body.get_string_from_utf8()) - if parsed == null or not (parsed is Dictionary): - return out - var json: Dictionary = parsed - var tag: String = String(json.get("tag_name", "")) - if tag.is_empty(): - return out - var remote_version := tag.trim_prefix("v") - var local_version := ClientConfigurator.get_plugin_version() - if not _is_newer(remote_version, local_version): - return out - - var url := "" - var checksum_url := "" - var signature_url := "" - var assets: Array = json.get("assets", []) - for asset in assets: - var asset_dict: Dictionary = asset - var asset_name := String(asset_dict.get("name", "")) - if asset_name == "godot-ai-plugin.zip": - url = String(asset_dict.get("browser_download_url", "")) - elif asset_name == "godot-ai-plugin.zip.sha256": - checksum_url = String(asset_dict.get("browser_download_url", "")) - elif asset_name == "godot-ai-plugin.zip.sha256.sig": - signature_url = String(asset_dict.get("browser_download_url", "")) - - var forced := ClientConfigurator.mode_override() == "user" - var label_text := "Update available: v%s" % remote_version - if forced: - ## Forced-user mode (EditorSetting or env) is the only way the banner - ## lights up in a dev tree; suffix so the operator notices. - label_text += " (forced)" - - out["has_update"] = true - out["version"] = remote_version - out["forced"] = forced - out["label_text"] = label_text - out["download_url"] = url - out["checksum_url"] = checksum_url - out["signature_url"] = signature_url - return out - - -## True only for an `https://` URL whose host is a key of -## `_TRUSTED_DOWNLOAD_PATH_PREFIXES` AND whose path starts with that host's -## required prefix — trusted host alone is not enough; the URL must be a -## hi-godot/godot-ai release asset (#599). Parses the authority by hand -## (GDScript has no URL parser): strips userinfo via the LAST `@` so a spoof -## like `https://github.com@evil.com/...` resolves to `evil.com` (rejected), -## and strips any `:port`. The path is compared case-sensitively (GitHub -## release paths are case-sensitive). Static so the guard is unit-testable -## without instancing the manager. -static func _is_trusted_download_url(url: String) -> bool: - const SCHEME := "https://" - if not url.begins_with(SCHEME): - return false - if url.find("\\") >= 0: - return false - var rest := url.substr(SCHEME.length()) - var authority := rest - var path := "" - var slash := rest.find("/") - if slash >= 0: - authority = rest.substr(0, slash) - path = rest.substr(slash) - ## Host is everything after the LAST '@' (userinfo precedes it). - var at := authority.rfind("@") - if at >= 0: - authority = authority.substr(at + 1) - var colon := authority.find(":") - if colon >= 0: - authority = authority.substr(0, colon) - var host := authority.to_lower() - if not _TRUSTED_DOWNLOAD_PATH_PREFIXES.has(host): - return false - ## Scope the checks below to the path proper (#713): direct CDN asset - ## URLs carry signed query params (X-Amz-Credential=...%2F...) whose - ## legitimate %2F tokens made every CDN prefix unreachable when the - ## needle scan covered the query string. Routing is decided by the - ## path, so the query is safe to ignore. - var qmark := path.find("?") - if qmark >= 0: - path = path.substr(0, qmark) - ## Reject dot-segments (and their percent-encoded forms) anywhere in the - ## path: "/hi-godot/godot-ai/releases/download/../../evil/..." passes a - ## raw string-prefix test but normalizes server-side to a different repo, - ## defeating the scoping (#599 review). Also reject percent-encoded - ## slashes, which some servers decode before routing. - var lower_path := path.to_lower() - for needle in ["/../", "/..", "%2e", "%2f", "%5c"]: - if lower_path.contains(needle): - return false - return path.begins_with(String(_TRUSTED_DOWNLOAD_PATH_PREFIXES[host])) - - -static func _is_newer(remote: String, local: String) -> bool: - var r := remote.split(".") - var l := local.split(".") - for i in range(max(r.size(), l.size())): - var rv := int(r[i]) if i < r.size() else 0 - var lv := int(l[i]) if i < l.size() else 0 - if rv > lv: - return true - if rv < lv: - return false - return false - - -# ---- HTTPRequest callbacks (instance-side) ----------------------------- - -func _on_update_check_completed( - result: int, - response_code: int, - _headers: PackedStringArray, - body: PackedByteArray -) -> void: - var parsed := parse_releases_response(result, response_code, body) - if not bool(parsed.get("has_update", false)): - return - if not _can_self_update(): - install_state_changed.emit({ - "button_text": "Upgrade Godot", - "button_disabled": true, - "label_text": _manual_update_label(String(parsed.get("version", ""))), - "banner_visible": true, - }) - return - _latest_download_url = String(parsed.get("download_url", "")) - _latest_checksum_url = String(parsed.get("checksum_url", "")) - _latest_signature_url = String(parsed.get("signature_url", "")) - _latest_remote_version = String(parsed.get("version", "")) - update_check_completed.emit(parsed) - - -func _on_download_completed( - result: int, - response_code: int, - _headers: PackedStringArray, - _body: PackedByteArray -) -> void: - if _download_request != null: - _download_request.queue_free() - _download_request = null - - if result != HTTPRequest.RESULT_SUCCESS or response_code != 200: - print("MCP | update download failed: result=%d code=%d" % [result, response_code]) - ## Failure parity with _fail_verification (#713): HTTPRequest's - ## download_file mode leaves whatever partial/error bytes it wrote - ## staged at UPDATE_TEMP_ZIP — drop them so no later step can ever - ## pick up a half-downloaded archive. - DirAccess.remove_absolute(ProjectSettings.globalize_path(UPDATE_TEMP_ZIP)) - install_state_changed.emit({ - "button_text": "Download failed (%d)" % response_code, - "button_disabled": false, - }) - return - - # Deferred so the HTTPRequest callback returns before the next step starts. - _verify_then_install.call_deferred() - - -# ---- Integrity verification (#523, #599, #687) -------------------------- - -## Gate the extract on (1) an RSA signature over the checksum sidecar and -## (2) a SHA-256 match of the archive against that sidecar. TLS + host -## pinning constrain where the bytes came from; the digest verifies the -## bytes themselves (in-transit corruption, single-object substitution); -## the signature verifies the digest's *provenance*. Both `download_url` -## and `checksum_url` come from the same GitHub Releases API response over -## the same channel, so anyone able to modify the release's assets (leaked -## repo token, compromised release workflow) can regenerate the sidecar to -## match a tampered zip — but cannot forge the `.sha256.sig` signature, -## whose private key lives only in an Actions secret outside the repo -## token's scope (#687). -## -## Verification is MANDATORY (#599): no `.sha256` sidecar — mistake or -## tamper — refuses to install. The signature is mandatory for every -## release at or above SIGNING_REQUIRED_FROM_VERSION: a missing signature -## there is a strip-attack signal, not a compat case, and hard-fails. Only -## releases predating signing take the legacy checksum-only path. -func _verify_then_install() -> void: - _pending_sidecar_body = PackedByteArray() - _pending_expected_digest = "" - - if _latest_checksum_url.is_empty(): - _fail_verification( - "release published no godot-ai-plugin.zip.sha256 sidecar; " - + "refusing unverified install (#599)" - ) - return - - ## A present-but-untrusted checksum URL is a tamper signal, not a - ## backward-compat case — refuse rather than silently skip. Trusted - ## means a GitHub host AND this repo's release-asset path (#599). - if not _is_trusted_download_url(_latest_checksum_url): - _fail_verification("checksum URL is not a trusted hi-godot/godot-ai release asset") - return - - if _latest_signature_url.is_empty(): - if _signature_required(_latest_remote_version): - _fail_verification( - "release v%s ships no godot-ai-plugin.zip.sha256.sig signature. " - % _latest_remote_version - + "Every release from v%s on is signed" % SIGNING_REQUIRED_FROM_VERSION - + " — a missing signature means a stripped or tampered release (#687)" - ) - return - print( - "MCP | self-update: release v%s predates signing; " % _latest_remote_version - + "using legacy checksum-only verification (#687)" - ) - elif not _is_trusted_download_url(_latest_signature_url): - _fail_verification("signature URL is not a trusted hi-godot/godot-ai release asset") - return - - install_state_changed.emit({"button_text": "Verifying..."}) - if _verify_request != null: - _verify_request.queue_free() - _verify_request = HTTPRequest.new() - _verify_request.max_redirects = 10 - _verify_request.request_completed.connect(_on_checksum_completed) - add_child(_verify_request) - var err := _verify_request.request(_latest_checksum_url) - if err != OK: - _verify_request.queue_free() - _verify_request = null - _fail_verification("could not request checksum (error %d)" % err) - - -func _on_checksum_completed( - result: int, - response_code: int, - _headers: PackedStringArray, - body: PackedByteArray -) -> void: - if _verify_request != null: - _verify_request.queue_free() - _verify_request = null - - if result != HTTPRequest.RESULT_SUCCESS or response_code != 200: - _fail_verification("checksum download failed (result=%d code=%d)" % [result, response_code]) - return - - var expected := _parse_sha256_digest(body.get_string_from_utf8()) - if expected.is_empty(): - _fail_verification("malformed checksum file") - return - - ## Signature verification (when armed) runs over the exact sidecar bytes - ## the digest was parsed from — hold both until the signature verdict. - if not _latest_signature_url.is_empty(): - _pending_sidecar_body = body - _pending_expected_digest = expected - _fetch_signature() - return - - ## Legacy pre-signing release: `_verify_then_install` already gated this - ## on the remote version predating SIGNING_REQUIRED_FROM_VERSION. - _finish_digest_check_and_install(expected) - - -## Download the `.sha256.sig` release asset; `_on_signature_completed` -## verifies it over the held sidecar bytes before the digest is trusted. -func _fetch_signature() -> void: - if _signature_request != null: - _signature_request.queue_free() - _signature_request = HTTPRequest.new() - _signature_request.max_redirects = 10 - _signature_request.request_completed.connect(_on_signature_completed) - add_child(_signature_request) - var err := _signature_request.request(_latest_signature_url) - if err != OK: - _signature_request.queue_free() - _signature_request = null - _fail_verification("could not request signature (error %d)" % err) - - -func _on_signature_completed( - result: int, - response_code: int, - _headers: PackedStringArray, - body: PackedByteArray -) -> void: - if _signature_request != null: - _signature_request.queue_free() - _signature_request = null - - if result != HTTPRequest.RESULT_SUCCESS or response_code != 200: - _fail_verification( - "signature download failed (result=%d code=%d)" % [result, response_code] - ) - return - - if not _verify_sidecar_signature(RELEASE_SIGNING_PUBLIC_KEY_PEM, _pending_sidecar_body, body): - _fail_verification( - "release signature does not verify against the embedded public key — " - + "the checksum sidecar was not produced by the release pipeline (#687)" - ) - return - - print("MCP | self-update release signature verified (rsa-4096/sha256)") - _finish_digest_check_and_install(_pending_expected_digest) - - -## Final gate shared by the signed and legacy paths: the staged archive's -## SHA-256 must match the (now-trusted) sidecar digest before extract. -func _finish_digest_check_and_install(expected: String) -> void: - _pending_sidecar_body = PackedByteArray() - _pending_expected_digest = "" - - var zip_path := ProjectSettings.globalize_path(UPDATE_TEMP_ZIP) - var actual := FileAccess.get_sha256(zip_path).to_lower() - if actual.is_empty(): - _fail_verification("could not hash the downloaded archive") - return - if actual != expected: - _fail_verification( - "checksum mismatch (expected %s…, got %s…)" - % [expected.substr(0, 12), actual.substr(0, 12)] - ) - return - - print("MCP | self-update checksum verified (sha256 %s)" % actual) - install_state_changed.emit({"button_text": "Installing..."}) - _install_zip.call_deferred() - - -## True when `remote_version` falls inside the signing era — every release -## at or above SIGNING_REQUIRED_FROM_VERSION ships a signed sidecar, so a -## missing signature there must hard-fail rather than fall back to the -## legacy checksum-only path. An empty/unknown version fails closed. Static -## so it's unit-testable. -static func _signature_required(remote_version: String) -> bool: - if remote_version.strip_edges().is_empty(): - return true - return not _is_newer(SIGNING_REQUIRED_FROM_VERSION, remote_version) - - -## PKCS#1 v1.5 RSA verification of `signature` over SHA-256(`sidecar`) — -## the exact output of release.yml's `openssl dgst -sha256 -sign`. Takes -## the PEM as a parameter (rather than reading the const) so tests can -## exercise both verdicts with a generated throwaway keypair. Static so -## it's unit-testable without instancing the manager. -static func _verify_sidecar_signature( - public_key_pem: String, sidecar: PackedByteArray, signature: PackedByteArray -) -> bool: - if sidecar.is_empty() or signature.is_empty(): - return false - var key := CryptoKey.new() - if key.load_from_string(public_key_pem, true) != OK: - return false - var ctx := HashingContext.new() - if ctx.start(HashingContext.HASH_SHA256) != OK: - return false - ctx.update(sidecar) - var digest := ctx.finish() - var crypto := Crypto.new() - return crypto.verify(HashingContext.HASH_SHA256, digest, signature, key) - - -## Surface an integrity-check failure and drop the staged zip so the bad -## bytes can never reach the extract path. Keeps the button enabled for retry. -func _fail_verification(reason: String) -> void: - _pending_sidecar_body = PackedByteArray() - _pending_expected_digest = "" - push_error( - "MCP | self-update integrity check failed: %s. The download was not installed." - % reason - ) - print("MCP | self-update aborted (integrity): %s" % reason) - DirAccess.remove_absolute(ProjectSettings.globalize_path(UPDATE_TEMP_ZIP)) - install_state_changed.emit({ - "button_text": "Verification failed — retry", - "button_disabled": false, - }) - - -## Extract the hex digest from a `sha256sum`-style file (" ") or a -## bare digest line. Returns lowercase 64-char hex, or "" if the content isn't -## a valid SHA-256 digest. Static so it's unit-testable. See #523. -static func _parse_sha256_digest(text: String) -> String: - var trimmed := text.strip_edges() - if trimmed.is_empty(): - return "" - ## First whitespace-delimited token; `sha256sum` separates digest and - ## filename with two spaces, but some tools use tabs. - var normalized := trimmed.replace("\t", " ").replace("\n", " ").replace("\r", " ") - var tokens := normalized.split(" ", false) - if tokens.is_empty(): - return "" - var digest := String(tokens[0]).strip_edges().to_lower() - if digest.length() != 64: - return "" - for i in digest.length(): - var c := digest[i] - if not ((c >= "0" and c <= "9") or (c >= "a" and c <= "f")): - return "" - return digest - - -# ---- Install orchestration --------------------------------------------- - -func _install_zip() -> void: - ## Symlinked addons dir means an extract would clobber canonical - ## `plugin/` source through the link. Symlink detection is independent - ## of the mode override: even forced-user aborts here. See #116. - if ClientConfigurator.addons_dir_is_symlink(): - install_state_changed.emit({ - "button_text": "Dev checkout — update via git", - "button_disabled": true, - "banner_visible": false, - }) - return - - ## Drain in-flight workers + block new ones BEFORE any disk write. - ## Without this, focus-in landing in the extract -> reload window spawns - ## a worker that walks into a partially-overwritten script and - ## SIGABRTs in `GDScriptFunction::call`. - _install_in_flight = true - _drain_dock_workers() - - var has_runner: bool = ( - _plugin != null - and _plugin.has_method("install_downloaded_update") - ) - if has_runner: - install_state_changed.emit({"button_text": "Reloading..."}) - ## Runner takes over: plugin tears down, runner extracts + scans + - ## re-enables. `install_downloaded_update` calls - ## `prepare_for_update_reload()` internally (kills the server, - ## resets the spawn guard) - see plugin.gd::install_downloaded_update. - _plugin.install_downloaded_update(UPDATE_TEMP_ZIP, UPDATE_TEMP_DIR, _dock) - return - - DirAccess.remove_absolute(ProjectSettings.globalize_path(UPDATE_TEMP_ZIP)) - DirAccess.remove_absolute(ProjectSettings.globalize_path(UPDATE_TEMP_DIR)) - _install_in_flight = false - install_state_changed.emit({ - "button_text": "Reload runner missing", - "button_disabled": false, - }) - - -func _reload_after_update() -> void: - EditorInterface.set_plugin_enabled("res://addons/godot_ai/plugin.cfg", false) - EditorInterface.set_plugin_enabled("res://addons/godot_ai/plugin.cfg", true) - - -func _drain_dock_workers() -> void: - if _dock != null and _dock.has_method("prepare_for_self_update_drain"): - _dock.prepare_for_self_update_drain() diff --git a/addons/godot_ai/utils/update_manager.gd.uid b/addons/godot_ai/utils/update_manager.gd.uid deleted file mode 100644 index 4089a72..0000000 --- a/addons/godot_ai/utils/update_manager.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cegiyw3fjcwev diff --git a/addons/godot_ai/utils/update_mixed_state.gd b/addons/godot_ai/utils/update_mixed_state.gd deleted file mode 100644 index 96d0024..0000000 --- a/addons/godot_ai/utils/update_mixed_state.gd +++ /dev/null @@ -1,140 +0,0 @@ -@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] - if entry["is_dir"]: - stack.append(current.path_join(entry["name"])) - 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 deleted file mode 100644 index 6d608d9..0000000 --- a/addons/godot_ai/utils/update_mixed_state.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dd5rti52vgs71 diff --git a/addons/godot_ai/utils/uv_cache_cleanup.gd b/addons/godot_ai/utils/uv_cache_cleanup.gd deleted file mode 100644 index 86edbe1..0000000 --- a/addons/godot_ai/utils/uv_cache_cleanup.gd +++ /dev/null @@ -1,161 +0,0 @@ -@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 deleted file mode 100644 index 321659d..0000000 --- a/addons/godot_ai/utils/uv_cache_cleanup.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://d33ukg65qf7q0 diff --git a/addons/godot_ai/utils/variant_serializer.gd b/addons/godot_ai/utils/variant_serializer.gd deleted file mode 100644 index 21d8c6e..0000000 --- a/addons/godot_ai/utils/variant_serializer.gd +++ /dev/null @@ -1,102 +0,0 @@ -@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 supported 4.5+ 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 deleted file mode 100644 index b8e0a28..0000000 --- a/addons/godot_ai/utils/variant_serializer.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cte37mtbd61n3 diff --git a/addons/godot_ai/vision_routing.gd b/addons/godot_ai/vision_routing.gd deleted file mode 100644 index 2f42cef..0000000 --- a/addons/godot_ai/vision_routing.gd +++ /dev/null @@ -1,1043 +0,0 @@ -@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 _route_done: Callable -var _pending: Dictionary = {} # request_id -> {payload, data, connection, provider_id, provider, params} -var _threads: Dictionary = {} # request_id -> Thread ("_test" = ping thread) -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 _init() -> void: - _route_done = Callable(self, "_on_route_complete") - - -## Plugin teardown: stop workers and join in-flight threads so Godot never -## destroys a Thread mid-execution during a plugin reload. -func shutdown() -> void: - _active = false - ## Workers poll _active 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() - _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 - - -# --- 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: - 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: - _join_thread(rid) - if not _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) -> void: - var result := _describe_blocking(provider, image_b64, prompt, api_key) - _route_done.call_deferred(rid, 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 _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 _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 _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 _join_thread(rid: String) -> void: - ## Join the worker before dropping the Thread reference, otherwise Godot - ## warns "Thread object destroyed without completion". - var thread: Thread = _threads.get(rid) - if thread != null and thread.is_started(): - thread.wait_to_finish() - _threads.erase(rid) - - -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, status_label: Label, key_source: String) -> void: - var result := _ping_blocking(provider, api_key) - Callable(self, "_on_ping_done").call_deferred(result, provider, status_label, key_source) - - -func _on_ping_done(result: Dictionary, provider: Dictionary, status_label: Label, key_source: String) -> void: - _join_thread("_test") - 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 - var start_err := thread.start(_ping_worker.bind(provider, api_key, _tab_status, key_source)) - if start_err != OK: - ## A failed thread start must not leave the button disabled and the - ## status stuck on "Testing..." forever. - _threads.erase("_test") - _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 deleted file mode 100644 index c4c5bc2..0000000 --- a/addons/godot_ai/vision_routing.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dp444q4ocpx45