Replace dasher-pack with unified animation-pack using original Blender bone names
This commit is contained in:
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"}
|
||||
@@ -0,0 +1 @@
|
||||
uid://cyowqr1x12ilg
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://dhoe3ypkhm12v
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,263 @@
|
||||
@tool
|
||||
class_name McpJsonStrategy
|
||||
extends RefCounted
|
||||
|
||||
## Read–merge–write strategy for JSON-backed MCP clients.
|
||||
## All knobs come from the McpClient descriptor as plain data — no Callables.
|
||||
## See `_base.gd` for why descriptors are data-only.
|
||||
|
||||
|
||||
static func configure(client: McpClient, server_name: String, server_url: String) -> 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://bxougoq8xwg1
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -0,0 +1 @@
|
||||
uid://d36nywn2nkgts
|
||||
@@ -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())
|
||||
@@ -0,0 +1 @@
|
||||
uid://hdlwcfdr8mdk
|
||||
@@ -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())
|
||||
@@ -0,0 +1 @@
|
||||
uid://bvpbssfanukef
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -0,0 +1 @@
|
||||
uid://dc1x77i1cmb6w
|
||||
@@ -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"])
|
||||
@@ -0,0 +1 @@
|
||||
uid://d2whd6a5fofhg
|
||||
@@ -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())
|
||||
@@ -0,0 +1 @@
|
||||
uid://dqdmd2jw5qen7
|
||||
@@ -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())
|
||||
@@ -0,0 +1 @@
|
||||
uid://s8n0vfirf2pj
|
||||
@@ -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())
|
||||
@@ -0,0 +1 @@
|
||||
uid://qwb5udkf423q
|
||||
@@ -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())
|
||||
@@ -0,0 +1 @@
|
||||
uid://denjdf50qrf66
|
||||
@@ -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())
|
||||
@@ -0,0 +1 @@
|
||||
uid://cwpu48772vfj1
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -0,0 +1 @@
|
||||
uid://b6pqiok2mlsmg
|
||||
@@ -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())
|
||||
@@ -0,0 +1 @@
|
||||
uid://d152l0u0r6fsc
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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.0–1.0). Returns an empty
|
||||
## array if no candidates clear the threshold. Used by batch_execute to surface
|
||||
## "did you mean" suggestions when an unknown command is passed.
|
||||
func suggest_similar(cmd_name: String, limit: int = 3, threshold: float = 0.5) -> Array[String]:
|
||||
return FuzzySuggestions.rank(cmd_name, _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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://ctldk7ivsoo3i
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://b8yknttdjanm5
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://d3ui3yx6vdigl
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://dlul2gculiy1p
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://d1vqyt4uyo378
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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])
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -0,0 +1 @@
|
||||
uid://de2rwdoa4wabf
|
||||
@@ -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]
|
||||
@@ -0,0 +1 @@
|
||||
uid://c1irdrss0amex
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user