Replace dasher-pack with unified animation-pack using original Blender bone names

This commit is contained in:
2026-06-15 14:28:26 +08:00
parent 9dd3c59edf
commit 844ec194cb
297 changed files with 28680 additions and 1884 deletions
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Godot AI contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+53
View File
@@ -0,0 +1,53 @@
# 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, 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.3+ (4.4+ recommended)
- [uv](https://docs.astral.sh/uv/) (used to install the Python server)
<details>
<summary>Install uv</summary>
**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.
</details>
- 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)
+620
View File
@@ -0,0 +1,620 @@
@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 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 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
const SETTING_WS_PORT := "godot_ai/ws_port"
const SETTING_STARTUP_TRACE := "godot_ai/log_startup_timing"
## 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()
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)
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],
})
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 startup_trace_enabled() -> bool:
var raw := OS.get_environment(STARTUP_TRACE_ENV).strip_edges().to_lower()
if raw == "1" or raw == "true" or raw == "yes" or raw == "on":
return true
if Engine.is_editor_hint():
var es := EditorInterface.get_editor_settings()
if es != null and es.has_setting(SETTING_STARTUP_TRACE):
return bool(es.get_setting(SETTING_STARTUP_TRACE))
return false
## 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.
static func excluded_domains() -> String:
var es := EditorInterface.get_editor_settings()
if es == null or not es.has_setting(McpSettings.SETTING_EXCLUDED_DOMAINS):
return ""
var raw := str(es.get_setting(McpSettings.SETTING_EXCLUDED_DOMAINS))
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)
## Clamp `start` into the legal port range, then walk
## `candidate`..`candidate+span-1` and return the first port that is NOT
## currently excluded by Windows' winnat reservation table. Falls back to the
## clamped candidate if nothing clears (caller can apply anyway — user may
## just retry). On non-Windows this is a no-op: all ports pass, returns the
## clamped candidate.
static func suggest_free_port(start: int, span: int = 2048) -> int:
var candidate := clampi(start, MIN_PORT, MAX_PORT - span + 1)
return WindowsPortReservation.suggest_non_excluded_port(candidate, span, MAX_PORT)
# --- 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 = "") -> Dictionary:
var client := ClientRegistry.get_by_id(id)
if client == null:
return {"status": "error", "message": "Unknown client: %s" % id}
## 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 result := _dispatch_configure(client, url)
## 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")
static func check_status(id: String) -> Client.Status:
var client := ClientRegistry.get_by_id(id)
if client == null:
return Client.Status.NOT_CONFIGURED
return _dispatch_check_status(client, http_url())
static func check_status_for_url(id: String, url: String) -> Client.Status:
var client := ClientRegistry.get_by_id(id)
if client == null:
return Client.Status.NOT_CONFIGURED
return _dispatch_check_status(client, url)
static func check_status_for_url_with_cli_path(id: String, url: String, cli_path: String) -> Client.Status:
return check_status_details_for_url_with_cli_path(id, url, cli_path).get("status", Client.Status.NOT_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) -> Dictionary:
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": ""}
return _dispatch_check_status_with_cli_path_details(client, url, cli_path)
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}
## 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 = "") -> Dictionary:
var client := ClientRegistry.get_by_id(id)
if client == null:
return {"status": "error", "message": "Unknown client: %s" % id}
if url.is_empty():
url = http_url()
var result := _dispatch_remove(client)
return _verify_post_state(client, result, Client.Status.NOT_CONFIGURED, url, "remove")
# --- Strategy dispatch + verify (testable seam) --------------------------
static func _dispatch_configure(client: Client, url: String) -> Dictionary:
match client.config_type:
"json":
return JsonStrategy.configure(client, SERVER_NAME, url)
"toml":
return TomlStrategy.configure(client, SERVER_NAME, url)
"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 JsonStrategy.configure(client, SERVER_NAME, url)
return CliStrategy.configure(client, SERVER_NAME, url)
return {"status": "error", "message": "Unknown config_type for %s: %s" % [client.id, client.config_type]}
static func _dispatch_remove(client: Client) -> Dictionary:
match client.config_type:
"json":
return JsonStrategy.remove(client, SERVER_NAME)
"toml":
return TomlStrategy.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)
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) -> Client.Status:
return _dispatch_check_status_with_cli_path(client, url, "")
static func _dispatch_check_status_with_cli_path(client: Client, url: String, cli_path: String) -> Client.Status:
return _dispatch_check_status_with_cli_path_details(client, url, cli_path).get("status", Client.Status.NOT_CONFIGURED)
static func _dispatch_check_status_with_cli_path_details(client: Client, url: String, cli_path: String) -> Dictionary:
match client.config_type:
"json":
return {"status": JsonStrategy.check_status(client, SERVER_NAME, url), "error_msg": ""}
"toml":
return {"status": TomlStrategy.check_status(client, SERVER_NAME, url), "error_msg": ""}
"cli":
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():
return {"status": JsonStrategy.check_status(client, SERVER_NAME, url), "error_msg": ""}
return CliStrategy.check_status_details(client, SERVER_NAME, url, resolved_cli)
return {"status": Client.Status.NOT_CONFIGURED, "error_msg": ""}
## 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,
) -> Dictionary:
if result.get("status") != "ok":
return result
var actual := _dispatch_check_status(client, url)
if actual == expected:
return result
var path := client.resolved_config_path()
var path_hint := "" if path.is_empty() else " Inspect %s and remove the godot-ai entry by hand if needed." % path
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,
],
}
static func manual_command(id: String) -> String:
var client := ClientRegistry.get_by_id(id)
if client == null:
return ""
return ManualCommand.build(client, SERVER_NAME, http_url(), client.resolved_config_path())
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"
## 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` — UI dropdown in the dock,
## persists per-editor-install. Wins over the env var so a UI action
## always takes effect without relaunching the editor.
## 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 chose via the dock dropdown.
# Guarded on `Engine.is_editor_hint()` so this is a no-op when the
# plugin code runs inside the game subprocess (where EditorInterface
# isn't available). See CLAUDE.md "Game-side code: gate on
# Engine.is_editor_hint(), not OS.has_feature("editor")".
if Engine.is_editor_hint():
var es := EditorInterface.get_editor_settings()
if es != null and es.has_setting(MODE_OVERRIDE_SETTING):
var setting_val := str(es.get_setting(MODE_OVERRIDE_SETTING)).strip_edges().to_lower()
if setting_val == "dev" or setting_val == "user":
return setting_val
# 2. Env var fallback.
var raw := OS.get_environment(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())
return dir != null and 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 UI dropdown 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()
## Pin to the EXACT plugin version rather than `~=<minor>`. 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.
print("MCP | using uvx (godot-ai==%s)%s" % [version, " [refresh]" if refresh else ""])
var cmd: Array[String] = [uvx]
if refresh:
cmd.append("--refresh")
cmd.append_array(["--from", "godot-ai==%s" % 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())
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 `OS.execute`, 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 a single cold `OS.execute(uvx,
## ["--version"])` adds ~80 ms to the dock's first paint on Linux and
## more on Windows. 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 output: Array = []
if OS.execute(uvx, ["--version"], output, true) == 0 and output.size() > 0:
_uv_version_cache = output[0].strip_edges()
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 = ""
static var _venv_python_cache: String = ""
static var _venv_python_searched: bool = false
static func _cached_venv_python() -> String:
if not _venv_python_searched:
_venv_python_cache = _find_venv_python()
_venv_python_searched = true
return _venv_python_cache
static func _find_venv_python() -> String:
return _find_venv_python_in(ProjectSettings.globalize_path("res://").rstrip("/"))
## 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("/")
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/"
for i in 5:
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:
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 ""
static func _find_system_install() -> String:
var cmd := "which" if OS.get_name() != "Windows" else "where"
var output: Array = []
if OS.execute(cmd, ["godot-ai"], output, true) == 0 and output.size() > 0:
var found: String = output[0].strip_edges()
if not found.is_empty():
return found
return ""
@@ -0,0 +1 @@
uid://1kiy8hqyymyj
+161
View File
@@ -0,0 +1,161 @@
@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:
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)
var tmp_path := path + ".tmp"
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)
# 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
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()
@@ -0,0 +1 @@
uid://6fkb5uau0r4h
+211
View File
@@ -0,0 +1,211 @@
@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 doesn't match `http_url()` — typical after the
## user changes `godot_ai/http_port` and reloads. Distinguishing this from
## `NOT_CONFIGURED` lets the dock surface a "your saved client URLs are 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"
var id: String = "" ## stable key, e.g. "cursor"
var display_name: String = "" ## "Cursor"
var config_type: String = "" ## "json" | "toml" | "cli"
var doc_url: String = ""
# 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 = {}
## 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()
## 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 = {}
## stdio→HTTP bridge mode for clients that don't speak HTTP natively.
## NONE — entry is `{[entry_url_field]: url, **entry_extra_fields,
## ...entry_initial_fields (only for new entries)}`
## FLAT — Claude Desktop shape: `{"command": <uvx>, "args": [...bridge...]}`
## Verifier ALSO accepts a future url-style entry.
##
## Enum (vs. String) so a typo in a descriptor fails at parse time instead of
## silently falling through `match` to the non-bridge path.
enum UvxBridge { NONE, FLAT }
var entry_uvx_bridge: UvxBridge = UvxBridge.NONE
## Paths whose existence implies the user has this client installed.
## Used purely for the dock's "installed" badge.
var detect_paths: PackedStringArray = PackedStringArray()
# 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. Today only `claude_code` populates these.
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()
# 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.
func resolved_config_path() -> String:
return McpPathTemplate.resolve(path_template)
## 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:
var resolved := McpPathTemplate.expand(p)
if not resolved.is_empty() and (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
# ---------- stdio→http bridge helpers (Claude Desktop) --------------------
## Pinned mcp-proxy release used by every stdio-only client's bridge. uvx's
## cache key is version-specific, so pinning guarantees all users run the
## same vetted bridge — a malicious or broken future release on PyPI can't
## silently break everyone's Configure flow. Bump deliberately when the
## upstream publishes something we want.
const MCP_PROXY_VERSION := "0.11.0"
## Resolve `uvx` to an absolute path. GUI-launched apps (Claude Desktop)
## often run with a minimal PATH that excludes ~/.local/bin on macOS /
## Linux, so a bare "uvx" string in the config would fail at spawn time
## with the same "Server disconnected" symptom we're trying to cure. The
## shared three-tier McpCliFinder covers the well-known install dirs;
## returns bare "uvx" as a last-resort fallback so the entry is still
## well-formed even if the lookup failed.
static func resolve_uvx_path() -> String:
var names: Array[String] = []
names.append("uvx.exe" if OS.get_name() == "Windows" else "uvx")
var resolved := McpCliFinder.find(names)
return resolved if not resolved.is_empty() else "uvx"
## Build the `mcp-proxy` bridge argv (without the leading uvx command).
## Callers splice this into the client-specific command shape.
static func mcp_proxy_bridge_args(url: String) -> Array:
return ["mcp-proxy==" + MCP_PROXY_VERSION, "--transport", "streamablehttp", url]
## Environment overrides written alongside every auto-configured uvx-bridge
## entry. `UV_LINK_MODE=copy` tells uv to copy shared C extensions into each
## `builds-v0\.tmpXXXXXX\` build venv instead of hard-linking them from
## `archive-v0\`. On Windows that breaks the lock race documented in
## `utils/uv_cache_cleanup.gd` and the README — the running godot-ai server
## holds `_pydantic_core.pyd` mapped, the build venv's hard-linked copy
## inherits the lock, uv's post-install cleanup fails, and the MCP launcher
## reports "pywin32 wheel invalid / file in use" with no working transport.
## Cost on macOS/Linux is a few extra MB in the uvx cache — well worth it
## to keep one config shape across platforms.
static func bridge_env_for_uvx() -> Dictionary:
return {"UV_LINK_MODE": "copy"}
+1
View File
@@ -0,0 +1 @@
uid://cyowqr1x12ilg
+143
View File
@@ -0,0 +1,143 @@
@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
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()
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: <path> ..." 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:
## Read whatever made it to the pipes before we kill the
## process — partial output beats blank "timed out" when the
## CLI was emitting useful diagnostics on its way to hanging.
var partial_stdout := _drain_pipe(stdio)
var partial_stderr := _drain_pipe(stderr_pipe) if capture_stderr else ""
OS.kill(pid)
_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 pipe is FileAccess:
return (pipe as FileAccess).get_as_text()
return ""
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()
+1
View File
@@ -0,0 +1 @@
uid://dhoe3ypkhm12v
+175
View File
@@ -0,0 +1,175 @@
@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 <exe>'`) — 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 + `OS.execute`) 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 = {}
## 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 + `OS.execute` (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:
var shell := OS.get_environment("SHELL")
if shell.is_empty():
shell = "/bin/bash"
var login_output: Array = []
var stripped := exe_name.trim_suffix(".exe")
var login_exit := OS.execute(shell, ["-lc", "command -v %s" % stripped], login_output, true)
if login_exit == 0 and login_output.size() > 0:
var login_found: String = login_output[0].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 output: Array = []
var exit_code := OS.execute(lookup, [exe_name], output, true)
if exit_code == 0 and output.size() > 0:
var lines := PackedStringArray(output[0].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 `<dir>/<name>` (a POSIX bash shim
## for WSL / Git Bash users) AND `<dir>/<name>.cmd` (the actual Windows
## wrapper). `where <name>` 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]:
var home := OS.get_environment("HOME")
if home.is_empty():
home = OS.get_environment("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 := OS.get_environment("LOCALAPPDATA")
var prog := OS.get_environment("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",
]
@@ -0,0 +1 @@
uid://cnp5b6fcwou2y
+152
View File
@@ -0,0 +1,152 @@
@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. 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.
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) -> Dictionary:
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():
var pre_args := _format_args(client.cli_unregister_template, server_name, server_url)
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)
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": "%s configured (HTTP: %s)" % [client.display_name, 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
## `server_url`. The matching rule is the only sensible one for "list MCP
## entries" output across CLI clients we currently support: name AND url
## present → CONFIGURED; name only → MISMATCH; neither → NOT_CONFIGURED.
static func check_status(client: McpClient, server_name: String, server_url: String) -> McpClient.Status:
return check_status_with_cli_path(client, server_name, server_url, _resolve_cli(client))
static func check_status_with_cli_path(client: McpClient, server_name: String, server_url: String, cli: String) -> McpClient.Status:
return check_status_details(client, server_name, server_url, cli).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) -> 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 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(server_url) < 0:
return _status_details(McpClient.Status.CONFIGURED_MISMATCH)
return _status_details(McpClient.Status.CONFIGURED)
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.
static func format_args(template: PackedStringArray, server_name: String, server_url: String) -> Array[String]:
return _format_args(template, server_name, server_url)
static func _format_args(template: PackedStringArray, server_name: String, server_url: String) -> Array[String]:
var out: Array[String] = []
for arg in template:
var s := String(arg)
s = s.replace("{name}", server_name)
s = s.replace("{url}", server_url)
out.append(s)
return out
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)
@@ -0,0 +1 @@
uid://bvib7d8eabbcm
+263
View File
@@ -0,0 +1,263 @@
@tool
class_name McpJsonStrategy
extends RefCounted
## Readmergewrite 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) -> Dictionary:
var path := client.resolved_config_path()
if path.is_empty():
return {"status": "error", "message": "Could not resolve config path for %s on this OS" % client.display_name}
var read := _read_or_init(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"]]}
var config: Dictionary = read["data"]
var holder := _ensure_path(config, client.server_key_path)
## 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)
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": "%s configured (HTTP: %s)" % [client.display_name, server_url]}
static func check_status(client: McpClient, server_name: String, server_url: String) -> McpClient.Status:
var path := client.resolved_config_path()
if path.is_empty() or not FileAccess.file_exists(path):
return McpClient.Status.NOT_CONFIGURED
var read := _read_or_init(path)
if not read["ok"]:
return McpClient.Status.NOT_CONFIGURED
var config: Dictionary = read["data"]
var holder := _walk_path(config, client.server_key_path)
if not (holder is Dictionary) or not holder.has(server_name):
return McpClient.Status.NOT_CONFIGURED
var entry = holder[server_name]
if not (entry is Dictionary):
return McpClient.Status.NOT_CONFIGURED
## An entry under `server_name` exists — if the URL doesn't match,
## that's drift (the user changed the port and the client config is stale),
## not "never configured". The dock surfaces that as an amber banner.
return McpClient.Status.CONFIGURED if verify_entry(client, entry, server_url) else McpClient.Status.CONFIGURED_MISMATCH
static func remove(client: McpClient, server_name: String) -> Dictionary:
var path := client.resolved_config_path()
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 config: Dictionary = read["data"]
var holder := _walk_path(config, client.server_key_path)
if holder is Dictionary and holder.has(server_name):
holder.erase(server_name)
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": "%s configuration removed" % client.display_name}
## Synthesize the entry dict the strategy will write under
## `server_key_path[server_name]`. For non-bridge clients this is the
## existing entry (if any) with `entry_url_field` + every
## `entry_extra_fields` key force-set (the verified type pins) and every
## `entry_initial_fields` key set ONLY when absent (preserves user state
## like `alwaysAllow`/`autoApprove` arrays). For bridge clients (Claude
## Desktop) it composes the uvx + mcp-proxy command shape unconditionally
## — the bridge form has no user-mutable surface.
static func build_entry(client: McpClient, server_url: String, existing: Variant = null) -> Dictionary:
match client.entry_uvx_bridge:
McpClient.UvxBridge.FLAT:
return {
"command": McpClient.resolve_uvx_path(),
"args": McpClient.mcp_proxy_bridge_args(server_url),
"env": _merge_bridge_env(existing),
}
var entry: Dictionary = (existing as Dictionary).duplicate() 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. For bridge clients, recognise the
## bridge form (and, for `flat`, the future url-style form too — keeps the
## tolerance Claude Desktop has had since the npx-bridge migration).
##
## For non-bridge 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) -> bool:
match client.entry_uvx_bridge:
McpClient.UvxBridge.FLAT:
# Future url-style entry: accept if Claude Desktop ever speaks HTTP natively.
if entry.get(client.entry_url_field, "") == server_url:
return true
var cmd = entry.get("command", "")
if not (cmd is String and _command_is_uvx_like(cmd as String)):
return false
if not _bridge_args_are_valid(entry.get("args", []), server_url):
return false
return _bridge_env_matches(entry)
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
## Pre-fix entries lack `env.UV_LINK_MODE=copy` and hit the Windows uvx
## hard-link race documented in `utils/uv_cache_cleanup.gd`. Flag them as
## drift so the dock surfaces an amber banner and a Configure-click
## rewrites the entry with the env pin. Every key in `bridge_env_for_uvx()`
## must match verbatim — extra user keys are tolerated so a hand-added
## `PYTHONUNBUFFERED=1` etc. doesn't trigger drift forever.
static func _bridge_env_matches(entry: Dictionary) -> bool:
var env = entry.get("env", null)
if not (env is Dictionary):
return false
var pin := McpClient.bridge_env_for_uvx()
for k in pin:
if env.get(k) != pin[k]:
return false
return true
## Configure rewrites the bridge entry wholesale (the bridge form is
## identity-defined by command+args+env), but the verifier tolerates extra
## user-added env keys like `HTTP_PROXY` / `PYTHONUNBUFFERED`. Without
## merging, a Configure click on a CONFIGURED_MISMATCH entry would silently
## drop those keys — so layer the UV_LINK_MODE pin over whatever env block
## already exists on disk. New entries with no prior env get just the pin.
static func _merge_bridge_env(existing: Variant) -> Dictionary:
var pin := McpClient.bridge_env_for_uvx()
if not (existing is Dictionary):
return pin
var existing_env = (existing as Dictionary).get("env", null)
if not (existing_env is Dictionary):
return pin
var merged: Dictionary = (existing_env as Dictionary).duplicate()
for k in pin:
merged[k] = pin[k]
return merged
## Basename match for `uvx` / `uvx.exe`, accepting both the bare-name
## fallback and an absolute path resolved by `McpCliFinder`. Used by the
## FLAT bridge verifier — the only place we ever inspect a stored bridge
## command/path.
static func _command_is_uvx_like(cmd: String) -> bool:
var basename := cmd.get_file()
return basename == "uvx" or basename == "uvx.exe"
## Strict bridge-argv check: the args array must include the pinned
## `mcp-proxy` package spec, the `--transport streamablehttp` selector, and
## the expected URL. Pre-fix `args.has(url)` was lenient — entries with the
## wrong transport (`--transport sse`) or a different package would still
## verify CONFIGURED, hiding the broken bridge. Match `mcp-proxy` by prefix
## so a future MCP_PROXY_VERSION bump doesn't churn the verifier.
static func _bridge_args_are_valid(args: Variant, server_url: String) -> bool:
if not (args is Array):
return false
var has_mcp_proxy := false
for a in args:
if a is String and (a as String).begins_with("mcp-proxy"):
has_mcp_proxy = true
break
if not has_mcp_proxy:
return false
if not (args.has("--transport") and args.has("streamablehttp") and args.has(server_url)):
return false
return true
## 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:
if not FileAccess.file_exists(path):
return {"ok": true, "data": {}}
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
var err := FileAccess.get_open_error()
return {"ok": false, "error": "could not open for reading (error %d)" % err}
var content := file.get_as_text()
file.close()
# 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 content.begins_with(""):
content = content.substr(1)
if content.strip_edges().is_empty():
return {"ok": true, "data": {}}
var json := JSON.new()
if json.parse(content) != 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 {"ok": false, "error": msg}
if not (json.data is Dictionary):
return {"ok": false, "error": "top-level value is %s, expected object" % type_string(typeof(json.data))}
return {"ok": true, "data": json.data}
## 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
## 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
@@ -0,0 +1 @@
uid://g8a4iijpk22w
+113
View File
@@ -0,0 +1,113 @@
@tool
class_name McpManualCommand
extends RefCounted
## 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) -> String:
match client.config_type:
"cli":
return _build_cli(client, server_name, server_url, resolved_path)
"json":
return _build_json(client, server_name, server_url, resolved_path)
"toml":
return _build_toml(client, server_name, server_url, resolved_path)
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.)
static func _build_cli(client: McpClient, server_name: String, server_url: String, resolved_path: String = "") -> String:
if client.cli_register_template.is_empty() or client.cli_names.is_empty():
return ""
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 args := McpCliStrategy.format_args(client.cli_register_template, server_name, server_url)
var parts: Array[String] = [short_name]
parts.append_array(args)
var cmd := " ".join(parts)
# #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),
]
return cmd
static func _build_json(client: McpClient, server_name: String, server_url: String, resolved_path: String) -> String:
var entry := McpJsonStrategy.build_entry(client, server_url)
var entry_text := _format_entry_inline(entry)
var key := client.server_key_path[0] if client.server_key_path.size() > 0 else "mcpServers"
return "Edit %s and add under \"%s\":\n \"%s\": %s" % [resolved_path, key, server_name, entry_text]
static func _build_toml(client: McpClient, _server_name: String, server_url: String, resolved_path: String) -> String:
var header := _toml_header(client)
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)
## 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)
@@ -0,0 +1 @@
uid://ct1wmgfk408x0
+62
View File
@@ -0,0 +1,62 @@
@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.
## Pick the right entry from a {"darwin": ..., "windows": ..., "linux": ...} map.
static func resolve(template_map: Dictionary) -> String:
var key := _os_key()
if not template_map.has(key):
# Allow "unix" as a shorthand for both macOS and Linux.
if (key == "darwin" or key == "linux") and template_map.has("unix"):
key = "unix"
else:
return ""
var template: String = template_map[key]
return expand(template)
## 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 := OS.get_environment(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 _home() -> String:
var h := OS.get_environment("HOME")
if h.is_empty():
h = OS.get_environment("USERPROFILE")
return h
@@ -0,0 +1 @@
uid://5pd418va35ms
+71
View File
@@ -0,0 +1,71 @@
@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 preload below.
const _CLIENT_SCRIPTS := [
preload("res://addons/godot_ai/clients/claude_code.gd"),
preload("res://addons/godot_ai/clients/claude_desktop.gd"),
preload("res://addons/godot_ai/clients/codex.gd"),
preload("res://addons/godot_ai/clients/antigravity.gd"),
preload("res://addons/godot_ai/clients/cursor.gd"),
preload("res://addons/godot_ai/clients/windsurf.gd"),
preload("res://addons/godot_ai/clients/vscode.gd"),
preload("res://addons/godot_ai/clients/vscode_insiders.gd"),
preload("res://addons/godot_ai/clients/zed.gd"),
preload("res://addons/godot_ai/clients/gemini_cli.gd"),
preload("res://addons/godot_ai/clients/cline.gd"),
preload("res://addons/godot_ai/clients/kilo_code.gd"),
preload("res://addons/godot_ai/clients/roo_code.gd"),
preload("res://addons/godot_ai/clients/kiro.gd"),
preload("res://addons/godot_ai/clients/trae.gd"),
preload("res://addons/godot_ai/clients/cherry_studio.gd"),
preload("res://addons/godot_ai/clients/opencode.gd"),
preload("res://addons/godot_ai/clients/qwen_code.gd"),
preload("res://addons/godot_ai/clients/kimi_code.gd"),
]
static var _instances: Array[McpClient] = []
static var _by_id: Dictionary = {}
static func all() -> Array[McpClient]:
if _instances.is_empty():
_load()
return _instances
static func get_by_id(id: String) -> McpClient:
if _instances.is_empty():
_load()
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:
if _instances.is_empty():
_load()
return _by_id.has(id)
static func _load() -> void:
_instances.clear()
_by_id.clear()
for script in _CLIENT_SCRIPTS:
var inst: McpClient = script.new()
if inst.id.is_empty():
push_warning("MCP | client descriptor %s has empty id" % script.resource_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
+1
View File
@@ -0,0 +1 @@
uid://bxougoq8xwg1
+269
View File
@@ -0,0 +1,269 @@
@tool
class_name McpTomlStrategy
extends RefCounted
## Minimal TOML upsert: replace or insert one [section."name"] block whose body
## comes from substituting `{url}` in `client.toml_body_template`. No
## descriptor-supplied Callables — see `_base.gd`.
static func configure(client: McpClient, _server_name: String, server_url: String) -> Dictionary:
var path := client.resolved_config_path()
if path.is_empty():
return {"status": "error", "message": "Could not resolve config path for %s" % client.display_name}
var read := _read_or_init(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"]]}
if client.toml_body_template.is_empty():
return {"status": "error", "message": "%s descriptor missing toml_body_template" % client.display_name}
var lines: Array[String] = _split_lines(String(read["data"]))
var body: PackedStringArray = format_body(client.toml_body_template, server_url)
var section := _find_section(lines, _all_headers(client))
var header := _primary_header(client)
var new_lines: Array[String] = [header]
for b in body:
new_lines.append(b)
var output: Array[String] = []
if section.is_empty():
output.append_array(lines)
if not output.is_empty() and not output[-1].strip_edges().is_empty():
output.append("")
output.append_array(new_lines)
else:
output.append_array(_slice(lines, 0, section["start"]))
output.append_array(new_lines)
output.append_array(_slice(lines, section["end"], lines.size()))
if not McpAtomicWrite.write(path, "\n".join(output)):
return {"status": "error", "message": "Cannot write to %s" % path}
return {"status": "ok", "message": "%s configured (HTTP: %s)" % [client.display_name, server_url]}
static func check_status(client: McpClient, _server_name: String, server_url: String) -> McpClient.Status:
var path := client.resolved_config_path()
if path.is_empty() or not FileAccess.file_exists(path):
return McpClient.Status.NOT_CONFIGURED
var read := _read_or_init(path)
if not read["ok"]:
return McpClient.Status.NOT_CONFIGURED
var lines: Array[String] = _split_lines(String(read["data"]))
var section := _find_section(lines, _all_headers(client))
if section.is_empty():
return McpClient.Status.NOT_CONFIGURED
var configured_url := ""
var enabled := true
for i in range(section["start"] + 1, section["end"]):
var trimmed := lines[i].strip_edges()
if trimmed.begins_with("url ="):
var first := trimmed.find("\"")
var last := trimmed.rfind("\"")
if first >= 0 and last > first:
configured_url = trimmed.substr(first + 1, last - first - 1)
elif trimmed.begins_with("enabled ="):
enabled = trimmed.to_lower().find("false") < 0
## Section exists with our `SERVER_NAME` header — a URL mismatch (or a
## disabled entry) is drift, not "never configured". See `_base.gd`.
if configured_url != server_url or not enabled:
return McpClient.Status.CONFIGURED_MISMATCH
return McpClient.Status.CONFIGURED
static func remove(client: McpClient, _server_name: String) -> Dictionary:
var path := client.resolved_config_path()
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)
## Subtables in the namespace (e.g. [mcp_servers.godot-ai.tools.session_list]
## that codex users add to set per-tool approval_mode) must be removed
## too. Leaving them behind keeps `mcp_servers.godot-ai` implicitly
## defined, so a later configure that writes [mcp_servers."godot-ai"]
## produces a duplicate-key TOML error.
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():
if _is_any_section_header(lines[i]):
break
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 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
# --- helpers --------------------------------------------------------------
## Returns {"ok": true, "data": String} when the file is absent or readable,
## and {"ok": false, "error": String} when the file exists but cannot be
## opened. Callers must NOT fall back to an empty string 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:
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 t := f.get_as_text()
f.close()
return {"ok": true, "data": t}
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:
# Quoted form: [section."name"] for ids that contain hyphens.
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]
## TOML accepts bare keys ([A-Za-z0-9_-]+) unquoted in section headers,
## so [mcp_servers.godot-ai] is a valid hand-written form of the same
## logical key we emit as [mcp_servers."godot-ai"]. Match both during
## reconfigure / status / remove or a hand-edited (or older-plugin)
## bare-key file gets a duplicate quoted section appended that breaks
## the user's TOML parser.
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 p in parts:
if not _is_bare_key(String(p)):
return ""
return "[%s]" % ".".join(parts)
static func _is_bare_key(s: String) -> bool:
if s.is_empty():
return false
for i in range(s.length()):
var c := s.unicode_at(i)
var alpha := (c >= 65 and c <= 90) or (c >= 97 and c <= 122)
var digit := c >= 48 and c <= 57
var dash_or_under := c == 45 or c == 95 # '-' or '_'
if not (alpha or digit or dash_or_under):
return false
return true
## Subtable prefixes derived from each header in `headers`. Strips the
## closing `]` and appends `.` so a header `[a.b]` becomes the prefix
## `[a.b.` — matching subtables `[a.b.<rest>]` but NOT siblings like
## `[a.b-other]` (next char must be a dot, not anything bare-key-valid).
static func _subtable_prefixes(headers: Array[String]) -> Array[String]:
var out: Array[String] = []
for h in headers:
if h.length() > 2 and h.ends_with("]"):
out.append(h.substr(0, h.length() - 1) + ".")
return out
## Mirror of `_matches_any_header` for subtable prefixes — line must
## start with `[a.b.` and have a closing `]` followed only by whitespace
## or a comment.
static func _matches_subtable_prefix(line: String, prefixes: Array[String]) -> bool:
var trimmed := line.strip_edges()
for p in prefixes:
if not trimmed.begins_with(p):
continue
var rest := trimmed.substr(p.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
## Exact-header match. We cannot use a simple prefix check because
## `[mcp_servers."godot-ai"` is a prefix of `[mcp_servers."godot-ai-dev"]`,
## which would silently delete unrelated sections during remove().
static func _matches_any_header(line: String, headers: Array[String]) -> bool:
var trimmed := line.strip_edges()
for h in headers:
if not trimmed.begins_with(h):
continue
var remainder := trimmed.substr(h.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 {}
## Generic "is this line a TOML section header" check that tolerates an
## inline comment after the closing `]`, e.g. `[next_section] # note`.
## The pre-fix `nt.begins_with("[") and nt.ends_with("]")` rejected those
## lines, so a hand-written comment after a header would let the
## section-deletion / section-end loops walk straight through into the
## following section and clobber unrelated content.
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("#")
@@ -0,0 +1 @@
uid://cwdvxgn0aurqv
+19
View File
@@ -0,0 +1,19 @@
@tool
extends McpClient
func _init() -> void:
id = "antigravity"
display_name = "Antigravity"
config_type = "json"
doc_url = "https://www.antigravity.dev/"
path_template = {
"unix": "~/.gemini/antigravity/mcp_config.json",
"windows": "$USERPROFILE/.gemini/antigravity/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}
detect_paths = PackedStringArray(path_template.values())
@@ -0,0 +1 @@
uid://b4l1g0apa2hch
+20
View File
@@ -0,0 +1,20 @@
@tool
extends McpClient
func _init() -> void:
id = "cherry_studio"
display_name = "Cherry Studio"
config_type = "json"
doc_url = "https://docs.cherry-ai.com/advanced-basic/mcp"
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}
detect_paths = PackedStringArray(path_template.values())
@@ -0,0 +1 @@
uid://dwbuykxvbv5f7
+24
View File
@@ -0,0 +1,24 @@
@tool
extends McpClient
func _init() -> void:
id = "claude_code"
display_name = "Claude Code"
config_type = "cli"
doc_url = "https://docs.anthropic.com/en/docs/claude-code"
cli_names = PackedStringArray(["claude", "claude.exe"] if OS.get_name() == "Windows" else ["claude"])
cli_register_template = PackedStringArray(
["mcp", "add", "--scope", "user", "--transport", "http", "{name}", "{url}"]
)
cli_unregister_template = PackedStringArray(["mcp", "remove", "{name}"])
cli_status_args = PackedStringArray(["mcp", "list"])
## #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 whenever it resolves; this is what gets written
## otherwise. `claude mcp add --scope user --transport http` produces
## exactly this shape under `mcpServers` in ~/.claude.json:
## "godot-ai": { "type": "http", "url": "<url>" }
path_template = {"unix": "~/.claude.json", "windows": "~/.claude.json"}
server_key_path = PackedStringArray(["mcpServers"])
entry_extra_fields = {"type": "http"}
@@ -0,0 +1 @@
uid://cp1u1hdpa6f8d
+24
View File
@@ -0,0 +1,24 @@
@tool
extends McpClient
## Claude Desktop's mcpServers entries are stdio-only, so we bridge our HTTP
## server through `uvx mcp-proxy --transport streamablehttp <url>`. `uvx` is
## already a plugin prereq, so this works without requiring Node.js.
func _init() -> void:
id = "claude_desktop"
display_name = "Claude Desktop"
config_type = "json"
doc_url = "https://claude.ai/download"
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",
}
server_key_path = PackedStringArray(["mcpServers"])
## FLAT bridge: `{"command": "<uvx>", "args": [...]}`. The default
## verifier ALSO accepts a future url-style entry (Claude Desktop has
## been tolerant of both forms since the npx→uvx bridge migration).
entry_uvx_bridge = McpClient.UvxBridge.FLAT
detect_paths = PackedStringArray(path_template.values())
@@ -0,0 +1 @@
uid://bilntn5n8oqe3
+29
View File
@@ -0,0 +1,29 @@
@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"
doc_url = "https://github.com/cline/cline"
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": []}
detect_paths = PackedStringArray(path_template.values())
+1
View File
@@ -0,0 +1 @@
uid://d36nywn2nkgts
+18
View File
@@ -0,0 +1,18 @@
@tool
extends McpClient
func _init() -> void:
id = "codex"
display_name = "Codex"
config_type = "toml"
doc_url = "https://openai.com/index/codex/"
path_template = {"unix": "~/.codex/config.toml", "windows": "$USERPROFILE/.codex/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"])
toml_body_template = PackedStringArray([
"url = \"{url}\"",
"enabled = true",
])
detect_paths = PackedStringArray(path_template.values())
+1
View File
@@ -0,0 +1 @@
uid://hdlwcfdr8mdk
+12
View File
@@ -0,0 +1,12 @@
@tool
extends McpClient
func _init() -> void:
id = "cursor"
display_name = "Cursor"
config_type = "json"
doc_url = "https://docs.cursor.com/context/model-context-protocol"
path_template = {"unix": "~/.cursor/mcp.json", "windows": "$USERPROFILE/.cursor/mcp.json"}
server_key_path = PackedStringArray(["mcpServers"])
detect_paths = PackedStringArray(path_template.values())
+1
View File
@@ -0,0 +1 @@
uid://bvpbssfanukef
+16
View File
@@ -0,0 +1,16 @@
@tool
extends McpClient
func _init() -> void:
id = "gemini_cli"
display_name = "Gemini CLI"
config_type = "json"
doc_url = "https://github.com/google-gemini/gemini-cli"
path_template = {
"unix": "~/.gemini/settings.json",
"windows": "$USERPROFILE/.gemini/settings.json",
}
server_key_path = PackedStringArray(["mcpServers"])
entry_url_field = "httpUrl"
detect_paths = PackedStringArray(path_template.values())
@@ -0,0 +1 @@
uid://b8288pxninajy
+24
View File
@@ -0,0 +1,24 @@
@tool
extends McpClient
func _init() -> void:
id = "kilo_code"
display_name = "Kilo Code"
config_type = "json"
doc_url = "https://kilocode.ai/docs/features/mcp/using-mcp-in-kilo-code"
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": []}
detect_paths = PackedStringArray(path_template.values())
+1
View File
@@ -0,0 +1 @@
uid://dc1x77i1cmb6w
+15
View File
@@ -0,0 +1,15 @@
@tool
extends McpClient
func _init() -> void:
id = "kimi_code"
display_name = "Kimi Code"
config_type = "cli"
doc_url = "https://moonshotai.github.io/kimi-cli/"
cli_names = PackedStringArray(["kimi", "kimi.exe"] if OS.get_name() == "Windows" else ["kimi"])
cli_register_template = PackedStringArray(
["mcp", "add", "--transport", "http", "{name}", "{url}"]
)
cli_unregister_template = PackedStringArray(["mcp", "remove", "{name}"])
cli_status_args = PackedStringArray(["mcp", "list"])
+1
View File
@@ -0,0 +1 @@
uid://d2whd6a5fofhg
+17
View File
@@ -0,0 +1,17 @@
@tool
extends McpClient
func _init() -> void:
id = "kiro"
display_name = "Kiro"
config_type = "json"
doc_url = "https://kiro.dev/docs/mcp"
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}
detect_paths = PackedStringArray(path_template.values())
+1
View File
@@ -0,0 +1 @@
uid://dqdmd2jw5qen7
+21
View File
@@ -0,0 +1,21 @@
@tool
extends McpClient
## OpenCode stores MCP servers under `mcp.<name>` (not the typical mcpServers
## map) and uses `type: "remote"` for HTTP servers.
func _init() -> void:
id = "opencode"
display_name = "OpenCode"
config_type = "json"
doc_url = "https://opencode.ai/docs/mcp-servers"
path_template = {
"unix": "~/.config/opencode/opencode.json",
"windows": "$HOME/.config/opencode/opencode.json",
}
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}
detect_paths = PackedStringArray(path_template.values())
+1
View File
@@ -0,0 +1 @@
uid://s8n0vfirf2pj
+16
View File
@@ -0,0 +1,16 @@
@tool
extends McpClient
func _init() -> void:
id = "qwen_code"
display_name = "Qwen Code"
config_type = "json"
doc_url = "https://github.com/QwenLM/qwen-code"
path_template = {
"unix": "~/.qwen/settings.json",
"windows": "$USERPROFILE/.qwen/settings.json",
}
server_key_path = PackedStringArray(["mcpServers"])
entry_url_field = "httpUrl"
detect_paths = PackedStringArray(path_template.values())
+1
View File
@@ -0,0 +1 @@
uid://qwb5udkf423q
+29
View File
@@ -0,0 +1,29 @@
@tool
extends McpClient
func _init() -> void:
id = "roo_code"
display_name = "Roo Code"
config_type = "json"
doc_url = "https://docs.roocode.com/features/mcp/using-mcp-in-roo"
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": []}
detect_paths = PackedStringArray(path_template.values())
+1
View File
@@ -0,0 +1 @@
uid://denjdf50qrf66
+16
View File
@@ -0,0 +1,16 @@
@tool
extends McpClient
func _init() -> void:
id = "trae"
display_name = "Trae"
config_type = "json"
doc_url = "https://docs.trae.ai/ide/model-context-protocol"
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"])
detect_paths = PackedStringArray(path_template.values())
+1
View File
@@ -0,0 +1 @@
uid://cwpu48772vfj1
+20
View File
@@ -0,0 +1,20 @@
@tool
extends McpClient
## VS Code (stable) reads MCP servers from per-user mcp.json under
## `servers.<name>` with `{ "type": "http", "url": ... }`.
func _init() -> void:
id = "vscode"
display_name = "VS Code"
config_type = "json"
doc_url = "https://code.visualstudio.com/docs/copilot/chat/mcp-servers"
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"}
detect_paths = PackedStringArray(path_template.values())
+1
View File
@@ -0,0 +1 @@
uid://dl6cm044pihub
@@ -0,0 +1,17 @@
@tool
extends McpClient
func _init() -> void:
id = "vscode_insiders"
display_name = "VS Code Insiders"
config_type = "json"
doc_url = "https://code.visualstudio.com/docs/copilot/chat/mcp-servers"
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"}
detect_paths = PackedStringArray(path_template.values())
@@ -0,0 +1 @@
uid://cad5w4ofyg8a2
+16
View File
@@ -0,0 +1,16 @@
@tool
extends McpClient
func _init() -> void:
id = "windsurf"
display_name = "Windsurf"
config_type = "json"
doc_url = "https://docs.codeium.com/windsurf/mcp"
path_template = {
"unix": "~/.codeium/windsurf/mcp_config.json",
"windows": "$USERPROFILE/.codeium/windsurf/mcp_config.json",
}
server_key_path = PackedStringArray(["mcpServers"])
entry_url_field = "serverUrl"
detect_paths = PackedStringArray(path_template.values())
+1
View File
@@ -0,0 +1 @@
uid://b6pqiok2mlsmg
+19
View File
@@ -0,0 +1,19 @@
@tool
extends McpClient
## Zed registers MCP servers under `context_servers.<name>` and supports both
## stdio and streamable http transports.
func _init() -> void:
id = "zed"
display_name = "Zed"
config_type = "json"
doc_url = "https://zed.dev/docs/assistant/model-context-protocol"
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"])
detect_paths = PackedStringArray(path_template.values())
+1
View File
@@ -0,0 +1 @@
uid://d152l0u0r6fsc
+492
View File
@@ -0,0 +1,492 @@
@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_EVERY_N_ATTEMPTS := 10
## Backpressure policy: do not queue responses once the WebSocket's current
## outbound buffer plus the next payload would exceed this cap. Command
## responses get a compact structured error when that can still be sent;
## state events report failure so their callers can retry on a later tick.
const OUTBOUND_BUFFER_LIMIT_BYTES := 4 * 1024 * 1024
## Cap the inbound packet drain per `_process` tick. A flooding peer or a
## fast batch could otherwise saturate `_handle_message` in one frame and
## blow the documented 4ms budget. Packets beyond this cap spill to the
## next frame; the cumulative spill counter is logged so flood patterns
## are observable in `logs_read`. See audit-v2 finding #12 (issue #356).
const PACKET_DRAIN_CAP_PER_TICK := 32
const 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()
## Set by plugin.gd after resolving the configured WebSocket port once for the
## server spawn. Reconnects reuse this cached value so they keep dialing the
## same port the Python server was asked to bind.
var ws_port := ClientConfigurator.DEFAULT_WS_PORT
var _url := ""
var _connected := false
var _reconnect_attempt := 0
var _reconnect_timer := 0.0
var _session_id := ""
## 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
## 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
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()
match _peer.get_ready_state():
WebSocketPeer.STATE_OPEN:
if not _connected:
_connected = true
_reconnect_attempt = 0
log_buffer.log("connected to server")
_send_handshake()
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
_clear_on_disconnect()
var code := _peer.get_close_code()
log_buffer.log("disconnected (code %d)" % code)
connection_state_changed.emit(false)
_reconnect_timer -= delta
if _reconnect_timer <= 0.0:
_attempt_reconnect()
WebSocketPeer.STATE_CLOSING:
pass
WebSocketPeer.STATE_CONNECTING:
pass
## 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
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()
## 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)
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
if _should_log_reconnect_attempt(_reconnect_attempt):
log_buffer.log(
"reconnecting (attempt %d; next retry in %.0fs if needed)"
% [_reconnect_attempt, delay]
)
## 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()
_peer.outbound_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_attempt(attempt_number: int) -> bool:
## Log the first few failures for immediate diagnostics, then only periodic
## progress markers. Reconnect continues indefinitely; the log should not.
return (
attempt_number <= RECONNECT_VERBOSE_ATTEMPTS
or attempt_number % RECONNECT_LOG_EVERY_N_ATTEMPTS == 0
)
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({
"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(),
})
func _handle_message(raw: String) -> void:
var parsed = JSON.parse_string(raw)
if parsed == null:
push_warning("MCP: failed to parse message: %s" % raw)
return
if not (parsed is Dictionary):
return
if parsed.get("type", "") == "handshake_ack":
server_version = str(parsed.get("server_version", ""))
return
if parsed.has("request_id") and parsed.has("command"):
if dispatcher:
dispatcher.enqueue(parsed)
## 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 _send_json(response) and dispatcher != null:
dispatcher.complete_deferred_response(request_id)
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()
var _last_scene_path := ""
var _last_play_state := 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:
log_buffer.log("[event] readiness -> %s" % readiness)
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 message_bytes := text.to_utf8_buffer().size()
var buffered_bytes := _peer.get_current_outbound_buffered_amount()
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
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)
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,
},
},
}
## Build a human-readable session ID of form "<slug>@<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)
+1
View File
@@ -0,0 +1 @@
uid://bmnk8rsotiks2
@@ -0,0 +1,790 @@
@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 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 + 10s backstop = 13s, 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
## #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
var _log_buffer: McpLogBuffer
var _game_log_buffer: McpGameLogBuffer
## Pending request_id -> {connection, timer, timeout_callable}.
## We retain the bound timeout lambda so `_clear_pending` can disconnect
## it on success/error; otherwise the SceneTreeTimer pins the captured
## request_id until `timeout_sec` elapses (8s default).
var _pending: Dictionary = {}
## 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
signal game_ready
func _init(log_buffer: McpLogBuffer = null, game_log_buffer: McpGameLogBuffer = null) -> void:
_log_buffer = log_buffer
_game_log_buffer = game_log_buffer
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:
_game_ready = false
_ready_run_token = -1
_game_session_id = session_id
func begin_game_run() -> void:
_game_run_token += 1
_game_run_active = true
_game_ready = false
_ready_run_token = -1
_game_session_id = -1
if _log_buffer:
_log_buffer.log("[debug] game capture pending run token %d" % _game_run_token)
func end_game_run() -> void:
_game_run_active = false
_game_ready = false
_ready_run_token = -1
_game_session_id = -1
func is_game_capture_ready() -> bool:
return _game_run_active and _game_ready and _ready_run_token == _game_run_token
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. Also marks a fresh play
## cycle: rotate the game-log buffer so each run starts
## clean and gets a new run_id.
_game_ready = true
_ready_run_token = _game_run_token
game_ready.emit()
if _game_log_buffer:
var run_id := _game_log_buffer.clear_for_new_run()
if _log_buffer:
_log_buffer.log("[debug] <- mcp:hello from game_helper (run %s)" % run_id)
elif _log_buffer:
_log_buffer.log("[debug] <- mcp:hello from game_helper")
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)
while not is_game_capture_ready() and Time.get_ticks_msec() < deadline:
await tree.process_frame
if not is_game_capture_ready():
_send_error(connection, request_id, ErrorCodes.INTERNAL_ERROR,
"Game-side autoload never registered its debugger capture within %ds. Is the game actually running? Check Project Settings → Autoload for _mcp_game_helper." % int(GAME_READY_WAIT_SEC))
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
connection.send_deferred_response(request_id, {
"data": {
"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],
}
})
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)
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
_send_error(connection, request_id, ErrorCodes.INTERNAL_ERROR,
"Game screenshot timed out. The running game must include the _mcp_game_helper autoload (added automatically when the plugin is enabled — check Project Settings → Autoload). If the autoload is missing, re-enable the plugin and relaunch the game. For headless or custom-main-loop builds, use source='viewport' instead.")
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:
if connection == null or not is_instance_valid(connection):
return
var err := ErrorCodes.make(code, message)
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,
## and its message (the timeout_callable below) is intentionally generic. Drop
## timeout_sec at/below 8s and it pre-empts the game's actionable "Eval
## exceeded 8s" message — see the TIMEOUT ORDERING note on EVAL_TIMEOUT_SEC.
##
## #500: the *not-ready* path adds EVAL_READY_WAIT_SEC (3s) on top of this 10s
## backstop. That sum (13s) 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 — which is exactly the residual ~15s
## TimeoutError bucket #500 tracked down. Keep EVAL_READY_WAIT_SEC + timeout_sec
## < 15s if you tune either.
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
if is_game_capture_ready():
_send_eval(tree, code, request_id, connection, timeout_sec)
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)
func _wait_then_eval(
tree: SceneTree,
code: String,
request_id: String,
connection: McpConnection,
timeout_sec: float,
) -> 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.
var deadline := Time.get_ticks_msec() + int(EVAL_READY_WAIT_SEC * 1000.0)
while not is_game_capture_ready() and Time.get_ticks_msec() < deadline:
await tree.process_frame
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".
_send_error(connection, request_id, ErrorCodes.EVAL_GAME_NOT_READY,
"Game-side capture didn't register within %ds. The play session is already running, so the game is most likely still booting — wait a moment and retry. If it persists, the _mcp_game_helper autoload is missing or disabled (Project Settings → Autoload; added automatically when the plugin is enabled), or the game uses a custom main loop." % int(EVAL_READY_WAIT_SEC))
return
_send_eval(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:
var pending_entry = _pending.get(request_id)
if pending_entry == null:
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.INTERNAL_ERROR,
"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. Check logs_read(source='game')." % timeout_sec)
if _log_buffer:
_log_buffer.log("[debug] !! eval timeout (%s)" % request_id)
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] = {
"connection": connection,
"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)
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)
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
_send_error(connection, request_id, ErrorCodes.INTERNAL_ERROR, 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)
while not is_game_capture_ready() and Time.get_ticks_msec() < deadline:
await tree.process_frame
if not is_game_capture_ready():
_send_error(connection, request_id, ErrorCodes.INTERNAL_ERROR,
"Game-side autoload never registered its debugger capture within %ds. Is the game actually running?" % int(GAME_READY_WAIT_SEC))
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])
@@ -0,0 +1 @@
uid://bd1k63iye1bsl
+293
View File
@@ -0,0 +1,293 @@
@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
var _pending_deferred: Dictionary = {} # request_id -> {command, started_ms, timeout_ms}
var _log_buffer
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,
"stop_project": 4500,
"take_screenshot": 30000,
"game_eval": 15000,
"game_command": 15000,
}
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) -> void:
_log_buffer = log_buffer
## Register a command handler. The callable receives (params: Dictionary) -> Dictionary.
func register(command_name: String, handler: Callable) -> void:
_handlers[command_name] = handler
## 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:
_handlers.clear()
_command_queue.clear()
_pending_deferred.clear()
_log_buffer = null
## 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 _handlers.has(command):
return ErrorCodes.make(ErrorCodes.UNKNOWN_COMMAND, "Unknown command: %s" % command)
return _call_handler(command, params)
## Whether a command is registered.
func has_command(command: String) -> bool:
return _handlers.has(command)
## Rank registered commands by similarity to `cmd_name` and return the top `limit`
## matches. Uses Godot's built-in String.similarity() (0.01.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, _handlers.keys(), limit, threshold, 0.0, 0.0)
## 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 _handlers.has(command):
result = _call_handler(command, params)
else:
result = ErrorCodes.make(ErrorCodes.UNKNOWN_COMMAND, "Unknown command: %s" % command)
if result.get("_deferred", false):
_register_deferred(request_id, command)
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()
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:
var result: Dictionary = _handlers[command].call(params)
## 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
func _register_deferred(request_id: String, command: String) -> void:
if request_id.is_empty():
return
_pending_deferred[request_id] = {
"command": command,
"started_ms": Time.get_ticks_msec(),
"timeout_ms": _deferred_timeout_ms_for_command(command),
}
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()
responses.append(response)
if mcp_logging and _log_buffer != null:
_log_buffer.log("[defer] %s (request %s) -> timeout" % [command, request_id])
return responses
static func _capture_compact_backtrace(max_frames: int = 8) -> String:
# Use Engine.call() instead of a direct Engine.capture_script_backtraces()
# reference: the method is Godot 4.4+, and 4.3's GDScript parser type-checks
# the static call against GDScriptNativeClass at parse time and rejects the
# whole script even when guarded by has_method() at runtime.
if Engine.has_method("capture_script_backtraces"):
var traces: Array = Engine.call("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)
+1
View File
@@ -0,0 +1 @@
uid://ctldk7ivsoo3i
+95
View File
@@ -0,0 +1,95 @@
@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")
## 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:
size_flags_vertical = Control.SIZE_EXPAND_FILL
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"
_log_toggle.button_pressed = true
_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.size_flags_vertical = Control.SIZE_EXPAND_FILL
_log_display.custom_minimum_size = Vector2(0, 120)
_log_display.scroll_following = true
_log_display.bbcode_enabled = false
_log_display.selection_enabled = true
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:
_log_display.visible = enabled
logging_enabled_changed.emit(enabled)
@@ -0,0 +1 @@
uid://cr5nbnd6vj3b8
@@ -0,0 +1,78 @@
@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)
@@ -0,0 +1 @@
uid://hlggbo1q65eq
@@ -0,0 +1,71 @@
@tool
class_name McpNodeValidator
extends RefCounted
## Shared resolve-or-error helper that subsumes the 38+ sites where
## handlers each rolled their own "is the editor ready, does the path
## resolve, otherwise return EDITOR_NOT_READY / NODE_NOT_FOUND" guard.
##
## audit-v2 #20 (issue #364). Uses the audit-v2 #21 (issue #365) error
## vocabulary.
## Local const names alias the preloaded scripts. The naming choice is
## stylistic, not an upgrade-safety boundary: bare `McpErrorCodes.MEMBER`
## and `ErrorCodes.MEMBER` both depend on the Script object Godot has for
## `error_codes.gd`. The transient #398 parse errors were caused by the
## old runner scanning a mixed old/new plugin snapshot and seeing stale
## Script-object content; the runner now writes one v(N+1) snapshot before
## its scan.
const ScenePath := preload("res://addons/godot_ai/utils/scene_path.gd")
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
## Resolve a scene-relative path to the live Node, or return a structured
## error dict.
##
## Success shape: `{"node": Node, "scene_root": Node, "path": String}`.
## Error shape: matches `ErrorCodes.make(...)` so callers can
## `return resolved` to propagate.
##
## Errors (in order checked):
## - `MISSING_REQUIRED_PARAM`: `node_path` is empty
## - `EDITOR_NOT_READY`: no scene open
## - `EDITED_SCENE_MISMATCH`: caller pinned `scene_file` and the open
## scene's path doesn't match
## - `NODE_NOT_FOUND`: `node_path` doesn't resolve under the scene root
##
## `param_name` is the agent-facing name reported in the
## `MISSING_REQUIRED_PARAM` message — handlers pass "node_path",
## "player_path", "target_path", etc. so the error reads like the
## hand-written messages it replaces.
static func resolve_or_error(
node_path: String,
param_name: String = "path",
scene_file: String = "",
) -> Dictionary:
if node_path.is_empty():
return ErrorCodes.make(
ErrorCodes.MISSING_REQUIRED_PARAM,
"Missing required param: %s" % param_name,
)
var scene_check := ScenePath.require_edited_scene(scene_file)
if scene_check.has("error"):
return scene_check
var scene_root: Node = scene_check.node
var node := ScenePath.resolve(node_path, scene_root)
if node == null:
return ErrorCodes.make(
ErrorCodes.NODE_NOT_FOUND,
ScenePath.format_node_error(node_path, scene_root),
)
return {"node": node, "scene_root": scene_root, "path": node_path}
## When the caller needs the scene root but no specific node yet — e.g.
## handlers that walk children or filter by group. Returns either
## `{"scene_root": Node}` or an `ErrorCodes.make(...)` error dict.
static func require_scene_or_error(scene_file: String = "") -> Dictionary:
var scene_check := ScenePath.require_edited_scene(scene_file)
if scene_check.has("error"):
return scene_check
return {"scene_root": scene_check.node}
@@ -0,0 +1 @@
uid://dn75jifad0ghx
@@ -0,0 +1,48 @@
@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. Handlers should guard untrusted values with one of
## the require_*() helpers below and return its error dict on mismatch.
## 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)],
)
## Returns null iff `value` is an int. Floats are rejected — JSON decoders
## that emit `1.0` for an integer slot will surface a clear error here
## rather than silently truncating downstream.
static func require_int(name: String, value: Variant) -> Variant:
if typeof(value) == TYPE_INT:
return null
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"Param '%s' must be an int, got %s" % [name, type_string(typeof(value))],
)
## Returns null iff `value` is a bool.
static func require_bool(name: String, value: Variant) -> Variant:
if typeof(value) == TYPE_BOOL:
return null
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"Param '%s' must be a bool, got %s" % [name, type_string(typeof(value))],
)
@@ -0,0 +1 @@
uid://difa877m8dsla
@@ -0,0 +1,82 @@
@tool
class_name McpPropertyErrors
extends RefCounted
## Shared helper for building "Property not found" error messages that include
## "did you mean" suggestions and a tail of available property names. All
## handlers that validate user-supplied property names against a target Object
## (Node, Resource, …) should route through build_message() so agents get
## consistent, actionable errors on typos.
##
## Ranking combines Godot's built-in String.similarity() with a substring
## bonus so both "radus" → "radius" (edit distance) and "top" → "top_radius"
## (substring) surface naturally.
const _SIMILARITY_THRESHOLD: float = 0.4
const _SUBSTRING_BONUS: float = 0.5
const _MAX_SUGGESTIONS: int = 5
const _MAX_TAIL: int = 10
static func build_message(target: Object, bad_name: String) -> String:
if target == null:
return "Property '%s' not found" % bad_name
var class_label := _class_label(target)
var available := _available_property_names(target)
if available.is_empty():
return "Property '%s' not found on %s" % [bad_name, class_label]
var msg := "Property '%s' not found on %s" % [bad_name, class_label]
var suggestions := _rank_suggestions(bad_name, available)
if not suggestions.is_empty():
msg += ". Did you mean: %s?" % ", ".join(suggestions)
var tail_names := available.slice(0, min(_MAX_TAIL, available.size()))
msg += " (available: %s" % ", ".join(tail_names)
if available.size() > tail_names.size():
msg += ", ..."
msg += ")"
return msg
## Prefer a scripted class_name if the target has one, else the engine class.
static func _class_label(target: Object) -> String:
var scr := target.get_script()
if scr != null and scr.has_method("get_global_name"):
var gcn: String = scr.get_global_name()
if not gcn.is_empty():
return gcn
return target.get_class()
## Editor-visible properties, alphabetised, with internal/category entries dropped.
static func _available_property_names(target: Object) -> Array:
var names: Array = []
for p in target.get_property_list():
var usage: int = int(p.get("usage", 0))
if (usage & PROPERTY_USAGE_EDITOR) == 0:
continue
var name: String = p.get("name", "")
if name.is_empty() or name.begins_with("_"):
continue
names.append(name)
names.sort()
return names
static func _rank_suggestions(bad: String, available: Array) -> Array:
if bad.is_empty():
return []
var bad_lower := bad.to_lower()
var scored: Array = []
for n in available:
var score: float = bad.similarity(n)
if n.to_lower().find(bad_lower) != -1 or bad_lower.find(n.to_lower()) != -1:
score += _SUBSTRING_BONUS
if score >= _SIMILARITY_THRESHOLD:
scored.append([score, n])
scored.sort_custom(func(a, b): return a[0] > b[0])
var result: Array = []
for i in range(min(_MAX_SUGGESTIONS, scored.size())):
result.append(scored[i][1])
return result
@@ -0,0 +1 @@
uid://c74d560g4l86b
@@ -0,0 +1,825 @@
@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.INVALID_PARAMS,
"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")
@@ -0,0 +1 @@
uid://c0jrius46xsd4
@@ -0,0 +1,528 @@
@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(ErrorCodes.EDITOR_NOT_READY, "AnimationHandler not available")
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(ErrorCodes.EDITOR_NOT_READY, "AnimationHandler not available")
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(ErrorCodes.EDITOR_NOT_READY, "AnimationHandler not available")
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(ErrorCodes.EDITOR_NOT_READY, "AnimationHandler not available")
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
@@ -0,0 +1 @@
uid://c4s3h78bwvr6w
@@ -0,0 +1,465 @@
@tool
extends RefCounted
## 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(ErrorCodes.EDITOR_NOT_READY, "AnimationHandler not available")
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(ErrorCodes.EDITOR_NOT_READY, "AnimationHandler not available")
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(ErrorCodes.EDITOR_NOT_READY, "AnimationHandler not available")
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:
if value is Color:
return {"ok": value}
if value is String:
var s := value as String
var a := Color.from_string(s, Color(0, 0, 0, 0))
var b := Color.from_string(s, Color(1, 1, 1, 1))
if a == b:
return {"ok": a}
return {"error": "Cannot parse '%s' as Color for property '%s'" % [s, prop_name]}
if value is Dictionary and value.has("r") and value.has("g") and value.has("b"):
return {"ok": Color(float(value.r), float(value.g), float(value.b), float(value.get("a", 1.0)))}
return {"error": "Cannot coerce value to Color for property '%s' (expected string, {r,g,b}, or Color)" % prop_name}
TYPE_VECTOR2:
if value is Vector2:
return {"ok": value}
if value is Dictionary and value.has("x") and value.has("y"):
return {"ok": Vector2(float(value.x), float(value.y))}
if value is Array and value.size() >= 2:
return {"ok": Vector2(float(value[0]), float(value[1]))}
return {"error": "Cannot coerce value to Vector2 for property '%s' (expected {x,y}, [x,y], or Vector2)" % prop_name}
TYPE_VECTOR3:
if value is Vector3:
return {"ok": value}
if value is Dictionary and value.has("x") and value.has("y") and value.has("z"):
return {"ok": Vector3(float(value.x), float(value.y), float(value.z))}
return {"error": "Cannot coerce value to Vector3 for property '%s' (expected {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:
if value == null:
return null
match typeof(value):
TYPE_BOOL, TYPE_INT, TYPE_FLOAT, TYPE_STRING:
return value
TYPE_STRING_NAME:
return str(value)
TYPE_VECTOR2:
return {"x": value.x, "y": value.y}
TYPE_VECTOR3:
return {"x": value.x, "y": value.y, "z": value.z}
TYPE_COLOR:
return {"r": value.r, "g": value.g, "b": value.b, "a": value.a}
TYPE_NODE_PATH:
return str(value)
TYPE_ARRAY:
var arr: Array = []
for item in value:
arr.append(serialize_value(item))
return arr
TYPE_DICTIONARY:
var out := {}
for k in value:
out[str(k)] = serialize_value(value[k])
return out
return str(value)
@@ -0,0 +1 @@
uid://bguta2eb8blgf
+89
View File
@@ -0,0 +1,89 @@
@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.KNOWN_SECTIONS,
3,
0.3
)
var message := "Unknown class-info section(s): %s. Valid sections: %s" % [
", ".join(invalid_sections),
", ".join(ClassIntrospection.KNOWN_SECTIONS),
]
var result := ErrorCodes.make(ErrorCodes.INVALID_PARAMS, message)
result["error"]["data"] = {"suggestions": suggestions}
return result
@@ -0,0 +1 @@
uid://v3rkd7ueunii
+359
View File
@@ -0,0 +1,359 @@
@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(ErrorCodes.EDITOR_NOT_READY, "EditorFileSystem not available")
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
@@ -0,0 +1 @@
uid://cjtvod52xxocs
@@ -0,0 +1,91 @@
@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",
}
}
@@ -0,0 +1 @@
uid://bb0inov044jn6
+131
View File
@@ -0,0 +1,131 @@
@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.
const FORBIDDEN_SUBCOMMANDS := ["batch_execute"]
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")
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 cmd_name in FORBIDDEN_SUBCOMMANDS:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "commands[%d]: '%s' is not allowed as a sub-command" % [idx, cmd_name])
if not _dispatcher.has_command(cmd_name):
return _unknown_command_error(idx, cmd_name)
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
@@ -0,0 +1 @@
uid://dt7um75oofdrh
+1151
View File
@@ -0,0 +1,1151 @@
@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
func _is_logical_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))
return logical != null and logical == cam
# 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 not found: %s" % target_path)
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
@@ -0,0 +1 @@
uid://c0lcviccrlrl8
@@ -0,0 +1,81 @@
@tool
extends RefCounted
## Opinionated Camera2D / Camera3D presets.
##
## build(preset_name, overrides) -> {default_type, properties} | null
## properties are merged with caller overrides (overrides win).
const _PRESETS := {
# Top-down roguelite / arena — damped follow feel, drag deadzone.
"topdown_2d": {
"default_type": "2d",
"properties": {
"zoom": {"x": 2.0, "y": 2.0},
"anchor_mode": "drag_center",
"position_smoothing_enabled": true,
"position_smoothing_speed": 5.0,
"rotation_smoothing_enabled": false,
"drag_horizontal_enabled": true,
"drag_vertical_enabled": true,
"drag_left_margin": 0.2,
"drag_right_margin": 0.2,
"drag_top_margin": 0.2,
"drag_bottom_margin": 0.2,
},
},
# Platformer — tight horizontal follow, vertical snap with smoothing on.
"platformer_2d": {
"default_type": "2d",
"properties": {
"zoom": {"x": 1.5, "y": 1.5},
"anchor_mode": "drag_center",
"position_smoothing_enabled": true,
"position_smoothing_speed": 8.0,
"drag_horizontal_enabled": true,
"drag_vertical_enabled": false,
"drag_left_margin": 0.15,
"drag_right_margin": 0.15,
},
},
# Cinematic 3D — narrow FOV, long range. Good for dramatic wide shots.
"cinematic_3d": {
"default_type": "3d",
"properties": {
"fov": 40.0,
"near": 0.1,
"far": 500.0,
"projection": "perspective",
},
},
# Action 3D — wider FOV for first/third-person action gameplay.
"action_3d": {
"default_type": "3d",
"properties": {
"fov": 70.0,
"near": 0.1,
"far": 200.0,
"projection": "perspective",
},
},
}
static func list_presets() -> Array:
return _PRESETS.keys()
## Build a preset blueprint. Returns null if preset_name is unknown.
## overrides is merged on top of preset defaults (caller values win).
static func build(preset_name: String, overrides: Dictionary) -> Variant:
if not _PRESETS.has(preset_name):
return null
var preset: Dictionary = _PRESETS[preset_name]
var properties: Dictionary = (preset.get("properties", {}) as Dictionary).duplicate(true)
for key in overrides:
properties[key] = overrides[key]
return {
"default_type": preset.get("default_type", "2d"),
"properties": properties,
}
@@ -0,0 +1 @@
uid://bl3rfy72o3wy5
+143
View File
@@ -0,0 +1,143 @@
@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:
if value is Vector2:
return value
if value is Dictionary:
var d: Dictionary = value
return Vector2(float(d.get("x", 0)), float(d.get("y", 0)))
if value is Array and value.size() >= 2:
return Vector2(float(value[0]), float(value[1]))
if value is int or value is float:
return Vector2(float(value), float(value))
return null
static func parse_vector3(value: Variant) -> Variant:
if value is Vector3:
return value
if value is Dictionary:
var d: Dictionary = value
return Vector3(float(d.get("x", 0)), float(d.get("y", 0)), float(d.get("z", 0)))
if value is Array and value.size() >= 3:
return Vector3(float(value[0]), float(value[1]), float(value[2]))
return null
## 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
@@ -0,0 +1 @@
uid://bgjnubgnv6ses
@@ -0,0 +1,43 @@
@tool
extends RefCounted
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
## Handles MCP client configuration commands.
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 clients := []
for client_id in McpClientConfigurator.client_ids():
var status := McpClientConfigurator.check_status(client_id)
clients.append({
"id": client_id,
"display_name": McpClientConfigurator.client_display_name(client_id),
"status": McpClient.status_label(status),
"installed": McpClientConfigurator.is_installed(client_id),
})
return {"data": {"clients": clients}}
@@ -0,0 +1 @@
uid://bmo4foc5fq75c
@@ -0,0 +1,325 @@
@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,
}
}
## Populate a freshly-instantiated Control with the draw recipe in memory
## (no undo action). Used by PR2's pattern_corner_brackets, which wraps the
## node-add + set_script/set_meta in its own create_action.
static func attach_recipe_to(node: Control, coerced_ops: Array) -> void:
node.set_script(DRAW_RECIPE_SCRIPT)
node.set_meta("_ops", coerced_ops)
## 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}
@@ -0,0 +1 @@
uid://buat1mt0fjlqb
+243
View File
@@ -0,0 +1,243 @@
@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,
"Property '%s' not found on %s" % [property, node.get_class()]
)
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)
@@ -0,0 +1 @@
uid://dboqr06a1fvqx
+1098
View File
@@ -0,0 +1,1098 @@
@tool
extends RefCounted
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
const Telemetry := preload("res://addons/godot_ai/telemetry.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 _debugger_search_root_cache: Node
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) -> 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
func get_editor_state(_params: Dictionary) -> Dictionary:
var scene_root := EditorInterface.get_edited_scene_root()
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(),
}
## 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"]
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))
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)
"editor":
return _get_editor_logs(count, offset, include_details)
"all":
return _get_all_logs(count, offset, include_details)
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Unreachable")
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) -> Dictionary:
if _game_log_buffer == null:
return {
"data": {
"source": "game",
"lines": [],
"total_count": 0,
"returned_count": 0,
"offset": offset,
"run_id": "",
"is_running": false,
"dropped_count": 0,
}
}
var page := _entries_for_response(_game_log_buffer.get_range(offset, count), include_details)
return {
"data": {
"source": "game",
"lines": page,
"total_count": _game_log_buffer.total_count(),
"returned_count": page.size(),
"offset": offset,
"run_id": _game_log_buffer.run_id(),
"is_running": EditorInterface.is_playing_scene(),
"dropped_count": _game_log_buffer.dropped_count(),
}
}
func _get_editor_logs(count: int, offset: int, include_details: bool) -> 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.
var all_entries := _collect_editor_log_entries()
var page := _entries_for_response(_slice_entries(all_entries, offset, count), include_details)
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,
}
}
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)
if _game_log_buffer != null:
for entry in _game_log_buffer.get_range(0, _game_log_buffer.total_count()):
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)
var run_id := ""
var dropped := 0
if _game_log_buffer != null:
run_id = _game_log_buffer.run_id()
dropped = _game_log_buffer.dropped_count()
if _editor_log_buffer != null:
dropped += _editor_log_buffer.dropped_count()
return {
"data": {
"source": "all",
"lines": page,
"total_count": combined.size(),
"returned_count": page.size(),
"offset": offset,
"run_id": run_id,
"is_running": EditorInterface.is_playing_scene(),
"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]:
var entries: Array[Dictionary] = []
if _editor_log_buffer != null:
for entry in _editor_log_buffer.get_range(0, _editor_log_buffer.total_count()):
entries.append(entry)
for entry in _read_debugger_error_entries():
if not _has_equivalent_log_entry(entries, entry):
entries.append(entry)
return entries
func _read_debugger_error_entries() -> Array[Dictionary]:
var entries: Array[Dictionary] = []
for tree in _locate_debugger_error_trees():
for entry in _entries_from_debugger_error_tree(tree):
if not _has_equivalent_log_entry(entries, entry):
entries.append(entry)
return entries
func _locate_debugger_error_trees() -> Array[Tree]:
var trees: Array[Tree] = []
if _debugger_plugin == null and _debugger_errors_root == null:
return trees
var root: Node = _debugger_errors_root
if root == null:
root = _debugger_search_root()
if root == null:
return trees
_collect_debugger_error_trees(root, trees)
return trees
func _debugger_search_root() -> Node:
## logs_read is a polling tool, so per-call discovery must not recurse the
## entire editor UI. EditorDebuggerNode is the bottom-panel container that
## owns every ScriptEditorDebugger session tab and lives for the editor's
## lifetime — find it once from the base control, then scan only its
## subtree on later calls. The error Trees themselves can't be cached:
## they are identified by their content, and an emptied tree is
## indistinguishable from any other Tree.
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 _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")
## ScriptEditorDebugger lays out an error item's children flat, in order: an
## optional "<X Error>" row, one "<X Source>" row, then one row per stack
## frame. Only frame 0 carries the "<Stack Trace>" label (TTR-translated);
## later frames have an empty label. Every frame row carries [path, line]
## metadata, but so can the Error/Source rows, so metadata alone can't
## identify frames — the frame run has to be found first.
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:
## Non-English editor locale: the "<Stack Trace>" label is translated.
## Frames past the first are the only rows with an empty label and a
## real location; back up one row to recover the labeled first frame
## (rows before the frame run always have a non-empty label).
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
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
static func _has_equivalent_log_entry(entries: Array[Dictionary], candidate: Dictionary) -> bool:
var key := _log_entry_key(candidate)
for entry in entries:
if _log_entry_key(entry) == key:
return true
return false
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)),
]
## 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:
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(ErrorCodes.EDITOR_NOT_READY, "No 3D viewport available")
## 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():
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "Game is not running — use source='viewport' or start the project first")
## 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)
_:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Invalid source '%s' — use 'viewport', '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(ErrorCodes.EDITOR_NOT_READY, "No camera in 3D viewport")
## 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
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:
return _make_viewport_not_3d_error(
"",
"The editor 3D viewport is empty because no scene is open. Open a scene with `scene_open` first."
)
## 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
if scene_root is Node2D or scene_root is Control:
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) 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
return _make_viewport_not_3d_error(root_type, hint)
## 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(ErrorCodes.EDITOR_NOT_READY, hint)
err["error"]["data"] = {
"editor_state": "viewport_not_3d",
"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:
var err := ErrorCodes.make(ErrorCodes.EDITOR_NOT_READY, hint)
err["error"]["data"] = {
"editor_state": "viewport_empty",
"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:
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)
var img_bytes := image.save_png_to_buffer()
var base64_str := Marshalls.raw_to_base64(img_bytes)
return {
"data": {
"source": source,
"width": image.get_width(),
"height": image.get_height(),
"original_width": original_width,
"original_height": original_height,
"format": "png",
"image_base64": base64_str,
}
}
## 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:
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):
## No Clear button near this tree (synthetic roots in tests).
## A raw clear is acceptable there; the real panel always routes
## through the button below.
tree.clear()
return cleared
## Clear via ScriptEditorDebugger's own Clear button so the engine runs
## _clear_errors_list() — clearing the Tree directly leaves error_count/
## warning_count, the "Errors (N)" tab badge, the errors_cleared signal, and
## the toolbar button states out of sync with the emptied tree. The button is
## identified by its pressed-connection target, not its (translated) label.
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
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.
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(ErrorCodes.EDITOR_NOT_READY,
"Game is not running — start the project first")
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(ErrorCodes.EDITOR_NOT_READY,
"Game is not running — start the project first")
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", {})
_debugger_plugin.request_game_command(op, command_params, request_id, _connection)
return McpDispatcher.DEFERRED_RESPONSE
@@ -0,0 +1 @@
uid://dcro7yc8bor6v
@@ -0,0 +1,181 @@
@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)
@@ -0,0 +1 @@
uid://b1k7jldwjp5jt
@@ -0,0 +1,112 @@
@tool
extends RefCounted
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
## Handles file read/write operations and reimport within the Godot project.
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
# 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 existed_before := FileAccess.file_exists(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.close()
# 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",
}
McpResourceIO.attach_cleanup_hint(data, existed_before, [path])
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(ErrorCodes.EDITOR_NOT_READY, "EditorFileSystem not available")
var reimported: 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)
reimported.append(path)
return {
"data": {
"reimported": reimported,
"not_found": not_found,
"reimported_count": reimported.size(),
"not_found_count": not_found.size(),
"undoable": false,
"reason": "Reimport is a file system operation",
}
}
@@ -0,0 +1 @@
uid://c7ovtpdiumtju
+278
View File
@@ -0,0 +1,278 @@
@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] = []
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
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,
})
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"):
result[key] = true
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")
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)
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 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")
if not InputMap.has_action(action):
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Action '%s' not found" % action)
var key := "input/%s" % action
var old_setting = ProjectSettings.get_setting(key) if ProjectSettings.has_setting(key) else null
InputMap.erase_action(action)
if old_setting != null:
ProjectSettings.clear(key)
var err := ProjectSettings.save()
if err != OK:
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,
"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",
}
}
## 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)
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
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 _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)
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
@@ -0,0 +1 @@
uid://buk68rbwssqwp
@@ -0,0 +1,788 @@
@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
func _init(undo_redo: EditorUndoRedoManager) -> void:
_undo_redo = undo_redo
# ============================================================================
# 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 := ResourceSaver.save(mat, path)
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(ErrorCodes.EDITOR_NOT_READY, "EditorFileSystem not available")
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
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
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 := ResourceSaver.save(mat, save_to)
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 "",
"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.
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 path_err := _validate_material_path(path, "path", true)
if path_err != null:
return path_err
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 := ResourceSaver.save(mat, path)
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:
return
mat.set(property, value)
ResourceSaver.save(mat, mat_path)
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):
return
(mat as ShaderMaterial).set_shader_parameter(param_name, value)
ResourceSaver.save(mat, mat_path)
# ============================================================================
# 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
@@ -0,0 +1 @@
uid://blh4norn3rjga
@@ -0,0 +1,92 @@
@tool
extends RefCounted
## Curated material preset blueprints.
##
## Each preset returns {type, params}. Handler applies them through the
## normal material build path so they get undo + validation for free.
const _PRESETS := {
"metal": {
"type": "orm",
"params": {
"metallic": 1.0,
"roughness": 0.25,
"albedo_color": {"r": 0.85, "g": 0.85, "b": 0.88, "a": 1.0},
},
},
"glass": {
"type": "standard",
"params": {
"transparency": "alpha",
"albedo_color": {"r": 0.9, "g": 0.95, "b": 1.0, "a": 0.3},
"metallic": 0.0,
"metallic_specular": 0.5,
"roughness": 0.05,
"refraction_enabled": true,
"refraction_scale": 0.05,
},
},
"emissive": {
"type": "standard",
"params": {
"emission_enabled": true,
"emission_energy_multiplier": 3.0,
"emission": {"r": 1.0, "g": 1.0, "b": 1.0, "a": 1.0},
"albedo_color": {"r": 1.0, "g": 1.0, "b": 1.0, "a": 1.0},
},
},
"unlit": {
"type": "standard",
"params": {
"shading_mode": "unshaded",
"albedo_color": {"r": 1.0, "g": 1.0, "b": 1.0, "a": 1.0},
},
},
"matte": {
"type": "standard",
"params": {
"roughness": 1.0,
"metallic": 0.0,
"albedo_color": {"r": 0.7, "g": 0.7, "b": 0.7, "a": 1.0},
},
},
"ceramic": {
"type": "standard",
"params": {
"roughness": 0.4,
"metallic": 0.0,
"clearcoat_enabled": true,
"clearcoat": 0.7,
"clearcoat_roughness": 0.15,
"albedo_color": {"r": 0.95, "g": 0.95, "b": 0.95, "a": 1.0},
},
},
}
static func list() -> Array:
return _PRESETS.keys()
static func has(preset_name: String) -> bool:
return _PRESETS.has(preset_name)
## Returns a deep-copied {type, params} blueprint for the named preset, or
## null if the preset is unknown. Overrides are merged into params.
static func build(preset_name: String, overrides: Dictionary) -> Variant:
if not _PRESETS.has(preset_name):
return null
var entry: Dictionary = _PRESETS[preset_name].duplicate(true)
var params: Dictionary = entry.get("params", {})
# Allow overrides to change type, too.
if overrides.has("type"):
entry["type"] = overrides["type"]
for key in overrides:
if key == "type":
continue
params[key] = overrides[key]
entry["params"] = params
return entry
@@ -0,0 +1 @@
uid://bnuwye1r8ow7g
+255
View File
@@ -0,0 +1,255 @@
@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", "#rrggbbaa", named (red/blue/...) or dict.
## Returns null if the input cannot be parsed.
static func parse_color(value: Variant) -> Variant:
if value is Color:
return value
if value is String:
var s: String = value
var sentinel_a := Color(0, 0, 0, 0)
var sentinel_b := Color(1, 1, 1, 1)
var a := Color.from_string(s, sentinel_a)
var b := Color.from_string(s, sentinel_b)
if a != b:
return null
return a
if value is Dictionary:
var d: Dictionary = value
if d.has("r") and d.has("g") and d.has("b"):
return Color(float(d.r), float(d.g), float(d.b), float(d.get("a", 1.0)))
if value is Array and value.size() >= 3:
var arr: Array = value
var alpha := float(arr[3]) if arr.size() >= 4 else 1.0
return Color(float(arr[0]), float(arr[1]), float(arr[2]), alpha)
return null
static func parse_vector3(value: Variant) -> Variant:
if value is Vector3:
return value
if value is Dictionary:
var d: Dictionary = value
return Vector3(float(d.get("x", 0)), float(d.get("y", 0)), float(d.get("z", 0)))
if value is Array and value.size() >= 3:
return Vector3(float(value[0]), float(value[1]), float(value[2]))
return null
static func parse_vector2(value: Variant) -> Variant:
if value is Vector2:
return value
if value is Dictionary:
var d: Dictionary = value
return Vector2(float(d.get("x", 0)), float(d.get("y", 0)))
if value is Array and value.size() >= 2:
return Vector2(float(value[0]), float(value[1]))
return null
## Parse a {stops: [{time, color}]} gradient dict into a Gradient resource.
static func parse_gradient(value: Variant) -> Variant:
if value is Gradient:
return value
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 = PackedFloat32Array()
var colors: PackedColorArray = PackedColorArray()
for stop in stops_array:
if not (stop is Dictionary):
return null
var t := float(stop.get("time", 0.0))
var c = parse_color(stop.get("color"))
if c == null:
return null
offsets.append(t)
colors.append(c)
var grad := Gradient.new()
grad.offsets = offsets
grad.colors = colors
return grad
## 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
@@ -0,0 +1 @@
uid://daqgjkflia8nk
+866
View File
@@ -0,0 +1,866 @@
@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 class_err := ResourceHandler._validate_resource_class(type_str)
if class_err != null:
return class_err
var instance := ClassDB.instantiate(type_str)
if instance == null or not (instance is Resource):
return ErrorCodes.make(
ErrorCodes.INTERNAL_ERROR,
"Failed to instantiate %s as a Resource" % type_str
)
var res: Resource = instance
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
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 `/<root>` (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 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_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
_:
return null
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_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 "[\"...\", ...]"
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 <Type>` 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:
TYPE_VECTOR2:
if value is Dictionary and value.has_all(VECTOR2_KEYS):
return Vector2(value["x"], value["y"])
TYPE_VECTOR3:
if value is Dictionary and value.has_all(VECTOR3_KEYS):
return Vector3(value["x"], value["y"], value["z"])
TYPE_COLOR:
if value is Dictionary and value.has_all(COLOR_KEYS):
return Color(value["r"], value["g"], value["b"], value.get("a", 1.0))
if value is String:
return Color(value)
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_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
# PackedByteArray intentionally unhandled — needs design decision
# (base64 string vs. raw int list); JSON has no native byte type.
return value
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
var properties: Array[Dictionary] = []
for prop in node.get_property_list():
var usage: int = prop.get("usage", 0)
if not (usage & PROPERTY_USAGE_EDITOR):
continue
# Safe read: custom script getters can error; skip bad properties
# rather than letting one bad read timeout the entire request.
var value = node.get(prop.name)
if value == null and prop.type != TYPE_NIL:
continue
properties.append({
"name": prop.name,
"type": type_string(prop.type),
"value": _serialize_value(value),
})
return {
"data": {
"path": node_path,
"node_type": node.get_class(),
"properties": properties,
"count": properties.size(),
}
}
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 <op> 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)
@@ -0,0 +1 @@
uid://qhhd5mm5awym
@@ -0,0 +1,761 @@
@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({})
(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
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":
draw_mesh = QuadMesh.new()
(draw_mesh as QuadMesh).size = Vector2(0.25, 0.25)
var draw_config: Dictionary = blueprint.get("draw", {})
draw_material = ParticleValues.build_draw_material(draw_config)
(draw_mesh as QuadMesh).material = draw_material
draw_pass_mesh_created = true
draw_material_created = true
# 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:
continue # Silently skip: 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:
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:
continue # Silently skip: 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:
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,
"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
# ============================================================================
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
@@ -0,0 +1 @@
uid://byfc0pnyb5qww
@@ -0,0 +1,282 @@
@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.
## Overrides may include top-level "main" / "process" / "draw" dicts, or bare
## keys that get routed based on which group they belong to.
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", {})
for key in overrides:
var val = overrides[key]
if key == "main" and val is Dictionary:
for k in val:
main[k] = val[k]
elif key == "process" and val is Dictionary:
for k in val:
process[k] = val[k]
elif key == "draw" and val is Dictionary:
for k in val:
draw[k] = val[k]
elif _MAIN_KEYS.has(key):
main[key] = val
else:
process[key] = val
entry["main"] = main
entry["process"] = process
entry["draw"] = draw
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,
}
@@ -0,0 +1 @@
uid://bss2ccpmsxo4p
+228
View File
@@ -0,0 +1,228 @@
@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())
static func build_draw_material(config: Dictionary) -> StandardMaterial3D:
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.
for key in config:
var prop_name := String(key)
var prop_type := _object_property_type(mat, prop_name)
if prop_type == TYPE_NIL:
continue
var coerce_result := MaterialValues.coerce_material_value(
prop_name, config[prop_name], prop_type
)
if coerce_result.ok:
mat.set(prop_name, coerce_result.value)
return mat
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)
@@ -0,0 +1 @@
uid://bnnnjq06dmclc
@@ -0,0 +1,337 @@
@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 node not found: %s" % source_path)
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: <error dict>}` 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}
@@ -0,0 +1 @@
uid://cdg8kthqla1cj
+262
View File
@@ -0,0 +1,262 @@
@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")
var _connection: McpConnection
var _debugger_plugin
func _init(connection: McpConnection = null, debugger_plugin = null) -> void:
_connection = connection
_debugger_plugin = debugger_plugin
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")
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 {
"data": {
"mode": mode,
"scene": params.get("scene", ""),
"autosave": autosave,
"was_already_running": true,
"undoable": false,
"reason": "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')")
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()
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
return {
"data": {
"mode": mode,
"scene": params.get("scene", ""),
"autosave": autosave,
"was_already_running": false,
"undoable": false,
"reason": "Play/stop is a runtime action",
}
}
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)
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",
}
}
func _finish_stop_project_deferred(request_id: String) -> void:
# 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.
var tree := _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(ErrorCodes.EDITOR_NOT_READY, "EditorFileSystem not available")
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)
@@ -0,0 +1 @@
uid://brf8u32hvha68
@@ -0,0 +1,398 @@
@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(ErrorCodes.EDITOR_NOT_READY, "EditorFileSystem not available")
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 class_err := _validate_resource_class(type_str)
if class_err != null:
return class_err
var instance := ClassDB.instantiate(type_str)
if instance == null:
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate %s" % type_str)
if not (instance is Resource):
return ErrorCodes.make(
ErrorCodes.INTERNAL_ERROR,
"Instantiated %s but result is not a Resource (got %s)" % [type_str, instance.get_class()]
)
var res: Resource = instance
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
## 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.
static func _apply_resource_properties(res: Resource, properties: Dictionary) -> Variant:
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()
var err := ErrorCodes.make(
ErrorCodes.PROPERTY_NOT_ON_CLASS,
"Property '%s' not found on %s. Call resource_get_info('%s') to list available properties." % [key, res.get_class(), res.get_class()]
)
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__", "")
var class_err := _validate_resource_class(sub_type)
if class_err != null:
return class_err
var sub_instance := ClassDB.instantiate(sub_type)
if sub_instance == null or not (sub_instance is Resource):
return ErrorCodes.make(
ErrorCodes.INTERNAL_ERROR,
"Failed to instantiate %s as a Resource for property '%s'" % [sub_type, key]
)
var sub_res: Resource = sub_instance
var remaining: Dictionary = (v as Dictionary).duplicate()
remaining.erase("__class__")
if not remaining.is_empty():
var nested_err := _apply_resource_properties(sub_res, remaining)
if nested_err != null:
return nested_err
v = sub_res
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,
"Property '%s' not found on %s" % [property, node.get_class()]
)
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):
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_* 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}
@@ -0,0 +1 @@
uid://dwwd0n3c56ir
+267
View File
@@ -0,0 +1,267 @@
@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 scene_root := EditorInterface.get_edited_scene_root()
if scene_root == null:
return {"data": {"nodes": [], "message": "No scene open"}}
var nodes: Array[Dictionary] = []
_walk_tree(scene_root, nodes, 0, max_depth, scene_root)
return {"data": {"nodes": nodes, "total_count": nodes.size()}}
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
var packed := PackedScene.new()
packed.pack(root)
root.free()
if _connection:
_connection.pause_processing = true
var err := ResourceSaver.save(packed, path)
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",
}
}
## Open an existing scene by file path.
func open_scene(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, "Scene not found: %s" % path)
EditorInterface.open_scene_from_path(path)
return {
"data": {
"path": path,
"undoable": false,
"reason": "Scene navigation cannot be undone via editor undo",
}
}
## 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) -> void:
if depth > max_depth:
return
out.append({
"name": node.name,
"type": node.get_class(),
"path": McpScenePath.from_node(node, scene_root),
"children_count": node.get_child_count(),
})
for child in node.get_children():
_walk_tree(child, out, depth + 1, max_depth, scene_root)
@@ -0,0 +1 @@
uid://7ms40gm6t2r4
+398
View File
@@ -0,0 +1,398 @@
@tool
extends RefCounted
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
## Handles script creation, reading, attaching, detaching, and symbol inspection.
var _undo_redo: EditorUndoRedoManager
var _connection: McpConnection
# Bounded settle window for `ResourceLoader.exists(path)` after `scan()` so
# that an agent calling create_script -> attach_script back-to-back doesn't
# race the editor's import pipeline (#261). Polled once per frame, with an
# elapsed-time cap below the dispatcher's create_script deferred timeout. If
# import is still not visible at the cap, we still return committed=true
# instead of letting the already-written file surface as DEFERRED_TIMEOUT.
const _IMPORT_SETTLE_MAX_FRAMES := 300
const _IMPORT_SETTLE_MAX_MSEC := 3500
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")
# 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 existed_before := FileAccess.file_exists(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.close()
# 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)
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",
}
# `.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():
_finish_create_script_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}
# `static` is load-bearing: the deferred completion captures no `self`, so the
# coroutine survives even if the ScriptHandler RefCounted is freed mid-await.
# Under concurrent script_create storms with editor_reload_plugin fired during
# the burst, the handler instance is otherwise GC'd between `await` and resume,
# producing "Resumed function '_finish_create_script_deferred()' 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.
static func _finish_create_script_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})
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),
}
}
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
var write := FileAccess.open(path, FileAccess.WRITE)
if write == null:
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to open file for writing: %s" % path)
write.store_string(new_content)
write.close()
# 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": {
"path": path,
"replacements": replacements,
"size": new_content.length(),
"old_size": content.length(),
"undoable": false,
"reason": "File system operations cannot be undone via editor undo",
}
}
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
if line.begins_with("class_name "):
class_name_str = line.substr(11).strip_edges()
# 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(),
}
}
@@ -0,0 +1 @@
uid://dhub87454jxb3
+258
View File
@@ -0,0 +1,258 @@
@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: String = params.get("path", "")
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/<Name>`` 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/<Name>/...``) 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)
_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])
_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)
_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,
}
@@ -0,0 +1 @@
uid://b4n8byjeqeddm
+82
View File
@@ -0,0 +1,82 @@
@tool
extends RefCounted
## Discovers and runs McpTestSuite scripts from res://tests/.
## Exposes run_tests and get_test_results as MCP commands.
var _runner: McpTestRunner
var _undo_redo: EditorUndoRedoManager
var _log_buffer: McpLogBuffer
func _init(undo_redo: EditorUndoRedoManager, log_buffer: McpLogBuffer) -> void:
_runner = McpTestRunner.new()
_undo_redo = undo_redo
_log_buffer = log_buffer
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 discovery := _discover_suites()
var suites: Array = discovery.suites
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),
]
return {"data": {"error": msg, "total": 0, "load_errors": discovery.errors}}
var ctx := {
"undo_redo": _undo_redo,
"log_buffer": _log_buffer,
}
var results := _runner.run_suites(suites, suite_filter, test_filter, ctx, verbose, exclude_test_filter)
if not discovery.errors.is_empty():
results["load_errors"] = discovery.errors
return {"data": results}
func get_test_results(params: Dictionary) -> Dictionary:
var verbose: bool = params.get("verbose", false)
return {"data": _runner.get_results(verbose)}
func _discover_suites() -> Dictionary:
## Returns {"suites": Array, "errors": Array[String]}.
## Resilient: a broken script doesn't kill discovery of the rest.
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"]}
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 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}
@@ -0,0 +1 @@
uid://bfg3c6iinhwmx
+199
View File
@@ -0,0 +1,199 @@
@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,
"Property '%s' not found on %s" % [property, node.get_class()]
)
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}
@@ -0,0 +1 @@
uid://cmloikhre8lhe
+488
View File
@@ -0,0 +1,488 @@
@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
func _init(undo_redo: EditorUndoRedoManager) -> void:
_undo_redo = undo_redo
# ============================================================================
# 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 := ResourceSaver.save(theme, path)
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)
ResourceSaver.save(theme, theme_path)
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)
ResourceSaver.save(theme, theme_path)
# ============================================================================
# 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, <side1>, <side2>, ...} dict and apply it to StyleBoxFlat via
## its set_<prop_prefix><side> 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)
ResourceSaver.save(theme, theme_path)
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)
ResourceSaver.save(theme, theme_path)
# ============================================================================
# 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.
static func _parse_color(value: Variant) -> Variant:
if value is Color:
return value
if value is String:
var s: String = value
# Color.from_string returns the default on parse failure, so call it twice
# with distinct sentinels — if both agree, parsing succeeded.
var sentinel_a := Color(0, 0, 0, 0)
var sentinel_b := Color(1, 1, 1, 1)
var a := Color.from_string(s, sentinel_a)
var b := Color.from_string(s, sentinel_b)
if a != b:
return null
return a
if value is Dictionary:
var d: Dictionary = value
if d.has("r") and d.has("g") and d.has("b"):
return Color(float(d.r), float(d.g), float(d.b), float(d.get("a", 1.0)))
return null
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
@@ -0,0 +1 @@
uid://gjyldaddj7mu
+533
View File
@@ -0,0 +1,533 @@
@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:
if value is Color:
return {"ok": true, "value": value}
if value is String:
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 {"ok": true, "value": a}
return {"ok": false}
if value is Dictionary and value.has("r") and value.has("g") and value.has("b"):
return {
"ok": true,
"value": Color(float(value.r), float(value.g), float(value.b), float(value.get("a", 1.0))),
}
return {"ok": false}
TYPE_VECTOR2:
if value is Vector2:
return {"ok": true, "value": value}
if value is Dictionary and value.has("x") and value.has("y"):
return {"ok": true, "value": Vector2(float(value.x), float(value.y))}
if value is Array and value.size() == 2:
return {"ok": true, "value": Vector2(float(value[0]), float(value[1]))}
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."
)
@@ -0,0 +1 @@
uid://ckm6f1objpgvw
+2496
View File
@@ -0,0 +1,2496 @@
@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 DEV_MODE_SETTING := "godot_ai/dev_mode"
const CLIENT_STATUS_REFRESH_COOLDOWN_MSEC := 15 * 1000
const CLIENT_STATUS_REFRESH_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 _client_grid: VBoxContainer
var _client_configure_all_btn: Button
var _clients_summary_label: Label
var _clients_window: Window
var _dev_mode_toggle: CheckButton
var _install_label: Label
# Settings 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 & 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()
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
## Per-client UI handles, keyed by client id. Each entry holds the row's
## status dot, configure button, remove button, manual-command panel + text.
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
## 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] = []
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) and apply the result via call_deferred
## once the subprocess returns or the wall-clock budget in McpCliExec
## kicks in. 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.
##
## No orphan-thread list (unlike the refresh worker): action threads
## never get abandoned mid-flight. McpCliExec's wall-clock budget caps
## the worst case at ~10s, so the `_exit_tree` / `McpUpdateManager`
## install-time drain blocks briefly and finishes — there's no path that
## "gives up" on an action thread the way `_abandon_client_status_refresh_thread`
## does for the refresh worker.
var _client_action_threads: Dictionary = {}
var _client_action_generations: Dictionary = {}
# 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
var _last_connected := false
var _last_status_text := ""
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
## 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:
if _connection == null:
return
_prune_orphaned_client_status_refresh_threads()
_check_client_status_refresh_timeout()
_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:
_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. Bounded by `McpCliExec` wall-clock budgets, so the
## worst case is a ~10s blocking drain, vs. an unbounded SIGSEGV.
##
## Generation-bumped per-row so any pending `call_deferred(
## "_apply_client_action_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
_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()
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)
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)
# --- 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_panel.add_child(HSeparator.new())
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.4 manual-update 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())
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)
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)
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)
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)
add_child(HSeparator.new())
# --- Clients ---
var clients_row := HBoxContainer.new()
clients_row.add_theme_constant_override("separation", 8)
var clients_header := _make_header("Clients")
clients_row.add_child(clients_header)
_clients_summary_label = Label.new()
_clients_summary_label.add_theme_color_override("font_color", COLOR_MUTED)
_clients_summary_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
clients_row.add_child(_clients_summary_label)
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_row.add_child(clients_refresh_btn)
var clients_open_btn := Button.new()
clients_open_btn.text = "Clients & Settings"
clients_open_btn.tooltip_text = "Open the MCP settings 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_row.add_child(clients_open_btn)
add_child(clients_row)
# 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)
add_child(_drift_banner)
_clients_window = Window.new()
_clients_window.title = "MCP Clients & 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)
## Two-tab secondary window: Clients (existing per-client rows) and Tools
## (domain-exclusion checkboxes for clients that cap total tool count,
## like Antigravity at 100). Adding a third tab is one more _build_*_tab
## call and a set_tab_title line — 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)
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)
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)
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
## 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)
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)
_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,
"manual_panel": manual_panel,
"manual_text": manual_text,
}
# --- Status updates ---
func _update_status() -> void:
var connected: bool = _connection.is_connected
## 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.<new_method>()` 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`.
var status_text: String
var status_color: Color
if _server_restart_in_progress:
status_text = "Restarting server..."
status_color = COLOR_AMBER
elif connected:
status_text = "Connected"
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:
status_text = "Port %d held by another process" % ClientConfigurator.http_port()
status_color = Color.RED
elif state == ServerStateScript.NO_COMMAND:
status_text = "No server command found"
status_color = Color.RED
elif Time.get_ticks_msec() < _startup_grace_until_msec:
## Inside startup grace — distinguish from real disconnect so
## first-run users don't assume it's broken while uvx downloads.
status_text = "Starting server…"
status_color = COLOR_AMBER
else:
status_text = "Disconnected"
status_color = Color.RED
_update_crash_panel(server_status)
_refresh_server_version_label(server_status)
var changed: bool = connected != _last_connected or status_text != _last_status_text
if not changed:
return
_last_connected = connected
_last_status_text = status_text
_status_icon.color = status_color
_status_label.text = status_text
_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
)
var port_picker_visible := (
state == ServerStateScript.PORT_EXCLUDED
or state == ServerStateScript.FOREIGN_PORT
)
_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]
if not message.is_empty():
return message
return "Port %d is occupied by an incompatible server. Stop it or change both HTTP and WS ports." % port
ServerStateScript.FOREIGN_PORT:
return "Another process is already bound to port %d. Pick a free port or stop the other process." % port
ServerStateScript.CRASHED:
## Both spawn attempts failed on the uvx tier — almost always
## means PyPI hasn't propagated this version yet (~10 min after
## publish). `_start_server` already tried `--refresh` once, so
## the next realistic move is to wait and reload.
if ClientConfigurator.get_server_launch_mode() == "uvx":
var version := ClientConfigurator.get_plugin_version()
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." % version
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 ""
## 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())
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 dispatcher logging routing.
func _on_log_logging_enabled_changed(enabled: bool) -> void:
if _connection and _connection.dispatcher:
_connection.dispatcher.mcp_logging = 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() -> 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())
_server_label.text = "WS: %d HTTP: %d" % [ws_port, ClientConfigurator.http_port()]
# --- 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:
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).
var is_dev := ClientConfigurator.is_dev_checkout()
var uv_missing := not is_dev and ClientConfigurator.check_uv_version().is_empty()
_setup_section.visible = dev or uv_missing
# --- 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():
_dispatch_stale_server_restart()
_server_restart_in_progress = false
_last_rendered_server_text = ""
_refresh_server_version_label()
return
call_deferred("_restart_stale_server_after_feedback")
func _restart_stale_server_after_feedback() -> void:
await get_tree().create_timer(0.15).timeout
if not _dispatch_stale_server_restart():
_server_restart_in_progress = false
_last_rendered_server_text = ""
_refresh_server_version_label()
func _dispatch_stale_server_restart() -> bool:
if _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"):
return bool(_plugin.recover_incompatible_server())
elif _plugin.has_method("force_restart_server"):
_plugin.force_restart_server()
return true
return false
# --- Setup section ---
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():
_setup_container.add_child(_make_status_row("uv", uv_version, Color.GREEN))
## 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 = "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 addons_path := ProjectSettings.globalize_path("res://addons/godot_ai")
var dir := DirAccess.open(addons_path.get_base_dir())
if dir == null or not dir.is_link(addons_path):
return ""
var target := dir.read_link(addons_path)
if target.is_empty():
return ""
if target.is_relative_path():
target = addons_path.get_base_dir().path_join(target).simplify_path()
return target
func _make_status_row(label_text: String, value_text: String, value_color: Color) -> HBoxContainer:
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 6)
var label := Label.new()
label.text = label_text
label.add_theme_color_override("font_color", COLOR_MUTED)
label.custom_minimum_size.x = 60
row.add_child(label)
var value := Label.new()
value.text = value_text
value.add_theme_color_override("font_color", value_color)
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 _on_install_uv() -> void:
match OS.get_name():
"Windows":
OS.execute("powershell", ["-ExecutionPolicy", "ByPass", "-c", "irm https://astral.sh/uv/install.ps1 | iex"], [], false)
_:
OS.execute("bash", ["-c", "curl -LsSf https://astral.sh/uv/install.sh | sh"], [], false)
## Drop the cached uvx path AND the cached `uvx --version` so the
## next `_refresh_setup_status` finds and reads the freshly-installed
## binary instead of returning the pre-install "not found" result.
## Routing through the configurator here 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_uvx_cli_cache()
ClientConfigurator.invalidate_uv_version_cache()
_refresh_setup_status.call_deferred()
# --- 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 server_url := ClientConfigurator.http_url()
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
var err := thread.start(
Callable(self, "_run_client_action_worker").bind(client_id, action, server_url, generation)
)
if err != OK:
_client_action_threads.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, generation: int) -> void:
var result: Dictionary
if action == "remove":
result = ClientConfigurator.remove(client_id, server_url)
else:
result = ClientConfigurator.configure(client_id, server_url)
if _refresh_state != ClientRefreshStateScript.SHUTTING_DOWN:
call_deferred("_apply_client_action_result", client_id, action, result, generation)
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:
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)
_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":
_apply_row_status(client_id, success_status)
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:
_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.has_worker_alive(_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()
# 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
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 = "Settings"
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()
core_count.add_theme_color_override("font_color", COLOR_MUTED)
core_row.add_child(core_count)
core_row.tooltip_text = ", ".join(ToolCatalog.CORE_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)
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 && 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?"
_tools_close_confirm.dialog_text = (
"You've checked/unchecked domains but haven't clicked Apply.\n"
+ "Close the window 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()
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 silently dropped — matches the Python side's
## warn-and-continue behavior when it sees an unknown name.
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()
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()
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.has_worker_alive(_refresh_state)
_refresh_drift_banner(mismatched_ids)
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
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 _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 _refresh_state == ClientRefreshStateScript.SHUTTING_DOWN:
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 server_url := ClientConfigurator.http_url()
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, 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(), "", "")
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 callback
## arriving 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 callback 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()
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 _refresh_state == ClientRefreshStateScript.SHUTTING_DOWN:
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 server_url := ClientConfigurator.http_url()
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, 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, generation: int) -> void:
var results: Dictionary = {}
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", ""))
)
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", ""),
}
if _refresh_state != ClientRefreshStateScript.SHUTTING_DOWN:
call_deferred("_apply_client_status_refresh_results", results, 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()
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)
match status:
Client.Status.CONFIGURED:
dot.color = Color.GREEN
configure_btn.text = "Reconfigure"
remove_btn.visible = true
name_label.text = 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
name_label.text = "%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
# --- 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 the pre-4.4 "Updated! Restart the editor."
## terminal state — the only outcome the manager paints green for.
_update_label.add_theme_color_override("font_color", Color.GREEN)
+1
View File
@@ -0,0 +1 @@
uid://b8yknttdjanm5
+7
View File
@@ -0,0 +1,7 @@
[plugin]
name="Godot AI"
description="MCP server and AI tools for Godot"
author="Godot AI"
version="2.7.3"
script="plugin.gd"
+1690
View File
@@ -0,0 +1,1690 @@
@tool
extends EditorPlugin
const GAME_HELPER_AUTOLOAD_NAME := "_mcp_game_helper"
const GAME_HELPER_AUTOLOAD_PATH := "res://addons/godot_ai/runtime/game_helper.gd"
## 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")`. Loaded dynamically because
## `extends Logger` requires Godot 4.5+. The logger script lives in the
## `.gdignore`'d `runtime/loggers/` folder so Godot's editor scan never
## parses it (no "Could not find base class Logger" error on < 4.5), and
## LoggerLoader compiles it from source at runtime only after the
## ClassDB.class_exists("Logger") gate below. See issue #231 / #475.
const LoggerLoader := preload("res://addons/godot_ai/runtime/logger_loader.gd")
## 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"
const UPDATE_RELOAD_RUNNER_SCRIPT := preload("res://addons/godot_ai/update_reload_runner.gd")
## Preloaded so `_stop_server` / `force_restart_server` have a local script
## dependency for the cleanup helper. See utils/uv_cache_cleanup.gd for what
## this does and why it lives next to the server-stop hot path.
const UvCacheCleanup := preload("res://addons/godot_ai/utils/uv_cache_cleanup.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")
const StartupPathScript := preload("res://addons/godot_ai/utils/mcp_startup_path.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 Dock := preload("res://addons/godot_ai/mcp_dock.gd")
const DebuggerPlugin := preload("res://addons/godot_ai/debugger/mcp_debugger_plugin.gd")
const ClientConfigurator := preload("res://addons/godot_ai/client_configurator.gd")
const WindowsPortReservation := preload("res://addons/godot_ai/utils/windows_port_reservation.gd")
## Handlers — preloaded as consts instead of registered via `class_name` so
## they don't pollute the project-wide global scope. A user project that
## happens to define its own `InputHandler`, `SceneHandler`, etc. would
## otherwise hard-error on plugin enable.
const EditorHandler := preload("res://addons/godot_ai/handlers/editor_handler.gd")
const SceneHandler := preload("res://addons/godot_ai/handlers/scene_handler.gd")
const NodeHandler := preload("res://addons/godot_ai/handlers/node_handler.gd")
const ProjectHandler := preload("res://addons/godot_ai/handlers/project_handler.gd")
const ClientHandler := preload("res://addons/godot_ai/handlers/client_handler.gd")
const ScriptHandler := preload("res://addons/godot_ai/handlers/script_handler.gd")
const ResourceHandler := preload("res://addons/godot_ai/handlers/resource_handler.gd")
const ApiHandler := preload("res://addons/godot_ai/handlers/api_handler.gd")
const FilesystemHandler := preload("res://addons/godot_ai/handlers/filesystem_handler.gd")
const SignalHandler := preload("res://addons/godot_ai/handlers/signal_handler.gd")
const AutoloadHandler := preload("res://addons/godot_ai/handlers/autoload_handler.gd")
const InputHandler := preload("res://addons/godot_ai/handlers/input_handler.gd")
const TestHandler := preload("res://addons/godot_ai/handlers/test_handler.gd")
const BatchHandler := preload("res://addons/godot_ai/handlers/batch_handler.gd")
const UiHandler := preload("res://addons/godot_ai/handlers/ui_handler.gd")
const ThemeHandler := preload("res://addons/godot_ai/handlers/theme_handler.gd")
const AnimationHandler := preload("res://addons/godot_ai/handlers/animation_handler.gd")
const MaterialHandler := preload("res://addons/godot_ai/handlers/material_handler.gd")
const ParticleHandler := preload("res://addons/godot_ai/handlers/particle_handler.gd")
const CameraHandler := preload("res://addons/godot_ai/handlers/camera_handler.gd")
const AudioHandler := preload("res://addons/godot_ai/handlers/audio_handler.gd")
const PhysicsShapeHandler := preload("res://addons/godot_ai/handlers/physics_shape_handler.gd")
const EnvironmentHandler := preload("res://addons/godot_ai/handlers/environment_handler.gd")
const TextureHandler := preload("res://addons/godot_ai/handlers/texture_handler.gd")
const CurveHandler := preload("res://addons/godot_ai/handlers/curve_handler.gd")
const ControlDrawRecipeHandler := preload("res://addons/godot_ai/handlers/control_draw_recipe_handler.gd")
## 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
const SERVER_STATUS_PATH := "/godot-ai/status"
const SERVER_STATUS_PROBE_TIMEOUT_MS := 800
const SERVER_HANDSHAKE_VERSION_TIMEOUT_MS := 5 * 1000
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.
##
## `_editor_logger` is untyped because its script extends Godot 4.5+'s Logger
## class: `logger_loader.gd` compiles it at runtime from on-disk source
## (FileAccess + `GDScript.new()`) past the `ClassDB.class_exists("Logger")`
## gate in `_attach_editor_logger`, so the plugin still parses on 4.4. Null on
## Godot < 4.5 or before `_attach_editor_logger` runs; "attached" state IS
## exactly "non-null".
var _connection
var _dispatcher
var _telemetry
var _log_buffer
var _game_log_buffer
var _editor_log_buffer
var _editor_logger
var _dock
var _handlers: Array = [] # prevent GC of RefCounted handlers
var _debugger_plugin
## 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
## 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 = {}
var _startup_trace_netsh_start_count := 0
func _init() -> void:
_lifecycle = ServerLifecycleManager.new(self)
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)
if _mcp_disabled_for_headless_launch():
_headless_disabled = true
print("MCP | plugin disabled in headless mode")
return
## Self-update from a pre-loggers/ version leaves the old logger scripts
## orphaned at runtime/*.gd (the runner only writes files in the new ZIP,
## it doesn't prune). Those still `extends Logger` and re-emit the parse
## errors on Godot < 4.5. Delete them once so upgraders match a fresh
## install. No-op on fresh installs and dev checkouts (files absent).
_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")
_log_buffer = LogBuffer.new()
_start_server()
_startup_trace_phase("server_start")
_game_log_buffer = GameLogBuffer.new()
_editor_log_buffer = EditorLogBuffer.new()
_attach_editor_logger()
_dispatcher = Dispatcher.new(_log_buffer)
_startup_trace_phase("core_objects")
_connection = Connection.new()
_connection.log_buffer = _log_buffer
_connection.ws_port = _resolved_ws_port
_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()
_telemetry = Telemetry.new(_connection)
_debugger_plugin = DebuggerPlugin.new(_log_buffer, _game_log_buffer)
add_debugger_plugin(_debugger_plugin)
_ensure_game_helper_autoload()
var editor_handler := EditorHandler.new(_log_buffer, _connection, _debugger_plugin, _game_log_buffer, _editor_log_buffer)
var scene_handler := SceneHandler.new(_connection)
var node_handler := NodeHandler.new(get_undo_redo())
var project_handler := ProjectHandler.new(_connection, _debugger_plugin)
var client_handler := ClientHandler.new()
var script_handler := ScriptHandler.new(get_undo_redo(), _connection)
var resource_handler := ResourceHandler.new(get_undo_redo(), _connection)
var api_handler := ApiHandler.new()
var filesystem_handler := FilesystemHandler.new()
var signal_handler := SignalHandler.new(get_undo_redo())
var autoload_handler := AutoloadHandler.new()
var input_handler := InputHandler.new()
var test_handler := TestHandler.new(get_undo_redo(), _log_buffer)
var batch_handler := BatchHandler.new(_dispatcher, get_undo_redo())
var ui_handler := UiHandler.new(get_undo_redo())
var theme_handler := ThemeHandler.new(get_undo_redo())
var animation_handler := AnimationHandler.new(get_undo_redo())
var material_handler := MaterialHandler.new(get_undo_redo())
var particle_handler := ParticleHandler.new(get_undo_redo())
var camera_handler := CameraHandler.new(get_undo_redo())
var audio_handler := AudioHandler.new(get_undo_redo())
var physics_shape_handler := PhysicsShapeHandler.new(get_undo_redo())
var environment_handler := EnvironmentHandler.new(get_undo_redo(), _connection)
var texture_handler := TextureHandler.new(get_undo_redo(), _connection)
var curve_handler := CurveHandler.new(get_undo_redo(), _connection)
var control_draw_recipe_handler := ControlDrawRecipeHandler.new(get_undo_redo())
_handlers = [editor_handler, scene_handler, node_handler, project_handler, client_handler, script_handler, resource_handler, api_handler, filesystem_handler, signal_handler, autoload_handler, input_handler, test_handler, batch_handler, ui_handler, theme_handler, animation_handler, material_handler, particle_handler, camera_handler, audio_handler, physics_shape_handler, environment_handler, texture_handler, curve_handler, control_draw_recipe_handler]
_dispatcher.register("get_editor_state", editor_handler.get_editor_state)
_dispatcher.register("get_scene_tree", scene_handler.get_scene_tree)
_dispatcher.register("get_open_scenes", scene_handler.get_open_scenes)
_dispatcher.register("find_nodes", scene_handler.find_nodes)
_dispatcher.register("create_scene", scene_handler.create_scene)
_dispatcher.register("open_scene", scene_handler.open_scene)
_dispatcher.register("save_scene", scene_handler.save_scene)
_dispatcher.register("save_scene_as", scene_handler.save_scene_as)
_dispatcher.register("get_selection", editor_handler.get_selection)
_dispatcher.register("create_node", node_handler.create_node)
_dispatcher.register("delete_node", node_handler.delete_node)
_dispatcher.register("reparent_node", node_handler.reparent_node)
_dispatcher.register("set_property", node_handler.set_property)
_dispatcher.register("rename_node", node_handler.rename_node)
_dispatcher.register("duplicate_node", node_handler.duplicate_node)
_dispatcher.register("move_node", node_handler.move_node)
_dispatcher.register("add_to_group", node_handler.add_to_group)
_dispatcher.register("remove_from_group", node_handler.remove_from_group)
_dispatcher.register("set_selection", node_handler.set_selection)
_dispatcher.register("get_node_properties", node_handler.get_node_properties)
_dispatcher.register("get_children", node_handler.get_children)
_dispatcher.register("get_groups", node_handler.get_groups)
_dispatcher.register("get_logs", editor_handler.get_logs)
_dispatcher.register("clear_logs", editor_handler.clear_logs)
_dispatcher.register("take_screenshot", editor_handler.take_screenshot)
_dispatcher.register("get_performance_monitors", editor_handler.get_performance_monitors)
_dispatcher.register("reload_plugin", editor_handler.reload_plugin)
_dispatcher.register("quit_editor", editor_handler.quit_editor)
_dispatcher.register("game_eval", editor_handler.game_eval)
_dispatcher.register("game_command", editor_handler.game_command)
_dispatcher.register("get_project_setting", project_handler.get_project_setting)
_dispatcher.register("set_project_setting", project_handler.set_project_setting)
_dispatcher.register("run_project", project_handler.run_project)
_dispatcher.register("stop_project", project_handler.stop_project)
_dispatcher.register("search_filesystem", project_handler.search_filesystem)
_dispatcher.register("configure_client", client_handler.configure_client)
_dispatcher.register("remove_client", client_handler.remove_client)
_dispatcher.register("check_client_status", client_handler.check_client_status)
_dispatcher.register("create_script", script_handler.create_script)
_dispatcher.register("patch_script", script_handler.patch_script)
_dispatcher.register("read_script", script_handler.read_script)
_dispatcher.register("attach_script", script_handler.attach_script)
_dispatcher.register("detach_script", script_handler.detach_script)
_dispatcher.register("find_symbols", script_handler.find_symbols)
_dispatcher.register("search_resources", resource_handler.search_resources)
_dispatcher.register("load_resource", resource_handler.load_resource)
_dispatcher.register("assign_resource", resource_handler.assign_resource)
_dispatcher.register("create_resource", resource_handler.create_resource)
_dispatcher.register("get_resource_info", resource_handler.get_resource_info)
_dispatcher.register("get_class_info", api_handler.get_class_info)
_dispatcher.register("read_file", filesystem_handler.read_file)
_dispatcher.register("write_file", filesystem_handler.write_file)
_dispatcher.register("reimport", filesystem_handler.reimport)
_dispatcher.register("list_signals", signal_handler.list_signals)
_dispatcher.register("connect_signal", signal_handler.connect_signal)
_dispatcher.register("disconnect_signal", signal_handler.disconnect_signal)
_dispatcher.register("list_autoloads", autoload_handler.list_autoloads)
_dispatcher.register("add_autoload", autoload_handler.add_autoload)
_dispatcher.register("remove_autoload", autoload_handler.remove_autoload)
_dispatcher.register("list_actions", input_handler.list_actions)
_dispatcher.register("add_action", input_handler.add_action)
_dispatcher.register("remove_action", input_handler.remove_action)
_dispatcher.register("bind_event", input_handler.bind_event)
_dispatcher.register("run_tests", test_handler.run_tests)
_dispatcher.register("get_test_results", test_handler.get_test_results)
_dispatcher.register("batch_execute", batch_handler.batch_execute)
_dispatcher.register("set_anchor_preset", ui_handler.set_anchor_preset)
_dispatcher.register("set_text", ui_handler.set_text)
_dispatcher.register("build_layout", ui_handler.build_layout)
_dispatcher.register("create_theme", theme_handler.create_theme)
_dispatcher.register("theme_set_color", theme_handler.set_color)
_dispatcher.register("theme_set_constant", theme_handler.set_constant)
_dispatcher.register("theme_set_font_size", theme_handler.set_font_size)
_dispatcher.register("theme_set_stylebox_flat", theme_handler.set_stylebox_flat)
_dispatcher.register("apply_theme", theme_handler.apply_theme)
_dispatcher.register("animation_player_create", animation_handler.create_player)
_dispatcher.register("animation_create", animation_handler.create_animation)
_dispatcher.register("animation_add_property_track", animation_handler.add_property_track)
_dispatcher.register("animation_add_method_track", animation_handler.add_method_track)
_dispatcher.register("animation_set_autoplay", animation_handler.set_autoplay)
_dispatcher.register("animation_play", animation_handler.play)
_dispatcher.register("animation_stop", animation_handler.stop)
_dispatcher.register("animation_list", animation_handler.list_animations)
_dispatcher.register("animation_get", animation_handler.get_animation)
_dispatcher.register("animation_create_simple", animation_handler.create_simple)
_dispatcher.register("animation_delete", animation_handler.delete_animation)
_dispatcher.register("animation_validate", animation_handler.validate_animation)
_dispatcher.register("animation_preset_fade", animation_handler.preset_fade)
_dispatcher.register("animation_preset_slide", animation_handler.preset_slide)
_dispatcher.register("animation_preset_shake", animation_handler.preset_shake)
_dispatcher.register("animation_preset_pulse", animation_handler.preset_pulse)
_dispatcher.register("material_create", material_handler.create_material)
_dispatcher.register("material_set_param", material_handler.set_param)
_dispatcher.register("material_set_shader_param", material_handler.set_shader_param)
_dispatcher.register("material_get", material_handler.get_material)
_dispatcher.register("material_list", material_handler.list_materials)
_dispatcher.register("material_assign", material_handler.assign_material)
_dispatcher.register("material_apply_to_node", material_handler.apply_to_node)
_dispatcher.register("material_apply_preset", material_handler.apply_preset)
_dispatcher.register("particle_create", particle_handler.create_particle)
_dispatcher.register("particle_set_main", particle_handler.set_main)
_dispatcher.register("particle_set_process", particle_handler.set_process)
_dispatcher.register("particle_set_draw_pass", particle_handler.set_draw_pass)
_dispatcher.register("particle_restart", particle_handler.restart_particle)
_dispatcher.register("particle_get", particle_handler.get_particle)
_dispatcher.register("particle_apply_preset", particle_handler.apply_preset)
_dispatcher.register("camera_create", camera_handler.create_camera)
_dispatcher.register("camera_configure", camera_handler.configure)
_dispatcher.register("camera_set_limits_2d", camera_handler.set_limits_2d)
_dispatcher.register("camera_set_damping_2d", camera_handler.set_damping_2d)
_dispatcher.register("camera_follow_2d", camera_handler.follow_2d)
_dispatcher.register("camera_get", camera_handler.get_camera)
_dispatcher.register("camera_list", camera_handler.list_cameras)
_dispatcher.register("camera_apply_preset", camera_handler.apply_preset)
_dispatcher.register("audio_player_create", audio_handler.create_player)
_dispatcher.register("audio_player_set_stream", audio_handler.set_stream)
_dispatcher.register("audio_player_set_playback", audio_handler.set_playback)
_dispatcher.register("audio_play", audio_handler.play)
_dispatcher.register("audio_stop", audio_handler.stop)
_dispatcher.register("audio_list", audio_handler.list_streams)
_dispatcher.register("physics_shape_autofit", physics_shape_handler.autofit)
_dispatcher.register("environment_create", environment_handler.create_environment)
_dispatcher.register("gradient_texture_create", texture_handler.create_gradient_texture)
_dispatcher.register("noise_texture_create", texture_handler.create_noise_texture)
_dispatcher.register("curve_set_points", curve_handler.set_points)
_dispatcher.register(
"control_draw_recipe", control_draw_recipe_handler.control_draw_recipe
)
_connection.dispatcher = _dispatcher
add_child(_connection)
_startup_trace_phase("handlers_registered")
# Dock panel
_dock = Dock.new()
_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()
var startup_path: String = str(_lifecycle.get_startup_path())
_startup_trace_finish(startup_path if not startup_path.is_empty() else "loaded")
## 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:
return
_telemetry.record_dev_server_toggle(action)
## 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:
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_version + to_version are empty strings here
## — only ``status`` and ``error`` are known at flush time.
_telemetry.record_self_update(status, "", "", error)
func _exit_tree() -> void:
if _headless_disabled:
_server_started_this_session = false
_headless_disabled = false
return
## 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()
# Break the Callable -> handler ref chain before dropping _handlers, so the
# array clear actually decrefs the handler RefCounteds to zero.
if _dispatcher:
_dispatcher.clear()
# Handler destructors run here, while their class_name scripts are still loaded.
_handlers.clear()
if _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
_stop_server()
## 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")
## 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"). Logger subclassing is 4.5+ only; the
## ClassDB gate keeps the plugin loadable on 4.4 with no-op editor logs
## (the buffer stays empty, logs_read returns no entries).
##
## 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:
if not (ClassDB.class_exists("Logger") and OS.has_method("add_logger")):
return
var logger_script := LoggerLoader.build(LoggerLoader.EDITOR_LOGGER_PATH)
if logger_script == null:
return
_editor_logger = logger_script.new(_editor_log_buffer)
OS.call("add_logger", _editor_logger)
## Remove the pre-2.5.8 logger scripts left at runtime/*.gd by a self-update
## (the runner doesn't prune files dropped between versions). They `extends
## Logger` and would re-emit "Could not find base class Logger" parse errors
## on Godot < 4.5 even though the live copies now live in the .gdignore'd
## runtime/loggers/ folder. Idempotent: existence-guarded, so it's a no-op on
## fresh installs and symlinked dev checkouts.
func _cleanup_legacy_logger_scripts() -> void:
var legacy := [
"res://addons/godot_ai/runtime/editor_logger.gd",
"res://addons/godot_ai/runtime/editor_logger.gd.uid",
"res://addons/godot_ai/runtime/game_logger.gd",
"res://addons/godot_ai/runtime/game_logger.gd.uid",
]
for res_path in legacy:
if FileAccess.file_exists(res_path):
DirAccess.remove_absolute(ProjectSettings.globalize_path(res_path))
func _detach_editor_logger() -> void:
if _editor_logger != null and OS.has_method("remove_logger"):
OS.call("remove_logger", _editor_logger)
_editor_logger = null
## Register the game-side autoload on plugin enable. Runs the helper inside
## the game process so the editor-side debugger plugin can request
## framebuffer captures over EngineDebugger messages. Removed on
## _disable_plugin so disabling the plugin leaves project.godot clean.
func _enable_plugin() -> void:
if _mcp_disabled_for_headless_launch():
return
_ensure_game_helper_autoload()
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")
)
static func _mcp_disabled_for_headless(args: PackedStringArray, display_name: String, allow_value: String) -> bool:
if McpSettings.truthy(allow_value):
return false
return _args_request_headless(args) or display_name.to_lower() == "headless"
static func _args_request_headless(args: PackedStringArray) -> bool:
for i in range(args.size()):
var arg := args[i]
if arg == "--headless":
return true
if arg == "--display-driver" and i + 1 < args.size() and args[i + 1] == "headless":
return true
if arg.begins_with("--display-driver=") and arg.get_slice("=", 1) == "headless":
return true
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_counters[counter] = int(_startup_trace_counters.get(counter, 0)) + amount
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()
_startup_trace_counters["netsh"] = (
WindowsPortReservation.netsh_query_count() - _startup_trace_netsh_start_count
)
print(
"MCP startup trace | done path=%s total_ms=%d counters=%s"
% [path, now - _startup_trace_start_ms, str(_startup_trace_counters)]
)
func _start_server() -> void:
_lifecycle.start_server()
## 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["reachable"] = true
result["name"] = str(parsed.get("name", ""))
result["version"] = _extract_server_version(parsed)
result["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 "".
result["package_path"] = str(parsed.get("package_path", ""))
return result
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:
set_process(
_lifecycle.get_adoption_watch_deadline_ms() > 0
or _lifecycle.is_awaiting_server_version()
)
func _process(_delta: float) -> void:
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()
func get_resolved_ws_port() -> int:
return _resolved_ws_port
func _set_resolved_ws_port(port: int) -> void:
_resolved_ws_port = port
if _connection != null:
_connection.ws_port = 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 increment and the `_ProofPlugin` override hook on the plugin.
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":
_startup_trace_count("lsof")
return PortResolver.is_port_in_use_via_scrape(port)
return false
if OS.get_name() == "Windows":
_startup_trace_count("netstat")
else:
_startup_trace_count("lsof")
return PortResolver.is_port_in_use_via_scrape(port)
## 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.
func _evaluate_strong_port_occupant_proof(port: int, live: Dictionary = {}) -> Dictionary:
var result := {"proof": "", "pids": []}
var listener_pids := _find_all_pids_on_port(port)
if listener_pids.is_empty():
return result
var record := _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():
if listener_pids.has(record_pid) and _pid_alive_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
):
return {"proof": "status_matches_record", "pids": listener_pids}
return result
## See `_evaluate_strong_port_occupant_proof` for the `live` contract.
## Threads `live` through the strong-proof delegate so neither helper
## probes when the caller already knows the port-owner status.
func _evaluate_recovery_port_occupant_proof(port: int, live: Dictionary = {}) -> Dictionary:
var proof := _evaluate_strong_port_occupant_proof(port, live)
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": []}
func _recover_strong_port_occupant(port: int, wait_s: float, pre_kill_live: Dictionary = {}) -> bool:
return _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)
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 <user>/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=<token>` and `--pid-file <token>`; 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/<pid>/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 <pid> -o args=` on macOS / *BSD,
## which lack a Linux-style `/proc/<pid>/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}
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))
return {"pid": pid, "version": version, "ws_port": ws_port}
func _write_managed_server_record(pid: int, version: String) -> 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)
func _clear_managed_server_record() -> void:
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)
func prepare_for_update_reload() -> void:
_lifecycle.prepare_for_update_reload()
func _adopt_compatible_server(record_version: String, current_version: String, owner: int) -> String:
return _lifecycle.adopt_compatible_server(record_version, current_version, owner)
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() -> bool:
if not _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
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)
func _kill_processes_and_windows_spawn_children(pids: Array[int]) -> Array[int]:
var unique: Array[int] = []
for pid in pids:
if pid > 0 and not unique.has(pid):
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:
OS.kill(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()
+1
View File
@@ -0,0 +1 @@
uid://d3ui3yx6vdigl
+107
View File
@@ -0,0 +1,107 @@
@tool
extends Control
## Runtime helper attached by control_draw_recipe and pattern_corner_brackets.
## 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
)
"corner_brackets":
# Synthesized op used by pattern_corner_brackets. Draws 8 line
# segments at the four corners of self.size, so brackets track
# parent resizes. Emitted by PatternHandler, not control_draw_recipe.
var L := float(op.get("length", 18.0))
var T := float(op.get("thickness", 2.0))
var c: Color = op.color
var w := size.x
var h := size.y
# Top-left
draw_line(Vector2(0, 0), Vector2(L, 0), c, T)
draw_line(Vector2(0, 0), Vector2(0, L), c, T)
# Top-right
draw_line(Vector2(w, 0), Vector2(w - L, 0), c, T)
draw_line(Vector2(w, 0), Vector2(w, L), c, T)
# Bottom-left
draw_line(Vector2(0, h), Vector2(L, h), c, T)
draw_line(Vector2(0, h), Vector2(0, h - L), c, T)
# Bottom-right
draw_line(Vector2(w, h), Vector2(w - L, h), c, T)
draw_line(Vector2(w, h), Vector2(w, h - L), c, T)
@@ -0,0 +1 @@
uid://da3fqfqv6gtgm
+869
View File
@@ -0,0 +1,869 @@
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
const LoggerLoader := preload("res://addons/godot_ai/runtime/logger_loader.gd")
var _registered := false
## Untyped because the McpGameLogger script is loaded dynamically (it
## extends Logger, which only exists in Godot 4.5+).
var _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
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
## 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 subclassing was added in Godot 4.5 — gate on
## ClassDB so the rest of the helper still loads on older engines.
## game_logger.gd lives in the `.gdignore`'d runtime/loggers/ folder so
## it never parse-errors during a < 4.5 editor scan; LoggerLoader
## compiles it from source at runtime, only past this gate.
if ClassDB.class_exists("Logger") and OS.has_method("add_logger"):
var logger_script := LoggerLoader.build(LoggerLoader.GAME_LOGGER_PATH)
if logger_script != null:
_logger = logger_script.new()
OS.call("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:
## 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 and OS.has_method("remove_logger"):
OS.call("remove_logger", _logger)
_logger_attached = false
_logger = null
## Dispatched for messages prefixed "mcp:" on the debugger channel.
## Different Godot versions pass either the tail ("take_screenshot") or the
## full message ("mcp:take_screenshot") to the capture callable — accept
## both forms so this works across 4.2/4.3/4.4/4.5.
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":
_handle_eval(data)
return true
"eval_check":
_handle_eval_check(data)
return true
"game_command":
_handle_game_command(data)
return true
return false
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 viewport := get_tree().root
if viewport == null:
_reply_error(request_id, "No game root viewport available")
return
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 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)
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)
var png := image.save_png_to_buffer()
var b64 := Marshalls.raw_to_base64(png)
EngineDebugger.send_message("mcp:screenshot_response", [
request_id,
b64,
image.get_width(),
image.get_height(),
original_width,
original_height,
])
func _reply_error(request_id: String, message: String) -> void:
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_state":
result = _game_input_state(json.data)
_:
_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():
scene_root = EditorInterface.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 := _dict_to_vector2(params.get("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_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}
func _dict_to_vector2(value: Variant) -> Vector2:
var viewport := get_viewport()
var fallback := viewport.get_mouse_position() if viewport != null else Vector2.ZERO
if value is Dictionary:
if value.is_empty() or (not value.has("x") and not value.has("y")):
return fallback
return Vector2(float(value.get("x", fallback.x)), float(value.get("y", fallback.y)))
return fallback
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 actionable
## "Eval exceeded 8s" message; the editor timer emits a *generic* "Game eval
## timed out" message. Raise this at/above the editor timer (or drop that
## timer below this) and the generic message wins the race, silently losing
## the diagnostic this fix exists to provide. Nothing enforces the order —
## change one, re-check the other two.
##
## 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_<token>`, 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))
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
func _reply_eval_error(request_id: String, message: String) -> void:
EngineDebugger.send_message("mcp:eval_error", [request_id, message])
func _reply_eval_response(request_id: String, value: Variant) -> void:
EngineDebugger.send_message("mcp:eval_response",
[request_id, JSON.stringify(_variant_to_json(value))])
## #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)
@@ -0,0 +1 @@
uid://gfybkdtsclti
+56
View File
@@ -0,0 +1,56 @@
@tool
extends RefCounted
## Runtime builder for the `extends Logger` scripts in `runtime/loggers/`.
##
## `Logger` is a Godot 4.5+ class. A `.gd` file that statically declares
## `extends Logger` is rejected by the parser on Godot < 4.5 — and Godot's
## editor filesystem scan parses *every* `.gd` under the project, so just
## shipping `editor_logger.gd` / `game_logger.gd` printed two
## `Parse Error: Could not find base class "Logger"` lines on every 4.3/4.4
## editor startup (#475 follow-up). They were functionally harmless (the
## scripts are only ever instanced behind a `ClassDB.class_exists("Logger")`
## gate) but they were real red error text we shouldn't ship.
##
## Fix: the two logger scripts live in `runtime/loggers/`, which carries a
## `.gdignore` so the editor scan skips the folder entirely — no parse, no
## error, on any engine. This loader reads the source off disk with
## `FileAccess` (unaffected by `.gdignore`, which only governs the resource
## importer) and compiles it at runtime via `GDScript.new()`. Callers gate
## on `ClassDB.class_exists("Logger")` first, so `build()` only ever runs on
## 4.5+, where `extends Logger` resolves cleanly.
##
## This script itself does NOT extend Logger, so it parses on every engine
## and is safe to `preload` from `plugin.gd` and `game_helper.gd`.
const EDITOR_LOGGER_PATH := "res://addons/godot_ai/runtime/loggers/editor_logger.gd"
const GAME_LOGGER_PATH := "res://addons/godot_ai/runtime/loggers/game_logger.gd"
## Compile a `.gdignore`'d logger script from its on-disk source. Returns the
## ready-to-instance GDScript, or null if the file is missing (e.g. excluded
## from an exported game) or fails to compile. Callers must already have
## confirmed `ClassDB.class_exists("Logger")` — building an `extends Logger`
## script on an engine without the class will fail the reload() and return
## null, which the gated callers treat as "logging unavailable".
static func build(path: String) -> GDScript:
if not FileAccess.file_exists(path):
return null
var source := FileAccess.get_file_as_string(path)
if source.is_empty():
return null
var script := GDScript.new()
script.source_code = source
## Deliberately do NOT set `script.resource_path`: this builds a fresh
## anonymous GDScript every call, and a reload cycle (editor_reload_plugin,
## self-update disable→enable) calls build() again for the same path. Two
## live Resources sharing one non-empty resource_path trips Godot's
## "Another resource is loaded from path ..." error and leaves the new
## script with an empty path anyway — re-introducing red console text on
## every reload, the exact thing this folder's .gdignore set out to remove.
## game_helper.gd::_handle_eval compiles from source the same way and also
## omits resource_path. The script still resolves its absolute preloads /
## class_names fine without a path.
if script.reload() != OK:
return null
return script
@@ -0,0 +1 @@
uid://d3plpedkpvec6
@@ -0,0 +1,151 @@
@tool
extends Logger
## Editor-process Logger subclass.
##
## NOTE: deliberately no `class_name` — `extends Logger` requires the Logger
## class which Godot only exposes from 4.5+. This file lives in the
## `.gdignore`'d `runtime/loggers/` folder so Godot's editor filesystem scan
## skips it entirely — on Godot < 4.5 it is never parsed, so it emits no
## "Could not find base class Logger" error (it used to, before #475's
## follow-up). plugin.gd builds it from source at runtime via
## `logger_loader.gd` and only calls OS.add_logger() after gating on
## ClassDB.class_exists("Logger"), so the `extends Logger` parse only ever
## happens on 4.5+ where it resolves. 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.
## This script is compiled from source at runtime by logger_loader.gd; 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")
## McpEditorLogBuffer — untyped because this script is loaded dynamically and
## McpEditorLogBuffer's class_name isn't yet registered on the parser at the
## time `extends Logger` resolves. 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
@@ -0,0 +1,158 @@
@tool
extends Logger
## Game-process Logger subclass.
##
## NOTE: deliberately no `class_name` — `extends Logger` requires the Logger
## class which Godot only exposes from 4.5+. This file lives in the
## `.gdignore`'d `runtime/loggers/` folder so Godot's editor filesystem scan
## skips it entirely — on Godot < 4.5 it is never parsed, so it emits no
## "Could not find base class Logger" error (it used to, before #475's
## follow-up). game_helper.gd builds it from source at runtime via
## `logger_loader.gd` and only calls OS.add_logger() after gating on
## ClassDB.class_exists("Logger"). 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 in script_backtraces:
if bt != null and bt.get_frame_count() > 0:
for i 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 (with inlined path:line @ function) of the most recent
## script-type runtime error, or "" if none seen this run.
func last_script_error_text() -> String:
_mutex.lock()
var v: String = _recent_script_errors[-1]["text"] if not _recent_script_errors.is_empty() else ""
_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
+199
View File
@@ -0,0 +1,199 @@
## 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_plugin_reload(success: bool, error: String = "") -> void:
var data := {"success": success}
if error != "":
data["error"] = error.substr(0, 200)
record_event("plugin_reload", data)
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.
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()
+1
View File
@@ -0,0 +1 @@
uid://dlul2gculiy1p
+38
View File
@@ -0,0 +1,38 @@
@tool
extends RefCounted
## Minimal duck-typed stand-in for Godot's built-in `ScriptBacktrace`
## class (the type of `script_backtraces[i]` entries inside `_log_error`).
## Mirrors the getter surface `_log_error`'s `script_backtraces` argument
## exposes (`get_frame_count` + per-frame file/line/function), so test
## suites for `editor_logger` and `game_logger` can exercise the
## backtrace-remapping path without a live script execution — Godot
## doesn't expose a constructor for the real ScriptBacktrace.
##
## Defaults to a single frame for existing tests, but can carry multiple
## frames so detail payload tests can verify full stack preservation.
var _frames: Array[Dictionary] = []
func _init(file: String, line: int, function: String, frames: Array[Dictionary] = []) -> void:
if frames.is_empty():
_frames = [{"path": file, "line": line, "function": function}]
else:
_frames = frames
func get_frame_count() -> int:
return _frames.size()
func get_frame_file(idx: int) -> String:
return str(_frames[idx].get("path", ""))
func get_frame_line(idx: int) -> int:
return int(_frames[idx].get("line", 0))
func get_frame_function(idx: int) -> String:
return str(_frames[idx].get("function", ""))
@@ -0,0 +1 @@
uid://d2xpmw5kvtjr7
+244
View File
@@ -0,0 +1,244 @@
@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.
var _results: Array[Dictionary] = []
var _last_run_ms: int = 0
func run_suite(suite: McpTestSuite, test_filter: String = "", exclude_test_filter: String = "") -> void:
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
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,
})
continue
suite._reset()
suite.setup()
suite.call(method_name)
suite.teardown()
## 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 := EditorInterface.get_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 suite._skipped:
_results.append({
"suite": name,
"test": method_name,
"passed": true,
"skipped": true,
"message": suite._skip_reason,
"assertion_count": 0,
})
continue
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)"
_results.append({
"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:
_results.clear()
var start := Time.get_ticks_msec()
## 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
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 := EditorInterface.get_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": "<suite_setup>",
"passed": false,
"message": "suite_setup() failed: %s (subsequent tests not run)" % suite._suite_failed_message,
"assertion_count": 0,
})
elif suite._suite_skipped:
_results.append({
"suite": suite.suite_name(),
"test": "<suite_setup>",
"passed": true,
"skipped": true,
"message": "suite_setup() skipped: %s" % suite._suite_skipped_reason,
"assertion_count": 0,
})
else:
run_suite(suite, test_filter, exclude_test_filter)
suite.suite_teardown()
## 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)
_last_run_ms = Time.get_ticks_msec() - start
McpLogBuffer.console_echo = _prev_console_echo
return get_results(verbose)
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
@@ -0,0 +1 @@
uid://367b77qh5grt
+277
View File
@@ -0,0 +1,277 @@
@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
# ----- 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 = ""
# ----- 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 = ""
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.4"). 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_44_only_api() -> void:
## if skip_on_godot_lt("4.4", "Engine.capture_script_backtraces is 4.4+"):
## 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
## 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()
@@ -0,0 +1 @@
uid://dlrq2s7jhp71s
+86
View File
@@ -0,0 +1,86 @@
@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
## `<domain>_manage` tool.
const CORE_TOOLS := [
"editor_state",
"node_get_properties",
"scene_get_hierarchy",
"session_activate",
]
## 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": "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": "ui", "label": "ui", "count": 1, "tools": ["ui_manage"]},
]
## 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()
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()
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)
+1
View File
@@ -0,0 +1 @@
uid://d1vqyt4uyo378
+532
View File
@@ -0,0 +1,532 @@
@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 := ""
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
_wait_frames(PRE_DISABLE_DRAIN_FRAMES, "_disable_old_plugin")
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
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 {}
f.store_buffer(content)
var write_error := f.get_error()
f.close()
if write_error != OK:
print("MCP | update extract failed: write error %d for %s" % [
write_error,
temp_path,
])
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()
_record_pending_self_update({"status": "success"})
## 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
@@ -0,0 +1 @@
uid://cu6c75n3x2pik
@@ -0,0 +1,239 @@
@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")
const DEFAULT_SECTIONS := ["properties", "methods", "signals", "enums", "constants"]
const KNOWN_SECTIONS := ["properties", "methods", "signals", "enums", "constants", "inheritors"]
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 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
@@ -0,0 +1 @@
uid://caedbsmsl6fk4
+104
View File
@@ -0,0 +1,104 @@
@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()
func _init() -> void:
super._init(MAX_LINES)
func append(level: String, text: String, path: String = "", line: int = 0, function: String = "", details: Dictionary = {}) -> void:
var entry := {
"source": "editor",
"level": _coerce_level(level),
"text": text,
"path": path,
"line": line,
"function": function,
}
if not details.is_empty():
entry["details"] = details.duplicate(true)
_mutex.lock()
_append_entry(entry)
_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 clear() -> int:
_mutex.lock()
var n := _total_count_unlocked()
_clear_storage()
_mutex.unlock()
return n
@@ -0,0 +1 @@
uid://b6ynms0856hhq
+84
View File
@@ -0,0 +1,84 @@
@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"
# 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: 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-side _mcp_game_helper autoload never registered its debugger
## capture within EVAL_READY_WAIT_SEC. Carved out of INTERNAL_ERROR so this
## boot-window / missing-autoload race stops masquerading as the opaque "eval
## hung" 10s timeout in telemetry — the same split #490 made for compile/runtime
## errors. NOT a hang: it fires fast (~3s) and is caller-actionable (let the game
## finish booting and retry, or check the autoload is enabled).
const EVAL_GAME_NOT_READY := "EVAL_GAME_NOT_READY"
## 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"
## Build a standard error response dictionary.
static func make(code: String, message: String) -> Dictionary:
return {"status": "error", "error": {"code": code, "message": message}}
## 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])
+1
View File
@@ -0,0 +1 @@
uid://d2klnglf5p861
@@ -0,0 +1,39 @@
@tool
extends RefCounted
## Shared fuzzy ranking for typo suggestions.
static func rank(
needle: String,
candidates: Array,
limit: int = 5,
threshold: float = 0.4,
substring_bonus: float = 0.5,
prefix_bonus: float = 1.0
) -> Array[String]:
if needle.is_empty() or candidates.is_empty():
return []
var needle_lower := needle.to_lower()
var scored: Array = []
for raw_candidate in candidates:
var candidate := str(raw_candidate)
var candidate_lower := candidate.to_lower()
var score := needle.similarity(candidate)
if prefix_bonus != 0.0 and candidate_lower.begins_with(needle_lower):
score += prefix_bonus
elif substring_bonus != 0.0 and (
candidate_lower.contains(needle_lower) or needle_lower.contains(candidate_lower)
):
score += substring_bonus
if score >= threshold:
scored.append([score, candidate])
scored.sort_custom(func(a, b):
if a[0] == b[0]:
return a[1] < b[1]
return a[0] > b[0]
)
var result: Array[String] = []
for index in range(min(limit, scored.size())):
result.append(scored[index][1])
return result
@@ -0,0 +1 @@
uid://bxwaws6w0xw60
+50
View File
@@ -0,0 +1,50 @@
@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 each time clear_for_new_run() fires (called on the game's
## mcp:hello boot beacon), giving agents a stable cursor for "lines since
## this play started".
##
## 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 := ""
func _init() -> void:
super._init(MAX_LINES)
func append(level: String, text: String, details: Dictionary = {}) -> void:
var entry := {"source": "game", "level": _coerce_level(level), "text": text}
if not details.is_empty():
entry["details"] = details.duplicate(true)
_append_entry(entry)
## Rotate the run identifier and drop all buffered entries. Called when the
## game-side autoload sends its mcp:hello beacon, marking a fresh play cycle.
## Returns the new run_id.
func clear_for_new_run() -> String:
_clear_storage()
_run_id = _generate_run_id()
return _run_id
func run_id() -> String:
return _run_id
static func _generate_run_id() -> String:
## Opaque to agents — they only check equality. Time-based is plenty
## unique within a single editor session and avoids the RNG-seed
## reproducibility footgun.
return "r%d" % Time.get_ticks_msec()
@@ -0,0 +1 @@
uid://biojw0xl64haw
+113
View File
@@ -0,0 +1,113 @@
@tool
class_name McpLogBacktrace
extends RefCounted
## Helpers for interpreting Godot's `_log_error` virtual arguments.
## (Named `McpLogBacktrace`, not `ScriptBacktrace`: Godot ships a built-in
## `ScriptBacktrace` class — the type of `script_backtraces[i]` entries
## — so class_name'ing ours the same would collide. Verified against
## the engine's `--doctool` output in 4.6.)
##
## Both `editor_logger.gd` and `game_logger.gd` need to:
## - Map `error_type` (0=ERROR, 1=WARNING, 2=SCRIPT, 3=SHADER) to a
## two-bucket "error" / "warn" string so callers can filter without
## consulting the enum.
## - Fall back to `code` when `rationale` is empty — single-arg
## `push_error("msg")` leaves rationale empty and stuffs the user's
## string into `code`; without the fallback the user message is
## silently lost. The two-arg form `push_error(code, rationale)`
## populates both and rationale wins.
## - Remap the source location to the first frame of `script_backtraces[0]`
## when present. `push_error` / `push_warning` always report
## `file=core/variant/variant_utility.cpp`; the actual user GDScript
## caller is in the backtrace.
##
## Centralising the rules keeps the next push_error semantics shift
## (already happened once between 4.5 and 4.6, see PR #78) a one-place
## fix instead of a two-place hunt.
## Coalesce the per-virtual-arg shape Godot hands `_log_error` into a
## flat record. Always walks `script_backtraces` for the first non-empty
## frame; loggers that need to filter by source path call this first and
## then check the resolved `path` field.
##
## Returns: `{level, message, path, line, function, details}`
## - `level`: "error" or "warn" (warn iff `error_type == 1`).
## - `message`: `rationale` when non-empty, else `code`.
## - `path` / `line` / `function`: first backtrace frame when one is
## available; otherwise the original `file` / `line` / `function`.
## - `details`: original `_log_error` fields plus the first non-empty
## backtrace as frames, mirroring the debugger Errors tab context.
const ERROR_TYPE_NAMES := {
0: "error",
1: "warning",
2: "script",
3: "shader",
}
static func resolve_error(
function: String,
file: String,
line: int,
code: String,
rationale: String,
error_type: int,
script_backtraces: Array,
) -> Dictionary:
var src_file := file
var src_line := line
var src_function := function
var frames: Array[Dictionary] = []
## First non-empty frame wins, not just `script_backtraces[0]` —
## chained errors can leave the leading entry empty with the actual
## user frame in `script_backtraces[1]`.
for bt in script_backtraces:
if bt != null and bt.get_frame_count() > 0:
frames = _frames_from_backtrace(bt)
src_file = str(frames[0].get("path", ""))
src_line = int(frames[0].get("line", 0))
src_function = str(frames[0].get("function", ""))
break
var message := rationale if not rationale.is_empty() else code
return {
"level": "warn" if error_type == 1 else "error",
"message": message,
"path": src_file,
"line": src_line,
"function": src_function,
"details": {
"message": message,
"code": code,
"rationale": rationale,
"error_type": error_type,
"error_type_name": _error_type_name(error_type),
"source": {
"path": file,
"line": line,
"function": function,
},
"resolved": {
"path": src_file,
"line": src_line,
"function": src_function,
},
"frames": frames,
},
}
static func _frames_from_backtrace(bt) -> Array[Dictionary]:
var frames: Array[Dictionary] = []
for i in bt.get_frame_count():
frames.append({
"path": bt.get_frame_file(i),
"line": bt.get_frame_line(i),
"function": bt.get_frame_function(i),
})
return frames
static func _error_type_name(error_type: int) -> String:
return str(ERROR_TYPE_NAMES.get(error_type, "unknown"))
@@ -0,0 +1 @@
uid://b8t9kznr2pqxa
+63
View File
@@ -0,0 +1,63 @@
@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
func log(msg: String) -> void:
var line := "MCP | %s" % msg
if enabled and console_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
+1
View File
@@ -0,0 +1 @@
uid://ddkslse7511e6
@@ -0,0 +1,23 @@
@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"
@@ -0,0 +1 @@
uid://klhsu1cuhcue
@@ -0,0 +1,98 @@
@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 when the dock should reject new refresh spawns. Used by the
## focus-in / manual button / cooldown-timer entrypoints.
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
@@ -0,0 +1 @@
uid://dv4tukg6eioww
+189
View File
@@ -0,0 +1,189 @@
@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(<int>)` 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 only for READY. Other "ok-ish" states (SPAWNING) are still in
## flight; READY is the only state where the plugin can treat the server
## as fully healthy.
static func is_healthy(state: int) -> bool:
return state == READY
## 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
@@ -0,0 +1 @@
uid://d3ial4erjonlq
+34
View File
@@ -0,0 +1,34 @@
@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"
@@ -0,0 +1 @@
uid://cikdvq2x4vs4x
+171
View File
@@ -0,0 +1,171 @@
@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://<id>` — 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.
for segment in path.trim_prefix("res://").split("/", false):
if segment.to_lower() == ".godot":
return "Refusing to write under res://.godot/ (editor metadata)"
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])
@@ -0,0 +1 @@
uid://blxntmd65ljyu
+315
View File
@@ -0,0 +1,315 @@
@tool
class_name McpPortResolver
extends RefCounted
## Pure-static port discovery / OS-specific scrapers. No instance state,
## no editor dependencies. plugin.gd has thin instance shims that wrap
## these and increment the cold-start trace counters.
## 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"
const WindowsPortReservation := preload("res://addons/godot_ai/utils/windows_port_reservation.gd")
static func can_bind_local_port(port: int) -> bool:
var server := TCPServer.new()
var err := server.listen(port, "127.0.0.1")
if err == OK:
server.stop()
return true
return false
## True when `port` is bound on 127.0.0.1. Probes via TCPServer first,
## falls back to OS scraping. Callers that want to bracket the slow
## scrape with a trace counter should call `is_port_in_use_via_scrape`
## after their own `can_bind_local_port` probe.
static func is_port_in_use(port: int) -> bool:
if can_bind_local_port(port):
## On POSIX, an IPv6 wildcard listener can coexist with a
## successful 127.0.0.1 bind probe. Confirm with lsof so startup
## sees the same listener set that shutdown/recovery would see.
if OS.get_name() != "Windows":
return is_port_in_use_via_scrape(port)
return false
return is_port_in_use_via_scrape(port)
static func is_port_in_use_via_scrape(port: int) -> bool:
var output: Array = []
if OS.get_name() == "Windows":
var exit_code := OS.execute("netstat", ["-ano"], output, true)
if exit_code == 0 and output.size() > 0:
return parse_windows_netstat_listening(str(output[0]), port)
return false
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()
## Return the PID currently listening on the given TCP port, or 0 if
## the port is free. Thin convenience wrapper around `find_all_pids_on_port`
## — the per-OS scraping logic lives in one place.
static func find_pid_on_port(port: int, trace: Callable = Callable()) -> int:
var pids := find_all_pids_on_port(port, trace)
return pids[0] if not pids.is_empty() else 0
## Returns every PID bound LISTEN on `port`. Used by the kill paths so
## both the uvicorn reloader parent AND its worker child are caught when
## 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
## 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.
static func find_all_pids_on_port(port: int, trace: Callable = Callable()) -> Array[int]:
if OS.get_name() == "Windows":
var output: Array = []
_trace(trace, "netstat")
var exit_code := OS.execute("netstat", ["-ano"], output, true)
if exit_code == 0 and not output.is_empty():
var netstat_pids := parse_windows_netstat_pids(str(output[0]), port)
if not netstat_pids.is_empty():
return netstat_pids
_trace(trace, "powershell")
return find_listener_pids_windows(port)
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():
var empty: Array[int] = []
return empty
return parse_lsof_pids(str(output[0]))
static func _trace(trace: Callable, counter: String) -> void:
if trace.is_valid():
trace.call(counter)
static func find_listener_pids_windows(port: int) -> Array[int]:
var script := (
"Get-NetTCPConnection -LocalPort %d -State Listen "
+ "-ErrorAction SilentlyContinue | "
+ "Select-Object -ExpandProperty OwningProcess"
) % port
var output: Array = []
var exit_code := execute_windows_powershell(script, output)
return windows_listener_pids_from_execute_result(exit_code, output)
static func execute_windows_powershell(script: String, output: Array) -> int:
var args := ["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script]
for exe in windows_powershell_candidates():
output.clear()
var exit_code := OS.execute(exe, args, output, true)
if exit_code == 0:
return exit_code
return -1
static func windows_powershell_candidates() -> Array[String]:
var candidates: Array[String] = []
var system_root := OS.get_environment("SystemRoot")
if system_root.is_empty():
system_root = "C:/Windows"
system_root = system_root.replace("\\", "/").trim_suffix("/")
candidates.append(system_root + "/System32/WindowsPowerShell/v1.0/powershell.exe")
candidates.append("powershell.exe")
candidates.append("pwsh.exe")
return candidates
static func windows_listener_pids_from_execute_result(exit_code: int, output: Array) -> Array[int]:
var empty: Array[int] = []
if exit_code == 0 and not output.is_empty():
return parse_pid_lines(str(output[0]))
return empty
static func windows_listener_execute_result_in_use(exit_code: int, output: Array) -> bool:
return not windows_listener_pids_from_execute_result(exit_code, output).is_empty()
## 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).
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
static func parse_pid_lines(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():
var pid := int(stripped)
if pid > 0 and not pids.has(pid):
pids.append(pid)
return pids
## Parse a Windows `netstat -ano` dump and return PIDs of rows whose
## local address ends with `:port` AND state is `LISTENING`. Substring
## matching the whole dump is wrong: a remote address containing
## `:port` would false-positive against an unrelated ESTABLISHED row.
static func parse_windows_netstat_pid(stdout: String, port: int) -> int:
var pids := parse_windows_netstat_pids(stdout, port)
return pids[0] if not pids.is_empty() else 0
static func parse_windows_netstat_pids(stdout: String, port: int) -> Array[int]:
var pids: Array[int] = []
var port_suffix := ":%d" % port
for line in stdout.split("\n"):
var s := line.strip_edges()
if s.is_empty():
continue
var fields := split_on_whitespace(s)
if fields.size() < 5: # proto, local, remote, state, pid
continue
if fields[3] != "LISTENING":
continue
if not fields[1].ends_with(port_suffix):
continue
var pid_str := fields[fields.size() - 1]
if pid_str.is_valid_int():
var pid := int(pid_str)
if pid > 0 and not pids.has(pid):
pids.append(pid)
return pids
static func parse_windows_netstat_listening(stdout: String, port: int) -> bool:
return parse_windows_netstat_pid(stdout, port) > 0
## `String.split(" ", false)` only splits on single spaces; netstat
## columns are separated by runs of spaces / tabs. Collapse manually.
static func split_on_whitespace(s: String) -> PackedStringArray:
var out: PackedStringArray = []
var cur := ""
for i in s.length():
var c := s.substr(i, 1)
if c == " " or c == "\t":
if not cur.is_empty():
out.append(cur)
cur = ""
else:
cur += c
if not cur.is_empty():
out.append(cur)
return out
static func read_pid_file() -> int:
if not FileAccess.file_exists(SERVER_PID_FILE):
return 0
var f := FileAccess.open(SERVER_PID_FILE, FileAccess.READ)
if f == null:
return 0
var content := f.get_as_text().strip_edges()
f.close()
if content.is_empty() or not content.is_valid_int():
return 0
var pid := int(content)
return pid if pid > 0 else 0
static func clear_pid_file() -> void:
if FileAccess.file_exists(SERVER_PID_FILE):
DirAccess.remove_absolute(ProjectSettings.globalize_path(SERVER_PID_FILE))
## `kill -0` returns 0 for both running and zombie processes; Godot
## never `waitpid`s on `OS.create_process` children, so a fast-failing
## 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:
if pid <= 0:
return false
if OS.get_name() == "Windows":
var output: Array = []
var exit_code := OS.execute("tasklist", ["/FI", "PID eq %d" % pid, "/NH", "/FO", "CSV"], output, true)
if exit_code != 0 or output.is_empty():
return false
for line in output:
if str(line).find("\"%d\"" % pid) >= 0:
return true
return false
var output: Array = []
var exit_code := OS.execute("ps", ["-p", str(pid), "-o", "stat="], output, true)
if exit_code != 0 or output.is_empty():
return false
var stat := str(output[0]).strip_edges()
return not stat.is_empty() and not stat.begins_with("Z")
## 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.
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):
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)
## Choose a non-Windows-reserved WS port. Returns `configured` when free;
## otherwise the first non-excluded port within `span` of it. Optional
## `log_buffer` is a duck-typed sink (`log(String)`) that gets the
## remap notice so users see why the port shifted.
static func resolve_ws_port(configured: int, max_port: int, log_buffer = null) -> int:
var resolved := WindowsPortReservation.suggest_non_excluded_port(
configured,
2048,
max_port
)
if resolved != configured:
var message := "WebSocket port %d is reserved by Windows; using %d" % [configured, resolved]
print("MCP | %s" % message)
if log_buffer != null:
log_buffer.log(message)
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,
max_port: int,
span: int = 2048
) -> int:
return WindowsPortReservation.suggest_non_excluded_port_from_output(
netsh_output,
configured_port,
span,
max_port
)
@@ -0,0 +1 @@
uid://pk0212qfh61x
+131
View File
@@ -0,0 +1,131 @@
@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.
## 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]
)
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 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}
## 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}
+1
View File
@@ -0,0 +1 @@
uid://de2rwdoa4wabf
+146
View File
@@ -0,0 +1,146 @@
@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/<scene_root_name>[/...]" 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
## /root/<scene_root_name>[/...] 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": <scene_root>}. 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.) intentionally don't carry
# this payload because there's no useful caller hint to give.
var err := ErrorCodes.make(ErrorCodes.EDITOR_NOT_READY, "No scene open")
err["error"]["data"] = {
"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 "<unsaved>"
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/<X>[/...] where <X> is not the scene root → suggest /<sceneRoot>/<X>[/...]
## 2. path doesn't start with "/" → suggest "/<sceneRoot>/<path>"
## 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/<sceneRoot>/... 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]
+1
View File
@@ -0,0 +1 @@
uid://c1irdrss0amex
+904
View File
@@ -0,0 +1,904 @@
@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.<method>()` 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
var _server_spawn_ms: int = 0
var _server_exit_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 = ""
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
## 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
func _init(host) -> void:
_host = host
# ---- 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,
}
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 disarm_adoption_watch() -> void:
_adoption_watch_deadline_ms = 0
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
## 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"}
_set_incompatible_server(live, expected, ClientConfigurator.http_port())
if _host._connection != null:
_host._connection.connect_blocked = true
_host._connection.connect_block_reason = _server_status_message
_host._connection.disconnect_from_server()
_host._update_process_enabled()
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())
if _host._connection != null:
_host._connection.connect_blocked = true
_host._connection.connect_block_reason = _server_status_message
_host._connection.disconnect_from_server()
_host._update_process_enabled()
# ---- 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) -> 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).
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)
)
var proof: Dictionary = _host._evaluate_recovery_port_occupant_proof(port, live)
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)"))
_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 <path>)" 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 ""
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. Stop the old server or "
+ "change both HTTP and WS ports."
) % [port, version, actual_ws_port, path_suffix, expected_version, expected_ws_port]
return (
"Port %d is occupied by godot-ai server v%s%s; plugin expects v%s. "
+ "Stop the old server or change both HTTP and WS ports."
) % [port, version, path_suffix, expected_version]
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
OS.set_environment("GODOT_AI_OWNER_PID", str(OS.get_process_id()))
return true
## 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
func start_server() -> 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
var port := ClientConfigurator.http_port()
var ws_port := ClientConfigurator.ws_port()
var current_version := _expected_server_version()
_server_expected_version = current_version
if bool(_host._is_port_in_use(port)):
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)
var live: Dictionary = _host._probe_live_server_status_for_port(port)
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
var owner := int(_host._find_managed_pid(port))
var owner_label := adopt_compatible_server(record_version, current_version, owner)
_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.
if not recover_strong_port_occupant(port, 3.0, live):
_host._server_started_this_session = true
var post_recovery_live: Dictionary = _host._probe_live_server_status_for_port(port)
_set_incompatible_server(post_recovery_live, current_version, port)
_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")
var server_cmd := ClientConfigurator.get_server_command()
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
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()
_server_pid = OS.create_process(cmd, args)
var spawned_pid := int(_server_pid)
if owner_env_set:
OS.unset_environment("GODOT_AI_OWNER_PID")
## 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
_host._server_started_this_session = true
transition_state(McpServerStateScript.SPAWNING)
## 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)
_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:
set_terminal_diagnosis(McpServerStateScript.CRASHED)
_startup_path = McpStartupPathScript.CRASHED
push_warning("MCP | failed to start server")
## 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):
_server_pid = real_pid
elif not PortResolver.pid_alive(spawn_pid):
if elapsed >= int(_host.SPAWN_GRACE_MS) and not McpServerStateScript.is_terminal_diagnosis(_server_state):
if bool(_host._should_retry_with_refresh()):
_refresh_retried = true
respawn_with_refresh()
return
_server_exit_ms = elapsed
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()
return
if elapsed >= int(_host.SERVER_WATCH_MS):
## Survived startup — mid-session crashes surface via WebSocket disconnect.
_host._stop_server_watch()
## 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()
_server_pid = OS.create_process(cmd, args)
if owner_env_set:
OS.unset_environment("GODOT_AI_OWNER_PID")
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
var current_version := _expected_server_version()
_host._write_managed_server_record(spawn_pid, current_version)
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.
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) -> String:
_server_actual_name = "godot-ai"
_can_recover_incompatible = false
if record_version == current_version and owner > 0:
_server_pid = owner
_host._write_managed_server_record(owner, current_version)
return McpAdoptionLabelScript.MANAGED
_server_pid = -1
_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.
func recover_strong_port_occupant(port: int, wait_s: float, pre_kill_live: Dictionary = {}) -> bool:
var proof: Dictionary = _host._evaluate_strong_port_occupant_proof(port, pre_kill_live)
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 killed: Array = _host._kill_processes_and_windows_spawn_children(targets)
if not killed.is_empty():
print("MCP | killed pids %s on port %d" % [str(killed), port])
_host._wait_for_port_free(port, wait_s)
if bool(_host._is_port_in_use(port)):
return false
_host._clear_managed_server_record()
_host._clear_pid_file()
return true
func stop_server() -> void:
_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] = [int(_server_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
_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 `uvx mcp-proxy`.
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 we have
## proof of ownership over the occupant. Pure-state in the sense that
## nothing is killed — that's `recover_incompatible_server`.
func can_recover_incompatible_server() -> bool:
if _server_state != McpServerStateScript.INCOMPATIBLE:
return false
var port := ClientConfigurator.http_port()
if not bool(_host._is_port_in_use(port)):
return false
var proof: Dictionary = _host._evaluate_recovery_port_occupant_proof(port)
return not str(proof.get("proof", "")).is_empty()
func recover_incompatible_server() -> bool:
if _server_state != McpServerStateScript.INCOMPATIBLE:
return false
var port := ClientConfigurator.http_port()
var proof: Dictionary = _host._evaluate_recovery_port_occupant_proof(port)
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)
var killed: Array = _host._kill_processes_and_windows_spawn_children(targets)
if not killed.is_empty():
print("MCP | killed pids %s on port %d" % [str(killed), port])
_host._wait_for_port_free(port, 5.0)
if _host._is_port_in_use(port):
## 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 = ""
_server_actual_version = ""
_server_actual_name = ""
_can_recover_incompatible = false
_host._server_started_this_session = false
_server_pid = -1
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:
_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.
transition_state(McpServerStateScript.STOPPING)
_host._kill_processes_and_windows_spawn_children(_host._find_all_pids_on_port(port))
_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 upcoming `uvx mcp-proxy` build doesn't
## inherit the same cleanup-failure path that triggered the restart.
UvCacheCleanup.purge_stale_builds()
reset_for_force_restart()
start_server()
@@ -0,0 +1 @@
uid://bwfx8b0w2mgf6
@@ -0,0 +1,136 @@
@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. Mirrors `plugin.gd::SERVER_HANDSHAKE_VERSION_TIMEOUT_MS`
## — 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
## Invoked when `_on_connection_established` notices that we transitioned
## out of FOREIGN_PORT — the server may yet prove itself compatible.
## Re-arming is idempotent: if already active, no-op; otherwise the
## caller's connection + last-known expected version are reused.
func rearm_for_foreign_port_recovery(connection) -> void:
if _active:
return
arm(connection, _expected_version)
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)
@@ -0,0 +1 @@
uid://ciqldbuaq8i8u
+39
View File
@@ -0,0 +1,39 @@
@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"
## 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))
## 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
+1
View File
@@ -0,0 +1 @@
uid://pefrtofs7ijw
@@ -0,0 +1,156 @@
@tool
class_name McpStructuredLogRing
extends RefCounted
## Head-indexed circular buffer of structured log entries shared by
## game_log_buffer and editor_log_buffer.
##
## Once `_max_lines` (set in subclass `_init`) is reached, new appends
## overwrite the oldest slot at `_head`, keeping append O(1) on overflow
## — the previous slice() approach reallocated the full retained array
## on every drop, which a chatty game would pay for thousands of times
## per second.
##
## Lockless. Subclasses needing thread-safety (editor_log_buffer is
## written from any thread a Godot Logger virtual can fire on) wrap each
## public method with their own Mutex around the `_*_unlocked` helpers.
## Keeping the base lockless means the hot game-side path (single thread,
## called from _process) doesn't pay an unused mutex cost.
##
## Entry shape is owned by subclasses — `_append_entry` takes a
## ready-built Dictionary so each buffer can carry the fields it needs
## (game: `source/level/text`; editor: adds `path/line/function`).
const VALID_LEVELS := ["info", "warn", "error"]
var _max_lines: int
var _storage: Array[Dictionary] = []
## Next write position within `_storage`. While filling (before first
## wrap) equals `_storage.size()`; once full, points at the oldest entry
## (the one about to be overwritten).
var _head := 0
var _dropped_count := 0
## Monotonic number of entries appended since this ring was created. Unlike
## `_storage.size()` and `_dropped_count`, this intentionally survives clear()
## so callers can use it as a stable "next entry to read" cursor.
var _appended_total := 0
func _init(max_lines: int) -> void:
_max_lines = max_lines
## Append `entry` to the ring, evicting the oldest slot when full.
## Subclasses build the dict with their per-source shape and pass it in.
func _append_entry(entry: Dictionary) -> void:
if _storage.size() < _max_lines:
_storage.append(entry)
_head = _storage.size() % _max_lines
else:
## Full — overwrite oldest in place, advance head, count the drop.
_storage[_head] = entry
_head = (_head + 1) % _max_lines
_dropped_count += 1
_appended_total += 1
## Lockless slice. Subclasses with a mutex wrap their `get_range` /
## `get_recent` overrides around this; the lockless base implementations
## of those public methods just delegate here.
func _get_range_unlocked(offset: int, count: int) -> Array[Dictionary]:
var size := _storage.size()
var start := maxi(0, offset)
var stop := mini(size, start + count)
var out: Array[Dictionary] = []
for i in range(start, stop):
out.append(_storage[_logical_to_physical(i)])
return out
func get_range(offset: int, count: int) -> Array[Dictionary]:
return _get_range_unlocked(offset, count)
func get_recent(count: int) -> Array[Dictionary]:
var size := _storage.size()
var start := maxi(0, size - count)
return _get_range_unlocked(start, size - start)
## Lockless cursor read. The cursor is the next sequence to read: calling
## get_since(appended_total()) after a snapshot returns only later appends.
func _get_since_unlocked(since_seq: int, limit: int = -1) -> Dictionary:
var size := _storage.size()
var oldest_seq := _appended_total - size
var start_seq := mini(maxi(since_seq, oldest_seq), _appended_total)
var start := start_seq - oldest_seq
var available := maxi(0, size - start)
var count := available
if limit >= 0:
count = mini(available, limit)
var entries := _get_range_unlocked(start, count)
var next_cursor := start_seq + entries.size()
return {
"cursor": since_seq,
"oldest_cursor": oldest_seq,
"next_cursor": next_cursor,
"appended_total": _appended_total,
"truncated": since_seq < oldest_seq,
"has_more": next_cursor < _appended_total,
"entries": entries,
}
func get_since(since_seq: int, limit: int = -1) -> Dictionary:
return _get_since_unlocked(since_seq, limit)
## Lockless accessors. Subclasses with a mutex use these under their lock
## so the field reads stay encapsulated in the base instead of leaking
## `_storage` / `_dropped_count` reach-through into the subclass.
func _total_count_unlocked() -> int:
return _storage.size()
func _dropped_count_unlocked() -> int:
return _dropped_count
func _appended_total_unlocked() -> int:
return _appended_total
func total_count() -> int:
return _total_count_unlocked()
func dropped_count() -> int:
return _dropped_count_unlocked()
func appended_total() -> int:
return _appended_total_unlocked()
## Translate a logical index (0 = oldest retained) to a physical
## `_storage` slot. Before the first wrap, storage-order is logical-
## order. After wrapping, the oldest entry lives at `_head`.
func _logical_to_physical(logical: int) -> int:
if _storage.size() < _max_lines:
return logical
return (_head + logical) % _max_lines
## Reset the ring to empty. Subclasses with a mutex wrap this with their
## lock; subclasses that surface `clear` to callers (McpEditorLogBuffer)
## return the prior size from their wrapper.
func _clear_storage() -> void:
_storage.clear()
_head = 0
_dropped_count = 0
## Coerce unknown levels to "info" so a misbehaving sender can't poison
## downstream filters with arbitrary strings.
static func _coerce_level(level: String) -> String:
return level if level in VALID_LEVELS else "info"
@@ -0,0 +1 @@
uid://c4yh3jqfn6dwe
+443
View File
@@ -0,0 +1,443 @@
@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")
## 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 _latest_download_url: 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 (dock dropdown > 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. The dock calls this before re-issuing a
## check after a mode-override flip — without the cancel, `request()`
## returns ERR_BUSY and the dropdown change silently fails to repaint.
func cancel_check() -> void:
if _http_request != null:
_http_request.cancel_request()
## Reset the cached download URL. The dock calls this on mode-override
## flips so a fresh check paints over a clean banner.
func clear_pending_download() -> void:
_latest_download_url = ""
## True when the running Godot can self-update in place. Godot < 4.4 takes
## the `_install_zip_inline` extract-then-restart path, and that engine's
## stricter `GDScript::reload()` (`!p_keep_state && has_instances` ->
## `ERR_ALREADY_IN_USE`) turns the extract-over-live-scripts into a reload
## error flood plus a SIGSEGV in `EditorDockManager::remove_dock` /
## `SceneTree::finalize` on the restart/quit (#475). So on < 4.4 we don't
## run the in-editor pipeline at all — the user updates manually.
## 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.4.
static func _version_can_self_update(major: int, minor: int) -> bool:
return major > 4 or (major == 4 and minor >= 4)
## Banner guidance for the gated (< 4.4) path. Shown up-front at check time
## (with the available version) and again on click, so the user understands
## the manual-update flow before they press anything. Single source of truth
## so check-time and click-time text never drift.
static func _manual_update_label(version: String) -> String:
var prefix := "Update available"
if not version.is_empty():
prefix = "Update v%s available" % version
return (
prefix
+ " — in-editor update needs Godot 4.4+. Open the download page, then "
+ "replace addons/godot_ai/ manually and relaunch."
)
## Driven by the dock's Update button. On Godot < 4.4 (see `_can_self_update`)
## the in-editor install is disabled — we open the release page for a manual
## download instead, never entering the extract pipeline that crashes those
## engines. With no resolved download URL — either the check never completed,
## or the release didn't ship a matching asset — also falls back to opening
## the release page. Otherwise kicks off the download → extract → reload
## pipeline.
func start_install() -> void:
if not _can_self_update():
## Only claim success + lock the button if the browser actually opened.
## On failure (no handler, headless) keep the button enabled so the
## user can retry. Either way, leave the version-bearing guidance label
## from check time in place — don't re-emit label_text.
if OS.shell_open(RELEASES_PAGE) == OK:
install_state_changed.emit({
"button_text": "Opened download page",
"button_disabled": true,
})
else:
install_state_changed.emit({
"button_text": "Couldn't open browser — retry",
"button_disabled": false,
})
return
if _latest_download_url.is_empty():
OS.shell_open(RELEASES_PAGE)
return
install_state_changed.emit({
"button_text": "Downloading...",
"button_disabled": true,
})
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
##
## 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": "",
}
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 assets: Array = json.get("assets", [])
for asset in assets:
var asset_dict: Dictionary = asset
if String(asset_dict.get("name", "")) == "godot-ai-plugin.zip":
url = String(asset_dict.get("browser_download_url", ""))
break
var forced := ClientConfigurator.mode_override() == "user"
var label_text := "Update available: v%s" % remote_version
if forced:
## Forced-user mode (dropdown 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
return out
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
_latest_download_url = String(parsed.get("download_url", ""))
update_check_completed.emit(parsed)
## On engines that can't self-update (Godot < 4.4, #475), surface the
## full manual-update guidance AND relabel the button up-front — before
## any click — so the user knows what the button does and why.
if not _can_self_update():
install_state_changed.emit({
"button_text": "Open download page",
"label_text": _manual_update_label(String(parsed.get("version", ""))),
})
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])
install_state_changed.emit({
"button_text": "Download failed (%d)" % response_code,
"button_disabled": false,
})
return
install_state_changed.emit({"button_text": "Installing..."})
# Deferred so the HTTPRequest callback returns before the extract starts.
_install_zip.call_deferred()
# ---- 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 version := Engine.get_version_info()
var has_runner: bool = (
_plugin != null
and _plugin.has_method("install_downloaded_update")
)
## Same major-aware predicate as the _can_self_update() gate, so a future
## Godot 5.x (minor 0) takes the runner path the gate promised — not the
## pre-4.4 inline extract. A bare `minor >= 4` here would route 5.0 to the
## crash-prone inline path even though the gate let it in.
if _version_can_self_update(int(version.get("major", 0)), int(version.get("minor", 0))) and 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
_install_zip_inline(version)
func _install_zip_inline(version: Dictionary) -> void:
## Pre-4.4 fallback. EditorInterface.set_plugin_enabled off/on is
## re-entry-unsafe on older Godot; we extract in-process and ask the
## user to restart.
var zip_path := ProjectSettings.globalize_path(UPDATE_TEMP_ZIP)
var install_base := ProjectSettings.globalize_path("res://")
var reader := ZIPReader.new()
if reader.open(zip_path) != OK:
_install_in_flight = false
install_state_changed.emit({
"button_text": "Extract failed",
"button_disabled": false,
})
return
var files := reader.get_files()
for file_path in files:
if not file_path.begins_with("addons/godot_ai/"):
continue
if file_path.ends_with("/"):
DirAccess.make_dir_recursive_absolute(install_base.path_join(file_path))
else:
var dir := file_path.get_base_dir()
DirAccess.make_dir_recursive_absolute(install_base.path_join(dir))
var content := reader.read_file(file_path)
var f := FileAccess.open(install_base.path_join(file_path), FileAccess.WRITE)
if f != null:
f.store_buffer(content)
f.close()
reader.close()
DirAccess.remove_absolute(zip_path)
DirAccess.remove_absolute(ProjectSettings.globalize_path(UPDATE_TEMP_DIR))
## Kill the old server before the reload so the re-enabled plugin spawns
## a fresh one against the new plugin version (#132).
if _plugin != null and _plugin.has_method("prepare_for_update_reload"):
_plugin.prepare_for_update_reload()
if _version_can_self_update(int(version.get("major", 0)), int(version.get("minor", 0))):
install_state_changed.emit({"button_text": "Scanning..."})
## Filesystem scan must complete before plugin reload — otherwise
## plugin.gd re-parses against a ClassDB that hasn't seen the new
## files yet, parse errors, dock tears down silently. See #127.
var fs := EditorInterface.get_resource_filesystem()
if fs != null:
fs.filesystem_changed.connect(
_on_filesystem_scanned_for_update, CONNECT_ONE_SHOT
)
fs.scan()
else:
_reload_after_update.call_deferred()
else:
## Pre-4.4: no plugin reload; refreshes resume on the old dock
## instance until the user restarts.
_install_in_flight = false
install_state_changed.emit({
"button_text": "Restart editor to apply",
"button_disabled": true,
"label_text": "Updated! Restart the editor.",
"outcome": "success",
})
func _on_filesystem_scanned_for_update() -> void:
install_state_changed.emit({"button_text": "Reloading..."})
_reload_after_update.call_deferred()
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()
@@ -0,0 +1 @@
uid://cegiyw3fjcwev
+140
View File
@@ -0,0 +1,140 @@
@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
@@ -0,0 +1 @@
uid://dd5rti52vgs71
+161
View File
@@ -0,0 +1,161 @@
@tool
class_name McpUvCacheCleanup
extends RefCounted
## Sweeps stale `.tmp*` build venvs out of `%LOCALAPPDATA%\uv\cache\builds-v0`.
##
## Background
## ----------
## When Claude Desktop's MCP launcher invokes `uvx mcp-proxy ...` to talk to
## a running godot-ai server, 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\<hash>\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 }
@@ -0,0 +1 @@
uid://d33ukg65qf7q0
@@ -0,0 +1,71 @@
@tool
extends RefCounted
## Converts Godot Variants into values that can be encoded as JSON.
static func serialize(value: Variant) -> Variant:
if value == null:
return null
match typeof(value):
TYPE_BOOL, TYPE_INT, TYPE_FLOAT, TYPE_STRING:
return value
TYPE_STRING_NAME:
return str(value)
TYPE_VECTOR2, TYPE_VECTOR2I:
return {"x": value.x, "y": value.y}
TYPE_VECTOR3, TYPE_VECTOR3I:
return {"x": value.x, "y": value.y, "z": value.z}
TYPE_VECTOR4, TYPE_VECTOR4I, TYPE_QUATERNION:
return {"x": value.x, "y": value.y, "z": value.z, "w": value.w}
TYPE_COLOR:
return {"r": value.r, "g": value.g, "b": value.b, "a": 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": 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)
@@ -0,0 +1 @@
uid://cte37mtbd61n3
@@ -0,0 +1,166 @@
@tool
class_name McpWindowsPortReservation
extends RefCounted
## Detects whether Windows has reserved a TCP port range that covers the
## plugin's server port. Hyper-V, WSL2, Docker Desktop, and Windows
## Sandbox all grab port ranges at boot via the winnat service. When a
## user's chosen port sits inside a reserved range, bind(2) fails with
## WinError 10013 ("forbidden by its access permissions") rather than
## 10048 ("address in use") — `netstat` shows nothing because no process
## owns the port, making the failure invisible. See issue #146.
const NETSH_ARGS := ["interface", "ipv4", "show", "excludedportrange", "protocol=tcp"]
const NETSH_CACHE_TTL_MS := 2000
static var _netsh_cache_text := ""
static var _netsh_cache_msec := 0
static var _netsh_cache_valid := false
static var _netsh_query_count := 0
## Returns true if `port` falls inside a currently-reserved range on this
## Windows host. No-op on non-Windows (returns false).
static func is_port_excluded(port: int) -> bool:
if OS.get_name() != "Windows":
return false
var now_ms := Time.get_ticks_msec()
var cached := _get_cached_excluded_output(now_ms)
if bool(cached.get("hit", false)):
return parse_excluded(str(cached.get("text", "")), port)
var output: Array = []
var exit_code := _execute_netsh_excluded_ranges(output)
if exit_code != 0 or output.is_empty():
return false
var text := str(output[0])
_store_excluded_output(text, now_ms)
return parse_excluded(text, port)
static func _store_excluded_output(text: String, now_ms: int) -> void:
_netsh_cache_text = text
_netsh_cache_msec = now_ms
_netsh_cache_valid = true
static func _get_cached_excluded_output(now_ms: int) -> Dictionary:
if not _netsh_cache_valid:
return {"hit": false, "text": ""}
if now_ms - _netsh_cache_msec > NETSH_CACHE_TTL_MS:
return {"hit": false, "text": ""}
return {"hit": true, "text": _netsh_cache_text}
static func _clear_cache_for_tests() -> void:
_netsh_cache_text = ""
_netsh_cache_msec = 0
_netsh_cache_valid = false
static func netsh_query_count() -> int:
return _netsh_query_count
static func _execute_netsh_excluded_ranges(output: Array) -> int:
_netsh_query_count += 1
return OS.execute("netsh", NETSH_ARGS, output, true)
## Parse the `netsh` excluded-port-range output and return true if `port`
## sits inside any reserved range. Exposed for testing; the live check
## uses `is_port_excluded`. Expected input format:
##
## Protocol tcp Port Exclusion Ranges
##
## Start Port End Port
## ---------- --------
## 80 80
## 5040 5040
## 8000 8099
##
## * - Administered port exclusions.
static func parse_excluded(text: String, port: int) -> bool:
return _ranges_contain(parse_excluded_ranges(text), port)
## Parse the `netsh` excluded-port-range output once into inclusive ranges.
static func parse_excluded_ranges(text: String) -> Array[Vector2i]:
var ranges: Array[Vector2i] = []
for line in text.split("\n"):
var trimmed := line.strip_edges()
if trimmed.is_empty() or trimmed.begins_with("-") or trimmed.begins_with("*"):
continue
var parts: PackedStringArray = trimmed.split(" ", false)
if parts.size() < 2:
continue
if not parts[0].is_valid_int() or not parts[1].is_valid_int():
continue
var start_p := int(parts[0])
var end_p := int(parts[1])
ranges.append(Vector2i(start_p, end_p))
return ranges
static func _ranges_contain(ranges: Array[Vector2i], port: int) -> bool:
for r in ranges:
if port >= r.x and port <= r.y:
return true
return false
## Return the first port in `start`..`start+span-1` that is not excluded by
## Windows' port reservation table. Runs `netsh` once, unlike probing every
## candidate with `is_port_excluded`, which keeps fallback port selection cheap
## when Hyper-V / WSL2 / Docker reserve many adjacent ranges.
static func suggest_non_excluded_port(start: int, span: int = 2048, max_port: int = 65535) -> int:
if OS.get_name() != "Windows":
return start
var now_ms := Time.get_ticks_msec()
var cached := _get_cached_excluded_output(now_ms)
if bool(cached.get("hit", false)):
return suggest_non_excluded_port_from_output(str(cached.get("text", "")), start, span, max_port)
var output: Array = []
var exit_code := _execute_netsh_excluded_ranges(output)
if exit_code != 0 or output.is_empty():
return start
var text := str(output[0])
_store_excluded_output(text, now_ms)
return suggest_non_excluded_port_from_output(text, start, span, max_port)
## Pure parser-backed helper for tests and for `suggest_non_excluded_port`.
static func suggest_non_excluded_port_from_output(text: String, start: int, span: int = 2048, max_port: int = 65535) -> int:
var ranges := parse_excluded_ranges(text)
var limit := mini(start + span - 1, max_port)
var p := start
while p <= limit:
var advanced := false
for r in ranges:
if p >= r.x and p <= r.y:
p = r.y + 1
advanced = true
break
if not advanced:
return p
return start
## User-facing hint for the proactive port-reservation detection path —
## rendered when `is_port_excluded(port)` returns true *before* we even
## try to bind. Same copy as the post-crash WinError-10013 branch in
## `hint_from_output` so the two entry points agree.
static func port_excluded_hint(port: int) -> String:
return "Port %d is reserved by Windows (often Hyper-V / WSL2 / Docker Desktop). In an admin PowerShell: `net stop winnat; net start winnat`, then click Reconnect." % port
## Scan captured server output for known failure signatures and return a
## short, user-facing hint. Empty string means no match.
static func hint_from_output(lines: PackedStringArray, port: int) -> String:
var joined := "\n".join(lines).to_lower()
if joined.find("winerror 10013") >= 0 or joined.find("forbidden by its access permissions") >= 0:
return port_excluded_hint(port)
if joined.find("errno 98") >= 0 or joined.find("winerror 10048") >= 0 or joined.find("address already in use") >= 0:
return "Port %d is already in use by another process. Stop the conflicting process, then click Reconnect." % port
if joined.find("modulenotfounderror") >= 0 or joined.find("no module named") >= 0:
return "The `godot-ai` Python package didn't load. Try `uv cache clean`, then Reconnect."
return ""
@@ -0,0 +1 @@
uid://bt7mxpjcdrobq