first commit

This commit is contained in:
Leonid Pershin
2026-08-10 20:39:06 +03:00
commit f16f60445c
281 changed files with 43576 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Godot AI contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+53
View File
@@ -0,0 +1,53 @@
# Godot AI
Connect AI assistants to a live Godot editor via the [Model Context Protocol](https://modelcontextprotocol.io/introduction) (MCP).
Godot AI bridges Claude Code, Codex, Antigravity, and other MCP clients with your editor — inspect scenes, create nodes, modify properties, run tests, search project files, and more, all from a prompt.
## Quick Start
1. Copy `addons/godot_ai/` into your project's `addons/` folder
2. Enable the plugin: **Project > Project Settings > Plugins > Godot AI**
3. Pick your MCP client in the **Godot AI** dock and press **Configure**
The plugin auto-starts the MCP server and connects over WebSocket. No manual configuration required.
## Requirements
- Godot 4.5+ (4.7+ 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)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1 @@
uid://1kiy8hqyymyj
+206
View File
@@ -0,0 +1,206 @@
@tool
class_name McpAtomicWrite
extends RefCounted
## Write text to a file via temp + rename so a crash mid-write never leaves
## the user's MCP config truncated. Creates the parent dir if needed and
## keeps a one-shot `.backup` of the prior file.
##
## On filesystems where rename-over-existing fails (Windows under AV / lock
## pressure, some SMB shares), falls back to overwrite-copy plus a
## backup-restore on failure. The original file is never removed before the
## new bytes are verified on disk — if both the rename and the copy fail,
## the user's prior config is restored from the `.backup` snapshot. See
## issue #297 finding #10 for the data-loss scenario this guards against.
static func write(path: String, content: String) -> bool:
# If the target is a symlink (stow/chezmoi-managed dotfiles), rename-over
# would replace the LINK with a regular file, silently detaching the
# config from the user's dotfile repo (#534). Resolve the link chain and
# write to the real target so the symlink survives.
path = _resolve_symlink_target(path)
var dir_path := path.get_base_dir()
if not DirAccess.dir_exists_absolute(dir_path):
if DirAccess.make_dir_recursive_absolute(dir_path) != OK:
return false
# Decide the permission mode the final file (and its backup) must carry
# BEFORE we replace anything. A rewrite must preserve the prior file's
# mode: the Claude CLI creates ~/.claude.json as 0600 (it holds OAuth
# creds + history), and a naive FileAccess write + DirAccess copy would
# silently relax that to the umask default (0644) and leak it on shared
# machines. A brand-new config defaults to owner-only 0600 since these
# files routinely carry tokens. On platforms without POSIX permissions
# (Windows) the get/set calls no-op and this logic is inert. See #297
# finding TC-1.
var had_original := FileAccess.file_exists(path)
var target_mode := _resolve_target_mode(path, had_original)
# Suffix the temp name with this process's PID so two editors writing the
# same config concurrently (both clicking Configure) can't interleave
# bytes on a shared fixed ".tmp" path (#534). Each process stages its own
# temp file; the final rename remains the atomic commit point.
var tmp_path := "%s.tmp.%d" % [path, OS.get_process_id()]
var file := FileAccess.open(tmp_path, FileAccess.WRITE)
if file == null:
return false
# Lock the temp inode down BEFORE writing any bytes. FileAccess.open creates
# it at the umask default (often 0644); chmod'ing the still-empty file first
# means the config contents are never on disk under a world-readable mode in
# the create->chmod gap. rename preserves the inode mode, so the swapped-in
# file lands correct and is never briefly world-readable under the target name.
_apply_mode(tmp_path, target_mode)
file.store_string(content)
# Push Godot's internal buffer out to the OS before the rename. Godot
# exposes no fsync, so the bytes aren't guaranteed durable on the physical
# disk until the OS flushes its own cache — a power loss in that window can
# still lose the data. But flush() ensures the rename can't be ordered ahead
# of the write at the application layer, which is the failure this guards.
file.flush()
file.close()
# Re-assert the mode on the closed inode. The pre-write chmod above closes
# the world-readable window; this second apply is the authoritative one
# (a chmod issued while the FileAccess handle is still open doesn't reliably
# stick inside the editor) and guarantees the final mode before the rename,
# which preserves it.
_apply_mode(tmp_path, target_mode)
# Verify the staged temp landed intact before committing it anywhere. The
# copy-fallback path below already guards this (`_written_size_matches` at
# the rename-fallback check); the rename path was the one gap — under
# disk-full/quota the temp can be silently truncated, and an unverified
# rename would swap a truncated file over the live target while the
# caller is told the write succeeded (#687).
if not _written_size_matches(tmp_path, content):
DirAccess.remove_absolute(tmp_path)
return false
# Best-effort: snapshot the prior file before we touch the target so we
# can restore on a failed swap. The backup is also kept on success as a
# one-shot rollback aid for the user — give it the same (preserved) mode
# so a 0600 config's backup isn't itself a world-readable copy.
#
# copy_absolute creates the backup at the umask default and we can only
# chmod it afterward, so there's a sub-millisecond window where the backup
# carries default perms. Accepted: it duplicates bytes already sitting at
# `path` (which the caller created 0600) inside the user's own config dir,
# and Godot exposes no API to create the copy pre-chmod'd. Not worth
# reimplementing copy by hand to shave that window.
var backup_path := path + ".backup"
var backup_made := false
if had_original:
DirAccess.remove_absolute(backup_path)
if DirAccess.copy_absolute(path, backup_path) == OK:
backup_made = true
_apply_mode(backup_path, target_mode)
if DirAccess.rename_absolute(tmp_path, path) == OK:
return true
# Rename-over-existing rejected (Windows + AV / lock timing, some SMB
# shares). Use overwrite-copy as the recovery path: copy_absolute never
# removes the original before writing the new bytes, so a failure here
# leaves the user's prior config in place rather than nuking it.
if DirAccess.copy_absolute(tmp_path, path) == OK and _written_size_matches(path, content):
# copy_absolute creates the destination with the default mode, so
# re-apply the preserved/owner-only mode after the copy lands.
_apply_mode(path, target_mode)
DirAccess.remove_absolute(tmp_path)
return true
# Copy didn't land cleanly. Restore the destination to its pre-call state.
if backup_made:
# Restore the snapshot we took before the swap. `copy_absolute`
# overwrites the destination, so we don't pre-remove `path` — the
# pre-remove created a window where `path` was gone if the
# subsequent copy itself failed. If the restore copy fails now the
# user's prior bytes are still in `.backup` for manual recovery
# and the false return value tells the caller the swap didn't
# complete.
DirAccess.copy_absolute(backup_path, path)
_apply_mode(path, target_mode)
elif not had_original and FileAccess.file_exists(path):
# No prior file existed but copy_absolute landed partial bytes at
# `path`. Remove them so the failure leaves nothing on disk rather
# than a truncated/invalid new file. The `file_exists` guard keeps
# us off non-file destinations (a path that points at a directory
# yields `had_original=false` too, but we must not try to delete
# the directory). Issue #297 PR review.
DirAccess.remove_absolute(path)
# (If `had_original` is true but the snapshot couldn't be taken, the
# original on disk is whatever copy_absolute managed to write before
# failing. This is a best-effort path — the false return value tells the
# caller the swap didn't complete; recovery beyond that requires a
# backup we couldn't take.)
DirAccess.remove_absolute(tmp_path)
return false
## Follow a symlink chain at `path` and return the final real target, so the
## temp+rename lands on the linked-to file instead of replacing the link.
##
## Best-effort by design: DirAccess.is_link()/read_link() are only implemented
## on platforms with POSIX symlinks (Linux/macOS; on Windows and other
## platforms is_link() returns false), and opening the parent dir can fail for
## exotic paths. In every "can't tell" case we return `path` unchanged, which
## is exactly the pre-#534 behavior — never worse, symlink-preserving where
## the engine lets us detect one.
static func _resolve_symlink_target(path: String) -> String:
var resolved := path
# Bounded hops so a symlink cycle can't loop us forever.
for _hop in 8:
var base_dir := resolved.get_base_dir()
var da := DirAccess.open(base_dir)
if da == null or not da.is_link(resolved):
return resolved
var target := da.read_link(resolved)
if target.is_empty():
return resolved
if target.is_relative_path():
target = base_dir.path_join(target)
resolved = target.simplify_path()
return resolved
static func _resolve_target_mode(path: String, had_original: bool) -> int:
# Preserve the prior file's POSIX mode on a rewrite; default a brand-new
# config (or any case we can't read a mode for) to owner read+write (0600).
#
# get_unix_permissions returns 0 both on Windows (no POSIX perms) and for a
# genuine 0000 file. Treating 0 as "use the 0600 floor" is deliberate, not a
# missed case: these are config files the plugin must read and write, 0000 is
# unusable, and re-applying 0000 would lock the owner out next run. 0600 is
# still owner-only so this never widens access. (A genuinely-0000 file can't
# reach a rewrite through the config strategies anyway — their read-first
# guard fails to open it and refuses the write before we get here.)
if had_original:
var existing := FileAccess.get_unix_permissions(path)
if existing > 0:
return existing
return FileAccess.UNIX_READ_OWNER | FileAccess.UNIX_WRITE_OWNER
static func _apply_mode(path: String, mode: int) -> void:
# Best-effort. set_unix_permissions returns ERR_UNAVAILABLE on platforms
# without POSIX permissions (Windows); that's expected and ignored so the
# write still works there. mode <= 0 should never happen (resolve always
# returns >0) but is guarded so a future caller can't chmod a file to nothing.
if mode <= 0:
return
var err := FileAccess.set_unix_permissions(path, mode)
# Surface a real chmod failure (not the Windows no-op) so permission
# hardening on a sensitive config doesn't fail completely silently.
if err != OK and err != ERR_UNAVAILABLE:
push_warning("MCP | could not set permissions on %s (error %d)" % [path, err])
static func _written_size_matches(path: String, content: String) -> bool:
# `store_string` writes UTF-8 bytes with no BOM and no newline translation,
# so the byte length on disk must match `to_utf8_buffer().size()` exactly.
var f := FileAccess.open(path, FileAccess.READ)
if f == null:
return false
var on_disk := f.get_length()
f.close()
return on_disk == content.to_utf8_buffer().size()
@@ -0,0 +1 @@
uid://6fkb5uau0r4h
+427
View File
@@ -0,0 +1,427 @@
@tool
class_name McpClient
extends RefCounted
## Descriptor for one MCP client (Cursor, Claude Desktop, Codex, ...).
##
## Subclasses set fields in `_init()` and MUST NOT carry Callables — strategies
## (json/toml/cli) interpret the data. Enforced by
## `test_clients.gd::test_descriptors_are_data_only`.
##
## Why no Callables: per-client `.gd` files get hot-reloaded on disk-mtime
## change. A worker thread mid-call into a descriptor lambda races the
## bytecode swap and SEGVs (issue #229). Bonus: also obsoletes the stale-
## Callable workaround from #192.
## CONFIGURED_MISMATCH = an entry with our `SERVER_NAME` exists in the user's
## client config, but its URL or launch command doesn't match the current
## ports/version/exclusions — typical after a setting change or update.
## Distinguishing this from `NOT_CONFIGURED` lets the dock surface a "your
## saved client configuration is stale" banner instead of conflating it with
## "you never configured this client".
enum Status { NOT_CONFIGURED, CONFIGURED, CONFIGURED_MISMATCH, ERROR }
## Lowercase string label for a `Status` value. Single source of truth so the
## MCP `client_status` tool, the dock, and the verify-after-write diagnostic
## in `McpClientConfigurator` all emit the same names — agents pattern-match
## against this set, so a fifth value being silently introduced would break
## them.
static func status_label(status: McpClient.Status) -> String:
match status:
Status.CONFIGURED:
return "configured"
Status.NOT_CONFIGURED:
return "not_configured"
Status.CONFIGURED_MISMATCH:
return "configured_mismatch"
return "error"
## One-line configure success message, shared by every strategy so the dock
## and the `client_manage` tool describe the transport that was actually
## written. Command-shape clients register the stdio `godot-ai attach`
## bridge — the URL-era "(HTTP: <url>)" suffix would name a transport the
## write never touched (found live in the #838 Windows smoke).
static func configured_message(client: McpClient, server_url: String) -> String:
if client.command_shape != CommandShape.NONE:
return "%s configured (stdio attach)" % client.display_name
return "%s configured (HTTP: %s)" % [client.display_name, server_url]
var id: String = "" ## stable key, e.g. "cursor"
var display_name: String = "" ## "Cursor"
var config_type: String = "" ## "json" | "toml" | "yaml" | "cli"
# JSON / TOML clients ------------------------------------------------------
## {"darwin": "~/...", "windows": "$APPDATA/...", "linux": "$XDG_CONFIG_HOME/..."}
## Keys may also use "unix" as a shorthand for darwin+linux.
var path_template: Dictionary = {}
## Optional ordered path candidates by platform. Each value is an Array of
## templates; one `*` may appear in a directory segment so packaged-app roots
## can be discovered without hardcoding publisher hashes.
##
## Resolution contract:
## 1. Existing files win in descriptor order, except that a unique wildcard
## match is authoritative even before its config leaf exists. A matching
## package root therefore creates inside that package rather than writing
## a fallback path that may become invisible after copy-on-write. When
## that private leaf is new, Configure seeds it from the first later
## existing candidate so read-through content is not shadowed.
## 2. If no file or wildcard package match exists, the first non-wildcard
## template is the deterministic create target.
## 3. Multiple matches within any wildcard group are ambiguous and fail
## closed instead of choosing an arbitrary package.
##
## Exact-file and config-home environment overrides still have higher
## priority. When this map has no entry for the current platform,
## `path_template` remains the fallback.
var config_path_candidates: Dictionary = {}
## De-duplicate persistent path-ambiguity warnings across recurring status
## refreshes. The actionable message still returns on every resolution; only
## the editor-console echo is single-shot until the ambiguity clears/changes.
var _last_config_path_warning := ""
var _config_path_warning_mutex := Mutex.new()
## Path inside the config object where the per-server map lives.
## Cursor / Claude Desktop / most others: ["mcpServers"]
## VS Code: ["servers"]
## OpenCode: ["mcp"]
var server_key_path: PackedStringArray = PackedStringArray()
## Field inside the entry dict that holds our server URL.
## "url" by default; some clients use "serverUrl" or "httpUrl".
var entry_url_field: String = "url"
## Required entry fields — written on every Configure AND verified by the
## default verifier. Use this for transport pins (e.g. `type:
## "streamable-http"`) where a missing/wrong value breaks negotiation: a
## legacy entry without the pin fails verification and surfaces as drift.
##
## DO NOT put user-mutable state here (auto-approval lists, `disabled`
## flags, opt-in toggles). Verifying those treats every user customisation
## as drift, and Configure-All-Mismatched then silently overwrites them
## back to defaults — see the `entry_initial_fields` doc below.
var entry_extra_fields: Dictionary = {}
## Default fields written ONLY when the entry doesn't yet exist. Reconfigure
## preserves whatever the user (or the client itself) has set; the verifier
## ignores these keys entirely. Use for opt-in flags and user-state arrays —
## e.g. Roo / Cline / Kilo `alwaysAllow` / `autoApprove` lists, `disabled:
## false`, `isActive: true`. The pre-#229 behaviour was equivalent: per-
## client `entry_builder` lambdas seeded these as defaults but the
## per-client `verify_entry` lambdas only checked transport pins, so a
## user-customised array was `CONFIGURED`, not drift. Splitting the field
## restores that contract under the data-only descriptor model.
var entry_initial_fields: Dictionary = {}
## Client-owned stdio launch shape. Each strategy renders the shape in its
## config language:
##
## - FLAT — `command` string + `args` array as sibling keys. JSON and YAML
## strategies. A client whose docs require a type discriminator next to the
## flat keys (VS Code's `type: "stdio"`, Claude Code's fallback file) stays
## FLAT and declares it via `command_transport_key` / `command_transport_value`,
## so TYPED_FLAT remains reserved vocabulary.
## - COMMAND_ARRAY — the launch argv carried as one array. In the JSON
## strategy the entry's `command` field IS that array (OpenCode's
## `"command": ["uvx", …]`). In the TOML strategy the launcher renders as a
## `command = "…"` line plus an `args = […]` array (Codex, Grok) — the name
## refers to the argv-as-TOML-array body it emits.
## - NESTED_COMMAND — command/args nested inside a sub-object. Reserved; no
## current client needs it and strategies reject it with an actionable error.
##
## CLI-registered clients (`config_type == "cli"`) express the launch through
## `cli_register_template` tokens instead; their `command_shape` governs the
## JSON-fallback file rendering (Claude Code, #463).
##
## Values are data-only shared vocabulary; keeping them data-only avoids
## reintroducing the descriptor Callable race from #229.
enum CommandShape { NONE, FLAT, TYPED_FLAT, COMMAND_ARRAY, NESTED_COMMAND }
var command_shape: CommandShape = CommandShape.NONE
## Whether manual instructions may offer the client's native URL transport as
## an alternative to its command shape. This is capability metadata, not a
## consequence of `command_shape`: Codex supports a URL block, while Claude
## Desktop's local `claude_desktop_config.json` entries are stdio-only.
var command_supports_url_fallback: bool = false
## Optional discriminator required by a client's command transport shape
## (for example `type = "stdio"`). Empty means command+args are sufficient.
var command_transport_key: String = ""
var command_transport_value: Variant = null
## Whether this client's Windows stdio entry must launch through the
## GUI-subsystem pythonw bootstrap (#827). The bootstrap exists for clients
## that run console-subsystem MCP commands in a visible terminal (Codex);
## Electron-family spawners hide child consoles themselves, and at least one
## (Antigravity) hangs tool calls when handed a GUI-subsystem executable
## (#863). Set false to write the plain console launcher on Windows.
var needs_consoleless_launcher: bool = true
## Keys from the legacy transport that Configure must delete. Codex removes
## `url`, because Codex rejects a server entry containing both URL and stdio
## launch fields.
var command_legacy_keys: PackedStringArray = PackedStringArray()
## Keys inside a preserved JSON `env` object that belonged to a legacy launch
## shape and must be removed during migration. Other environment values remain
## user-owned and survive Configure. Currently consumed by the JSON strategy.
var command_env_legacy_keys: PackedStringArray = PackedStringArray()
## Defaults seeded only for a new entry. Reconfigure preserves user values.
## Codex uses this for enabled/startup/tool timeout defaults.
var command_initial_fields: Dictionary = {}
## Declarative documentation of fields owned by the user and timeout fields
## supported by this client. Strategies preserve these values and tests pin
## the descriptor contract; no control flow lives on the descriptor.
var command_user_fields: PackedStringArray = PackedStringArray()
var command_timeout_fields: PackedStringArray = PackedStringArray()
## Paths whose existence implies the user has this client installed.
## Used purely for the dock's "installed" badge. `is_installed()` additionally
## checks `resolved_config_path()`, so a config relocated via an environment
## override is detected without listing it here.
var detect_paths: PackedStringArray = PackedStringArray()
# Config-path env overrides --------------------------------------------------
## Some clients name the exact config file in an environment variable
## (OpenCode: `$OPENCODE_CONFIG`). When the variable is set and non-empty, it
## wins over directory-valued `config_home_env` and `path_template`. Relative
## values fail closed because the editor and client may have different working
## directories; auto-configuration cannot safely assume they resolve alike.
var config_file_env: String = ""
## Some clients honor an env var that relocates their entire config home
## (Codex: `$CODEX_HOME/config.toml`; Claude Code: `$CLAUDE_CONFIG_DIR/.claude.json`).
## When `config_home_env` names an env var that is set and non-empty,
## `resolved_config_path()` returns `<env value>/<config_home_env_subpath>`
## instead of resolving `path_template`. Both fields must be non-empty for the
## override to apply. Only declare a mapping when the client's docs guarantee
## the env var relocates the exact file we write — a wrong mapping writes the
## MCP entry somewhere the client never reads and Configure false-succeeds.
var config_home_env: String = ""
## Path of the config file relative to the env var's directory, e.g.
## "config.toml". Joined verbatim — no per-OS variants needed because the env
## value itself is already an absolute (or ~-prefixed) directory.
var config_home_env_subpath: String = ""
# CLI clients --------------------------------------------------------------
var cli_names: PackedStringArray = PackedStringArray()
## Argument templates with `{name}` and `{url}` tokens; the strategy
## substitutes them at call time. Tokens are matched verbatim — no escaping
## semantics, no shell expansion. Command-shape templates additionally use the
## whole-element tokens `{command}` / `{args...}` (see `McpCliStrategy.format_args`).
## Populated by CLI descriptors (currently `claude_code`; `kimi_code` moved to
## mcp.json in #813).
var cli_register_template: PackedStringArray = PackedStringArray()
var cli_unregister_template: PackedStringArray = PackedStringArray()
## Args run to read current state; stdout is scanned for the server name and
## URL. Presence of `name` AND `url` → CONFIGURED, name only → MISMATCH,
## neither → NOT_CONFIGURED.
var cli_status_args: PackedStringArray = PackedStringArray()
# Codex / TOML clients -----------------------------------------------------
## Dotted TOML path under which our entry lives, e.g. ["mcp_servers", "godot-ai"].
## Strategies build the [section."name"] header from this.
var toml_section_path: PackedStringArray = PackedStringArray()
var toml_legacy_section_aliases: PackedStringArray = PackedStringArray()
## Lines (without the [header]) emitted under the section, with `{url}`
## tokens. Substituted at call time.
var toml_body_template: PackedStringArray = PackedStringArray()
## Resolved absolute config path for this client on the current OS. Exact-file
## overrides win first, followed by directory-valued `config_home_env`, then
## ordered candidates / `path_template`. Ignoring either override can write a
## file the client never reads and false-succeed.
func resolved_config_path() -> String:
return str(resolved_config_path_details().get("path", ""))
## Detailed sibling used by status/configure/remove so safe resolution
## failures reach the dock instead of collapsing into NOT_CONFIGURED. `error`
## is empty for ordinary unsupported/missing path mappings to preserve the
## long-standing status behavior for clients not installed on this platform.
func resolved_config_path_details() -> Dictionary:
## Reflected reads: after an in-session self-update, an instance created
## before the update can answer Nil for vars the update added, and the
## typed calls below would each hard-error (Nil -> Dictionary, #850's
## per-row error wall). Fail with the
## one repair message instead; the registry's coherence probe drives the
## same text on the status path.
var candidates: Variant = get("config_path_candidates")
var template: Variant = get("path_template")
var file_env: Variant = get("config_file_env")
if not (candidates is Dictionary) or not (template is Dictionary) or not (file_env is String):
return {"path": "", "error": McpClientRegistry.RESTART_TO_FINISH_UPDATE}
var file_override := config_file_override_details()
if not str(file_override.get("path", "")).is_empty() or not str(file_override.get("error", "")).is_empty():
_clear_config_path_warning()
return file_override
var override := config_home_override()
if not override.is_empty():
_clear_config_path_warning()
return {"path": override, "error": ""}
var candidate_key := McpPathTemplate.platform_key(candidates)
if not candidate_key.is_empty():
return _resolve_ordered_config_path_candidates(candidates[candidate_key])
_clear_config_path_warning()
return {"path": McpPathTemplate.resolve(template), "error": ""}
## The exact-file env override plus any fail-closed diagnostic. Empty path and
## error means no override applies (no mapping, unset, or blank env var).
func config_file_override_details() -> Dictionary:
if config_file_env.is_empty():
return {"path": "", "error": ""}
## env_lookup, not OS.get_environment: this can run on dock workers (#691).
var raw_path := McpPathTemplate.env_lookup(config_file_env).strip_edges()
if raw_path.is_empty():
return {"path": "", "error": ""}
var expanded := McpPathTemplate.expand(raw_path)
if not expanded.is_absolute_path():
return {
"path": "",
"error": "%s's $%s override must be an absolute config-file path; got %s" % [
display_name, config_file_env, raw_path,
],
}
if DirAccess.dir_exists_absolute(expanded):
return {
"path": "",
"error": "%s's $%s override must point to a config file, not a directory: %s" % [
display_name, config_file_env, expanded,
],
}
return {"path": expanded, "error": ""}
func _resolve_ordered_config_path_candidates(templates: Variant) -> Dictionary:
if not (templates is Array or templates is PackedStringArray):
_clear_config_path_warning()
return {"path": "", "error": ""}
var ordered_templates: Array = []
for template_variant in templates:
ordered_templates.append(str(template_variant))
var fallback_create_path := ""
for index in range(ordered_templates.size()):
var template := str(ordered_templates[index])
var group := McpPathTemplate.expand_path_candidates(template)
if group.size() > 1:
var message := (
"%s has multiple matching config package paths for %s: %s. "
+ "Remove the stale package installation or edit the intended config manually."
) % [display_name, template, ", ".join(group)]
_warn_config_path_once(message)
return {"path": "", "error": message}
if group.is_empty():
continue
var path := String(group[0])
if FileAccess.file_exists(path):
_clear_config_path_warning()
return {"path": path, "error": ""}
# A wildcard only resolves when its package directory exists. Treat that
# installation evidence as authoritative and create its private config
# directly instead of relying on copy-on-write read-through. Preserve
# anything currently visible through read-through by naming the first
# later existing candidate as a one-time seed source.
if template.contains("*"):
var seed_path := _first_existing_later_candidate(ordered_templates, index + 1)
_clear_config_path_warning()
return {"path": path, "error": "", "seed_path": seed_path}
if fallback_create_path.is_empty():
fallback_create_path = path
_clear_config_path_warning()
return {"path": fallback_create_path, "error": ""}
func _first_existing_later_candidate(templates: Array, start_index: int) -> String:
for index in range(start_index, templates.size()):
var group := McpPathTemplate.expand_path_candidates(str(templates[index]))
# A seed is optional. Never choose among an ambiguous later wildcard;
# the authoritative target was already resolved by the earlier group.
if group.size() != 1:
continue
var path := String(group[0])
if FileAccess.file_exists(path):
return path
return ""
func _warn_config_path_once(message: String) -> void:
_config_path_warning_mutex.lock()
var should_warn := message != _last_config_path_warning
_last_config_path_warning = message
_config_path_warning_mutex.unlock()
if should_warn:
push_warning(message)
func _clear_config_path_warning() -> void:
_config_path_warning_mutex.lock()
_last_config_path_warning = ""
_config_path_warning_mutex.unlock()
## The env-var-relocated config path, or "" when no override applies
## (no mapping declared, env var unset, or env var empty/whitespace).
func config_home_override() -> String:
if config_home_env.is_empty() or config_home_env_subpath.is_empty():
return ""
## env_lookup, not OS.get_environment: this runs on dock worker threads,
## which must not race the spawn window's setenv/unsetenv (#691).
var home := McpPathTemplate.env_lookup(config_home_env).strip_edges()
if home.is_empty():
return ""
# Expand a leading ~ so `CODEX_HOME=~/codex-alt` behaves like the shell.
return McpPathTemplate.expand(home).path_join(config_home_env_subpath)
## True when a CLI client also declares where its config file lives, so it can
## fall back to writing that file directly when the CLI binary isn't on PATH.
## #463: Claude Code installed only as a VS Code / Cursor extension exposes no
## `claude` binary, but `claude mcp add --scope user` just writes `mcpServers`
## into ~/.claude.json — so we can produce the same entry ourselves.
func has_json_fallback() -> bool:
return config_type == "cli" and not path_template.is_empty() and not server_key_path.is_empty()
## True if the user appears to have this client installed locally.
func is_installed() -> bool:
if config_type == "cli":
if not McpCliFinder.find(_array_from_packed(cli_names)).is_empty():
return true
# CLI not on PATH. A cli client with a JSON fallback (Claude Code as a
# VS Code/Cursor extension, #463) still counts as installed if its
# fallback config file already exists.
if has_json_fallback():
var cfg := resolved_config_path()
return not cfg.is_empty() and FileAccess.file_exists(cfg)
return false
for p in detect_paths:
for resolved in McpPathTemplate.expand_path_candidates(p):
if FileAccess.file_exists(resolved) or DirAccess.dir_exists_absolute(resolved):
return true
# Fall back to "config file already exists" — usually means installed at some point.
var cfg := resolved_config_path()
return not cfg.is_empty() and FileAccess.file_exists(cfg)
static func _array_from_packed(packed: PackedStringArray) -> Array[String]:
var out: Array[String] = []
for s in packed:
out.append(s)
return out
## Slice a PackedStringArray into a new PackedStringArray over [from, to).
## Used by `_toml_strategy` and `_manual_command` to peel the section path
## apart for `[a.b."c"]` header rendering.
static func _packed_slice(packed: PackedStringArray, from: int, to: int) -> PackedStringArray:
var out := PackedStringArray()
for i in range(from, to):
out.append(packed[i])
return out
+1
View File
@@ -0,0 +1 @@
uid://cyowqr1x12ilg
+169
View File
@@ -0,0 +1,169 @@
@tool
class_name McpCliExec
extends RefCounted
## Wall-clock-bounded CLI invocation. Every dock shell-out to a per-client
## CLI (`claude mcp list`, `claude mcp add ...`, etc.) goes through here so
## a hung subprocess can't trap the calling thread forever.
##
## Without the timeout, a contended `claude mcp list` has been observed to
## hang for 6+ minutes (issues #238, #239) — wedging the dock's status
## refresh worker, and on the Configure / Remove paths the editor main
## thread itself.
##
## Why poll/kill instead of `OS.execute(..., true)`: GDScript can't
## interrupt a blocking `OS.execute`, so a hung CLI takes its caller's
## thread with it. `OS.execute_with_pipe` returns immediately with a PID;
## we drive the wait ourselves and `OS.kill` the orphan if budget
## expires. CLI registry commands have bounded output (a few hundred
## bytes), so we don't bother draining the pipe during the poll loop —
## the kernel buffer absorbs it.
##
## Returns a Dictionary with:
## exit_code: process exit code (0 = success). -1 on timeout / spawn failure.
## stdout: captured stdout text. May be partial on timeout.
## stderr: captured stderr text. May be partial on timeout. Empty when
## `capture_stderr` is false.
## output: stdout + (newline + stderr if non-empty). Convenience for
## the common case of "show whatever the CLI said when it
## failed" — `claude mcp add` writes its real diagnostics to
## stderr, so callers that only read `stdout` would surface
## a generic "exit code 1" instead.
## timed_out: true if we killed the process at the wall-clock budget.
## spawn_failed: true if `OS.execute_with_pipe` didn't return a usable PID.
const DEFAULT_TIMEOUT_MS := 8000
const _POLL_INTERVAL_MS := 50
const _KILL_GRACE_MS := 500
static func run(
exe: String,
args: Array,
timeout_ms: int = DEFAULT_TIMEOUT_MS,
capture_stderr: bool = true
) -> Dictionary:
if exe.is_empty():
return _spawn_failed_result()
return _run_piped(exe, args, timeout_ms, capture_stderr)
static func _run_piped(
exe: String,
args: Array,
timeout_ms: int,
capture_stderr: bool,
) -> Dictionary:
var spawn_exe := exe
var spawn_args := args
if OS.get_name() == "Windows":
var lower := exe.to_lower()
if lower.ends_with(".cmd") or lower.ends_with(".bat"):
## CreateProcessW can't launch `.cmd` / `.bat` scripts on its
## own — they're cmd.exe input, not PE binaries. Without this
## wrap, the moment `McpCliFinder` resolves a Node-style shim
## (npm's `claude.cmd`, pnpm's wrappers, …) the next
## `OS.execute_with_pipe` surfaces "Could not create child
## process: <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:
## Kill before draining: a pipe read can block while the child is
## still alive. Once it exits, drain any buffered partial output.
OS.kill(pid)
var kill_deadline := Time.get_ticks_msec() + _KILL_GRACE_MS
while OS.is_process_running(pid) and Time.get_ticks_msec() < kill_deadline:
OS.delay_msec(_POLL_INTERVAL_MS)
var partial_stdout := ""
var partial_stderr := ""
if not OS.is_process_running(pid):
partial_stdout = _drain_pipe(stdio)
partial_stderr = _drain_pipe(stderr_pipe) if capture_stderr else ""
_close_pipes(stdio, stderr_pipe)
return {
"exit_code": -1,
"stdout": partial_stdout,
"stderr": partial_stderr,
"output": _join_streams(partial_stdout, partial_stderr),
"timed_out": true,
"spawn_failed": false,
}
OS.delay_msec(_POLL_INTERVAL_MS)
var stdout := _drain_pipe(stdio)
var stderr_text := _drain_pipe(stderr_pipe) if capture_stderr else ""
_close_pipes(stdio, stderr_pipe)
return {
"exit_code": OS.get_process_exit_code(pid),
"stdout": stdout,
"stderr": stderr_text,
"output": _join_streams(stdout, stderr_text),
"timed_out": false,
"spawn_failed": false,
}
static func _spawn_failed_result() -> Dictionary:
return {
"exit_code": -1,
"stdout": "",
"stderr": "",
"output": "",
"timed_out": false,
"spawn_failed": true,
}
static func _drain_pipe(pipe: Variant) -> String:
if not (pipe is FileAccess):
return ""
var f := pipe as FileAccess
var bytes := PackedByteArray()
var max_bytes := 1 << 20 # 1 MiB, far above expected client CLI output.
while bytes.size() < max_bytes:
var chunk := f.get_buffer(mini(4096, max_bytes - bytes.size()))
if chunk.is_empty():
break
bytes.append_array(chunk)
if f.eof_reached():
break
return bytes.get_string_from_utf8()
static func _join_streams(stdout: String, stderr_text: String) -> String:
## Most CLIs write their actionable diagnostics to one stream or the
## other, never both — so concatenation gives "the message" without
## the caller having to guess which key to read. Newline-separate so
## callers that grep don't see two lines run together.
if stderr_text.is_empty():
return stdout
if stdout.is_empty():
return stderr_text
return "%s\n%s" % [stdout, stderr_text]
static func _close_pipes(stdio: Variant, stderr_pipe: Variant) -> void:
if stdio is FileAccess:
(stdio as FileAccess).close()
if stderr_pipe is FileAccess:
(stderr_pipe as FileAccess).close()
+1
View File
@@ -0,0 +1 @@
uid://dhoe3ypkhm12v
+181
View File
@@ -0,0 +1,181 @@
@tool
class_name McpCliFinder
extends RefCounted
## Generic three-tier CLI resolution for clients whose binary lives somewhere
## a GUI-launched Godot's minimal PATH won't see:
## 1. Well-known install locations (~/.local/bin, /opt/homebrew/bin, ...)
## 2. Login shell lookup (`bash -lc 'command -v <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 + bounded subprocess lookup) runs unlocked, so a
## main-thread `invalidate()` can never block on a worker's subprocess.
## Two workers racing the same exe both call `_resolve()` and both write
## back the same answer; that's wasted work, not corruption.
static var _mutex: Mutex = Mutex.new()
static var _cache: Dictionary = {} # exe_name -> resolved path (or "")
static var _searched: Dictionary = {}
const _LOOKUP_TIMEOUT_MS := 3000
## Find any of the supplied exe names; returns the first hit.
## On Windows pass the .exe variant in `exe_names` if relevant.
static func find(exe_names: Array[String]) -> String:
for name in exe_names:
var hit := _find_one(name)
if not hit.is_empty():
return hit
return ""
## Drop cache for one exe (call after the user installs / reinstalls).
static func invalidate(exe_name: String = "") -> void:
_mutex.lock()
if exe_name.is_empty():
_cache.clear()
_searched.clear()
else:
_cache.erase(exe_name)
_searched.erase(exe_name)
_mutex.unlock()
static func _find_one(exe_name: String) -> String:
_mutex.lock()
var already_searched: bool = _searched.get(exe_name, false)
var cached: String = _cache.get(exe_name, "")
_mutex.unlock()
if already_searched:
return cached
# `_resolve()` does FileAccess + bounded subprocess lookup (forks
# `bash -lc` / `which`), which can take 100ms-1s. Holding the mutex across that
# would let a concurrent `invalidate()` on the main thread freeze the
# editor for the duration of the subprocess — which defeats the whole
# point of running CLI lookup off the main thread.
var hit := _resolve(exe_name)
_mutex.lock()
_cache[exe_name] = hit
_searched[exe_name] = true
_mutex.unlock()
return hit
static func _resolve(exe_name: String) -> String:
var is_windows := OS.get_name() == "Windows"
# 1. Well-known locations
for dir in _well_known_dirs():
var full := dir.path_join(exe_name)
if FileAccess.file_exists(full):
return full
# 2. Login shell lookup (Unix only)
if not is_windows:
## env_lookup, not OS.get_environment: CLI resolution runs on dock
## worker threads (configure/remove actions) and must not race the
## spawn window's setenv/unsetenv (#691).
var shell := McpPathTemplate.env_lookup("SHELL")
if shell.is_empty():
shell = "/bin/bash"
var stripped := exe_name.trim_suffix(".exe")
var login_result := McpCliExec.run(shell, ["-lc", "command -v %s" % stripped], _LOOKUP_TIMEOUT_MS, false)
if int(login_result.get("exit_code", -1)) == 0:
var login_found: String = str(login_result.get("stdout", "")).strip_edges()
if not login_found.is_empty() and FileAccess.file_exists(login_found):
return login_found
# 3. which / where with inherited PATH
var lookup := "where" if is_windows else "which"
var result := McpCliExec.run(lookup, [exe_name], _LOOKUP_TIMEOUT_MS, false)
if int(result.get("exit_code", -1)) == 0:
var output := str(result.get("stdout", ""))
var lines := PackedStringArray(output.split("\n"))
var found := _pick_best_path(lines) if is_windows else lines[0].strip_edges()
if not found.is_empty():
return found
return ""
## Executable extensions Windows' CreateProcessW can launch from a path
## (after the cmd.exe wrap in `_cli_exec.gd`). Order is preference: `.exe`
## is a native PE binary; `.cmd` / `.bat` go through the shell; `.com` is
## the legacy COM-format executable that some shims still ship.
const _WINDOWS_EXEC_EXTS := [".exe", ".cmd", ".bat", ".com"]
## Pick the best path from `where` output on Windows.
##
## npm-installed Node CLIs ship as BOTH `<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]:
## env_lookup, not OS.get_environment — see _resolve()'s worker-thread
## note (#691).
var home := McpPathTemplate.env_lookup("HOME")
if home.is_empty():
home = McpPathTemplate.env_lookup("USERPROFILE")
match OS.get_name():
"macOS":
return [
home.path_join(".local/bin"),
home.path_join(".claude/local"),
home.path_join(".cargo/bin"),
"/opt/homebrew/bin",
"/usr/local/bin",
]
"Windows":
var local := McpPathTemplate.env_lookup("LOCALAPPDATA")
var prog := McpPathTemplate.env_lookup("ProgramFiles")
var paths: Array[String] = []
if not home.is_empty():
paths.append(home.path_join(".claude/local"))
paths.append(home.path_join(".local/bin"))
paths.append(home.path_join(".cargo/bin"))
paths.append(home.path_join("AppData/Local/Programs/uv"))
if not local.is_empty():
paths.append(local.path_join("Programs/uv"))
if not prog.is_empty():
paths.append(prog.path_join("uv"))
return paths
_:
return [
home.path_join(".local/bin"),
home.path_join(".claude/local"),
home.path_join(".cargo/bin"),
"/usr/local/bin",
]
@@ -0,0 +1 @@
uid://cnp5b6fcwou2y
+215
View File
@@ -0,0 +1,215 @@
@tool
class_name McpCliStrategy
extends RefCounted
## Strategy for MCP clients that own their own state via a CLI (e.g.
## `claude mcp add`). Reads `cli_register_template` / `cli_unregister_template`
## / `cli_status_args` from the descriptor and substitutes `{name}` / `{url}`
## tokens. Command-shape descriptors additionally use the whole-element launch
## tokens `{command}` / `{args...}` (see `format_args`). No descriptor-supplied
## Callables — see `_base.gd` for why.
##
## Every shell-out goes through `McpCliExec.run`, which wraps the call in a
## wall-clock timeout. A hung CLI (e.g. `claude mcp list` under
## inter-Claude-Code contention) gets killed at the budget instead of
## locking up the caller forever — see issues #238 / #239.
const _CONFIGURE_TIMEOUT_MS := 10000
const _REMOVE_TIMEOUT_MS := 10000
const _STATUS_TIMEOUT_MS := 6000
static func configure(
client: McpClient,
server_name: String,
server_url: String,
launch: Dictionary = {},
) -> Dictionary:
## Fail closed before any subprocess runs: a command-shape client without a
## verified attach launcher must not register anything (see
## docs/client-configuration.md — an ERROR beats an entry known to be broken).
var launch_error := command_launch_error(client, launch)
if not launch_error.is_empty():
return {"status": "error", "message": launch_error}
var cli := _resolve_cli(client)
if cli.is_empty():
return {"status": "error", "message": "%s not found" % client.display_name}
# Best-effort prior cleanup so re-configure is idempotent. Bounded to
# the same budget — a hung unregister shouldn't block the configure
# that follows.
if not client.cli_unregister_template.is_empty():
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, launch)
var result := McpCliExec.run(cli, args, _CONFIGURE_TIMEOUT_MS)
if result.get("timed_out", false):
return {
"status": "error",
"message": "Configure %s timed out after %ds — see 'Run this manually' below to retry by hand" % [
client.display_name, _CONFIGURE_TIMEOUT_MS / 1000,
],
}
if result.get("spawn_failed", false):
return {"status": "error", "message": "Failed to spawn %s" % client.display_name}
if int(result.get("exit_code", -1)) == 0:
return {"status": "ok", "message": McpClient.configured_message(client, server_url)}
## `claude mcp add` writes its real failure diagnostics to stderr, so
## prefer `output` (stdout + stderr) over `stdout` alone — otherwise
## the user sees "exit code 1" instead of the actual error.
var combined := str(result.get("output", "")).strip_edges()
var err := combined if not combined.is_empty() else "exit code %d" % int(result.get("exit_code", -1))
return {"status": "error", "message": "Failed to configure %s: %s" % [client.display_name, err]}
## Run the descriptor's `cli_status_args`, scan stdout for `server_name` and
## the expected target. The matching rule is the only sensible one for "list
## MCP entries" output across CLI clients we currently support: name AND
## target present → CONFIGURED; name only → MISMATCH; neither →
## NOT_CONFIGURED. For URL descriptors the target is `server_url`; for
## command-shape descriptors it is the resolved attach launcher path (the
## listing prints the registered command line, not a URL). Command-shape CLI
## clients with a JSON fallback file get exact drift detection via the JSON
## strategy instead — the configurator prefers that path and only lands here
## for CLI clients whose state isn't file-readable.
static func check_status(
client: McpClient, server_name: String, server_url: String, launch: Dictionary = {}
) -> McpClient.Status:
return check_status_with_cli_path(client, server_name, server_url, _resolve_cli(client), launch)
static func check_status_with_cli_path(
client: McpClient, server_name: String, server_url: String, cli: String, launch: Dictionary = {}
) -> McpClient.Status:
return check_status_details(client, server_name, server_url, cli, launch).get("status", McpClient.Status.NOT_CONFIGURED)
## Detailed variant used by the dock's refresh worker so it can surface a
## "probe timed out" badge on the affected row instead of silently
## conflating the timeout with NOT_CONFIGURED. Returns
## `{"status": Status, "error_msg": String}`. The caller plumbs
## `error_msg` straight into `_apply_row_status`.
static func check_status_details(
client: McpClient, server_name: String, server_url: String, cli: String, launch: Dictionary = {}
) -> Dictionary:
if cli.is_empty():
return _status_details(McpClient.Status.NOT_CONFIGURED)
if client.cli_status_args.is_empty():
return _status_details(McpClient.Status.NOT_CONFIGURED)
var expected_target := server_url
if client.command_shape != McpClient.CommandShape.NONE:
## Same fail-closed contract as configure: without a verified launcher
## there is no target to compare against, and guessing would report a
## broken entry as green.
var launch_error := command_launch_error(client, launch)
if not launch_error.is_empty():
return _status_details(McpClient.Status.ERROR, launch_error)
expected_target = str(launch.get("command", ""))
var result := McpCliExec.run(
cli,
McpClient._array_from_packed(client.cli_status_args),
_STATUS_TIMEOUT_MS,
false
)
if result.get("timed_out", false):
return _status_details(McpClient.Status.ERROR, "probe timed out")
if result.get("spawn_failed", false):
return _status_details(McpClient.Status.NOT_CONFIGURED)
if int(result.get("exit_code", -1)) != 0:
return _status_details(McpClient.Status.NOT_CONFIGURED)
var text := str(result.get("stdout", ""))
if text.find(server_name) < 0:
return _status_details(McpClient.Status.NOT_CONFIGURED)
## Server registered, but pointing somewhere else — drift after a
## port change. Surface as mismatch so the dock offers Reconfigure.
if text.find(expected_target) < 0:
return _status_details(McpClient.Status.CONFIGURED_MISMATCH)
return _status_details(McpClient.Status.CONFIGURED)
## Empty string when this client's launch requirements are satisfied. A
## command-shape descriptor requires a successfully resolved attach launch;
## URL descriptors (`CommandShape.NONE`) never require one. Mirrors
## `McpJsonStrategy.command_launch_error` / the TOML equivalent.
static func command_launch_error(client: McpClient, launch: Dictionary) -> String:
if client.command_shape == McpClient.CommandShape.NONE:
return ""
if not bool(launch.get("ok", false)):
return str(launch.get("error", "No compatible attach launcher was found."))
return ""
static func _status_details(status: McpClient.Status, error_msg: String = "") -> Dictionary:
return {"status": status, "error_msg": error_msg}
static func remove(client: McpClient, server_name: String) -> Dictionary:
var cli := _resolve_cli(client)
if cli.is_empty():
return {"status": "error", "message": "%s not found" % client.display_name}
if client.cli_unregister_template.is_empty():
return {"status": "error", "message": "%s descriptor missing cli_unregister_template" % client.display_name}
var args := _format_args(client.cli_unregister_template, server_name, "")
var result := McpCliExec.run(cli, args, _REMOVE_TIMEOUT_MS)
if result.get("timed_out", false):
return {
"status": "error",
"message": "Remove %s timed out after %ds — see 'Run this manually' below to retry by hand" % [
client.display_name, _REMOVE_TIMEOUT_MS / 1000,
],
}
if result.get("spawn_failed", false):
return {"status": "error", "message": "Failed to spawn %s" % client.display_name}
if int(result.get("exit_code", -1)) == 0:
return {"status": "ok", "message": "%s configuration removed" % client.display_name}
## `claude mcp add` writes its real failure diagnostics to stderr, so
## prefer `output` (stdout + stderr) over `stdout` alone — otherwise
## the user sees "exit code 1" instead of the actual error.
var combined := str(result.get("output", "")).strip_edges()
var err := combined if not combined.is_empty() else "exit code %d" % int(result.get("exit_code", -1))
return {"status": "error", "message": "Failed to remove %s: %s" % [client.display_name, err]}
## Substitute `{name}` and `{url}` tokens in every template entry.
## Tokens match verbatim — `{name_suffix}` is NOT touched, so callers don't
## have to worry about partial-token collisions in their argv.
##
## Launch tokens are whole-element only: an element that is exactly
## `{command}` becomes the resolved attach launcher path, and an element that
## is exactly `{args...}` is spliced into the argv as one element per launch
## arg. Whole-element matching keeps a literal brace inside a path or flag
## from ever triggering an expansion.
static func format_args(
template: PackedStringArray, server_name: String, server_url: String, launch: Dictionary = {}
) -> Array[String]:
return _format_args(template, server_name, server_url, launch)
static func _format_args(
template: PackedStringArray, server_name: String, server_url: String, launch: Dictionary = {}
) -> Array[String]:
var out: Array[String] = []
for arg in template:
var s := String(arg)
if s == "{command}":
out.append(str(launch.get("command", "")))
continue
if s == "{args...}":
for launch_arg in launch.get("args", []):
out.append(str(launch_arg))
continue
s = s.replace("{name}", server_name)
s = s.replace("{url}", server_url)
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
+341
View File
@@ -0,0 +1,341 @@
@tool
class_name McpJsonStrategy
extends RefCounted
## Readmergewrite strategy for JSON-backed MCP clients.
## All knobs come from the McpClient descriptor as plain data — no Callables.
## See `_base.gd` for why descriptors are data-only.
static func configure(
client: McpClient,
server_name: String,
server_url: String,
launch: Dictionary = {},
) -> Dictionary:
var resolution := client.resolved_config_path_details()
var path := str(resolution.get("path", ""))
var path_error := str(resolution.get("error", ""))
if not path_error.is_empty():
return {"status": "error", "message": path_error}
if path.is_empty():
return {"status": "error", "message": "Could not resolve config path for %s on this OS" % client.display_name}
var seed_path := str(resolution.get("seed_path", ""))
var read_path := seed_path if not FileAccess.file_exists(path) and not seed_path.is_empty() else path
var read := _read_or_init(read_path)
if not read["ok"]:
return {"status": "error", "message": "Refusing to overwrite %s: %s. Fix or move the file, then re-run Configure." % [read_path, read["error"]]}
var launch_error := command_launch_error(client, launch)
if not launch_error.is_empty():
return {"status": "error", "message": launch_error}
var config: Dictionary = read["data"]
var holder := _ensure_path(config, 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, launch)
if not McpAtomicWrite.write(path, JSON.stringify(_narrow_integral_numbers(config), "\t", false)):
return {"status": "error", "message": "Cannot write to %s" % path}
return {"status": "ok", "message": McpClient.configured_message(client, server_url)}
static func check_status(
client: McpClient,
server_name: String,
server_url: String,
launch: Dictionary = {},
) -> McpClient.Status:
return check_status_details(client, server_name, server_url, launch).get("status", McpClient.Status.NOT_CONFIGURED)
## Detailed variant feeding the dock's error_msg plumbing (#711): a config
## file that EXISTS but can't be read or parsed is Status.ERROR carrying the
## read/parse error, not NOT_CONFIGURED — the write path refuses to touch
## such a file (see `_read_or_init`), so the status dot must say "broken
## file", not "click Configure".
static func check_status_details(
client: McpClient,
server_name: String,
server_url: String,
launch: Dictionary = {},
) -> Dictionary:
var resolution := client.resolved_config_path_details()
var path := str(resolution.get("path", ""))
var path_error := str(resolution.get("error", ""))
if not path_error.is_empty():
return {"status": McpClient.Status.ERROR, "error_msg": path_error}
if path.is_empty() or not FileAccess.file_exists(path):
return {"status": McpClient.Status.NOT_CONFIGURED, "error_msg": ""}
var read := _read_or_init(path)
if not read["ok"]:
return {"status": McpClient.Status.ERROR, "error_msg": String(read["error"])}
var 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 {"status": McpClient.Status.NOT_CONFIGURED, "error_msg": ""}
var entry = holder[server_name]
if not (entry is Dictionary):
return {"status": McpClient.Status.NOT_CONFIGURED, "error_msg": ""}
var launch_error := command_launch_error(client, launch)
if not launch_error.is_empty():
return {"status": McpClient.Status.ERROR, "error_msg": launch_error}
## 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.
if verify_entry(client, entry, server_url, launch):
return {"status": McpClient.Status.CONFIGURED, "error_msg": ""}
return {"status": McpClient.Status.CONFIGURED_MISMATCH, "error_msg": ""}
static func remove(client: McpClient, server_name: String) -> Dictionary:
var resolution := client.resolved_config_path_details()
var path := str(resolution.get("path", ""))
var path_error := str(resolution.get("error", ""))
if not path_error.is_empty():
return {"status": "error", "message": path_error}
if path.is_empty() or not FileAccess.file_exists(path):
return {"status": "ok", "message": "Not configured"}
var read := _read_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 writes under
## `server_key_path[server_name]`. Both URL and command entries deep-copy the
## existing dict before overwriting strategy-owned fields, preserving unknown
## client additions as well as descriptor-documented user fields.
static func build_entry(
client: McpClient,
server_url: String,
existing: Variant = null,
launch: Dictionary = {},
) -> Dictionary:
if _is_supported_command_shape(client.command_shape):
var command_entry: Dictionary = (existing as Dictionary).duplicate(true) if existing is Dictionary else {}
if client.command_shape == McpClient.CommandShape.COMMAND_ARRAY:
## OpenCode-style: the entry's `command` field IS the argv array.
## A stale sibling `args` from a FLAT-style hand edit would be
## ambiguous next to it, so it is strategy-owned and removed.
command_entry["command"] = _launch_argv(launch)
command_entry.erase("args")
else:
command_entry["command"] = str(launch.get("command", ""))
command_entry["args"] = _array_copy(launch.get("args", []))
if not client.command_transport_key.is_empty():
command_entry[client.command_transport_key] = client.command_transport_value
for key in client.command_initial_fields:
if not command_entry.has(key):
command_entry[key] = client.command_initial_fields[key]
for key in client.command_legacy_keys:
command_entry.erase(String(key))
_remove_legacy_env_keys(command_entry, client.command_env_legacy_keys)
return command_entry
if client.command_shape != McpClient.CommandShape.NONE:
return {}
return build_url_entry(client, server_url, existing)
static func build_url_entry(client: McpClient, server_url: String, existing: Variant = null) -> Dictionary:
var entry: Dictionary = (existing as Dictionary).duplicate(true) if existing is Dictionary else {}
entry[client.entry_url_field] = server_url
for k in client.entry_extra_fields:
entry[k] = client.entry_extra_fields[k]
for k in client.entry_initial_fields:
if not entry.has(k):
entry[k] = client.entry_initial_fields[k]
return entry
## Default verifier for a stored entry. Command entries must match every
## launch-affecting value exactly; legacy URL or env keys are migration drift.
## For URL clients, assert `entry[entry_url_field] == url` AND every
## key in `entry_extra_fields` matches verbatim. Type-pinning for Cline /
## Roo / Kilo (`type: "streamable-http"` etc.) falls out of this — pre-fix
## entries that lack the type field fail verification and surface as drift.
static func verify_entry(
client: McpClient,
entry: Dictionary,
server_url: String,
launch: Dictionary = {},
) -> bool:
if client.command_shape != McpClient.CommandShape.NONE:
if not _is_supported_command_shape(client.command_shape) or not bool(launch.get("ok", false)):
return false
for key in client.command_legacy_keys:
if entry.has(String(key)):
return false
var env = entry.get("env", null)
if env is Dictionary:
for key in client.command_env_legacy_keys:
if env.has(String(key)):
return false
if client.command_shape == McpClient.CommandShape.COMMAND_ARRAY:
if not _arrays_equal(entry.get("command", null), _launch_argv(launch)):
return false
if entry.has("args"):
return false
else:
if entry.get("command") != launch.get("command"):
return false
if not _arrays_equal(entry.get("args", null), launch.get("args", null)):
return false
if not client.command_transport_key.is_empty():
if not entry.has(client.command_transport_key):
return false
if entry.get(client.command_transport_key) != client.command_transport_value:
return false
return true
if entry.get(client.entry_url_field, "") != server_url:
return false
for k in client.entry_extra_fields:
if entry.get(k) != client.entry_extra_fields[k]:
return false
return true
static func command_launch_error(client: McpClient, launch: Dictionary) -> String:
if client.command_shape == McpClient.CommandShape.NONE:
return ""
if not _is_supported_command_shape(client.command_shape):
return "%s uses a command shape not supported by JSON yet" % client.display_name
if not bool(launch.get("ok", false)):
return str(launch.get("error", "No compatible attach launcher was found."))
return ""
static func _is_supported_command_shape(shape: McpClient.CommandShape) -> bool:
return shape == McpClient.CommandShape.FLAT or shape == McpClient.CommandShape.COMMAND_ARRAY
## The full launch argv as one array: launcher path followed by every arg.
static func _launch_argv(launch: Dictionary) -> Array:
var argv: Array = [str(launch.get("command", ""))]
argv.append_array(_array_copy(launch.get("args", [])))
return argv
static func _remove_legacy_env_keys(entry: Dictionary, legacy_keys: PackedStringArray) -> void:
if legacy_keys.is_empty():
return
var existing_env = entry.get("env", null)
if not (existing_env is Dictionary):
return
var env: Dictionary = (existing_env as Dictionary).duplicate(true)
for key in legacy_keys:
env.erase(String(key))
if env.is_empty():
entry.erase("env")
else:
entry["env"] = env
static func _array_copy(value: Variant) -> Array:
if value is Array:
return (value as Array).duplicate(true)
if value is PackedStringArray:
return McpClient._array_from_packed(value)
return []
static func _arrays_equal(left: Variant, right: Variant) -> bool:
if not (left is Array or left is PackedStringArray):
return false
if not (right is Array or right is PackedStringArray):
return false
var left_array := _array_copy(left)
var right_array := _array_copy(right)
if left_array.size() != right_array.size():
return false
for i in range(left_array.size()):
if left_array[i] != right_array[i]:
return false
return true
## 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
+264
View File
@@ -0,0 +1,264 @@
@tool
class_name McpManualCommand
extends RefCounted
const SHELL_POSIX := "posix"
const SHELL_POWERSHELL := "powershell"
## Keep this intersection deliberately small. PowerShell treats a leading `@`
## as splatting syntax and commas as list separators, while POSIX shells accept
## both literally; quoting either is safer than trying to infer token position.
const _SHELL_BARE_SAFE_CHARS := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_+=:./-"
## Synthesize the "Run this manually" string the dock surfaces when
## auto-configure can't find a CLI / write a file. Generated from the
## descriptor's declarative fields — there is no per-client builder
## Callable. See `_base.gd` for why descriptors are data-only.
static func build(
client: McpClient,
server_name: String,
server_url: String,
resolved_path: String,
launch: Dictionary = {},
) -> String:
match client.config_type:
"cli":
return _build_cli(client, server_name, server_url, resolved_path, launch)
"json":
return _build_json(client, server_name, server_url, resolved_path, launch)
"toml":
return _build_toml(client, server_name, server_url, resolved_path, launch)
"yaml":
return _build_yaml(client, server_name, server_url, resolved_path, launch)
return ""
## CLI clients: format the register template against the *short* CLI name so
## the user can paste it into a terminal regardless of where their binary
## lives. (The auto-configure path resolves to an absolute uvx-style path;
## that's noise for a paste-into-terminal hint. The attach launcher path
## inside a command-shape line stays absolute — status verification compares
## the registered command against the resolved launcher verbatim.)
static func _build_cli(
client: McpClient,
server_name: String,
server_url: String,
resolved_path: String = "",
launch: Dictionary = {},
) -> String:
if client.cli_register_template.is_empty() or client.cli_names.is_empty():
return ""
var shell_kind := _shell_kind_for_platform()
var short_name: String = String(client.cli_names[0])
# Prefer the non-.exe form for a cross-platform-looking command line.
for n in client.cli_names:
if not String(n).ends_with(".exe"):
short_name = String(n)
break
var cmd := ""
if client.command_shape != McpClient.CommandShape.NONE:
var launch_error := McpCliStrategy.command_launch_error(client, launch)
if not launch_error.is_empty():
cmd = "Attach launch command unavailable: %s" % launch_error
else:
var args := McpCliStrategy.format_args(client.cli_register_template, server_name, server_url, launch)
var parts: Array[String] = [short_name]
for arg in args:
parts.append(String(arg))
cmd = _format_shell_command(parts, shell_kind)
else:
var args := McpCliStrategy.format_args(client.cli_register_template, server_name, server_url)
var parts: Array[String] = [short_name]
for arg in args:
parts.append(String(arg))
cmd = _format_shell_command(parts, shell_kind)
# #463: a CLI client with a JSON fallback (Claude Code) may have no `claude`
# binary at all — e.g. installed only as a VS Code/Cursor extension. The CLI
# line above is useless to that user, so also show the config-file edit that
# auto-configure falls back to writing.
if client.has_json_fallback() and not resolved_path.is_empty():
return "%s\n\nNo `%s` CLI (e.g. installed as a VS Code/Cursor extension)? %s" % [
cmd, short_name, _build_json(client, server_name, server_url, resolved_path, launch),
]
return cmd
static func _shell_kind_for_platform() -> String:
return SHELL_POWERSHELL if OS.get_name() == "Windows" else SHELL_POSIX
## Render a command for one explicitly named shell. The label is load-bearing:
## POSIX and PowerShell use different escaping for embedded single quotes, so
## presenting the command without its target shell invites a bad copy/paste.
static func _format_shell_command(parts: Array[String], shell_kind: String) -> String:
var rendered: Array[String] = []
for part in parts:
rendered.append(_shell_display_arg(part, shell_kind))
var label := "Run in PowerShell:" if shell_kind == SHELL_POWERSHELL else "Run in a POSIX shell:"
return "%s\n%s" % [label, " ".join(rendered)]
## Quote one argv element for the paste-into-terminal hint. Single-quoted
## strings are literal in both supported shells, but embedded single quotes
## have shell-specific spellings. Backslashes, double quotes, dollar signs,
## and PowerShell backticks remain byte-for-byte unchanged inside the quotes.
static func _shell_display_arg(arg: String, shell_kind: String) -> String:
if arg.is_empty():
return "''"
var stays_bare := true
for index in range(arg.length()):
if _SHELL_BARE_SAFE_CHARS.find(arg.substr(index, 1)) < 0:
stays_bare = false
break
if stays_bare:
return arg
if shell_kind == SHELL_POWERSHELL:
return "'%s'" % arg.replace("'", "''")
return "'%s'" % arg.replace("'", "'\"'\"'")
static func _build_json(
client: McpClient,
server_name: String,
server_url: String,
resolved_path: String,
launch: Dictionary = {},
) -> String:
var key := client.server_key_path[0] if client.server_key_path.size() > 0 else "mcpServers"
if client.command_shape != McpClient.CommandShape.NONE:
var lines: Array[String] = []
var launch_error := McpJsonStrategy.command_launch_error(client, launch)
if launch_error.is_empty():
var command_entry := McpJsonStrategy.build_entry(client, server_url, null, launch)
lines.append("Edit %s and add under \"%s\":" % [resolved_path, key])
lines.append(" \"%s\": %s" % [server_name, _format_entry_inline(command_entry)])
else:
lines.append("Attach launch command unavailable: %s" % launch_error)
if client.command_supports_url_fallback:
lines.append("")
lines.append("Advanced fallback — use this URL-mode entry instead; never configure both shapes together. URL mode depends on your client's own reconnect behavior. If the server is down when the client starts, restarting the client may be required.")
lines.append("Edit %s and add under \"%s\":" % [resolved_path, key])
var fallback_entry := McpJsonStrategy.build_url_entry(client, server_url)
lines.append(" \"%s\": %s" % [server_name, _format_entry_inline(fallback_entry)])
return "\n".join(lines)
var entry := McpJsonStrategy.build_entry(client, server_url)
return "Edit %s and add under \"%s\":\n \"%s\": %s" % [resolved_path, key, server_name, _format_entry_inline(entry)]
static func _build_toml(
client: McpClient,
_server_name: String,
server_url: String,
resolved_path: String,
launch: Dictionary = {},
) -> String:
var header := _toml_header(client)
if client.command_shape != McpClient.CommandShape.NONE:
var lines: Array[String] = []
var rendered := McpTomlStrategy.render_body(client, server_url, launch)
if bool(rendered.get("ok", false)):
lines.append("Edit %s and add:" % resolved_path)
lines.append(" %s" % header)
for body_line in rendered.get("lines", []):
lines.append(" %s" % str(body_line))
else:
lines.append("Attach launch command unavailable: %s" % str(rendered.get("error", "no compatible launcher found")))
if client.command_supports_url_fallback:
lines.append("")
lines.append("Advanced fallback — replace the command/args block above with this URL-mode block; never configure both shapes together. URL mode depends on your client's own reconnect behavior. If the server is down when the client starts, restarting the client may be required.")
lines.append("Edit %s and add:" % resolved_path)
lines.append(" %s" % header)
lines.append(" url = %s" % McpTomlStrategy.encode_basic_string(server_url))
return "\n".join(lines)
var body := McpTomlStrategy.format_body(client.toml_body_template, server_url)
var lines: Array[String] = ["Edit %s and add:" % resolved_path, " %s" % header]
for b in body:
lines.append(" %s" % String(b))
return "\n".join(lines)
static func _build_yaml(
client: McpClient,
server_name: String,
server_url: String,
resolved_path: String,
launch: Dictionary = {},
) -> String:
var key := client.server_key_path[0] if client.server_key_path.size() > 0 else "mcp_servers"
if client.command_shape != McpClient.CommandShape.NONE:
var lines: Array[String] = []
var launch_error := McpYamlStrategy.command_launch_error(client, launch)
if launch_error.is_empty():
var command_entry := McpYamlStrategy.build_entry(client, server_url, null, launch)
lines.append("Edit %s and add under '%s':" % [resolved_path, key])
for entry_line in McpYamlStrategy.render_entry_lines(server_name, command_entry):
lines.append(String(entry_line))
else:
lines.append("Attach launch command unavailable: %s" % launch_error)
if client.command_supports_url_fallback:
lines.append("")
lines.append("Advanced fallback — use this URL-mode entry instead; never configure both shapes together. URL mode depends on your client's own reconnect behavior. If the server is down when the client starts, restarting the client may be required.")
lines.append("Edit %s and add under '%s':" % [resolved_path, key])
var fallback_entry := {client.entry_url_field: server_url}
for entry_line in McpYamlStrategy.render_entry_lines(server_name, fallback_entry):
lines.append(String(entry_line))
return "\n".join(lines)
var entry := McpYamlStrategy.build_entry(client, server_url)
var lines: Array[String] = [
"Edit %s and add under '%s':" % [resolved_path, key],
" %s:" % server_name,
]
for k in entry:
lines.append(" %s: %s" % [k, str(entry[k])])
return "\n".join(lines)
## 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
+206
View File
@@ -0,0 +1,206 @@
@tool
class_name McpPathTemplate
extends RefCounted
## Expands ~ / $HOME / $APPDATA / $XDG_CONFIG_HOME / $LOCALAPPDATA / $USERPROFILE
## inside path templates so per-client descriptors can declare paths declaratively
## without hand-rolling per-OS lookups.
## #691: dock worker threads (client-status refresh, configure/remove
## actions) and the #678 startup walk's discovery worker expand these
## templates off the main thread, while the spawn step mutates the
## process-global environment around `OS.create_process`
## (`GODOT_AI_OWNER_PID`, `GODOT_AI_PLUGIN_SPAWNED`, `PYTHONPATH`,
## `GODOT_AI_DISABLE_TELEMETRY`). A glibc `getenv` racing a concurrent
## `setenv` can return a freed pointer — rare but process-fatal. All env
## reads in this layer therefore go through `env_lookup`: on the MAIN
## thread it reads live and refreshes a mutex-guarded snapshot; off the
## main thread it serves from the snapshot, so no `OS.get_environment`
## runs concurrently with the spawn window's mutations. Callers pre-warm
## every var their workers can touch via `warm_env_snapshot` (plugin
## `_enter_tree` and the dock's phase-1 refresh prep, both main-thread,
## both before any worker starts).
static var _env_snapshot := {}
static var _env_snapshot_mutex := Mutex.new()
## Every var this layer and its sibling consumers (`_base.gd`
## `config_file_override_details`, `config_home_override`, `_cli_finder.gd` lookups,
## `client_configurator.gd` mode/trace reads) can touch off-main.
## Descriptor-declared config-file/config-home env names are passed as extras
## by the warm callers.
const _BASE_ENV_VARS: Array[String] = [
"HOME",
"USERPROFILE",
"XDG_CONFIG_HOME",
"APPDATA",
"LOCALAPPDATA",
"SHELL",
"ProgramFiles",
"GODOT_AI_MODE",
"GODOT_AI_STARTUP_TRACE",
## #804 (#752 adoption): _find_venv_python reads this via env_lookup on
## the dock's worker path; without pre-warming, a set override reads as
## empty there and is silently ignored — the exact misconfiguration the
## push_warning in client_configurator.gd exists to surface.
"GODOT_AI_VENV_PYTHON",
]
## Thread-safe env read (#691). Main thread: live read + snapshot refresh.
## Worker thread: snapshot only, so it can never race a main-thread
## setenv/unsetenv. A worker read of a never-warmed var returns "" — the
## same value an unset var reads as — never a live OS.get_environment,
## which would reintroduce the race for exactly the vars nobody thought
## to warm. Missing warm-up degrades resolution; it must not touch the
## process-global environment off-main.
static func env_lookup(name: String) -> String:
if OS.get_thread_caller_id() == OS.get_main_thread_id():
var live := OS.get_environment(name)
_env_snapshot_mutex.lock()
_env_snapshot[name] = live
_env_snapshot_mutex.unlock()
return live
_env_snapshot_mutex.lock()
var cached: Variant = _env_snapshot.get(name, null)
_env_snapshot_mutex.unlock()
if cached != null:
return str(cached)
return ""
## Main-thread pre-warm so subsequent worker reads never touch the real
## environment. Idempotent; safe to call before every worker dispatch.
static func warm_env_snapshot(extra_vars: PackedStringArray = PackedStringArray()) -> void:
for var_name in _BASE_ENV_VARS:
env_lookup(var_name)
for var_name in extra_vars:
if not String(var_name).is_empty():
env_lookup(String(var_name))
## Pick the right entry from a {"darwin": ..., "windows": ..., "linux": ...} map.
static func resolve(template_map: Dictionary) -> String:
var key := platform_key(template_map)
if key.is_empty():
return ""
var template: String = template_map[key]
return expand(template)
## Return the platform-specific key present in a descriptor map. `unix` is a
## shorthand for macOS and Linux. Public so descriptors can use the same
## platform selection for ordered path-candidate arrays as for one path.
static func platform_key(template_map: Dictionary) -> String:
var key := _os_key()
if template_map.has(key):
return key
if (key == "darwin" or key == "linux") and template_map.has("unix"):
return "unix"
return ""
## Expand one path template into zero or more concrete paths. A single `*` is
## allowed inside one DIRECTORY segment (for example `Packages/Claude_*`). The
## wildcard is resolved by enumerating that segment's parent; the remaining
## suffix may name a file that does not exist yet, which lets callers derive a
## deterministic create target for a fresh packaged-app install.
##
## Multiple wildcards fail closed and return no candidates. A wildcard final
## segment may identify an installation directory for `detect_paths`. Returned
## paths are sorted for deterministic tests and diagnostics; callers still
## reject ambiguous config groups rather than picking one.
static func expand_path_candidates(template: String) -> PackedStringArray:
var expanded := expand(template)
if expanded.is_empty():
return PackedStringArray()
var star := expanded.find("*")
if star < 0:
return PackedStringArray([expanded])
if expanded.find("*", star + 1) >= 0:
return PackedStringArray()
var slash_before := maxi(expanded.rfind("/", star), expanded.rfind("\\", star))
var forward_after := expanded.find("/", star)
var backward_after := expanded.find("\\", star)
var slash_after := forward_after
if slash_after < 0 or (backward_after >= 0 and backward_after < slash_after):
slash_after = backward_after
if slash_before < 0:
return PackedStringArray()
var parent := expanded.substr(0, slash_before)
var pattern := (
expanded.substr(slash_before + 1)
if slash_after < 0
else expanded.substr(slash_before + 1, slash_after - slash_before - 1)
)
var suffix := "" if slash_after < 0 else expanded.substr(slash_after + 1)
var pattern_star := pattern.find("*")
if pattern_star < 0:
return PackedStringArray()
var prefix := pattern.substr(0, pattern_star)
var ending := pattern.substr(pattern_star + 1)
var dir := DirAccess.open(parent)
if dir == null:
return PackedStringArray()
var matches := PackedStringArray()
for child in dir.get_directories():
if _wildcard_segment_matches(String(child), prefix, ending):
var matched_path := parent.path_join(String(child))
matches.append(matched_path if suffix.is_empty() else matched_path.path_join(suffix))
matches.sort()
return matches
## Substitute env vars and ~ in a single template string.
static func expand(template: String) -> String:
if template.is_empty():
return ""
var out := template
if out.begins_with("~/") or out == "~":
var home := _home()
out = home if out == "~" else home.path_join(out.substr(2))
# $HOME, $APPDATA, $LOCALAPPDATA, $USERPROFILE, $XDG_CONFIG_HOME
for var_name in ["XDG_CONFIG_HOME", "LOCALAPPDATA", "USERPROFILE", "APPDATA", "HOME"]:
var token := "$%s" % var_name
if out.find(token) >= 0:
var value := env_lookup(var_name)
if value.is_empty() and var_name == "XDG_CONFIG_HOME":
value = _home().path_join(".config")
if value.is_empty() and var_name == "APPDATA":
value = _home().path_join("AppData/Roaming")
if value.is_empty() and var_name == "LOCALAPPDATA":
value = _home().path_join("AppData/Local")
if value.is_empty() and var_name == "HOME":
value = _home()
out = out.replace(token, value)
return out
static func _os_key() -> String:
match OS.get_name():
"macOS":
return "darwin"
"Windows":
return "windows"
_:
return "linux"
static func _wildcard_segment_matches(value: String, prefix: String, ending: String) -> bool:
# Prefix/suffix tests alone allow the two fixed portions to overlap inside a
# too-short value. Glob semantics require room for both portions even when
# `*` matches an empty string.
if value.length() < prefix.length() + ending.length():
return false
if OS.get_name() == "Windows":
return value.to_lower().begins_with(prefix.to_lower()) and value.to_lower().ends_with(ending.to_lower())
return value.begins_with(prefix) and value.ends_with(ending)
static func _home() -> String:
var h := env_lookup("HOME")
if h.is_empty():
h = env_lookup("USERPROFILE")
return h
@@ -0,0 +1 @@
uid://5pd418va35ms
+151
View File
@@ -0,0 +1,151 @@
@tool
class_name McpClientRegistry
extends RefCounted
## Central enumeration of every supported MCP client. Adding a new client
## means: drop a file in clients/, then append one path below.
##
## Paths, not preloads (#736): a preload array pulled all client descriptor
## scripts into the boot-time compile closure of everything that preloads
## this registry (plugin.gd via client_configurator.gd and mcp_dock.gd),
## stalling "Initializing plugins" on every editor boot. Descriptors are
## only needed when the dock refreshes client statuses or a client_*
## command runs, so they load lazily on first registry access.
const _CLIENT_SCRIPT_PATHS := [
"res://addons/godot_ai/clients/claude_code.gd",
"res://addons/godot_ai/clients/claude_desktop.gd",
"res://addons/godot_ai/clients/codex.gd",
"res://addons/godot_ai/clients/grok.gd",
"res://addons/godot_ai/clients/antigravity.gd",
"res://addons/godot_ai/clients/cursor.gd",
"res://addons/godot_ai/clients/windsurf.gd",
"res://addons/godot_ai/clients/vscode.gd",
"res://addons/godot_ai/clients/vscode_insiders.gd",
"res://addons/godot_ai/clients/zed.gd",
"res://addons/godot_ai/clients/gemini_cli.gd",
"res://addons/godot_ai/clients/cline.gd",
"res://addons/godot_ai/clients/kilo_code.gd",
"res://addons/godot_ai/clients/roo_code.gd",
"res://addons/godot_ai/clients/zoo_code.gd",
"res://addons/godot_ai/clients/kiro.gd",
"res://addons/godot_ai/clients/trae.gd",
"res://addons/godot_ai/clients/cherry_studio.gd",
"res://addons/godot_ai/clients/opencode.gd",
"res://addons/godot_ai/clients/qwen_code.gd",
"res://addons/godot_ai/clients/kimi_code.gd",
"res://addons/godot_ai/clients/hermes.gd",
]
static var _instances: Array[McpClient] = []
static var _by_id: Dictionary = {}
## First registry access can come from the dock's client-status refresh
## worker thread while the main thread hits it via a client_* command —
## serialize the one-time load so a racing thread can never observe a
## half-built registry. load() itself is thread-safe via ResourceLoader.
static var _load_mutex := Mutex.new()
## True when even a fresh rebuild yields instances missing base-schema
## fields — the deep stale-script state after an in-session self-update
## (#850; docs/releasing.md release-shape rules). Only an editor restart
## heals it; callers surface RESTART_TO_FINISH_UPDATE instead of erroring
## per client. Never reset within a session: rebuilding again cannot help,
## it would only repeat the load work and warning on every dock sweep.
static var _stale_session := false
const RESTART_TO_FINISH_UPDATE := (
"Godot AI was updated in this editor session. Restart the editor to finish the update."
)
static func all() -> Array[McpClient]:
_ensure_loaded()
return _instances
static func get_by_id(id: String) -> McpClient:
_ensure_loaded()
return _by_id.get(id, null)
static func ids() -> PackedStringArray:
var out := PackedStringArray()
for c in all():
out.append(c.id)
return out
static func has_id(id: String) -> bool:
_ensure_loaded()
return _by_id.has(id)
## True when this editor session is running a self-update whose script
## reloads left descriptor state unusable. Client operations short-circuit
## with RESTART_TO_FINISH_UPDATE rather than spamming per-field errors.
static func stale_session_detected() -> bool:
_ensure_loaded()
return _stale_session
## An instance is coherent when fields added to the CURRENT McpClient schema
## read back with their declared types. After an in-session self-update,
## hot-patched or pre-update instances answer Nil for vars the update added
## (#850: `config_path_candidates` and
## `config_file_env` read as Nil, crashing platform_key / String()). The
## reflected `get()` avoids typed-access errors on such instances.
static func _instance_is_coherent(inst: Object) -> bool:
if inst == null:
return false
return (
inst.get("config_path_candidates") is Dictionary
and inst.get("config_file_env") is String
and inst.get("path_template") is Dictionary
)
static func _cache_is_coherent() -> bool:
return not _instances.is_empty() and _instance_is_coherent(_instances[0])
static func _ensure_loaded() -> void:
if _stale_session:
return
if _cache_is_coherent():
return
_load_mutex.lock()
## Re-check under the lock: another thread may have rebuilt (or concluded
## staleness) while this one waited.
if not _stale_session and not _cache_is_coherent():
## Covers both the first load and the post-self-update rebuild: this
## registry file can survive an update unchanged, so its statics keep
## serving pre-update instances to freshly reloaded callers. A rebuild
## instantiates from the reloaded descriptor scripts, which repairs
## every case except a stale base Script object itself.
_load()
if not _instances.is_empty() and not _cache_is_coherent():
_stale_session = true
push_warning("MCP | %s" % RESTART_TO_FINISH_UPDATE)
_load_mutex.unlock()
static func _load() -> void:
## Build into locals and publish whole containers last, so the lock-free
## fast path in _ensure_loaded can never see a partially-filled registry.
var instances: Array[McpClient] = []
var by_id: Dictionary = {}
for path in _CLIENT_SCRIPT_PATHS:
var script := load(path) as GDScript
if script == null:
push_warning("MCP | failed to load client descriptor %s" % path)
continue
var inst: McpClient = script.new()
if inst.id.is_empty():
push_warning("MCP | client descriptor %s has empty id" % path)
continue
if by_id.has(inst.id):
push_warning("MCP | duplicate client id: %s" % inst.id)
continue
instances.append(inst)
by_id[inst.id] = inst
_by_id = by_id
_instances = instances
+1
View File
@@ -0,0 +1 @@
uid://bxougoq8xwg1
+730
View File
@@ -0,0 +1,730 @@
@tool
class_name McpTomlStrategy
extends RefCounted
## TOML upsert for URL entries and client-owned command entries.
##
## This remains deliberately smaller than a general TOML parser, but the
## parts that affect migration are semantic: assignments retain their whole
## value span (including multiline arrays/strings), and command/args status
## verification decodes TOML strings and arrays rather than comparing text.
static func configure(
client: McpClient,
_server_name: String,
server_url: String,
launch: Dictionary = {},
) -> Dictionary:
var resolution := client.resolved_config_path_details()
var path := str(resolution.get("path", ""))
var path_error := str(resolution.get("error", ""))
if not path_error.is_empty():
return {"status": "error", "message": path_error}
if path.is_empty():
return {"status": "error", "message": "Could not resolve config path for %s" % client.display_name}
var seed_path := str(resolution.get("seed_path", ""))
var read_path := seed_path if not FileAccess.file_exists(path) and not seed_path.is_empty() else path
var read := _read_or_init(read_path)
if not read["ok"]:
return {"status": "error", "message": "Refusing to overwrite %s: %s. Fix or move the file, then re-run Configure." % [read_path, read["error"]]}
var rendered := render_body(client, server_url, launch)
if not bool(rendered.get("ok", false)):
return {"status": "error", "message": str(rendered.get("error", "Could not build the TOML entry."))}
var lines: Array[String] = _split_lines(String(read["data"]))
var body: Array[String] = rendered["lines"]
var pinned_keys: Dictionary = rendered["pinned_keys"]
var initial_keys: Dictionary = rendered["initial_keys"]
var removed_keys: Dictionary = rendered["removed_keys"]
var section := _find_section(lines, _all_headers(client))
var header := _primary_header(client)
var new_lines: Array[String] = [header]
if section.is_empty():
new_lines.append_array(body)
var output_fresh: Array[String] = []
output_fresh.append_array(lines)
if not output_fresh.is_empty() and not output_fresh[-1].strip_edges().is_empty():
output_fresh.append("")
output_fresh.append_array(new_lines)
if not McpAtomicWrite.write(path, "\n".join(output_fresh)):
return {"status": "error", "message": "Cannot write to %s" % path}
return {"status": "ok", "message": McpClient.configured_message(client, server_url)}
var old_items := _value_items(lines, int(section["start"]) + 1, int(section["end"]))
var old_by_key := {}
for item in old_items:
var old_key := str(item.get("key", ""))
if not old_key.is_empty() and not old_by_key.has(old_key):
old_by_key[old_key] = item
var body_items := _value_items(body, 0, body.size())
var emitted_keys := {}
for item in body_items:
var key := str(item.get("key", ""))
if key.is_empty():
new_lines.append_array(item["lines"])
continue
emitted_keys[key] = true
if initial_keys.has(key) and old_by_key.has(key):
new_lines.append_array(old_by_key[key]["lines"])
else:
## Pinned keys always use the freshly rendered span. A generated key
## that is neither pinned nor initial is also rendered deterministically.
new_lines.append_array(item["lines"])
## Carry unknown/user-owned assignments and standalone comments verbatim.
## Whole item spans prevent multiline arrays/strings from being truncated.
for item in old_items:
var key := str(item.get("key", ""))
if not key.is_empty():
if emitted_keys.has(key) or pinned_keys.has(key) or removed_keys.has(key):
continue
new_lines.append_array(item["lines"])
continue
var item_lines: Array = item.get("lines", [])
for line in item_lines:
if not str(line).strip_edges().is_empty():
new_lines.append(str(line))
var output: Array[String] = []
output.append_array(_slice(lines, 0, int(section["start"])))
output.append_array(new_lines)
output.append_array(_slice(lines, int(section["end"]), lines.size()))
output = _rewrite_legacy_descendant_headers(output, client)
if not McpAtomicWrite.write(path, "\n".join(output)):
return {"status": "error", "message": "Cannot write to %s" % path}
return {"status": "ok", "message": McpClient.configured_message(client, server_url)}
static func check_status(
client: McpClient,
server_name: String,
server_url: String,
launch: Dictionary = {},
) -> McpClient.Status:
return check_status_details(client, server_name, server_url, launch).get("status", McpClient.Status.NOT_CONFIGURED)
static func check_status_details(
client: McpClient,
_server_name: String,
server_url: String,
launch: Dictionary = {},
) -> Dictionary:
var resolution := client.resolved_config_path_details()
var path := str(resolution.get("path", ""))
var path_error := str(resolution.get("error", ""))
if not path_error.is_empty():
return {"status": McpClient.Status.ERROR, "error_msg": path_error}
if path.is_empty() or not FileAccess.file_exists(path):
return {"status": McpClient.Status.NOT_CONFIGURED, "error_msg": ""}
var read := _read_or_init(path)
if not read["ok"]:
return {"status": McpClient.Status.ERROR, "error_msg": String(read["error"])}
var lines: Array[String] = _split_lines(String(read["data"]))
var section := _find_section(lines, _all_headers(client))
if section.is_empty():
return {"status": McpClient.Status.NOT_CONFIGURED, "error_msg": ""}
var items := _value_items(lines, int(section["start"]) + 1, int(section["end"]))
var by_key := {}
for item in items:
var key := str(item.get("key", ""))
if not key.is_empty() and not by_key.has(key):
by_key[key] = item
if client.command_shape != McpClient.CommandShape.NONE:
if not bool(launch.get("ok", false)):
return {
"status": McpClient.Status.ERROR,
"error_msg": str(launch.get("error", "No compatible attach launcher was found.")),
}
for legacy_key in client.command_legacy_keys:
if by_key.has(String(legacy_key)):
return {"status": McpClient.Status.CONFIGURED_MISMATCH, "error_msg": ""}
if not by_key.has("command") or not by_key.has("args"):
return {"status": McpClient.Status.CONFIGURED_MISMATCH, "error_msg": ""}
var command_value := _decode_toml_string(_item_value(by_key["command"]))
var args_value := _decode_toml_string_array(_item_value(by_key["args"]))
if not bool(command_value.get("ok", false)) or not bool(args_value.get("ok", false)):
return {"status": McpClient.Status.CONFIGURED_MISMATCH, "error_msg": ""}
if str(command_value.get("value", "")) != str(launch.get("command", "")):
return {"status": McpClient.Status.CONFIGURED_MISMATCH, "error_msg": ""}
if not _string_arrays_equal(args_value.get("value", []), launch.get("args", [])):
return {"status": McpClient.Status.CONFIGURED_MISMATCH, "error_msg": ""}
if not client.command_transport_key.is_empty():
var transport_key := client.command_transport_key
if not by_key.has(transport_key):
return {"status": McpClient.Status.CONFIGURED_MISMATCH, "error_msg": ""}
var decoded_transport := _decode_toml_scalar(_item_value(by_key[transport_key]))
if not bool(decoded_transport.get("ok", false)) or decoded_transport.get("value") != client.command_transport_value:
return {"status": McpClient.Status.CONFIGURED_MISMATCH, "error_msg": ""}
return {"status": McpClient.Status.CONFIGURED, "error_msg": ""}
if not by_key.has("url"):
return {"status": McpClient.Status.CONFIGURED_MISMATCH, "error_msg": ""}
var url_value := _decode_toml_string(_item_value(by_key["url"]))
if not bool(url_value.get("ok", false)) or str(url_value.get("value", "")) != server_url:
return {"status": McpClient.Status.CONFIGURED_MISMATCH, "error_msg": ""}
return {"status": McpClient.Status.CONFIGURED, "error_msg": ""}
static func remove(client: McpClient, _server_name: String) -> Dictionary:
var resolution := client.resolved_config_path_details()
var path := str(resolution.get("path", ""))
var path_error := str(resolution.get("error", ""))
if not path_error.is_empty():
return {"status": "error", "message": path_error}
if path.is_empty() or not FileAccess.file_exists(path):
return {"status": "ok", "message": "Not configured"}
var read := _read_or_init(path)
if not read["ok"]:
return {"status": "error", "message": "Refusing to rewrite %s: %s." % [path, read["error"]]}
var lines: Array[String] = _split_lines(String(read["data"]))
var headers := _all_headers(client)
var subtable_prefixes := _subtable_prefixes(headers)
var output: Array[String] = []
var i := 0
while i < lines.size():
if _matches_any_header(lines[i], headers) or _matches_subtable_prefix(lines[i], subtable_prefixes):
i += 1
while i < lines.size() and not _is_any_section_header(lines[i]):
i += 1
continue
output.append(lines[i])
i += 1
if not McpAtomicWrite.write(path, "\n".join(output)):
return {"status": "error", "message": "Cannot write to %s" % path}
return {"status": "ok", "message": "%s configuration removed" % client.display_name}
## Substitute `{url}` in every legacy URL body-template line.
static func format_body(template: PackedStringArray, server_url: String) -> PackedStringArray:
var out := PackedStringArray()
for line in template:
out.append(String(line).replace("{url}", server_url))
return out
## Encode a TOML basic string. This is intentionally public for the
## cross-language fixture test that parses the rendered sample with tomllib.
static func encode_basic_string(value: String) -> String:
return '"%s"' % value.replace("\\", "\\\\").replace('"', '\\"').replace("\b", "\\b").replace("\t", "\\t").replace("\n", "\\n").replace("\f", "\\f").replace("\r", "\\r")
## Multi-line string-array encoding used by command-shape entries.
static func encode_string_array(values: Variant) -> Array[String]:
var out: Array[String] = ["["]
for value in values:
out.append(" %s," % encode_basic_string(str(value)))
out.append("]")
return out
static func render_body(client: McpClient, server_url: String, launch: Dictionary) -> Dictionary:
if client.command_shape == McpClient.CommandShape.NONE:
if client.toml_body_template.is_empty():
return {"ok": false, "error": "%s descriptor missing toml_body_template" % client.display_name}
var legacy_body := format_body(client.toml_body_template, server_url)
var legacy_lines: Array[String] = []
var pinned := {}
var initial := {}
for idx in range(legacy_body.size()):
legacy_lines.append(String(legacy_body[idx]))
var key := _line_key(String(client.toml_body_template[idx]))
if key.is_empty():
continue
if String(client.toml_body_template[idx]).contains("{url}"):
pinned[key] = true
else:
initial[key] = true
return {
"ok": true,
"lines": legacy_lines,
"pinned_keys": pinned,
"initial_keys": initial,
"removed_keys": {},
}
if client.command_shape != McpClient.CommandShape.COMMAND_ARRAY:
return {"ok": false, "error": "%s uses a command shape not supported by TOML yet" % client.display_name}
if not bool(launch.get("ok", false)):
return {"ok": false, "error": str(launch.get("error", "No compatible attach launcher was found."))}
var command_lines: Array[String] = [
"command = %s" % encode_basic_string(str(launch.get("command", ""))),
]
command_lines.append_array(_encode_assignment("args", launch.get("args", [])))
var pinned_keys := {"command": true, "args": true}
if not client.command_transport_key.is_empty():
var encoded_transport := _encode_scalar(client.command_transport_value)
if encoded_transport.is_empty():
return {
"ok": false,
"error": "Unsupported TOML transport `%s` for %s" % [
client.command_transport_key, client.display_name,
],
}
command_lines.append("%s = %s" % [client.command_transport_key, encoded_transport])
pinned_keys[client.command_transport_key] = true
var initial_keys := {}
for key in client.command_initial_fields:
var encoded := _encode_assignment(str(key), client.command_initial_fields[key])
if encoded.is_empty():
return {"ok": false, "error": "Unsupported TOML default `%s` for %s" % [key, client.display_name]}
command_lines.append_array(encoded)
initial_keys[str(key)] = true
var removed_keys := {}
for key in client.command_legacy_keys:
removed_keys[String(key)] = true
return {
"ok": true,
"lines": command_lines,
"pinned_keys": pinned_keys,
"initial_keys": initial_keys,
"removed_keys": removed_keys,
}
static func _encode_assignment(key: String, value: Variant) -> Array[String]:
if value is Array or value is PackedStringArray:
var lines := encode_string_array(value)
lines[0] = "%s = %s" % [key, lines[0]]
return lines
var encoded := _encode_scalar(value)
var lines: Array[String] = []
if not encoded.is_empty():
lines.append("%s = %s" % [key, encoded])
return lines
static func _encode_scalar(value: Variant) -> String:
if value is String:
return encode_basic_string(value)
if value is bool:
return "true" if value else "false"
if value is int or value is float:
return str(value)
return ""
# --- span-aware merge helpers -------------------------------------------
static func _value_items(lines: Array[String], from: int, to: int) -> Array[Dictionary]:
var out: Array[Dictionary] = []
var i := from
while i < to:
var key := _line_key(lines[i])
if key.is_empty():
out.append({"key": "", "lines": [lines[i]]})
i += 1
continue
var end := _value_span_end(lines, i, to)
out.append({"key": key, "lines": _slice(lines, i, end)})
i = end
return out
static func _value_span_end(lines: Array[String], start: int, limit: int) -> int:
var state := {"quote": "", "square": 0, "curly": 0, "escaped": false}
for i in range(start, limit):
var begin := 0
if i == start:
var eq := _assignment_equal(lines[i])
begin = eq + 1 if eq >= 0 else 0
_scan_toml_value_line(lines[i], begin, state)
if str(state["quote"]).is_empty() and int(state["square"]) == 0 and int(state["curly"]) == 0:
return i + 1
return limit
static func _scan_toml_value_line(line: String, begin: int, state: Dictionary) -> void:
var i := begin
while i < line.length():
var quote := str(state["quote"])
if quote == '"""' or quote == "'''":
if line.substr(i).begins_with(quote):
state["quote"] = ""
i += 3
continue
if quote == '"""' and line.unicode_at(i) == 92 and not bool(state["escaped"]):
state["escaped"] = true
i += 1
continue
state["escaped"] = false
i += 1
continue
if quote == '"' or quote == "'":
var c := line.unicode_at(i)
if quote == '"' and c == 92 and not bool(state["escaped"]):
state["escaped"] = true
i += 1
continue
if c == quote.unicode_at(0) and not bool(state["escaped"]):
state["quote"] = ""
state["escaped"] = false
i += 1
continue
if line.substr(i).begins_with('"""'):
state["quote"] = '"""'
i += 3
continue
if line.substr(i).begins_with("'''"):
state["quote"] = "'''"
i += 3
continue
var c := line.unicode_at(i)
if c == 34:
state["quote"] = '"'
elif c == 39:
state["quote"] = "'"
elif c == 35:
break
elif c == 91:
state["square"] = int(state["square"]) + 1
elif c == 93:
state["square"] = maxi(0, int(state["square"]) - 1)
elif c == 123:
state["curly"] = int(state["curly"]) + 1
elif c == 125:
state["curly"] = maxi(0, int(state["curly"]) - 1)
i += 1
static func _line_key(line: String) -> String:
var eq := _assignment_equal(line)
if eq <= 0:
return ""
return line.substr(0, eq).strip_edges()
static func _assignment_equal(line: String) -> int:
var quote := 0
var escaped := false
for i in range(line.length()):
var c := line.unicode_at(i)
if quote != 0:
if quote == 34 and c == 92 and not escaped:
escaped = true
continue
if c == quote and not escaped:
quote = 0
escaped = false
continue
if c == 34 or c == 39:
quote = c
elif c == 35:
return -1
elif c == 61:
return i
return -1
# --- semantic value decoding --------------------------------------------
static func _item_value(item: Dictionary) -> String:
var item_lines: Array = item.get("lines", [])
if item_lines.is_empty():
return ""
var first := str(item_lines[0])
var eq := _assignment_equal(first)
if eq < 0:
return ""
var parts: Array[String] = [first.substr(eq + 1)]
for i in range(1, item_lines.size()):
parts.append(str(item_lines[i]))
return "\n".join(parts)
static func _decode_toml_scalar(raw: String) -> Dictionary:
var cleaned := _without_comments(raw).strip_edges()
if cleaned == "true":
return {"ok": true, "value": true}
if cleaned == "false":
return {"ok": true, "value": false}
var string_value := _decode_toml_string(cleaned)
if bool(string_value.get("ok", false)):
return string_value
if cleaned.is_valid_int():
return {"ok": true, "value": cleaned.to_int()}
if cleaned.is_valid_float():
return {"ok": true, "value": cleaned.to_float()}
return {"ok": false}
static func _decode_toml_string(raw: String) -> Dictionary:
var cleaned := _without_comments(raw).strip_edges()
if cleaned.length() >= 2 and cleaned.begins_with("'") and cleaned.ends_with("'"):
return {"ok": true, "value": cleaned.substr(1, cleaned.length() - 2)}
if cleaned.length() < 2 or not cleaned.begins_with('"') or not cleaned.ends_with('"'):
return {"ok": false}
var parsed: Variant = JSON.parse_string(cleaned)
if parsed is String:
return {"ok": true, "value": parsed}
return {"ok": false}
static func _decode_toml_string_array(raw: String) -> Dictionary:
var cleaned := _without_comments(raw).strip_edges()
if not cleaned.begins_with("["):
return {"ok": false}
var i := 1
var values: Array[String] = []
while true:
i = _skip_space(cleaned, i)
if i >= cleaned.length():
return {"ok": false}
if cleaned.unicode_at(i) == 93:
i = _skip_space(cleaned, i + 1)
return {"ok": i == cleaned.length(), "value": values}
var parsed := _parse_string_at(cleaned, i)
if not bool(parsed.get("ok", false)):
return {"ok": false}
values.append(str(parsed.get("value", "")))
i = _skip_space(cleaned, int(parsed.get("next", i)))
if i >= cleaned.length():
return {"ok": false}
var c := cleaned.unicode_at(i)
if c == 44:
i += 1
continue
if c == 93:
continue
return {"ok": false}
return {"ok": false} # Unreachable; keeps GDScript's return analysis explicit.
static func _parse_string_at(text: String, start: int) -> Dictionary:
if start >= text.length():
return {"ok": false}
var quote := text.unicode_at(start)
if quote != 34 and quote != 39:
return {"ok": false}
var i := start + 1
var escaped := false
while i < text.length():
var c := text.unicode_at(i)
if quote == 34 and c == 92 and not escaped:
escaped = true
i += 1
continue
if c == quote and not escaped:
var raw := text.substr(start, i - start + 1)
if quote == 39:
return {"ok": true, "value": raw.substr(1, raw.length() - 2), "next": i + 1}
var parsed: Variant = JSON.parse_string(raw)
if parsed is String:
return {"ok": true, "value": parsed, "next": i + 1}
return {"ok": false}
escaped = false
i += 1
return {"ok": false}
static func _without_comments(raw: String) -> String:
var out: Array[String] = []
for line in raw.split("\n"):
var quote := 0
var escaped := false
var kept := ""
for i in range(line.length()):
var c := line.unicode_at(i)
if quote != 0:
kept += line.substr(i, 1)
if quote == 34 and c == 92 and not escaped:
escaped = true
continue
if c == quote and not escaped:
quote = 0
escaped = false
continue
if c == 34 or c == 39:
quote = c
kept += line.substr(i, 1)
elif c == 35:
break
else:
kept += line.substr(i, 1)
out.append(kept)
return "\n".join(out)
static func _skip_space(text: String, start: int) -> int:
var i := start
while i < text.length() and text.substr(i, 1) in [" ", "\t", "\r", "\n"]:
i += 1
return i
static func _string_arrays_equal(left: Variant, right: Variant) -> bool:
if not (left is Array or left is PackedStringArray):
return false
if not (right is Array or right is PackedStringArray):
return false
if left.size() != right.size():
return false
for i in range(left.size()):
if str(left[i]) != str(right[i]):
return false
return true
# --- file / section helpers ---------------------------------------------
static func _read_or_init(path: String) -> Dictionary:
if not FileAccess.file_exists(path):
return {"ok": true, "data": ""}
var f := FileAccess.open(path, FileAccess.READ)
if f == null:
var err := FileAccess.get_open_error()
return {"ok": false, "error": "could not open for reading (error %d)" % err}
var text := f.get_as_text()
f.close()
return {"ok": true, "data": text}
static func _split_lines(content: String) -> Array[String]:
var out: Array[String] = []
for line in content.split("\n"):
out.append(line)
return out
static func _slice(lines: Array[String], from: int, to: int) -> Array[String]:
var out: Array[String] = []
for i in range(from, to):
out.append(lines[i])
return out
static func _primary_header(client: McpClient) -> String:
var parts := client.toml_section_path
if parts.size() < 2:
return "[%s]" % ".".join(parts)
var section := ".".join(McpClient._packed_slice(parts, 0, parts.size() - 1))
var name := parts[parts.size() - 1]
return "[%s.\"%s\"]" % [section, name]
static func _all_headers(client: McpClient) -> Array[String]:
var primary := _primary_header(client)
var out: Array[String] = [primary]
var bare := _bare_key_header(client)
if not bare.is_empty() and bare != primary:
out.append(bare)
for legacy in client.toml_legacy_section_aliases:
out.append("[%s]" % legacy)
return out
static func _bare_key_header(client: McpClient) -> String:
var parts := client.toml_section_path
if parts.is_empty():
return ""
for part in parts:
if not _is_bare_key(String(part)):
return ""
return "[%s]" % ".".join(parts)
static func _is_bare_key(value: String) -> bool:
if value.is_empty():
return false
for i in range(value.length()):
var c := value.unicode_at(i)
var alpha := (c >= 65 and c <= 90) or (c >= 97 and c <= 122)
var digit := c >= 48 and c <= 57
if not (alpha or digit or c == 45 or c == 95):
return false
return true
static func _subtable_prefixes(headers: Array[String]) -> Array[String]:
var out: Array[String] = []
for header in headers:
if header.length() > 2 and header.ends_with("]"):
out.append(header.substr(0, header.length() - 1) + ".")
return out
static func _matches_subtable_prefix(line: String, prefixes: Array[String]) -> bool:
var trimmed := line.strip_edges()
for prefix in prefixes:
if not trimmed.begins_with(prefix):
continue
var rest := trimmed.substr(prefix.length())
var bracket := rest.find("]")
if bracket < 0:
continue
var remainder := rest.substr(bracket + 1).strip_edges()
if remainder.is_empty() or remainder.begins_with("#"):
return true
return false
static func _matches_any_header(line: String, headers: Array[String]) -> bool:
var trimmed := line.strip_edges()
for header in headers:
if not trimmed.begins_with(header):
continue
var remainder := trimmed.substr(header.length()).strip_edges()
if remainder.is_empty() or remainder.begins_with("#"):
return true
return false
static func _find_section(lines: Array[String], headers: Array[String]) -> Dictionary:
for i in range(lines.size()):
if _matches_any_header(lines[i], headers):
var end := lines.size()
for j in range(i + 1, lines.size()):
if _is_any_section_header(lines[j]):
end = j
break
return {"start": i, "end": end}
return {}
static func _is_any_section_header(line: String) -> bool:
var trimmed := line.strip_edges()
if not trimmed.begins_with("["):
return false
var bracket := trimmed.find("]")
if bracket < 0:
return false
var remainder := trimmed.substr(bracket + 1).strip_edges()
return remainder.is_empty() or remainder.begins_with("#")
static func _rewrite_legacy_descendant_headers(
lines: Array[String], client: McpClient
) -> Array[String]:
if client.toml_legacy_section_aliases.is_empty():
return lines
var primary := _primary_header(client)
var primary_prefix := primary.substr(0, primary.length() - 1) + "."
var out: Array[String] = []
for line in lines:
var rewritten := line
var trimmed := line.strip_edges()
var indent_length := line.find("[")
var indent := line.substr(0, indent_length) if indent_length >= 0 else ""
for alias in client.toml_legacy_section_aliases:
var legacy_prefix := "[%s." % String(alias)
if trimmed.begins_with(legacy_prefix):
rewritten = indent + primary_prefix + trimmed.substr(legacy_prefix.length())
break
out.append(rewritten)
return out
@@ -0,0 +1 @@
uid://cwdvxgn0aurqv
+544
View File
@@ -0,0 +1,544 @@
@tool
class_name McpYamlStrategy
extends RefCounted
## Minimal YAML upsert for Hermes Agent MCP config.
##
## Hermes reads MCP servers from ~/.hermes/config.yaml under the
## `mcp_servers` key (snake_case, YAML). HTTP entries are transport-inferred:
## just `url` (plus optional `headers`), no `type` field. We only parse the
## `mcp_servers` block and re-emit it; other top-level keys in the user's
## config.yaml are preserved verbatim by round-tripping the raw lines around
## that block. No general YAML parser — Godot has none in stdlib, and Hermes
## only needs this one shape. See issue #640.
const INDENT := " " # YAML forbids tab indentation; match the 2-space style of ~/.hermes/config.yaml
static func configure(
client: McpClient,
server_name: String,
server_url: String,
launch: Dictionary = {},
) -> Dictionary:
var resolution := client.resolved_config_path_details()
var path := str(resolution.get("path", ""))
var path_error := str(resolution.get("error", ""))
if not path_error.is_empty():
return {"status": "error", "message": path_error}
if path.is_empty():
return {"status": "error", "message": "Could not resolve config path for %s on this OS" % client.display_name}
## Fail closed before touching the file — same contract as JSON/TOML.
var launch_error := command_launch_error(client, launch)
if not launch_error.is_empty():
return {"status": "error", "message": launch_error}
var seed_path := str(resolution.get("seed_path", ""))
var read_path := seed_path if not FileAccess.file_exists(path) and not seed_path.is_empty() else path
var read := _read(read_path)
if not read["ok"]:
return {"status": "error", "message": "Refusing to overwrite %s: %s. Fix or move the file, then re-run Configure." % [read_path, read["error"]]}
var text: String = read["data"]
var block := _extract_block(text)
var entries: Dictionary = block["entries"]
# Preserve existing entry's user-mutable keys; force the transport keys.
var existing: Dictionary = entries.get(server_name, {})
var new_entry := build_entry(client, server_url, existing, launch)
entries[server_name] = new_entry
var out := _assemble(text, block["prefix_lines"], entries, block["suffix_lines"])
if not McpAtomicWrite.write(path, out):
return {"status": "error", "message": "Cannot write to %s" % path}
return {"status": "ok", "message": McpClient.configured_message(client, server_url)}
static func check_status(
client: McpClient, server_name: String, server_url: String, launch: Dictionary = {}
) -> McpClient.Status:
return check_status_details(client, server_name, server_url, launch)["status"]
## Same contract as the JSON/TOML strategies (#711): {status, error_msg}.
## An existing-but-unreadable config is ERROR with the diagnostic — not
## NOT_CONFIGURED — so the dock row can tell "no config" from "config the
## editor can't read" instead of offering a Configure that would fail.
static func check_status_details(
client: McpClient, server_name: String, server_url: String, launch: Dictionary = {}
) -> Dictionary:
var resolution := client.resolved_config_path_details()
var path := str(resolution.get("path", ""))
var path_error := str(resolution.get("error", ""))
if not path_error.is_empty():
return {"status": McpClient.Status.ERROR, "error_msg": path_error}
if path.is_empty() or not FileAccess.file_exists(path):
return {"status": McpClient.Status.NOT_CONFIGURED, "error_msg": ""}
var read := _read(path)
if not read["ok"]:
return {
"status": McpClient.Status.ERROR,
"error_msg": "Cannot read %s: %s" % [path, read["error"]],
}
var block := _extract_block(String(read["data"]))
var entries: Dictionary = block["entries"]
if not entries.has(server_name):
return {"status": McpClient.Status.NOT_CONFIGURED, "error_msg": ""}
var entry: Variant = entries[server_name]
if not (entry is Dictionary):
return {"status": McpClient.Status.NOT_CONFIGURED, "error_msg": ""}
## An entry exists but no verified launcher does — mirror JSON/TOML: this
## is an environment ERROR, not entry drift.
var launch_error := command_launch_error(client, launch)
if not launch_error.is_empty():
return {"status": McpClient.Status.ERROR, "error_msg": launch_error}
if verify_entry(client, entry, server_url, launch):
return {"status": McpClient.Status.CONFIGURED, "error_msg": ""}
return {"status": McpClient.Status.CONFIGURED_MISMATCH, "error_msg": ""}
static func remove(client: McpClient, server_name: String) -> Dictionary:
var resolution := client.resolved_config_path_details()
var path := str(resolution.get("path", ""))
var path_error := str(resolution.get("error", ""))
if not path_error.is_empty():
return {"status": "error", "message": path_error}
if path.is_empty() or not FileAccess.file_exists(path):
return {"status": "ok", "message": "Not configured"}
var read := _read(path)
if not read["ok"]:
return {"status": "error", "message": "Refusing to rewrite %s: %s." % [path, read["error"]]}
var text: String = read["data"]
var block := _extract_block(text)
var entries: Dictionary = block["entries"]
if not entries.has(server_name):
return {"status": "ok", "message": "%s configuration removed" % client.display_name}
entries.erase(server_name)
var out := _assemble(text, block["prefix_lines"], entries, block["suffix_lines"])
if not McpAtomicWrite.write(path, out):
return {"status": "error", "message": "Cannot write to %s" % path}
return {"status": "ok", "message": "%s configuration removed" % client.display_name}
## Build the entry dict written under mcp_servers[server_name].
##
## URL mode (`CommandShape.NONE`): Hermes HTTP entries are transport-inferred
## — { url: <url> } plus whatever user-mutable keys (headers, enabled, tools,
## ...) the existing entry carries. Stdio-bridge keys are the one exception
## (see _STDIO_BRIDGE_KEYS). No `type` field.
##
## Command mode (`CommandShape.FLAT`, #838): flat `command` + `args` keys,
## transport inferred the same way — which is exactly why the descriptor's
## `command_legacy_keys` (url, headers) must be scrubbed: a Hermes entry with
## both a url and a command picks the wrong transport. User keys survive.
static func build_entry(
client: McpClient,
server_url: String,
existing: Variant = null,
launch: Dictionary = {},
) -> Dictionary:
if client.command_shape == McpClient.CommandShape.FLAT:
var command_entry: Dictionary = (existing as Dictionary).duplicate(true) if existing is Dictionary else {}
command_entry["command"] = str(launch.get("command", ""))
command_entry["args"] = _array_copy(launch.get("args", []))
if not client.command_transport_key.is_empty():
command_entry[client.command_transport_key] = client.command_transport_value
for key in client.command_initial_fields:
if not command_entry.has(key):
command_entry[key] = client.command_initial_fields[key]
for key in client.command_legacy_keys:
command_entry.erase(String(key))
return command_entry
if client.command_shape != McpClient.CommandShape.NONE:
## Every production caller checks command_launch_error first. Keep this
## builder defensive too so a future unsupported shape cannot silently
## degrade into a flat YAML entry.
return {}
var entry: Dictionary = {}
if existing is Dictionary:
## User-mutable keys (headers, enabled, tools, ...) survive a
## reconfigure — the same preservation contract the JSON strategy's
## entry_initial_fields split implements. Only the stdio-bridge keys
## are scrubbed (see _STDIO_BRIDGE_KEYS), then the url is repointed.
entry = (existing as Dictionary).duplicate(true)
for stale_key in _STDIO_BRIDGE_KEYS:
entry.erase(stale_key)
entry[client.entry_url_field] = server_url
return entry
## Keys a prior stdio-bridge entry (e.g. `command: uvx mcp-proxy`) may carry.
## These must NOT survive a URL reconfigure: a Hermes entry with both a url
## and a command picks the wrong transport.
const _STDIO_BRIDGE_KEYS := ["command", "args", "env"]
## Empty string when this client's launch requirements are satisfied.
## Mirrors `McpJsonStrategy.command_launch_error`; YAML supports FLAT only.
static func command_launch_error(client: McpClient, launch: Dictionary) -> String:
if client.command_shape == McpClient.CommandShape.NONE:
return ""
if client.command_shape != McpClient.CommandShape.FLAT:
return "%s uses a command shape not supported by YAML yet" % client.display_name
if not bool(launch.get("ok", false)):
return str(launch.get("error", "No compatible attach launcher was found."))
return ""
## Verify a stored entry matches.
##
## URL mode: Hermes entries have no transport type pin, so verification is:
## url matches. Extra keys (headers, enabled, tools) are user-mutable and
## intentionally NOT checked (mirrors json entry_initial_fields).
##
## Command mode: every launch-affecting value must match exactly and legacy
## URL-transport keys must be gone — their presence is migration drift.
static func verify_entry(
client: McpClient,
entry: Dictionary,
server_url: String,
launch: Dictionary = {},
) -> bool:
if client.command_shape != McpClient.CommandShape.NONE:
if client.command_shape != McpClient.CommandShape.FLAT or not bool(launch.get("ok", false)):
return false
for key in client.command_legacy_keys:
if entry.has(String(key)):
return false
if entry.get("command") != launch.get("command"):
return false
if not _arrays_equal(entry.get("args", null), launch.get("args", null)):
return false
if not client.command_transport_key.is_empty():
if entry.get(client.command_transport_key, null) != client.command_transport_value:
return false
return true
return entry.get(client.entry_url_field, "") == server_url
static func _array_copy(value: Variant) -> Array:
if value is Array:
return (value as Array).duplicate(true)
if value is PackedStringArray:
return McpClient._array_from_packed(value)
return []
static func _arrays_equal(left: Variant, right: Variant) -> bool:
if not (left is Array or left is PackedStringArray):
return false
if not (right is Array or right is PackedStringArray):
return false
var left_array := _array_copy(left)
var right_array := _array_copy(right)
if left_array.size() != right_array.size():
return false
for i in range(left_array.size()):
if left_array[i] != right_array[i]:
return false
return true
# --- YAML block handling (scoped to mcp_servers) -------------------------
## Parse the file into three regions:
## prefix_lines — everything before `mcp_servers:` (may be empty)
## entries — the map of server_name -> {url, ...} under mcp_servers
## suffix_lines — everything after the mcp_servers block (may be empty)
## This lets us rewrite only the mcp_servers block and keep the rest of the
## user's config.yaml byte-for-byte intact.
static func _extract_block(text: String) -> Dictionary:
## allow_empty must stay true: dropping empty splits would silently strip
## the user's blank lines from the preserved prefix/suffix regions on
## every rewrite. The parse loops below already skip blank lines.
var lines := text.split("\n")
var prefix: PackedStringArray = []
var entries: Dictionary = {}
var suffix: PackedStringArray = []
var header_idx := -1
for i in range(lines.size()):
if lines[i].strip_edges().begins_with("mcp_servers:"):
header_idx = i
break
if header_idx < 0:
# No mcp_servers yet — whole file is prefix; block will be appended.
prefix = lines.duplicate()
return {"prefix_lines": prefix, "entries": entries, "suffix_lines": [], "header_idx": -1}
for i in range(0, header_idx):
prefix.append(lines[i])
# Determine the indent of the first entry so we can tell sibling
# entries (same indent) apart from nested keys (deeper indent) and
# parent-level keys (less indent). All server entries under
# `mcp_servers:` share one indent level; breaking on 0-indent alone
# mis-nests 2-space-indented siblings under the first entry.
var entry_indent := -1
var probe := header_idx + 1
while probe < lines.size() and _is_blank_or_comment(lines[probe]):
probe += 1
if probe < lines.size():
entry_indent = _indent_of(lines[probe])
# Empty block guard: the first nonblank line after the header must sit
# DEEPER than the header itself to be an entry. At or above the header's
# indent it is a sibling/parent key — parsing it as an entry would
# swallow the user's next top-level key and re-emit it nested under
# mcp_servers, corrupting the file.
if probe < lines.size() and entry_indent <= _indent_of(lines[header_idx]):
for j in range(header_idx + 1, lines.size()):
suffix.append(lines[j])
return {"prefix_lines": prefix, "entries": entries, "suffix_lines": suffix, "header_idx": header_idx}
var i := header_idx + 1
while i < lines.size():
var raw := lines[i]
## Comment-only lines inside the block are skipped like blanks —
## treating one as an entry header would re-emit it as a bogus
## `# comment:` server on rewrite. (Comments INSIDE the rewritten
## block are consequently dropped; comments outside the block live
## in prefix/suffix and survive verbatim.)
if _is_blank_or_comment(raw):
i += 1
continue
# Stop at any line indented less than a sibling entry (parent key
# or a new top-level section), or at the header's own level.
if _indent_of(raw) < entry_indent:
break
var entry := _parse_entry(raw, lines, i, entry_indent)
if not entry["name"].is_empty():
entries[entry["name"]] = entry["data"]
i = entry["next_idx"]
for j in range(i, lines.size()):
suffix.append(lines[j])
return {"prefix_lines": prefix, "entries": entries, "suffix_lines": suffix, "header_idx": header_idx}
## Parse one ` name:` entry starting at `lines[start]`. Consumes all deeper-
## indented sublines (url, headers, etc.) and returns the next sibling index.
static func _parse_entry(raw: String, lines: PackedStringArray, start: int, entry_indent: int) -> Dictionary:
var name := raw.strip_edges().trim_suffix(":").strip_edges()
var data: Dictionary = {}
var i := start + 1
while i < lines.size():
var l := lines[i]
## Comments inside an entry (e.g. ` # auth for CI`) would parse
## as a `# auth for CI` key — skip them like blanks.
if _is_blank_or_comment(l):
i += 1
continue
# A line at or above the entry's indent is a sibling/parent key.
if _indent_of(l) <= entry_indent:
break
var stripped := l.strip_edges()
var colon := stripped.find(":")
if colon < 0:
i += 1
continue
var key := stripped.substr(0, colon).strip_edges()
var val := stripped.substr(colon + 1).strip_edges()
if val.is_empty():
# Nested block (e.g. headers:). Parse as raw sub-dict lines for
# preservation; we don't introspect deeper than url at the top.
var sub := _parse_subblock(lines, i + 1, entry_indent)
data[key] = sub["value"]
i = sub["next_idx"]
else:
data[key] = _coerce_scalar(val)
i += 1
return {"name": name, "data": data, "next_idx": i}
## Parse a nested block (e.g. headers:) as a preserved sub-dictionary of
## scalar key/values. Deeper nesting is flattened into scalar strings — fine
## for Hermes' known shape (headers are flat key: value).
static func _parse_subblock(lines: PackedStringArray, start: int, entry_indent: int) -> Dictionary:
var sub: Dictionary = {}
var i := start
while i < lines.size():
var l := lines[i]
if _is_blank_or_comment(l):
i += 1
continue
# A line at or above the parent entry's indent ends the nested block.
if _indent_of(l) <= entry_indent:
break
var stripped := l.strip_edges()
var colon := stripped.find(":")
if colon < 0:
i += 1
continue
var key := stripped.substr(0, colon).strip_edges()
var val := stripped.substr(colon + 1).strip_edges()
if val.is_empty():
i += 1
continue
sub[key] = _coerce_scalar(val)
i += 1
return {"value": sub, "next_idx": i}
## Reassemble the full file text from prefix + a freshly built mcp_servers
## block + suffix. If the block didn't exist before, it is appended.
static func _assemble(_text: String, prefix: PackedStringArray, entries: Dictionary, suffix: PackedStringArray) -> String:
var out: PackedStringArray = []
for l in prefix:
out.append(l)
# Trim trailing blank lines from prefix so we don't stack double blanks.
while out.size() > 0 and out[out.size() - 1].strip_edges().is_empty():
out.remove_at(out.size() - 1)
if not _text.contains("mcp_servers:"):
# File existed but had no mcp_servers block — append it.
if out.size() > 0:
out.append("")
out.append("mcp_servers:")
for name in entries:
out.append_array(_emit_entry(name, entries[name]))
else:
out.append("mcp_servers:")
for name in entries:
out.append_array(_emit_entry(name, entries[name]))
# Suffix: keep as-is.
for l in suffix:
out.append(l)
return "\n".join(out)
## Public rendering seam for the dock's manual-instruction text, so the
## pasted YAML matches what Configure would write byte-for-byte.
static func render_entry_lines(name: String, data: Dictionary) -> PackedStringArray:
return _emit_entry(name, data)
## Emit one ` name:` entry with its scalar keys (top level only; headers
## sub-dict is re-emitted as nested scalars; arrays — the command entry's
## `args` — are emitted in flow style on one line).
static func _emit_entry(name: String, data: Dictionary) -> PackedStringArray:
var lines: PackedStringArray = []
lines.append(INDENT + "%s:" % name)
for key in data:
var val = data[key]
if val is Dictionary:
lines.append(INDENT + INDENT + "%s:" % key)
for sk in val:
lines.append(INDENT + INDENT + INDENT + "%s: %s" % [sk, _emit_scalar(val[sk])])
elif val is Array or val is PackedStringArray:
lines.append(INDENT + INDENT + "%s: %s" % [key, _emit_flow_array(_array_copy(val))])
else:
lines.append(INDENT + INDENT + "%s: %s" % [key, _emit_scalar(val)])
return lines
## Flow-style sequence with every item double-quoted. JSON string quoting is
## valid YAML double-quote style (shared escape set), so the same encoding
## both writes the file and — via JSON.parse_string in `_coerce_scalar` —
## reads it back for verification.
static func _emit_flow_array(values: Array) -> String:
var parts: Array[String] = []
for v in values:
parts.append(JSON.stringify(str(v)))
return "[%s]" % ", ".join(parts)
static func _emit_scalar(v: Variant) -> String:
match typeof(v):
TYPE_BOOL:
return "true" if bool(v) else "false"
TYPE_INT:
return str(int(v))
TYPE_FLOAT:
return str(float(v))
_:
return _emit_string_scalar(str(v))
## Plain YAML scalars cannot safely carry ": ", " #", quotes, flow
## indicators, or leading indicator characters — a Windows launcher path with
## spaces would silently corrupt the entry. Quote exactly when needed so
## existing plain values (urls, bools-as-strings) keep their current
## byte-shape on rewrite.
static func _emit_string_scalar(s: String) -> String:
if s.is_empty():
return "\"\""
var needs_quote := s.begins_with(" ") or s.ends_with(" ")
if not needs_quote:
for needle in [": ", " #", "\"", "'", "\n", "\t", "{", "}", "[", "]", ","]:
if s.contains(needle):
needs_quote = true
break
if not needs_quote:
for prefix in ["#", "-", "?", "&", "*", "!", "|", ">", "%", "@", "`"]:
if s.begins_with(prefix):
needs_quote = true
break
return JSON.stringify(s) if needs_quote else s
## Blank and comment-only lines carry no structure — every scan loop skips
## them the same way so a `# comment` can never be mistaken for an entry
## header or a key/value line.
static func _is_blank_or_comment(line: String) -> bool:
var stripped := line.strip_edges()
return stripped.is_empty() or stripped.begins_with("#")
## Returns the leading-whitespace indent width of a line (spaces + tabs
## counted as 1 each). Used to distinguish sibling entries (same indent)
## from nested keys (deeper indent) and parent-level keys (less indent).
static func _indent_of(line: String) -> int:
var n := 0
while n < line.length() and (line[n] == " " or line[n] == "\t"):
n += 1
return n
## Minimal scalar coercion for parsed YAML values. Quotes are stripped;
## bare true/false/numbers are typed; double-quoted flow sequences (the
## command entry's `args`) parse back into an Array. Good enough for Hermes'
## url/headers/command/args. A hand-edited args in block style or with
## unquoted items doesn't JSON-parse — it stays a raw string, compares
## unequal, and surfaces as CONFIGURED_MISMATCH, which Reconfigure
## normalizes back to the flow form.
static func _coerce_scalar(s: String) -> Variant:
var t := s.strip_edges()
if t.begins_with("[") and t.ends_with("]"):
var parsed_array: Variant = JSON.parse_string(t)
if parsed_array is Array:
return parsed_array
return t
if t.begins_with("\"") and t.ends_with("\""):
var parsed_string: Variant = JSON.parse_string(t)
if parsed_string is String:
return parsed_string
return t.substr(1, t.length() - 2)
if t.begins_with("'") and t.ends_with("'"):
return t.substr(1, t.length() - 2)
if t == "true":
return true
if t == "false":
return false
if t.is_valid_int():
return t.to_int()
if t.is_valid_float():
return t.to_float()
return t
## Returns {"ok": true, "data": String} when the file is absent or readable,
## and {"ok": false, "error": String} when unreadable. Callers must NOT fall
## back to an empty string on the error path — doing so blows away the user's
## other config.yaml entries on the next write.
static func _read(path: String) -> Dictionary:
if not FileAccess.file_exists(path):
return {"ok": true, "data": ""}
var f := FileAccess.open(path, FileAccess.READ)
if f == null:
var err := FileAccess.get_open_error()
return {"ok": false, "error": "could not open for reading (%s)" % error_string(err)}
var t := f.get_as_text()
f.close()
return {"ok": true, "data": t}
@@ -0,0 +1 @@
uid://cfnw4oe71ra1l
+39
View File
@@ -0,0 +1,39 @@
@tool
extends McpClient
func _init() -> void:
id = "antigravity"
display_name = "Antigravity"
config_type = "json"
## Antigravity moved its shared MCP config from `~/.gemini/antigravity/`
## to `~/.gemini/config/` (IDE + CLI now read the same file there); the
## old path is left in `detect_paths` below so an existing install is
## still recognized, but new/updated entries write to the current path.
path_template = {
"unix": "~/.gemini/config/mcp_config.json",
"windows": "$USERPROFILE/.gemini/config/mcp_config.json",
}
server_key_path = PackedStringArray(["mcpServers"])
entry_url_field = "serverUrl"
## `disabled` is user-state (they may have flipped the entry off in the
## UI); seeded on first Configure but preserved across reconfigure.
entry_initial_fields = {"disabled": false}
## Attach migration (#838). Antigravity stdio entries are flat
## command/args/env with no type discriminator — transport is inferred
## from `command` vs `serverUrl` presence (antigravity.google/docs/mcp),
## so the legacy `serverUrl` must not survive next to a command.
command_shape = McpClient.CommandShape.FLAT
command_legacy_keys = PackedStringArray(["serverUrl"])
command_initial_fields = {"disabled": false}
command_user_fields = PackedStringArray(["disabled", "disabledTools", "authProviderType", "env"])
command_supports_url_fallback = true
## Antigravity's spawner hangs stdio tool calls when the entry launches a
## GUI-subsystem pythonw.exe (#863), and it hides child console windows
## itself, so the visible-terminal problem the bootstrap solves (#827)
## never applies. Write the plain console launcher on Windows.
needs_consoleless_launcher = false
detect_paths = PackedStringArray(path_template.values() + [
"~/.gemini/antigravity/mcp_config.json",
"$USERPROFILE/.gemini/antigravity/mcp_config.json",
])
@@ -0,0 +1 @@
uid://b4l1g0apa2hch
+18
View File
@@ -0,0 +1,18 @@
@tool
extends McpClient
func _init() -> void:
id = "cherry_studio"
display_name = "Cherry Studio"
config_type = "json"
path_template = {
"darwin": "~/Library/Application Support/CherryStudio/mcp_servers.json",
"windows": "$APPDATA/CherryStudio/mcp_servers.json",
"linux": "$XDG_CONFIG_HOME/CherryStudio/mcp_servers.json",
}
server_key_path = PackedStringArray(["mcpServers"])
entry_extra_fields = {"type": "streamableHttp"}
## `isActive` is user-state (they may have toggled the server off in the UI).
## Seed on first Configure but preserve across reconfigure.
entry_initial_fields = {"isActive": true}
@@ -0,0 +1 @@
uid://dwbuykxvbv5f7
+53
View File
@@ -0,0 +1,53 @@
@tool
extends McpClient
func _init() -> void:
id = "claude_code"
display_name = "Claude Code"
config_type = "cli"
cli_names = PackedStringArray(["claude", "claude.exe"] if OS.get_name() == "Windows" else ["claude"])
## Stdio registration through the client-owned `godot-ai attach` bridge
## (#838). `--` stops claude's own flag parsing so the attach argv passes
## through verbatim; stdio is the CLI's default transport. Scope stays
## `user` — the same ~/.claude.json the pre-attach HTTP entry lived in.
cli_register_template = PackedStringArray(
["mcp", "add", "--scope", "user", "{name}", "--", "{command}", "{args...}"]
)
## Explicit scope: an unscoped `mcp remove` deletes from whichever scope
## matches first, which could eat a project-local entry the user made.
cli_unregister_template = PackedStringArray(["mcp", "remove", "--scope", "user", "{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 for Configure whenever it resolves; this is what gets
## written otherwise. `claude mcp add --scope user <name> -- <cmd> <args>`
## produces exactly this shape under `mcpServers` in ~/.claude.json
## (verified live against claude CLI in an isolated CLAUDE_CONFIG_DIR):
## "godot-ai": { "type": "stdio", "command": "<cmd>", "args": [...], "env": {} }
## The fallback writer omits the empty `env`; the verifier accepts both.
## Status always reads this file — it is the CLI's own store for user
## scope, and file reads give exact launch-drift detection that `mcp list`
## stdout scanning cannot.
path_template = {"unix": "~/.claude.json", "windows": "~/.claude.json"}
server_key_path = PackedStringArray(["mcpServers"])
## URL-mode shape, used only for the manual-instruction fallback text —
## `claude mcp add --scope user --transport http` writes {type: http, url}.
entry_extra_fields = {"type": "http"}
command_shape = McpClient.CommandShape.FLAT
command_transport_key = "type"
command_transport_value = "stdio"
## Legacy HTTP entries carried a `url`; Claude Code rejects an entry mixing
## url with command fields, and the stale `type: "http"` is repinned to
## "stdio" by the transport key above.
command_legacy_keys = PackedStringArray(["url"])
command_user_fields = PackedStringArray(["env"])
command_supports_url_fallback = true
## Documented: $CLAUDE_CONFIG_DIR relocates Claude Code's config home,
## including .claude.json ($CLAUDE_CONFIG_DIR/.claude.json). The preferred
## CLI path needs no help — the spawned `claude` binary inherits the
## editor's environment and resolves the dir itself — but the JSON
## fallback above would otherwise write ~/.claude.json that a relocated
## install never reads (#617).
config_home_env = "CLAUDE_CONFIG_DIR"
config_home_env_subpath = ".claude.json"
@@ -0,0 +1 @@
uid://cp1u1hdpa6f8d
+38
View File
@@ -0,0 +1,38 @@
@tool
extends McpClient
## Claude Desktop's mcpServers entries launch a local stdio process. The
## client-owned `godot-ai attach` bridge keeps that stdio session stable while
## adopting or starting the shared HTTP backend as Godot editors come and go.
func _init() -> void:
id = "claude_desktop"
display_name = "Claude Desktop"
config_type = "json"
path_template = {
"darwin": "~/Library/Application Support/Claude/claude_desktop_config.json",
"windows": "$APPDATA/Claude/claude_desktop_config.json",
"linux": "$XDG_CONFIG_HOME/Claude/claude_desktop_config.json",
}
## Store-installed Claude runs inside MSIX AppData virtualization. Godot is
## outside that container, so `%APPDATA%` names a different physical file
## once Claude has created its private copy. A unique Store package root is
## authoritative even before the config leaf exists: create the private file
## directly so a later copy-on-write cannot hide an entry written to roaming.
## With no Store package, use the conventional roaming path. The wildcard
## avoids coupling to the publisher-hash suffix.
config_path_candidates = {
"windows": [
"$LOCALAPPDATA/Packages/Claude_*/LocalCache/Roaming/Claude/claude_desktop_config.json",
"$APPDATA/Claude/claude_desktop_config.json",
],
}
detect_paths = PackedStringArray([
"$LOCALAPPDATA/Packages/Claude_*",
])
server_key_path = PackedStringArray(["mcpServers"])
command_shape = McpClient.CommandShape.FLAT
command_legacy_keys = PackedStringArray(["url"])
command_env_legacy_keys = PackedStringArray(["UV_LINK_MODE"])
command_user_fields = PackedStringArray(["env", "disabled"])
@@ -0,0 +1 @@
uid://bilntn5n8oqe3
+43
View File
@@ -0,0 +1,43 @@
@tool
extends McpClient
## Cline is a VS Code extension. Its MCP settings live in VS Code's
## globalStorage under the extension id `saoudrizwan.claude-dev`.
func _init() -> void:
id = "cline"
display_name = "Cline"
config_type = "json"
path_template = {
"darwin": "~/Library/Application Support/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json",
"windows": "$APPDATA/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json",
"linux": "$XDG_CONFIG_HOME/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json",
}
server_key_path = PackedStringArray(["mcpServers"])
## Cline (like Roo) defaults a typeless entry to SSE transport, which
## returns HTTP 400 against our streamable-http endpoint on `/mcp`. Pin
## the type explicitly. Cline's schema uses "streamableHttp" (camelCase,
## see src/services/mcp/schemas.ts in the cline repo) — distinct from
## Roo's "streamable-http" string. Parallel to the Roo fix in #190.
entry_extra_fields = {"type": "streamableHttp"}
## `disabled` and `autoApprove` are user-state (they may have flipped the
## entry off, or auto-approved specific tools). Seed on first Configure
## but preserve across reconfigure — see `entry_initial_fields` in `_base.gd`.
entry_initial_fields = {"disabled": false, "autoApprove": []}
## Attach migration (#838). Cline stdio entries are flat command/args/env;
## its schema accepts `type: "stdio"` and normalizes typeless command
## entries to it (apps/vscode/src/services/mcp/schemas.ts), so pin the
## type — that also repins the legacy "streamableHttp" value instead of
## letting it survive the deep-copy and misroute the transport.
command_shape = McpClient.CommandShape.FLAT
command_transport_key = "type"
command_transport_value = "stdio"
command_legacy_keys = PackedStringArray(["url", "headers"])
command_initial_fields = {"disabled": false, "autoApprove": []}
command_user_fields = PackedStringArray([
"disabled", "autoApprove", "timeout", "oauth", "metadata",
"remoteConfigured", "env", "cwd",
])
command_timeout_fields = PackedStringArray(["timeout"])
command_supports_url_fallback = true
+1
View File
@@ -0,0 +1 @@
uid://d36nywn2nkgts
+45
View File
@@ -0,0 +1,45 @@
@tool
extends McpClient
func _init() -> void:
id = "codex"
display_name = "Codex"
config_type = "toml"
path_template = {"unix": "~/.codex/config.toml", "windows": "$USERPROFILE/.codex/config.toml"}
## Documented: when $CODEX_HOME is set, Codex reads config.toml directly
## from it instead of ~/.codex (#617).
config_home_env = "CODEX_HOME"
config_home_env_subpath = "config.toml"
toml_section_path = PackedStringArray(["mcp_servers", "godot-ai"])
# Older Codex builds used the unquoted form with underscore-substituted ids.
toml_legacy_section_aliases = PackedStringArray(["mcp_servers.godot_ai"])
command_shape = McpClient.CommandShape.COMMAND_ARRAY
command_supports_url_fallback = true
command_legacy_keys = PackedStringArray(["url"])
## Initial-only: users may disable the entry or tune either timeout and
## Configure preserves that choice. Codex currently defaults to 10s for
## startup and 60s per tool; test_run legitimately has a 300s server
## budget, so the generated config leaves transport margin at the client.
command_initial_fields = {
"enabled": true,
"startup_timeout_sec": 60,
"tool_timeout_sec": 360,
}
command_timeout_fields = PackedStringArray([
"startup_timeout_sec",
"tool_timeout_sec",
])
command_user_fields = PackedStringArray([
"enabled",
"required",
"startup_timeout_sec",
"tool_timeout_sec",
"enabled_tools",
"disabled_tools",
"default_tools_approval_mode",
"env",
"env_vars",
"cwd",
])
detect_paths = PackedStringArray(path_template.values())
+1
View File
@@ -0,0 +1 @@
uid://hdlwcfdr8mdk
+21
View File
@@ -0,0 +1,21 @@
@tool
extends McpClient
func _init() -> void:
id = "cursor"
display_name = "Cursor"
config_type = "json"
path_template = {"unix": "~/.cursor/mcp.json", "windows": "$USERPROFILE/.cursor/mcp.json"}
server_key_path = PackedStringArray(["mcpServers"])
## Attach migration (#838). Cursor's stdio entries are flat command/args/env
## (cursor.com/docs/context/mcp). The docs' reference table documents
## `type: "stdio"`; pinning it also repins any hand-added `type: "http"`
## left on the legacy URL entry, which would otherwise survive the
## deep-copy migration and misroute the transport.
command_shape = McpClient.CommandShape.FLAT
command_transport_key = "type"
command_transport_value = "stdio"
command_legacy_keys = PackedStringArray(["url"])
command_user_fields = PackedStringArray(["env", "envFile"])
command_supports_url_fallback = true
+1
View File
@@ -0,0 +1 @@
uid://bvpbssfanukef
+25
View File
@@ -0,0 +1,25 @@
@tool
extends McpClient
func _init() -> void:
id = "gemini_cli"
display_name = "Gemini CLI"
config_type = "json"
path_template = {
"unix": "~/.gemini/settings.json",
"windows": "$USERPROFILE/.gemini/settings.json",
}
server_key_path = PackedStringArray(["mcpServers"])
entry_url_field = "httpUrl"
## Attach migration (#838). Gemini CLI stdio entries are flat
## command/args/env(+cwd); the config is one-of `command` | `url` (SSE) |
## `httpUrl` (docs/tools/mcp-server.md), so BOTH URL keys are legacy next
## to a command. `trust` bypasses tool confirmations — never seed it.
command_shape = McpClient.CommandShape.FLAT
command_legacy_keys = PackedStringArray(["httpUrl", "url"])
command_user_fields = PackedStringArray([
"timeout", "trust", "includeTools", "excludeTools", "env", "cwd",
])
command_timeout_fields = PackedStringArray(["timeout"])
command_supports_url_fallback = true
@@ -0,0 +1 @@
uid://b8288pxninajy
+35
View File
@@ -0,0 +1,35 @@
@tool
extends McpClient
func _init() -> void:
id = "grok"
display_name = "Grok Build"
config_type = "toml"
# Grok Build reads MCP servers from ~/.grok/config.toml
# (https://x.ai / Grok user guide: MCP servers section).
path_template = {
"unix": "~/.grok/config.toml",
"windows": "$USERPROFILE/.grok/config.toml",
}
toml_section_path = PackedStringArray(["mcp_servers", "godot-ai"])
# Some docs / older notes used an underscore form.
toml_legacy_section_aliases = PackedStringArray(["mcp_servers.godot_ai"])
## Attach migration (#838). Grok's stdio sections are flat command/args/env
## with no type discriminator (docs.x.ai/build/features/mcp-servers);
## url/headers are the HTTP form and must not survive next to a command.
## Docs default startup_timeout_sec to 30 — a cold `uvx` install of the
## pinned package can exceed that, so new entries seed 60 (preserved once
## the user tunes it). tool_timeout_sec's documented 6000s default already
## clears test_run's 300s budget, so it is left alone. The old body
## template's `enabled = true` line was never documented for Grok — it is
## no longer seeded, and an existing value survives as a user key.
command_shape = McpClient.CommandShape.COMMAND_ARRAY
command_legacy_keys = PackedStringArray(["url", "headers"])
command_initial_fields = {"startup_timeout_sec": 60}
command_user_fields = PackedStringArray([
"env", "enabled", "startup_timeout_sec", "tool_timeout_sec",
])
command_timeout_fields = PackedStringArray(["startup_timeout_sec", "tool_timeout_sec"])
command_supports_url_fallback = true
detect_paths = PackedStringArray(path_template.values())
+1
View File
@@ -0,0 +1 @@
uid://ckchsj5s3q1b0
+46
View File
@@ -0,0 +1,46 @@
@tool
extends McpClient
func _init() -> void:
id = "hermes"
display_name = "Hermes Agent"
config_type = "yaml"
# Hermes reads MCP config from ~/.hermes/config.yaml (YAML), NOT mcp.json.
# Verified against the official docs:
# https://hermes-agent.nousresearch.com/docs/user-guide/features/mcp
# Windows: Hermes stores config under $LOCALAPPDATA/hermes (NOT $APPDATA,
# which is Roaming) — confirmed by where the running Hermes process reads.
# NOTE: _path_template.expand() only substitutes $VAR tokens, not %VAR%.
path_template = {
"unix": "~/.hermes/config.yaml",
"windows": "$LOCALAPPDATA/hermes/config.yaml"
}
# Hermes uses the snake_case `mcp_servers` key (not `mcpServers`).
# PackedStringArray explicitly, matching every other descriptor — an
# untyped Array literal relies on implicit conversion that newer Godot
# builds enforce more strictly (the #722 CI lesson for Array[String]).
server_key_path = PackedStringArray(["mcp_servers"])
# HTTP entries use `url` (+ optional `headers`); transport is inferred —
# there is no `type` field in Hermes MCP config.
entry_url_field = "url"
# No transport pin: Hermes infers streamable-http from the URL.
entry_extra_fields = {}
entry_initial_fields = {}
## Attach migration (#838). Hermes stdio entries are flat command/args/env
## (hermes-agent.nousresearch.com/docs/user-guide/features/mcp), transport
## inferred exactly like the URL form — which is why `url` and the
## HTTP-only `headers` must not survive next to a command: an entry with
## both picks the wrong transport. `enabled`/`tools`/`env` stay user-owned.
command_shape = McpClient.CommandShape.FLAT
command_legacy_keys = PackedStringArray(["url", "headers"])
command_user_fields = PackedStringArray(["enabled", "tools", "env"])
command_supports_url_fallback = true
# Hermes is "installed" wherever the config.yaml lives; presence of the
# file is sufficient for the dock's installed badge.
detect_paths = PackedStringArray()
+1
View File
@@ -0,0 +1 @@
uid://ewmadhrvs5d7
+38
View File
@@ -0,0 +1,38 @@
@tool
extends McpClient
func _init() -> void:
id = "kilo_code"
display_name = "Kilo Code"
config_type = "json"
path_template = {
"darwin": "~/Library/Application Support/Code/User/globalStorage/kilocode.kilo-code/settings/mcp_settings.json",
"windows": "$APPDATA/Code/User/globalStorage/kilocode.kilo-code/settings/mcp_settings.json",
"linux": "$XDG_CONFIG_HOME/Code/User/globalStorage/kilocode.kilo-code/settings/mcp_settings.json",
}
server_key_path = PackedStringArray(["mcpServers"])
## Kilo Code (like Roo) defaults a typeless entry to SSE transport, which
## returns HTTP 400 against our streamable-http endpoint on `/mcp`. Pin
## the type explicitly. Parallel to the Roo fix in #190.
entry_extra_fields = {"type": "streamable-http"}
## `disabled` and `alwaysAllow` are user-state (they may have flipped the
## entry off, or auto-approved specific tools). Seed on first Configure
## but preserve across reconfigure — see `entry_initial_fields` in `_base.gd`.
entry_initial_fields = {"disabled": false, "alwaysAllow": []}
## Attach migration (#838). UNLIKE its Roo siblings the stdio entry must be
## TYPELESS: Kilo's v7 platform treats this file as a migration source and
## routes on `type` — any http type value sends the entry down the remote
## branch where it is dropped for lack of a url, and only a bare
## command/args/env entry is verified to work in BOTH the legacy extension
## and the v7 migrator (packages/opencode/src/kilocode/mcp-migrator.ts).
## `type` therefore joins the legacy keys instead of being repinned.
command_shape = McpClient.CommandShape.FLAT
command_legacy_keys = PackedStringArray(["url", "type", "headers"])
command_initial_fields = {"disabled": false, "alwaysAllow": []}
command_user_fields = PackedStringArray([
"disabled", "alwaysAllow", "timeout", "cwd", "watchPaths",
"disabledTools", "env",
])
command_timeout_fields = PackedStringArray(["timeout"])
command_supports_url_fallback = true
+1
View File
@@ -0,0 +1 @@
uid://dc1x77i1cmb6w
+34
View File
@@ -0,0 +1,34 @@
@tool
extends McpClient
func _init() -> void:
id = "kimi_code"
display_name = "Kimi Code"
## Kimi Code has no `mcp` CLI subcommand (verified against v0.28.1 —
## `kimi mcp` falls through to the root --help, and the docs at
## moonshotai.github.io/kimi-code/en/customization/mcp confirm servers are
## managed via ~/.kimi-code/mcp.json, not a CLI verb). JSON is therefore
## the only working config method, not a fallback.
config_type = "json"
path_template = {"unix": "~/.kimi-code/mcp.json", "windows": "~/.kimi-code/mcp.json"}
server_key_path = PackedStringArray(["mcpServers"])
entry_extra_fields = {"transport": "http"}
## Documented: `$KIMI_CODE_HOME/mcp.json` relocates the config
## (moonshotai.github.io/kimi-code/en/customization/mcp) — same
## false-success-write class as CODEX_HOME (#617).
config_home_env = "KIMI_CODE_HOME"
config_home_env_subpath = "mcp.json"
## Attach migration (#838). Kimi Code stdio entries are flat
## command/args/env(+cwd): "Entries with a `command` field are stdio
## servers". `transport` is only defined for SSE-with-url, so the legacy
## `transport: "http"` must be removed alongside `url`. Timeout fields are
## camelCase, unlike Codex's snake_case.
command_shape = McpClient.CommandShape.FLAT
command_legacy_keys = PackedStringArray(["url", "transport"])
command_user_fields = PackedStringArray([
"enabled", "startupTimeoutMs", "toolTimeoutMs", "enabledTools",
"disabledTools", "env", "cwd",
])
command_timeout_fields = PackedStringArray(["startupTimeoutMs", "toolTimeoutMs"])
command_supports_url_fallback = true
+1
View File
@@ -0,0 +1 @@
uid://d2whd6a5fofhg
+23
View File
@@ -0,0 +1,23 @@
@tool
extends McpClient
func _init() -> void:
id = "kiro"
display_name = "Kiro"
config_type = "json"
path_template = {
"unix": "~/.kiro/settings/mcp.json",
"windows": "$USERPROFILE/.kiro/settings/mcp.json",
}
server_key_path = PackedStringArray(["mcpServers"])
## `disabled` is user-state — preserved across reconfigure.
entry_initial_fields = {"disabled": false}
## Attach migration (#838). Kiro stdio entries are flat command/args/env
## with no type discriminator (kiro.dev/docs/mcp/configuration).
## `autoApprove` is user-state, same contract as the URL entry's fields.
command_shape = McpClient.CommandShape.FLAT
command_legacy_keys = PackedStringArray(["url"])
command_initial_fields = {"disabled": false}
command_user_fields = PackedStringArray(["disabled", "autoApprove", "disabledTools", "env"])
command_supports_url_fallback = true
+1
View File
@@ -0,0 +1 @@
uid://dqdmd2jw5qen7
+39
View File
@@ -0,0 +1,39 @@
@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"
## `$HOME` on Windows is deliberate: OpenCode reads ~/.config/... on ALL
## platforms (verified via `opencode debug paths`), and
## McpPathTemplate._home() falls back to USERPROFILE when HOME is unset —
## pinned by test_opencode_client_uses_home_config_on_windows. The documented
## `OPENCODE_CONFIG` override names an exact file and must win over this
## default for configure, status, remove, and manual instructions.
path_template = {
"unix": "~/.config/opencode/opencode.json",
"windows": "$HOME/.config/opencode/opencode.json",
}
config_file_env = "OPENCODE_CONFIG"
server_key_path = PackedStringArray(["mcp"])
entry_extra_fields = {"type": "remote"}
## `enabled` is user-state (they may have toggled the server off).
entry_initial_fields = {"enabled": true}
## Attach migration (#838). OpenCode local entries carry the launch as ONE
## argv array — `"command": ["uvx", …]` with no separate args key — plus a
## schema-REQUIRED `type: "local"` (McpLocalConfig in opencode.ai/config.json;
## env lives under `environment`, not `env`). The pin rewrites the legacy
## `type: "remote"` in place; url/headers are remote-only and must go.
command_shape = McpClient.CommandShape.COMMAND_ARRAY
command_transport_key = "type"
command_transport_value = "local"
command_legacy_keys = PackedStringArray(["url", "headers"])
command_initial_fields = {"enabled": true}
command_user_fields = PackedStringArray(["enabled", "timeout", "environment", "cwd"])
command_timeout_fields = PackedStringArray(["timeout"])
command_supports_url_fallback = true
+1
View File
@@ -0,0 +1 @@
uid://s8n0vfirf2pj
+26
View File
@@ -0,0 +1,26 @@
@tool
extends McpClient
func _init() -> void:
id = "qwen_code"
display_name = "Qwen Code"
config_type = "json"
path_template = {
"unix": "~/.qwen/settings.json",
"windows": "$USERPROFILE/.qwen/settings.json",
}
server_key_path = PackedStringArray(["mcpServers"])
entry_url_field = "httpUrl"
## Attach migration (#838). Qwen Code is a gemini-cli fork with the same
## flat stdio shape and one-of `command` | `url` | `httpUrl` rule
## (docs/users/features/mcp.md). Qwen adds `discoveryTimeoutMs` (stdio
## discovery handshake cap, default 30s).
command_shape = McpClient.CommandShape.FLAT
command_legacy_keys = PackedStringArray(["httpUrl", "url"])
command_user_fields = PackedStringArray([
"timeout", "trust", "includeTools", "excludeTools", "env", "cwd",
"discoveryTimeoutMs",
])
command_timeout_fields = PackedStringArray(["timeout", "discoveryTimeoutMs"])
command_supports_url_fallback = true
+1
View File
@@ -0,0 +1 @@
uid://qwb5udkf423q
+42
View File
@@ -0,0 +1,42 @@
@tool
extends McpClient
func _init() -> void:
id = "roo_code"
display_name = "Roo Code"
config_type = "json"
path_template = {
"darwin": "~/Library/Application Support/Code/User/globalStorage/rooveterinaryinc.roo-cline/settings/mcp_settings.json",
"windows": "$APPDATA/Code/User/globalStorage/rooveterinaryinc.roo-cline/settings/mcp_settings.json",
"linux": "$XDG_CONFIG_HOME/Code/User/globalStorage/rooveterinaryinc.roo-cline/settings/mcp_settings.json",
}
server_key_path = PackedStringArray(["mcpServers"])
## Roo defaults an entry with no "type" to SSE transport — which returns
## HTTP 400 against our streamable-http endpoint on `/mcp`. Pin the type
## explicitly so Roo negotiates streamable-http (the current MCP spec's
## recommended remote transport). See issue #189. The default verifier
## requires every entry_extra_fields key to match, so a pre-#189 typeless
## entry surfaces as drift instead of silently passing as configured.
entry_extra_fields = {"type": "streamable-http"}
## `disabled` and `alwaysAllow` are user-state (they may have flipped the
## entry off, or auto-approved specific tools like `session_manage`).
## Seed on first Configure but preserve across reconfigure — without this
## split, the Configure-All-Mismatched sweep silently wipes the user's
## auto-approval list every time the type pin or URL drifts.
entry_initial_fields = {"disabled": false, "alwaysAllow": []}
## Attach migration (#838). Roo stdio entries are flat command/args/env
## (+cwd); docs state `type` defaults to "stdio" for command configs and
## the stdio schema forbids url/headers. Pin type=stdio — it is documented
## and repins the legacy "streamable-http" value in place.
command_shape = McpClient.CommandShape.FLAT
command_transport_key = "type"
command_transport_value = "stdio"
command_legacy_keys = PackedStringArray(["url", "headers"])
command_initial_fields = {"disabled": false, "alwaysAllow": []}
command_user_fields = PackedStringArray([
"disabled", "alwaysAllow", "timeout", "disabledTools", "watchPaths",
"env", "cwd",
])
command_timeout_fields = PackedStringArray(["timeout"])
command_supports_url_fallback = true
+1
View File
@@ -0,0 +1 @@
uid://denjdf50qrf66
+23
View File
@@ -0,0 +1,23 @@
@tool
extends McpClient
func _init() -> void:
id = "trae"
display_name = "Trae"
config_type = "json"
path_template = {
"darwin": "~/Library/Application Support/Trae/User/mcp.json",
"windows": "$APPDATA/Trae/User/mcp.json",
"linux": "$XDG_CONFIG_HOME/Trae/User/mcp.json",
}
server_key_path = PackedStringArray(["mcpServers"])
## Attach migration (#838). Trae stdio entries are flat command/args/env
## with no type discriminator (docs.trae.cn/ide/add-mcp-servers); transport
## is inferred from `command` vs `url`, so url/headers are legacy next to a
## command. Entry stays minimal — Trae manages enable/disable in its UI,
## and its startup/run timeouts ride user-owned env vars, which survive.
command_shape = McpClient.CommandShape.FLAT
command_legacy_keys = PackedStringArray(["url", "headers"])
command_user_fields = PackedStringArray(["env"])
command_supports_url_fallback = true
+1
View File
@@ -0,0 +1 @@
uid://cwpu48772vfj1
+30
View File
@@ -0,0 +1,30 @@
@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"
path_template = {
"darwin": "~/Library/Application Support/Code/User/mcp.json",
"windows": "$APPDATA/Code/User/mcp.json",
"linux": "$XDG_CONFIG_HOME/Code/User/mcp.json",
}
server_key_path = PackedStringArray(["servers"])
entry_extra_fields = {"type": "http"}
## Attach migration (#838). VS Code stdio entries are flat command/args/env
## under `servers` with a documented `type: "stdio"` discriminator
## (code.visualstudio.com/docs/agents/reference/mcp-configuration). The
## stdio schema is `additionalProperties: false` (mcpConfiguration.ts), so
## removing the legacy url/headers is load-bearing — leftovers invalidate
## the whole entry, and the pin flips the legacy `type: "http"` in place.
command_shape = McpClient.CommandShape.FLAT
command_transport_key = "type"
command_transport_value = "stdio"
command_legacy_keys = PackedStringArray(["url", "headers"])
command_user_fields = PackedStringArray(["env", "envFile", "cwd", "sandboxEnabled", "dev"])
command_supports_url_fallback = true
+1
View File
@@ -0,0 +1 @@
uid://dl6cm044pihub
@@ -0,0 +1,23 @@
@tool
extends McpClient
func _init() -> void:
id = "vscode_insiders"
display_name = "VS Code Insiders"
config_type = "json"
path_template = {
"darwin": "~/Library/Application Support/Code - Insiders/User/mcp.json",
"windows": "$APPDATA/Code - Insiders/User/mcp.json",
"linux": "$XDG_CONFIG_HOME/Code - Insiders/User/mcp.json",
}
server_key_path = PackedStringArray(["servers"])
entry_extra_fields = {"type": "http"}
## Attach migration (#838). Identical format to vscode.gd — Insiders ships
## the same mcpConfiguration.ts schema; see that descriptor for citations.
command_shape = McpClient.CommandShape.FLAT
command_transport_key = "type"
command_transport_value = "stdio"
command_legacy_keys = PackedStringArray(["url", "headers"])
command_user_fields = PackedStringArray(["env", "envFile", "cwd", "sandboxEnabled", "dev"])
command_supports_url_fallback = true
@@ -0,0 +1 @@
uid://cad5w4ofyg8a2
+29
View File
@@ -0,0 +1,29 @@
@tool
extends McpClient
func _init() -> void:
# #623: Windsurf was rebranded to Devin Desktop by Cognition (June 2026).
# The id stays "windsurf" — it is the stable registry key used for
# configured-status lookups. The MCP config path is unchanged by the
# rebrand: per the official docs (docs.devin.ai/desktop/cascade/mcp) the
# global config still lives under the platform's `.codeium/windsurf/`
# directory (~/.codeium/windsurf/ on unix, $USERPROFILE/.codeium/windsurf/
# on Windows), and migrated installs carry their settings over in place.
id = "windsurf"
display_name = "Devin Desktop (Windsurf)"
config_type = "json"
path_template = {
"unix": "~/.codeium/windsurf/mcp_config.json",
"windows": "$USERPROFILE/.codeium/windsurf/mcp_config.json",
}
server_key_path = PackedStringArray(["mcpServers"])
entry_url_field = "serverUrl"
## Attach migration (#838). Stdio entries are flat command/args/env
## (docs.devin.ai/desktop/cascade/mcp); transport is inferred from
## `command` vs `serverUrl` presence and no type field is documented —
## do not write one.
command_shape = McpClient.CommandShape.FLAT
command_legacy_keys = PackedStringArray(["serverUrl"])
command_user_fields = PackedStringArray(["env"])
command_supports_url_fallback = true
+1
View File
@@ -0,0 +1 @@
uid://b6pqiok2mlsmg
+29
View File
@@ -0,0 +1,29 @@
@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"
path_template = {
"darwin": "~/.config/zed/settings.json",
"linux": "$XDG_CONFIG_HOME/zed/settings.json",
"windows": "$APPDATA/Zed/settings.json",
}
server_key_path = PackedStringArray(["context_servers"])
## Attach migration (#838). Current Zed's context_servers entries are an
## untagged serde enum discriminated by shape: `command` (string) + args/env
## → stdio, `url` → HTTP (zed.dev/docs/ai/mcp; settings_content/project.rs).
## BECAUSE the enum is untagged, HTTP-only keys left next to `command`
## (`url`, `headers`, `oauth`) make the entry match no variant and break it —
## removing them on migration is load-bearing, not cosmetic. `enabled`,
## `remote`, and `timeout` are user-state on the stdio variant.
command_shape = McpClient.CommandShape.FLAT
command_legacy_keys = PackedStringArray(["url", "headers", "oauth"])
command_user_fields = PackedStringArray(["enabled", "remote", "timeout", "env"])
command_timeout_fields = PackedStringArray(["timeout"])
command_supports_url_fallback = true
+1
View File
@@ -0,0 +1 @@
uid://d152l0u0r6fsc
+36
View File
@@ -0,0 +1,36 @@
@tool
extends McpClient
func _init() -> void:
id = "zoo_code"
display_name = "Zoo Code"
config_type = "json"
path_template = {
"darwin": "~/Library/Application Support/Code/User/globalStorage/zoocodeorganization.zoo-code/settings/mcp_settings.json",
"windows": "$APPDATA/Code/User/globalStorage/zoocodeorganization.zoo-code/settings/mcp_settings.json",
"linux": "$XDG_CONFIG_HOME/Code/User/globalStorage/zoocodeorganization.zoo-code/settings/mcp_settings.json",
}
server_key_path = PackedStringArray(["mcpServers"])
## Local validation against the installed extension shows Zoo stores MCP
## entries in `settings/mcp_settings.json` under `mcpServers`, matching Roo's
## shape. Its changelog also references Streamable HTTP support, so pin the
## transport explicitly to avoid any typeless entry falling back to SSE.
entry_extra_fields = {"type": "streamable-http"}
## Preserve user-controlled state across reconfigure, parallel to Roo/Kilo.
entry_initial_fields = {"disabled": false, "alwaysAllow": []}
## Attach migration (#838). Zoo's stdio zod schema is Roo's: flat
## command/args/env(+cwd), `type: z.enum(["stdio"]).optional()`, and
## url/headers explicitly forbidden on stdio entries (McpHub.ts) — so both
## are legacy keys and the documented type pin repins "streamable-http".
command_shape = McpClient.CommandShape.FLAT
command_transport_key = "type"
command_transport_value = "stdio"
command_legacy_keys = PackedStringArray(["url", "headers"])
command_initial_fields = {"disabled": false, "alwaysAllow": []}
command_user_fields = PackedStringArray([
"disabled", "alwaysAllow", "timeout", "watchPaths", "disabledTools",
"env", "cwd",
])
command_timeout_fields = PackedStringArray(["timeout"])
command_supports_url_fallback = true
+1
View File
@@ -0,0 +1 @@
uid://fmp0nlzcm3ukl
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
uid://bmnk8rsotiks2
File diff suppressed because it is too large Load Diff
@@ -0,0 +1 @@
uid://bd1k63iye1bsl
+463
View File
@@ -0,0 +1,463 @@
@tool
class_name McpDispatcher
extends RefCounted
## Routes incoming commands to handlers and manages the command queue
## with a per-frame time budget.
var _command_queue: Array[Dictionary] = []
var _handlers: Dictionary = {} # command_name -> Callable
## Lazy handler registration (#736): plugin.gd registers command names
## against a handler key plus a per-handler script path and constructor
## args, and the handler script is load()ed and instantiated at the FIRST
## dispatch of one of its commands. This keeps the ~30 handler scripts
## (and everything they preload) out of plugin.gd's eager compile
## closure, which stalled "Initializing plugins" on every editor boot.
## Materialized commands are promoted into `_handlers`, so the lazy dicts
## are only consulted on the first call per command.
var _lazy_handler_specs: Dictionary = {} # handler_key -> {path: String, args: Array}
var _lazy_handler_cache: Dictionary = {} # handler_key -> handler instance
var _lazy_commands: Dictionary = {} # command_name -> {handler: String, method: StringName}
var _pending_deferred: Dictionary = {} # request_id -> {command, started_ms, timeout_ms}
var _log_buffer
var _surfaced_error_tracker
## The McpConnection whose pause_processing handlers flip around unsafe
## editor operations (#288 guard). Set by plugin.gd; untyped to honor the
## self-update field-storage policy. When set, _call_handler restores the
## pause depth a crashed handler left unbalanced (#712) — without this a
## single handler crash inside a pause window freezes the transport
## forever (pause has no watchdog or disconnect reset by design).
var pause_target
var mcp_logging := true
var deferred_timeout_overrides_ms: Dictionary = {}
const DEFAULT_DEFERRED_TIMEOUT_MS := 4500
const DEFERRED_TIMEOUT_MS_BY_COMMAND := {
"create_script": 4500,
## Fresh-`.gd` writes defer through the same import-settle window as
## create_script (#714) — same headroom over IMPORT_SETTLE_MAX_MSEC.
"write_file": 4500,
"stop_project": 4500,
"run_project": 6000,
"take_screenshot": 30000,
"check_client_status": 30000,
"game_eval": 15000,
"game_command": 15000,
"scan_filesystem": 30000,
}
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
const FuzzySuggestions := preload("res://addons/godot_ai/utils/fuzzy_suggestions.gd")
func _init(log_buffer: McpLogBuffer, surfaced_error_tracker = null) -> void:
_log_buffer = log_buffer
_surfaced_error_tracker = surfaced_error_tracker
## Register a command handler. The callable receives (params: Dictionary) -> Dictionary.
func register(command_name: String, handler: Callable) -> void:
_handlers[command_name] = handler
## Declare a lazily-constructed handler (#736). `script_path` is load()ed
## and instantiated with `ctor_args` at the first dispatch of any command
## registered against `handler_key` via register_lazy. `ctor_args` may hold
## plugin-lifetime objects (connection, buffers, the dispatcher itself for
## batch); clear() drops them so teardown ordering matches the old eager
## registration (#46).
func register_lazy_handler(handler_key: String, script_path: String, ctor_args: Array) -> void:
_lazy_handler_specs[handler_key] = {"path": script_path, "args": ctor_args}
## Register a command that resolves to `method` on the lazily-constructed
## handler declared under `handler_key`. Same dispatch semantics as
## register(); only construction timing differs.
func register_lazy(command_name: String, handler_key: String, method: StringName) -> void:
_lazy_commands[command_name] = {"handler": handler_key, "method": method}
## Drop registered handlers, queued commands, and the log buffer ref so
## plugin.gd can release RefCounted handlers before Godot reloads their
## class_name scripts (issue #46). After clear(), the dispatcher is inert.
func clear() -> void:
## Stop lazy handlers before releasing the cache. Handler-owned polling
## coroutines retain any in-flight worker and deferred-response connection
## across frames, then join only after the worker is no longer alive.
for instance in _lazy_handler_cache.values():
if is_instance_valid(instance) and instance.has_method("prepare_for_teardown"):
instance.call("prepare_for_teardown")
_handlers.clear()
## Release lazily-constructed handler instances (and the ctor args that
## reference plugin-lifetime objects) at the same teardown point where
## eager handler Callables used to be dropped — their destructors must
## run while their scripts are still loaded (#46). This also breaks the
## dispatcher -> batch handler -> dispatcher ref cycle.
_lazy_handler_specs.clear()
_lazy_handler_cache.clear()
_lazy_commands.clear()
_command_queue.clear()
_pending_deferred.clear()
_log_buffer = null
_surfaced_error_tracker = null
pause_target = null
## Drop queued-but-unexecuted commands. Called by the connection on
## disconnect (#712): commands queued by the previous connection must not
## execute under the next one — the requester is gone, its in-flight
## futures were already failed server-side, and a mutation landing after
## reconnect is a surprise write nobody can correlate. Deferred bookkeeping
## has its own reset (clear_deferred_responses).
func clear_command_queue() -> void:
_command_queue.clear()
## Invoke a registered handler directly by name. Returns the handler's raw
## response dict (no request_id or status wrapping). Returns an UNKNOWN_COMMAND
## error dict if the command is not registered. Used by batch_execute.
func dispatch_direct(command: String, params: Dictionary) -> Dictionary:
if not has_command(command):
return ErrorCodes.make(ErrorCodes.UNKNOWN_COMMAND, "Unknown command: %s" % command)
## Strip the reserved deferred-reply key: only _dispatch may thread it.
## A caller-supplied _request_id (e.g. inside a batch_execute
## sub-command's params) would flip a deferred-capable handler into
## deferred mode against a request id the dispatcher never registered —
## the direct caller would get the DEFERRED sentinel instead of a result
## and the out-of-band reply would be dropped as expired.
if params.has("_request_id"):
params = params.duplicate()
params.erase("_request_id")
return _call_handler(command, params)
## Whether a command is registered (eagerly or lazily).
func has_command(command: String) -> bool:
return _handlers.has(command) or _lazy_commands.has(command)
## Rank registered commands by similarity to `cmd_name` and return the top `limit`
## matches. Uses Godot's built-in String.similarity() (0.01.0). Returns an empty
## array if no candidates clear the threshold. Used by batch_execute to surface
## "did you mean" suggestions when an unknown command is passed.
func suggest_similar(cmd_name: String, limit: int = 3, threshold: float = 0.5) -> Array[String]:
return FuzzySuggestions.rank(cmd_name, _registered_command_names(), limit, threshold, 0.0, 0.0)
## Union of eagerly-registered and lazily-registered command names.
## Materialized lazy commands live in both dicts, so dedupe via keys.
func _registered_command_names() -> Array:
var names: Dictionary = {}
for command in _handlers:
names[command] = true
for command in _lazy_commands:
names[command] = true
return names.keys()
## Enqueue a raw command dict received from the WebSocket.
func enqueue(cmd: Dictionary) -> void:
_command_queue.append(cmd)
func pending_deferred_count() -> int:
return _pending_deferred.size()
func clear_deferred_responses() -> void:
_pending_deferred.clear()
func has_pending_deferred_response(request_id: String) -> bool:
return request_id.is_empty() or _pending_deferred.has(request_id)
func complete_deferred_response(request_id: String) -> bool:
if request_id.is_empty():
return true
if not _pending_deferred.has(request_id):
return false
_pending_deferred.erase(request_id)
return true
## Handlers whose response flows out-of-band (e.g. debugger-channel capture)
## return this marker so tick() skips auto-sending a response. The handler is
## responsible for pushing the final response via McpConnection._send_json when
## the async operation completes. The dispatcher tracks the request_id and emits
## DEFERRED_TIMEOUT if the out-of-band response never arrives. The request_id is
## threaded through params under the "_request_id" key so the handler can
## correlate the response.
const DEFERRED_RESPONSE := {"_deferred": true}
## Process queued commands within a frame budget (milliseconds).
## Returns an array of response dictionaries to send back.
func tick(budget_ms: float = 4.0) -> Array[Dictionary]:
var responses: Array[Dictionary] = _collect_deferred_timeouts()
var start := Time.get_ticks_msec()
var idx := 0
while idx < _command_queue.size() and (Time.get_ticks_msec() - start) < budget_ms:
var cmd: Dictionary = _command_queue[idx]
var response := _dispatch(cmd)
if not response.get("_deferred", false):
responses.append(response)
idx += 1
if idx > 0:
_command_queue = _command_queue.slice(idx)
return responses
func _dispatch(cmd: Dictionary) -> Dictionary:
var request_id: String = cmd.get("request_id", "")
var command: String = cmd.get("command", "")
var raw_params: Dictionary = cmd.get("params", {})
## Duplicate so the internal _request_id key we thread through doesn't
## mutate the queued command's params (which is the same dict we're
## about to JSON-log below, and which later readers like batch_execute
## shouldn't see dispatcher-internal metadata from).
var params: Dictionary = raw_params.duplicate()
params["_request_id"] = request_id
if mcp_logging:
_log_buffer.log("[recv] %s(%s)" % [command, JSON.stringify(raw_params)])
var result: Dictionary
if has_command(command):
result = _call_handler(command, params)
else:
result = ErrorCodes.make(ErrorCodes.UNKNOWN_COMMAND, "Unknown command: %s" % command)
if result.get("_deferred", false):
## A handler may attach `_deferred_timeout_ms` to its deferred sentinel
## to claim a per-request budget larger than its command's shared entry
## (e.g. game_command's `input_sequence`, which steps frames well past
## the 15s that suits one-shot game ops). 0/absent falls back to the
## per-command table.
_register_deferred(request_id, command, int(result.get("_deferred_timeout_ms", 0)))
if mcp_logging:
_log_buffer.log("[defer] %s (request %s)" % [command, request_id])
return result
result["request_id"] = request_id
if not result.has("status"):
result["status"] = "ok"
## Stamp live editor readiness onto every command-response envelope so
## the server's `Session.readiness` cache self-heals on the very next
## tool call. Without this, a single dropped `readiness_changed` event
## (or a one-frame race around `pause_processing`) leaves the cache
## stuck at "playing" / "importing" long after the editor has settled,
## and write tools fail with EDITOR_NOT_READY against a writable editor.
## See connection.gd::send_deferred_response for the deferred-response
## counterpart, which stamps the same field.
result["readiness"] = McpConnection.get_readiness()
_stamp_error_watermark(result)
if mcp_logging:
var status: String = result.get("status", "ok")
if status == "ok":
_log_buffer.log("[send] %s -> ok" % command)
else:
var err_msg: String = result.get("error", {}).get("message", "unknown")
_log_buffer.log("[send] %s -> error: %s" % [command, err_msg])
return result
## Truncate JSON-stringified args at this many chars when stuffing them into
## a malformed-result error message — large dicts shouldn't bloat the
## response, but a few hundred chars usually pinpoints which param was the
## wrong shape.
const _MALFORMED_ARGS_MAX := 400
func _call_handler(command: String, params: Dictionary) -> Dictionary:
if not _handlers.has(command):
var materialize_error := _materialize_lazy_command(command)
if not materialize_error.is_empty():
return materialize_error
## #712: a handler that crashes between pause_processing = true and its
## matching false leaves the pause depth unbalanced — GDScript swallows
## the error, the dispatcher reports "malformed result", and the
## transport stays paused FOREVER (no watchdog, no disconnect reset).
## Restore balance at this boundary: the depth a handler leaves behind
## must equal the depth it started with.
var pause_depth_before: int = pause_target.pause_depth() if pause_target != null else 0
var result: Dictionary = _handlers[command].call(params)
if pause_target != null and pause_target.pause_depth() > pause_depth_before:
var leaked: int = pause_target.pause_depth() - pause_depth_before
while pause_target.pause_depth() > pause_depth_before:
pause_target.resume()
if mcp_logging and _log_buffer != null:
_log_buffer.log(
"[error] %s leaked %d pause_processing level(s) — restored (handler crash?)"
% [command, leaked]
)
## Handlers must return {"data": ...} on success or {"error": ...} on failure.
## Anything else (null, empty, missing keys) means the handler crashed
## mid-call — GDScript swallows the error and returns an empty dict.
if result == null or not (result.has("data") or result.has("error") or result.has("_deferred")):
var safe_params := params.duplicate()
safe_params.erase("_request_id")
var args_json := JSON.stringify(safe_params)
if args_json.length() > _MALFORMED_ARGS_MAX:
args_json = args_json.substr(0, _MALFORMED_ARGS_MAX) + "..."
var backtrace := _capture_compact_backtrace()
var msg := (
"Handler '%s' returned malformed result — likely a runtime error in the handler "
+ "(e.g. param type mismatch). Args received: %s"
) % [command, args_json]
if not backtrace.is_empty():
msg += "\nBacktrace:\n%s" % backtrace
if mcp_logging and _log_buffer != null:
var compact_backtrace := backtrace.replace("\n", " | ")
_log_buffer.log(
"[error] %s -> malformed result; args=%s; backtrace=%s"
% [command, args_json, compact_backtrace]
)
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, msg)
return result
## Resolve a lazily-registered command into a live Callable in `_handlers`.
## Loads + constructs the owning handler on first use (cached per handler
## key, so one load() covers every command the handler serves). Returns an
## empty dict on success or a protocol error dict on failure — a missing
## script or method is a plugin packaging bug and must surface loudly, not
## as a silent no-op.
func _materialize_lazy_command(command: String) -> Dictionary:
var command_spec: Dictionary = _lazy_commands.get(command, {})
if command_spec.is_empty():
return ErrorCodes.make(ErrorCodes.UNKNOWN_COMMAND, "Unknown command: %s" % command)
var handler_key: String = command_spec["handler"]
var instance = _lazy_handler_cache.get(handler_key)
if instance == null:
var handler_spec: Dictionary = _lazy_handler_specs.get(handler_key, {})
if handler_spec.is_empty():
return ErrorCodes.make(
ErrorCodes.INTERNAL_ERROR,
"No lazy handler '%s' declared for command '%s'" % [handler_key, command]
)
## Existence-check first so a missing script surfaces as one clean
## protocol error instead of also spraying engine load errors.
if not ResourceLoader.exists(handler_spec["path"]):
return ErrorCodes.make(
ErrorCodes.INTERNAL_ERROR,
"Missing handler script '%s' for command '%s'" % [handler_spec["path"], command]
)
var script := load(handler_spec["path"]) as GDScript
if script == null:
return ErrorCodes.make(
ErrorCodes.INTERNAL_ERROR,
"Failed to load handler script '%s' for command '%s'" % [handler_spec["path"], command]
)
instance = script.callv("new", handler_spec["args"])
if instance == null:
return ErrorCodes.make(
ErrorCodes.INTERNAL_ERROR,
"Failed to construct handler '%s' for command '%s'" % [handler_key, command]
)
_lazy_handler_cache[handler_key] = instance
var method: StringName = command_spec["method"]
if not instance.has_method(method):
return ErrorCodes.make(
ErrorCodes.INTERNAL_ERROR,
"Handler '%s' has no method '%s' for command '%s'" % [handler_key, method, command]
)
_handlers[command] = Callable(instance, method)
return {}
func _register_deferred(request_id: String, command: String, timeout_override_ms: int = 0) -> void:
if request_id.is_empty():
return
## A positive per-request override wins over the per-command table so a
## single deferred call can claim more headroom without globally widening
## the command's budget (see _dispatch: input_sequence needs ~30s, but the
## other game_command ops must keep their tight 15s).
var timeout_ms: int = (
timeout_override_ms if timeout_override_ms > 0
else _deferred_timeout_ms_for_command(command)
)
_pending_deferred[request_id] = {
"command": command,
"started_ms": Time.get_ticks_msec(),
"timeout_ms": timeout_ms,
}
func _deferred_timeout_ms_for_command(command: String) -> int:
if deferred_timeout_overrides_ms.has(command):
return int(deferred_timeout_overrides_ms[command])
return int(DEFERRED_TIMEOUT_MS_BY_COMMAND.get(command, DEFAULT_DEFERRED_TIMEOUT_MS))
func _collect_deferred_timeouts() -> Array[Dictionary]:
var responses: Array[Dictionary] = []
if _pending_deferred.is_empty():
return responses
var now := Time.get_ticks_msec()
for request_id in _pending_deferred.keys():
var entry: Dictionary = _pending_deferred[request_id]
var timeout_ms: int = entry.get("timeout_ms", DEFAULT_DEFERRED_TIMEOUT_MS)
var elapsed_ms := now - int(entry.get("started_ms", now))
if elapsed_ms < timeout_ms:
continue
_pending_deferred.erase(request_id)
var command: String = entry.get("command", "")
var response := ErrorCodes.make(
ErrorCodes.DEFERRED_TIMEOUT,
"Deferred response for '%s' timed out after %dms" % [command, timeout_ms]
)
response["request_id"] = request_id
response["error"]["data"] = {
"command": command,
"elapsed_ms": elapsed_ms,
"timeout_ms": timeout_ms,
}
## Same envelope-level readiness stamp as `_dispatch` — keep the
## self-heal channel symmetric across every reply shape the
## dispatcher emits so the server cache can't drift just because
## the editor happened to time out a deferred command.
response["readiness"] = McpConnection.get_readiness()
_stamp_error_watermark(response)
responses.append(response)
if mcp_logging and _log_buffer != null:
_log_buffer.log("[defer] %s (request %s) -> timeout" % [command, request_id])
return responses
func _stamp_error_watermark(response: Dictionary) -> void:
McpSurfacedErrorTracker.stamp_watermark(response, _surfaced_error_tracker)
static func _capture_compact_backtrace(max_frames: int = 8) -> String:
var traces: Array = Engine.capture_script_backtraces(false)
for bt in traces:
if bt != null and not bt.is_empty():
return _trim_backtrace_string(bt.format(0, 2), max_frames)
return _format_stack_frames(get_stack(), max_frames)
static func _trim_backtrace_string(text: String, max_frames: int) -> String:
var lines := text.strip_edges().split("\n")
var kept: Array[String] = []
for i in range(min(lines.size(), max_frames)):
kept.append(lines[i].strip_edges())
return "\n".join(kept)
static func _format_stack_frames(frames: Array, max_frames: int) -> String:
var lines: Array[String] = []
for i in range(min(frames.size(), max_frames)):
var frame: Dictionary = frames[i]
lines.append(
"%s:%s in %s"
% [
frame.get("source", "?"),
frame.get("line", 0),
frame.get("function", "?"),
]
)
return "\n".join(lines)
+1
View File
@@ -0,0 +1 @@
uid://ctldk7ivsoo3i
+100
View File
@@ -0,0 +1,100 @@
@tool
extends VBoxContainer
## Dock subpanel — renders the MCP request/response log buffer. Owns its own
## UI subtree, the line-count cursor, and the display-visibility toggle. Emits
## `logging_enabled_changed` so the dock can route the flag onto the
## connection dispatcher without the panel knowing the routing exists.
##
## Extracted from mcp_dock.gd as part of audit-v2 #360 — see the comment at
## the top of mcp_dock.gd for the broader extraction story.
signal logging_enabled_changed(enabled: bool)
const Dock := preload("res://addons/godot_ai/mcp_dock.gd")
## Preload (not the McpSettings class_name) for consistency with the parse
## hazard note on `_log_buffer` below.
const Settings := preload("res://addons/godot_ai/utils/settings.gd")
## Untyped: a `: McpLogBuffer` annotation hits the class_name registry at
## script-load and trips the self-update parse hazard (#398). The type fence
## stays on the `setup(log_buffer: McpLogBuffer)` parameter.
var _log_buffer
var _log_display: RichTextLabel
var _log_toggle: CheckButton
## Last `McpLogBuffer.total_logged()` value painted into the display. Tracking
## the buffer's monotonic sequence (rather than its bounded `total_count()`)
## keeps the viewer painting once the ring fills — a size-based cursor would
## freeze at MAX_LINES on every subsequent append. See PR #392 for the bug.
var _last_log_seq := 0
## Build the UI synchronously here so callers (and detached-tree tests that
## instantiate the dock with `McpDockScript.new()` and never enter the tree)
## can interact with the panel's controls right after `setup()`. Mirrors the
## pre-extraction inline-build behavior that test_dock.gd relies on.
##
## Idempotent: `_log_display == null` covers an unlikely double-`setup()` call
## without rebuilding (which would orphan the prior controls).
func setup(log_buffer: McpLogBuffer) -> void:
_log_buffer = log_buffer
if _log_display == null:
_build_ui()
func _build_ui() -> void:
add_child(HSeparator.new())
var log_header_row := HBoxContainer.new()
var log_header := Dock._make_header("MCP Log")
log_header.size_flags_horizontal = Control.SIZE_EXPAND_FILL
log_header_row.add_child(log_header)
_log_toggle = CheckButton.new()
_log_toggle.text = "Log"
## Restore the persisted choice — a hardcoded `true` here meant the
## toggle reset to noisy on every editor restart (#626).
_log_toggle.button_pressed = Settings.mcp_logging_enabled()
_log_toggle.toggled.connect(_on_log_toggled)
log_header_row.add_child(_log_toggle)
add_child(log_header_row)
_log_display = RichTextLabel.new()
_log_display.custom_minimum_size = Vector2(0, 80)
_log_display.scroll_following = true
_log_display.bbcode_enabled = false
_log_display.selection_enabled = true
_log_display.visible = _log_toggle.button_pressed
add_child(_log_display)
## Called from McpDock._process when the panel is visible. Appends any new
## log lines since the last tick.
func tick() -> void:
if _log_buffer == null or _log_display == null:
return
var seq: int = _log_buffer.total_logged()
if seq == _last_log_seq:
return
if seq < _last_log_seq:
## Buffer cleared via `McpLogBuffer.clear()` (the `clear_logs` MCP
## tool / `logs_clear` handler). The buffer resets `_total_logged`
## to 0, flipping the sequence backward. Without this branch the
## display would keep showing pre-clear lines forever — the viewer
## drifts permanently out of sync with the buffer. Reset display +
## cursor so the next append paints over a clean slate.
_log_display.clear()
_last_log_seq = 0
if seq == 0:
return
var new_lines: Array[String] = _log_buffer.get_recent(seq - _last_log_seq)
for line in new_lines:
_log_display.add_text(line + "\n")
_last_log_seq = seq
func _on_log_toggled(enabled: bool) -> void:
Settings.set_mcp_logging_enabled(enabled)
_log_display.visible = enabled
logging_enabled_changed.emit(enabled)
@@ -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,69 @@
@tool
extends EditorExportPlugin
## Strips the MCP game-helper autoload from exported builds (#740).
##
## plugin.gd writes `autoload/_mcp_game_helper` into project.godot so the
## editor-spawned game process loads the helper. Exports bake project
## settings into the pack's project.binary, so without this plugin every
## export ships the autoload — and users who exclude addons/godot_ai/**
## in their export preset get three "Failed to instantiate an autoload"
## errors at game start. Even when the files ARE shipped, the helper is
## editor-tooling: no exported build should carry it.
##
## Strip mechanics: clear the in-memory ProjectSettings entry in
## _export_begin, restore it in _export_end. The export pipeline reads
## the live ProjectSettings when it bakes project.binary, which happens
## after _export_begin — verified end-to-end by
## script/ci-export-strip-smoke, which exports a real pack and asserts
## the autoload is absent inside it. We never call ProjectSettings.save()
## while stripped, so project.godot on disk keeps the autoload
## throughout; only the export snapshot loses it.
##
## Failure containment: if an export aborts so hard that _export_end
## never fires, the damage is bounded to the editor's in-memory settings
## — the running game reads project.godot from disk, and plugin.gd's
## _ensure_game_helper_autoload() re-asserts the entry on the next
## plugin enable / editor launch.
## Must equal "autoload/" + plugin.gd's GAME_HELPER_AUTOLOAD_NAME.
## Duplicated (not preloaded from plugin.gd) to avoid a cyclic preload —
## plugin.gd preloads this script. The pairing is locked by
## test_export_strip.gd's constants-contract test.
const AUTOLOAD_KEY := "autoload/_mcp_game_helper"
var _saved_value: Variant = null
var _stripped := false
func _get_name() -> String:
return "GodotAIStripAutoload"
func _export_begin(_features: PackedStringArray, _is_debug: bool, _path: String, _flags: int) -> void:
## `_stripped` guard: if a previous export died before _export_end,
## don't overwrite the genuinely-saved value with the already-cleared
## state — restore semantics stay anchored to the original value.
if _stripped:
return
if not ProjectSettings.has_setting(AUTOLOAD_KEY):
return
_saved_value = ProjectSettings.get_setting(AUTOLOAD_KEY)
## Setting a project setting to null erases it.
ProjectSettings.set_setting(AUTOLOAD_KEY, null)
_stripped = true
print("MCP | export: stripping %s from the exported pack (restored in the editor after export)" % AUTOLOAD_KEY)
func _export_end() -> void:
if not _stripped:
return
ProjectSettings.set_setting(AUTOLOAD_KEY, _saved_value)
## Mirror _ensure_game_helper_autoload()'s registration shape so the
## restored entry is indistinguishable from the original: initial
## value "" keeps project.godot diff-clean, basic keeps it visible in
## the non-advanced settings view.
ProjectSettings.set_initial_value(AUTOLOAD_KEY, "")
ProjectSettings.set_as_basic(AUTOLOAD_KEY, true)
_saved_value = null
_stripped = false
@@ -0,0 +1 @@
uid://np0cp6fwpim7
@@ -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,30 @@
@tool
class_name McpParamValidators
extends RefCounted
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
## Type-check a JSON-decoded param Variant before assigning it into a typed
## GDScript local. The dispatcher only catches handler crashes as an opaque
## "malformed result" (issue #210), so a typed assignment like
## var group: String = params.get("group", "")
## will runtime-error and bubble up without telling the caller which param
## was the wrong shape. Only string params are guarded — int/bool params
## can't be: Godot's JSON parser decodes every number as float (a wire `5`
## arrives as `5.0`), so a strict int check would reject every legitimate
## integer a client sends, and GDScript's typed assignment already converts
## numeric Variants safely. Bool params arrive as real bools and a wrong
## type surfaces through the dispatcher's malformed-result path.
## Returns null iff `value` is a String or StringName. On any other type
## returns an INVALID_PARAMS error dict whose message names both `name` and
## the actual Variant type (via Godot's built-in `type_string`).
static func require_string(name: String, value: Variant) -> Variant:
var t := typeof(value)
if t == TYPE_STRING or t == TYPE_STRING_NAME:
return null
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"Param '%s' must be a String, got %s" % [name, type_string(t)],
)
@@ -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.NODE_NOT_FOUND,
"Cannot auto-create AnimationPlayer at %s: %s" % [
player_path, McpScenePath.format_parent_error(parent_path, scene_root)])
var new_player := AnimationPlayer.new()
new_player.name = player_name
var lib := AnimationLibrary.new()
new_player.add_animation_library("", lib)
return {
"player": new_player,
"library": lib,
"player_created": true,
"player_parent": parent,
}
## Resolve for read operations (no library requirement).
func _resolve_player_read(player_path: String) -> Dictionary:
var resolved := McpNodeValidator.resolve_or_error(player_path, "player_path")
if resolved.has("error"):
return resolved
var node: Node = resolved.node
if not node is AnimationPlayer:
return ErrorCodes.make(ErrorCodes.WRONG_TYPE,
"Node at %s is not an AnimationPlayer (got %s)" % [player_path, node.get_class()])
return {"player": node as AnimationPlayer}
## Resolve an animation by name, searching all libraries.
## Accepts bare clip names ("idle") and library-qualified names ("moves/idle")
## as returned by `list_animations` for non-default libraries.
func _resolve_animation(player: AnimationPlayer, anim_name: String) -> Dictionary:
if not player.has_animation(anim_name):
return ErrorCodes.make(ErrorCodes.PROPERTY_NOT_ON_CLASS,
"Animation '%s' not found on player. Available: %s" % [
anim_name,
", ".join(Array(player.get_animation_list()))
])
# If the caller passed "library/clip", look up in that specific library.
var slash := anim_name.find("/")
if slash >= 0:
var lib_key := anim_name.substr(0, slash)
var clip_key := anim_name.substr(slash + 1)
if player.has_animation_library(lib_key):
var lib: AnimationLibrary = player.get_animation_library(lib_key)
if lib.has_animation(clip_key):
return {"animation": lib.get_animation(clip_key), "library": lib, "library_key": lib_key}
# Otherwise scan libraries for a bare clip name.
for lib_name in player.get_animation_library_list():
var lib2: AnimationLibrary = player.get_animation_library(lib_name)
if lib2.has_animation(anim_name):
return {"animation": lib2.get_animation(anim_name), "library": lib2, "library_key": lib_name}
# Fallback — shouldn't happen if has_animation returned true.
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Animation found by player but not in any library")
@@ -0,0 +1 @@
uid://c0jrius46xsd4
@@ -0,0 +1,536 @@
@tool
extends RefCounted
## Curated motion presets for the AnimationPlayer surface.
##
## Each preset_* method:
## 1. Validates params + resolves the player (auto-creating its default lib).
## 2. Resolves the target node + classifies it as control / 2d / 3d.
## 3. Builds a single-track Animation with shape-appropriate keyframes.
## 4. Commits the add through the handler's shared `_commit_animation_add`
## so a single Ctrl-Z rolls back any auto-created library + the animation.
##
## Holds a WeakRef back to the AnimationHandler instance so the handler can
## continue to own this module strongly via `_presets` without forming a
## RefCounted cycle. Resolution / undo helpers live on the handler — keeping
## the `_undo_redo` member single-source there avoids drift.
const AnimationValues := preload("res://addons/godot_ai/handlers/animation_values.gd")
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
const ScenePath := preload("res://addons/godot_ai/utils/scene_path.gd")
var _handler_weak: WeakRef
func _init(handler) -> void:
_handler_weak = weakref(handler)
func _h():
return _handler_weak.get_ref()
# ============================================================================
# animation_preset_fade
# ============================================================================
func preset_fade(params: Dictionary) -> Dictionary:
var player_path: String = params.get("player_path", "")
var target_path: String = params.get("target_path", "")
var mode: String = params.get("mode", "in")
var duration: float = float(params.get("duration", 0.5))
var anim_name: String = params.get("animation_name", "")
var overwrite: bool = params.get("overwrite", false)
if player_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path")
if target_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: target_path")
if mode != "in" and mode != "out":
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
"Invalid mode '%s'. Valid: 'in', 'out'" % mode)
if duration <= 0.0:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "'duration' must be > 0")
var handler = _h()
if handler == null:
return ErrorCodes.make_not_ready(
ErrorCodes.SUB_EDITOR_UNAVAILABLE,
"AnimationHandler not available", false)
var resolved: Dictionary = handler._resolve_player(player_path)
if resolved.has("error"):
return resolved
var player: AnimationPlayer = resolved.player
var library: AnimationLibrary = resolved.library
var created_library := false
if library == null:
library = AnimationLibrary.new()
created_library = true
var target_resolved := _resolve_preset_target(player, target_path)
if target_resolved.has("error"):
return target_resolved
var target: Node = target_resolved.node
var track_target: String = target_resolved.track_path_root
# Fade requires a `modulate` property (CanvasItem/Control/Node2D/Sprite3D/etc).
var has_modulate := false
for p in target.get_property_list():
if p.name == "modulate":
has_modulate = true
break
if not has_modulate:
return ErrorCodes.make(ErrorCodes.WRONG_TYPE,
"Target '%s' (class %s) has no 'modulate' property — fade requires a CanvasItem, Control, Node2D, or Sprite3D"
% [target_path, target.get_class()])
if anim_name.is_empty():
anim_name = "fade_%s" % mode
var old_anim: Animation = null
if library.has_animation(anim_name):
if not overwrite:
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
"Animation '%s' already exists. Pass overwrite=true or delete it first." % anim_name)
old_anim = library.get_animation(anim_name)
var start_a: float = 0.0 if mode == "in" else 1.0
var end_a: float = 1.0 if mode == "in" else 0.0
var anim := Animation.new()
anim.length = duration
anim.loop_mode = Animation.LOOP_NONE
var track_path := "%s:modulate:a" % track_target
handler._do_add_property_track(anim, track_path, "linear", [
{"time": 0.0, "value": start_a, "transition": "linear"},
{"time": duration, "value": end_a, "transition": "linear"},
])
handler._commit_animation_add(
"MCP: Create animation %s" % anim_name,
player, library, created_library, anim_name, anim, old_anim,
)
return {
"data": {
"player_path": player_path,
"animation_name": anim_name,
"mode": mode,
"length": duration,
"track_count": anim.get_track_count(),
"library_created": created_library,
"overwritten": old_anim != null,
"undoable": true,
}
}
# ============================================================================
# animation_preset_slide
# ============================================================================
func preset_slide(params: Dictionary) -> Dictionary:
var player_path: String = params.get("player_path", "")
var target_path: String = params.get("target_path", "")
var direction: String = params.get("direction", "left")
var mode: String = params.get("mode", "in")
var duration: float = float(params.get("duration", 0.4))
var anim_name: String = params.get("animation_name", "")
var overwrite: bool = params.get("overwrite", false)
if player_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path")
if target_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: target_path")
if not ["left", "right", "up", "down"].has(direction):
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
"Invalid direction '%s'. Valid: 'left', 'right', 'up', 'down'" % direction)
if mode != "in" and mode != "out":
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
"Invalid mode '%s'. Valid: 'in', 'out'" % mode)
if duration <= 0.0:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "'duration' must be > 0")
var handler = _h()
if handler == null:
return ErrorCodes.make_not_ready(
ErrorCodes.SUB_EDITOR_UNAVAILABLE,
"AnimationHandler not available", false)
var resolved: Dictionary = handler._resolve_player(player_path)
if resolved.has("error"):
return resolved
var player: AnimationPlayer = resolved.player
var library: AnimationLibrary = resolved.library
var created_library := false
if library == null:
library = AnimationLibrary.new()
created_library = true
var target_resolved := _resolve_preset_target(player, target_path)
if target_resolved.has("error"):
return target_resolved
var target = target_resolved.node
var kind: String = target_resolved.kind
var track_target: String = target_resolved.track_path_root
# Default distance picks 3D units vs screen pixels based on target kind.
var default_distance: float = 1.0 if kind == "3d" else 100.0
var distance: float = float(params.get("distance", default_distance))
if distance == 0.0:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "'distance' must be non-zero")
var offset: Variant = _direction_offset(kind, direction, distance)
var current_pos: Variant = target.position
var start_pos: Variant
var end_pos: Variant
if mode == "in":
start_pos = current_pos + offset
end_pos = current_pos
else:
start_pos = current_pos
end_pos = current_pos + offset
if anim_name.is_empty():
anim_name = "slide_%s_%s" % [mode, direction]
var old_anim: Animation = null
if library.has_animation(anim_name):
if not overwrite:
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
"Animation '%s' already exists. Pass overwrite=true or delete it first." % anim_name)
old_anim = library.get_animation(anim_name)
var anim := Animation.new()
anim.length = duration
anim.loop_mode = Animation.LOOP_NONE
var track_path := "%s:position" % track_target
handler._do_add_property_track(anim, track_path, "linear", [
{"time": 0.0, "value": start_pos, "transition": "linear"},
{"time": duration, "value": end_pos, "transition": "linear"},
])
handler._commit_animation_add(
"MCP: Create animation %s" % anim_name,
player, library, created_library, anim_name, anim, old_anim,
)
return {
"data": {
"player_path": player_path,
"animation_name": anim_name,
"direction": direction,
"mode": mode,
"distance": distance,
"length": duration,
"track_count": anim.get_track_count(),
"library_created": created_library,
"overwritten": old_anim != null,
"undoable": true,
}
}
# ============================================================================
# animation_preset_shake
# ============================================================================
func preset_shake(params: Dictionary) -> Dictionary:
var player_path: String = params.get("player_path", "")
var target_path: String = params.get("target_path", "")
var duration: float = float(params.get("duration", 0.3))
var frequency: float = float(params.get("frequency", 30.0))
var rng_seed: int = int(params.get("seed", 0))
var anim_name: String = params.get("animation_name", "")
var overwrite: bool = params.get("overwrite", false)
if player_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path")
if target_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: target_path")
if duration <= 0.0:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "'duration' must be > 0")
if frequency <= 0.0:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "'frequency' must be > 0")
var handler = _h()
if handler == null:
return ErrorCodes.make_not_ready(
ErrorCodes.SUB_EDITOR_UNAVAILABLE,
"AnimationHandler not available", false)
var resolved: Dictionary = handler._resolve_player(player_path)
if resolved.has("error"):
return resolved
var player: AnimationPlayer = resolved.player
var library: AnimationLibrary = resolved.library
var created_library := false
if library == null:
library = AnimationLibrary.new()
created_library = true
var target_resolved := _resolve_preset_target(player, target_path)
if target_resolved.has("error"):
return target_resolved
var target = target_resolved.node
var kind: String = target_resolved.kind
var track_target: String = target_resolved.track_path_root
var default_intensity: float = 0.1 if kind == "3d" else 10.0
var intensity: float = float(params.get("intensity", default_intensity))
if intensity <= 0.0:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "'intensity' must be > 0")
if anim_name.is_empty():
anim_name = "shake"
var old_anim: Animation = null
if library.has_animation(anim_name):
if not overwrite:
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
"Animation '%s' already exists. Pass overwrite=true or delete it first." % anim_name)
old_anim = library.get_animation(anim_name)
var rng := RandomNumberGenerator.new()
if rng_seed != 0:
rng.seed = rng_seed
else:
rng.randomize()
# Samples between t=0 and t=duration (exclusive); bookended by at-rest keys.
var sample_count: int = int(ceil(frequency * duration))
if sample_count < 2:
sample_count = 2
var current_pos: Variant = target.position
var kfs: Array = []
kfs.append({"time": 0.0, "value": current_pos, "transition": "linear"})
for i in range(1, sample_count):
var t: float = (float(i) / float(sample_count)) * duration
var jx: float = rng.randf_range(-intensity, intensity)
var jy: float = rng.randf_range(-intensity, intensity)
var jittered: Variant
if kind == "3d":
var jz: float = rng.randf_range(-intensity, intensity)
jittered = current_pos + Vector3(jx, jy, jz)
else:
jittered = current_pos + Vector2(jx, jy)
kfs.append({"time": t, "value": jittered, "transition": "linear"})
kfs.append({"time": duration, "value": current_pos, "transition": "linear"})
var anim := Animation.new()
anim.length = duration
anim.loop_mode = Animation.LOOP_NONE
var track_path := "%s:position" % track_target
handler._do_add_property_track(anim, track_path, "linear", kfs)
handler._commit_animation_add(
"MCP: Create animation %s" % anim_name,
player, library, created_library, anim_name, anim, old_anim,
)
return {
"data": {
"player_path": player_path,
"animation_name": anim_name,
"length": duration,
"frequency": frequency,
"intensity": intensity,
"keyframe_count": kfs.size(),
"track_count": anim.get_track_count(),
"library_created": created_library,
"overwritten": old_anim != null,
"undoable": true,
}
}
# ============================================================================
# animation_preset_pulse
# ============================================================================
func preset_pulse(params: Dictionary) -> Dictionary:
var player_path: String = params.get("player_path", "")
var target_path: String = params.get("target_path", "")
var from_scale: float = float(params.get("from_scale", 1.0))
var to_scale: float = float(params.get("to_scale", 1.1))
var duration: float = float(params.get("duration", 0.4))
var anim_name: String = params.get("animation_name", "")
var overwrite: bool = params.get("overwrite", false)
if player_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path")
if target_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: target_path")
if duration <= 0.0:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "'duration' must be > 0")
if from_scale <= 0.0:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "'from_scale' must be > 0")
if to_scale <= 0.0:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "'to_scale' must be > 0")
var handler = _h()
if handler == null:
return ErrorCodes.make_not_ready(
ErrorCodes.SUB_EDITOR_UNAVAILABLE,
"AnimationHandler not available", false)
var resolved: Dictionary = handler._resolve_player(player_path)
if resolved.has("error"):
return resolved
var player: AnimationPlayer = resolved.player
var library: AnimationLibrary = resolved.library
var created_library := false
if library == null:
library = AnimationLibrary.new()
created_library = true
var target_resolved := _resolve_preset_target(player, target_path)
if target_resolved.has("error"):
return target_resolved
var kind: String = target_resolved.kind
var track_target: String = target_resolved.track_path_root
if anim_name.is_empty():
anim_name = "pulse"
var old_anim: Animation = null
if library.has_animation(anim_name):
if not overwrite:
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
"Animation '%s' already exists. Pass overwrite=true or delete it first." % anim_name)
old_anim = library.get_animation(anim_name)
var from_vec: Variant
var to_vec: Variant
if kind == "3d":
from_vec = Vector3(from_scale, from_scale, from_scale)
to_vec = Vector3(to_scale, to_scale, to_scale)
else:
from_vec = Vector2(from_scale, from_scale)
to_vec = Vector2(to_scale, to_scale)
var anim := Animation.new()
anim.length = duration
anim.loop_mode = Animation.LOOP_NONE
var track_path := "%s:scale" % track_target
handler._do_add_property_track(anim, track_path, "linear", [
{"time": 0.0, "value": from_vec, "transition": "linear"},
{"time": duration * 0.5, "value": to_vec, "transition": "linear"},
{"time": duration, "value": from_vec, "transition": "linear"},
])
handler._commit_animation_add(
"MCP: Create animation %s" % anim_name,
player, library, created_library, anim_name, anim, old_anim,
)
return {
"data": {
"player_path": player_path,
"animation_name": anim_name,
"from_scale": from_scale,
"to_scale": to_scale,
"length": duration,
"track_count": anim.get_track_count(),
"library_created": created_library,
"overwritten": old_anim != null,
"undoable": true,
}
}
# ============================================================================
# Helpers — preset resolution
# ============================================================================
## Resolve a preset target node and classify its transform kind.
##
## Accepts two `target_path` shapes:
## * Scene-absolute (starts with "/") — resolved through `ScenePath.resolve`,
## matching the convention used by every other scene-mutating tool. Targets
## outside the player's `root_node` subtree are converted to `..`-prefixed
## paths via `root_node.get_path_to(target)`, mirroring what the relative
## form accepts and how Godot stores track paths.
## * Relative — used as-is against the player's `root_node`, matching how
## animation tracks themselves are stored.
##
## Returns `{node, kind, track_path_root}` where `track_path_root` is the path
## (relative to `root_node`) that callers should embed in the track path. For
## scene-absolute inputs this is the converted relative path; for relative
## inputs it equals the input. `kind` ∈ {"control", "2d", "3d"}.
##
## Mirrors the same root-node fallback that
## `AnimationValues.resolve_track_prop_context` uses so tool inputs match how
## the track path will resolve at playback.
func _resolve_preset_target(player: AnimationPlayer, target_path: String) -> Dictionary:
var root_node := AnimationValues.player_root_node(player)
if root_node == null:
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
"AnimationPlayer at %s has no resolvable root_node (is the scene open?)" % str(player.get_path()))
var target: Node = null
var track_path_root: String = target_path
if target_path.begins_with("/"):
var scene_root := EditorInterface.get_edited_scene_root()
if scene_root == null:
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
"Cannot resolve scene-absolute target_path '%s': no scene open" % target_path)
target = ScenePath.resolve(target_path, scene_root)
if target == null:
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
ScenePath.format_node_error(target_path, scene_root))
# Convert to a root_node-relative path. For targets outside the
# subtree this yields a `..`-prefixed path, matching what the
# relative form already accepts (root_node.get_node_or_null
# resolves `..` segments) and what Godot's animation engine
# stores natively.
track_path_root = str(root_node.get_path_to(target))
else:
target = root_node.get_node_or_null(target_path)
if target == null:
# root_node.get_path() leaks the editor's SubViewport-wrapped
# path; use the clean scene-relative form so the hint is
# actionable.
var scene_root := EditorInterface.get_edited_scene_root()
var root_hint := ScenePath.from_node(root_node, scene_root) if scene_root != null else str(root_node.name)
var abs_example := "/%s/path/to/target" % scene_root.name if scene_root != null else "/SceneRoot/path/to/target"
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS,
("Target node not found at '%s' (resolved relative to AnimationPlayer's root_node '%s'). "
+ "Pass a path relative to root_node (e.g. \"path/to/target\") or a scene-absolute path (e.g. \"%s\").")
% [target_path, root_hint, abs_example])
var kind: String
if target is Control:
kind = "control"
elif target is Node2D:
kind = "2d"
elif target is Node3D:
kind = "3d"
else:
return ErrorCodes.make(ErrorCodes.WRONG_TYPE,
"Target '%s' must be a Control, Node2D, or Node3D (got %s)" % [target_path, target.get_class()])
return {"node": target, "kind": kind, "track_path_root": track_path_root}
## Build a directional offset for slide presets.
## Axis conventions:
## Control + Node2D (screen-space, y-down): left/right = ∓x, up = -y, down = +y
## Node3D (world-up): left/right = ∓x, up = +y, down = -y
static func _direction_offset(kind: String, direction: String, distance: float) -> Variant:
if kind == "3d":
match direction:
"left": return Vector3(-distance, 0.0, 0.0)
"right": return Vector3(distance, 0.0, 0.0)
"up": return Vector3(0.0, distance, 0.0)
"down": return Vector3(0.0, -distance, 0.0)
else:
match direction:
"left": return Vector2(-distance, 0.0)
"right": return Vector2(distance, 0.0)
"up": return Vector2(0.0, -distance)
"down": return Vector2(0.0, distance)
return null
@@ -0,0 +1 @@
uid://c4s3h78bwvr6w
@@ -0,0 +1,442 @@
@tool
extends RefCounted
const VariantSerializer := preload("res://addons/godot_ai/utils/variant_serializer.gd")
## Read-only animation introspection + shared value-coercion / serialization.
##
## Holds:
## - Static helpers used by both the write handler (track building, simple
## composer) and the preset module (target/property resolution).
## - Instance methods that back the read MCP ops: animation_list,
## animation_get, animation_validate.
##
## The instance methods need the handler to resolve players / animations.
## To keep that without introducing a RefCounted cycle (the handler holds a
## strong ref to this module via `_values`), the back-pointer is a WeakRef.
## When the handler is freed during plugin teardown, _h() returns null and
## the (no-longer-routable) calls short-circuit to a generic editor-not-ready
## error — matches the dispatcher already being torn down at that point.
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
const PropertyErrors := preload("res://addons/godot_ai/handlers/_property_errors.gd")
const _NAMED_TRANSITIONS := {
"linear": 1.0,
"ease_in": 2.0,
"ease_out": 0.5,
"ease_in_out": -2.0,
}
## Component letters accepted on each aggregate base type, paired with the
## scalar Variant type the component resolves to. A subpath like `position:y`
## on a Vector3 maps to TYPE_FLOAT; on a Vector3i it maps to TYPE_INT.
const _SUBPATH_COMPONENTS := {
TYPE_VECTOR2: ["xy", TYPE_FLOAT],
TYPE_VECTOR3: ["xyz", TYPE_FLOAT],
TYPE_VECTOR4: ["xyzw", TYPE_FLOAT],
TYPE_QUATERNION: ["xyzw", TYPE_FLOAT],
TYPE_COLOR: ["rgba", TYPE_FLOAT],
TYPE_VECTOR2I: ["xy", TYPE_INT],
TYPE_VECTOR3I: ["xyz", TYPE_INT],
TYPE_VECTOR4I: ["xyzw", TYPE_INT],
}
var _handler_weak: WeakRef
func _init(handler) -> void:
_handler_weak = weakref(handler)
func _h():
return _handler_weak.get_ref()
# ============================================================================
# animation_list (read)
# ============================================================================
func list_animations(params: Dictionary) -> Dictionary:
var player_path: String = params.get("player_path", "")
if player_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path")
var handler = _h()
if handler == null:
return ErrorCodes.make_not_ready(
ErrorCodes.SUB_EDITOR_UNAVAILABLE,
"AnimationHandler not available", false)
var resolved: Dictionary = handler._resolve_player_read(player_path)
if resolved.has("error"):
return resolved
var player: AnimationPlayer = resolved.player
var animations: Array[Dictionary] = []
for lib_name in player.get_animation_library_list():
var lib: AnimationLibrary = player.get_animation_library(lib_name)
for anim_name in lib.get_animation_list():
var anim: Animation = lib.get_animation(anim_name)
var display_name: String = anim_name if lib_name == "" else "%s/%s" % [lib_name, anim_name]
animations.append({
"name": display_name,
"length": anim.length,
"loop_mode": loop_mode_to_string(anim.loop_mode),
"track_count": anim.get_track_count(),
})
return {
"data": {
"player_path": player_path,
"animations": animations,
"count": animations.size(),
}
}
# ============================================================================
# animation_get (read)
# ============================================================================
func get_animation(params: Dictionary) -> Dictionary:
var player_path: String = params.get("player_path", "")
var anim_name: String = params.get("animation_name", "")
if player_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path")
if anim_name.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: animation_name")
var handler = _h()
if handler == null:
return ErrorCodes.make_not_ready(
ErrorCodes.SUB_EDITOR_UNAVAILABLE,
"AnimationHandler not available", false)
var resolved: Dictionary = handler._resolve_player_read(player_path)
if resolved.has("error"):
return resolved
var player: AnimationPlayer = resolved.player
var anim_resolved: Dictionary = handler._resolve_animation(player, anim_name)
if anim_resolved.has("error"):
return anim_resolved
var anim: Animation = anim_resolved.animation
var tracks: Array[Dictionary] = []
for i in anim.get_track_count():
var track_type := anim.track_get_type(i)
var type_name := track_type_to_string(track_type)
var keys: Array[Dictionary] = []
for k in anim.track_get_key_count(i):
var key_val = anim.track_get_key_value(i, k)
keys.append({
"time": anim.track_get_key_time(i, k),
"value": serialize_value(key_val),
"transition": anim.track_get_key_transition(i, k),
})
tracks.append({
"index": i,
"type": type_name,
"path": str(anim.track_get_path(i)),
"interpolation": interp_to_string(anim.track_get_interpolation_type(i)),
"key_count": keys.size(),
"keys": keys,
})
return {
"data": {
"player_path": player_path,
"name": anim_name,
"length": anim.length,
"loop_mode": loop_mode_to_string(anim.loop_mode),
"track_count": anim.get_track_count(),
"tracks": tracks,
}
}
# ============================================================================
# animation_validate (read-only)
# ============================================================================
func validate_animation(params: Dictionary) -> Dictionary:
var player_path: String = params.get("player_path", "")
var anim_name: String = params.get("animation_name", "")
if player_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path")
if anim_name.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: animation_name")
var handler = _h()
if handler == null:
return ErrorCodes.make_not_ready(
ErrorCodes.SUB_EDITOR_UNAVAILABLE,
"AnimationHandler not available", false)
var resolved: Dictionary = handler._resolve_player_read(player_path)
if resolved.has("error"):
return resolved
var player: AnimationPlayer = resolved.player
if not player.has_animation(anim_name):
return ErrorCodes.make(ErrorCodes.PROPERTY_NOT_ON_CLASS,
"Animation '%s' not found on player at %s" % [anim_name, player_path])
var anim: Animation = player.get_animation(anim_name)
var root_node := player_root_node(player)
var broken_tracks: Array[Dictionary] = []
var valid_count := 0
for i in anim.get_track_count():
var track_path_str := str(anim.track_get_path(i))
# Split on the FIRST colon (node↔property boundary), not the last.
# Godot's get_node_or_null strips the ":property" tail natively, so
# the valid/broken classification is the same either way — but for
# BROKEN tracks the broken_tracks[].node_path field is what callers
# read to diagnose the missing node, and rfind would surface
# "MissingTarget:modulate" instead of "MissingTarget" for subpath
# tracks like the "Target:modulate:a" shape preset_fade emits.
var colon := track_path_str.find(":")
var node_part: String
if colon >= 0:
node_part = track_path_str.substr(0, colon)
else:
node_part = track_path_str
var target_node: Node = null
if root_node != null:
target_node = root_node.get_node_or_null(node_part)
if target_node == null:
broken_tracks.append({
"index": i,
"path": track_path_str,
"type": track_type_to_string(anim.track_get_type(i)),
"issue": "node_not_found",
"node_path": node_part,
})
else:
valid_count += 1
return {
"data": {
"player_path": player_path,
"animation_name": anim_name,
"track_count": anim.get_track_count(),
"valid_count": valid_count,
"broken_count": broken_tracks.size(),
"broken_tracks": broken_tracks,
"valid": broken_tracks.is_empty(),
}
}
# ============================================================================
# Static helpers — shared with handler + presets
# ============================================================================
## Resolve the effective root node an AnimationPlayer animates against.
## Falls back to the player's parent when the explicit root_node NodePath is
## empty or unresolvable. Returns null when the player isn't in the tree.
##
## Mirrors the resolution Godot does at playback time so the validator,
## preset target resolver, and track-property coercer all see the same root.
static func player_root_node(player: AnimationPlayer) -> Node:
if not player.is_inside_tree():
return null
var rn := player.root_node
if rn != NodePath():
var n := player.get_node_or_null(rn)
if n != null:
return n
return player.get_parent()
## Coerce a JSON value to match the expected Godot type for the given
## track_path. Returns {"ok": value} or {"error": msg}.
## Passes the raw value through when the target node isn't in the scene
## yet (authoring-time path). Errors when the target exists but the
## property doesn't, or when parsing a typed value (Color/Vector2/Vector3)
## clearly fails — better to reject than silently store garbage.
## `override_root_node` lets callers supply the root to resolve target paths
## against when the player isn't in the tree yet (auto-create flow) — the
## player's future parent stands in for the root the AnimationPlayer will
## eventually use.
static func coerce_value_for_track(value: Variant, track_path: String, player: AnimationPlayer, override_root_node: Node = null) -> Dictionary:
var ctx := resolve_track_prop_context(track_path, player, override_root_node)
if ctx.has("error"):
return {"error": ctx.error}
return coerce_with_context(value, ctx)
## Resolve a track_path's target property type once, so callers coercing many
## keyframes avoid walking `get_property_list()` on every one. Returns:
## {pass_through: true} — no resolution / authoring-time
## {pass_through: false, prop_type, prop_name} — coerce against this type
## {error: msg} — property not found on target
##
## Supports Godot's native NodePath subpath form `property:sub` (e.g.
## `position:y`, `modulate:a`) — splits on the FIRST colon (node↔property
## boundary), resolves the base property on the target, and for known
## scalar subpaths (x/y/z/w on vectors, r/g/b/a on Color) narrows the
## coerce target to TYPE_FLOAT so JSON numbers land as floats, not dicts.
static func resolve_track_prop_context(track_path: String, player: AnimationPlayer, override_root_node: Node = null) -> Dictionary:
var colon := track_path.find(":")
if colon < 0:
return {"pass_through": true}
var node_part := track_path.substr(0, colon)
var prop_full := track_path.substr(colon + 1)
# Property may include a subpath: "position:y", "modulate:a", etc.
var sub_colon := prop_full.find(":")
var prop_base := prop_full if sub_colon < 0 else prop_full.substr(0, sub_colon)
var prop_sub := "" if sub_colon < 0 else prop_full.substr(sub_colon + 1)
var root_node: Node = override_root_node
if root_node == null:
root_node = player_root_node(player)
if root_node == null:
return {"pass_through": true}
var target: Node = root_node.get_node_or_null(node_part)
if target == null:
# Target node isn't in the scene yet — authoring-time path. Pass through.
return {"pass_through": true}
for p in target.get_property_list():
if p.name == prop_base:
var base_type: int = p.get("type", TYPE_NIL)
var coerce_type := base_type
if not prop_sub.is_empty():
var sub_type := subpath_component_type(base_type, prop_sub)
if sub_type == TYPE_NIL:
# Unknown subpath component — pass through so Godot's own
# NodePath resolution raises at playback if it's truly bogus,
# rather than fabricating a coerce error for a valid-but-
# uncommon form (e.g. Transform3D subpaths).
return {"pass_through": true}
coerce_type = sub_type
return {
"pass_through": false,
"prop_type": coerce_type,
"prop_name": prop_full,
}
# Target exists but the property doesn't. Reject loudly — silently storing
# the raw value here produces garbage keyframes at playback time.
return {"error":
"%s (target path: '%s')" %
[PropertyErrors.build_message(target, prop_base), node_part]}
## Map a `property:sub` subpath to its scalar component type. Returns
## TYPE_NIL when the base type / subkey pair isn't one we recognise —
## callers pass-through in that case rather than mis-coerce.
static func subpath_component_type(base_type: int, sub: String) -> int:
var entry = _SUBPATH_COMPONENTS.get(base_type)
if entry == null or sub.length() != 1:
return TYPE_NIL
return entry[1] if (entry[0] as String).contains(sub) else TYPE_NIL
static func coerce_with_context(value: Variant, ctx: Dictionary) -> Dictionary:
if ctx.get("pass_through", false):
return {"ok": value}
return coerce_for_type(value, ctx.prop_type, ctx.prop_name)
## Coerce a single value to the given Godot variant type. Returns
## {"ok": coerced} or {"error": msg}. Unknown types pass through.
static func coerce_for_type(value: Variant, prop_type: int, prop_name: String) -> Dictionary:
match prop_type:
TYPE_COLOR:
## Canonical strict parser (#714): same shapes as every other
## color-accepting handler, including [r,g,b(,a)] arrays.
var col = McpJsonValues.parse_color(value)
if col != null:
return {"ok": col}
return {"error": "Cannot coerce value to Color for property '%s' (expected \"#rrggbb(aa)\"/named string, {r,g,b[,a]}, [r,g,b(,a)], or Color)" % prop_name}
TYPE_VECTOR2:
var v2 = McpJsonValues.parse_vector2(value)
if v2 != null:
return {"ok": v2}
return {"error": "Cannot coerce value to Vector2 for property '%s' (expected {x,y}, [x,y], or Vector2)" % prop_name}
TYPE_VECTOR3:
var v3 = McpJsonValues.parse_vector3(value)
if v3 != null:
return {"ok": v3}
return {"error": "Cannot coerce value to Vector3 for property '%s' (expected {x,y,z}, [x,y,z], or Vector3)" % prop_name}
TYPE_FLOAT:
if value is int or value is float:
return {"ok": float(value)}
TYPE_INT:
if value is float or value is int:
return {"ok": int(value)}
TYPE_BOOL:
if value is int or value is float or value is bool:
return {"ok": bool(value)}
return {"ok": value}
# ============================================================================
# Static helpers — parsing + serializing
# ============================================================================
## Parse a transition value: named string or raw float.
## Named values live in `_NAMED_TRANSITIONS` so the mapping has a single source.
static func parse_transition(v: Variant) -> float:
if v is float or v is int:
return float(v)
if v is String:
var key: String = (v as String).to_lower()
if _NAMED_TRANSITIONS.has(key):
return float(_NAMED_TRANSITIONS[key])
return 1.0
## Map an Animation.TrackType enum to a stable string. Unknown types report
## as "unknown" rather than being silently coerced to "method" — callers that
## only produce value/method tracks can ignore the others; clients that want
## to round-trip bezier/audio/etc. get an honest label to key off.
static func track_type_to_string(track_type: int) -> String:
match track_type:
Animation.TYPE_VALUE: return "value"
Animation.TYPE_METHOD: return "method"
Animation.TYPE_POSITION_3D: return "position_3d"
Animation.TYPE_ROTATION_3D: return "rotation_3d"
Animation.TYPE_SCALE_3D: return "scale_3d"
Animation.TYPE_BLEND_SHAPE: return "blend_shape"
Animation.TYPE_BEZIER: return "bezier"
Animation.TYPE_AUDIO: return "audio"
Animation.TYPE_ANIMATION: return "animation"
_: return "unknown"
static func loop_mode_to_string(mode: int) -> String:
match mode:
Animation.LOOP_LINEAR: return "linear"
Animation.LOOP_PINGPONG: return "pingpong"
_: return "none"
static func interp_to_string(mode: int) -> String:
match mode:
Animation.INTERPOLATION_NEAREST: return "nearest"
Animation.INTERPOLATION_CUBIC: return "cubic"
_: return "linear"
## Convert a Godot Variant to a JSON-safe value.
static func serialize_value(value: Variant) -> Variant:
## Delegates to the shared serializer (#714) — the drifted private copy
## stringified rotation_3d keyframe Quaternions into opaque text where
## McpVariantSerializer emits the {x,y,z,w} dict callers can round-trip
## (it also NaN/Inf-guards floats, matching the wire contract).
return VariantSerializer.serialize(value)
@@ -0,0 +1 @@
uid://bguta2eb8blgf
+89
View File
@@ -0,0 +1,89 @@
@tool
extends RefCounted
## Read-only access to version-correct Godot class metadata.
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
const ClassIntrospection := preload("res://addons/godot_ai/utils/class_introspection.gd")
const FuzzySuggestions := preload("res://addons/godot_ai/utils/fuzzy_suggestions.gd")
func get_class_info(params: Dictionary) -> Dictionary:
var requested_class: String = params.get("class_name", "")
if requested_class.is_empty():
return ErrorCodes.make(
ErrorCodes.MISSING_REQUIRED_PARAM,
"Missing required param: class_name"
)
if not ClassDB.class_exists(requested_class):
var script_class := _global_script_class(requested_class)
if not script_class.is_empty():
return _script_class_error(requested_class, script_class)
return _unknown_class_error(requested_class)
if params.has("limit") and int(params.get("limit")) < 0:
return ErrorCodes.make(
ErrorCodes.INVALID_PARAMS,
"limit must be >= 0; use limit=0 only when an unlimited section is needed"
)
var section_check := ClassIntrospection.validate_sections(
params.get("sections", ClassIntrospection.DEFAULT_SECTIONS)
)
if not section_check.invalid.is_empty():
return _invalid_sections_error(section_check.invalid)
return {"data": ClassIntrospection.build(requested_class, params)}
static func _unknown_class_error(requested_class: String) -> Dictionary:
var suggestions := _suggest_classes(requested_class)
var message := "Unknown Godot class: %s" % requested_class
if not suggestions.is_empty():
message += ". Did you mean: %s?" % ", ".join(suggestions)
var result := ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, message)
result["error"]["data"] = {"suggestions": suggestions}
return result
static func _suggest_classes(requested_class: String) -> Array[String]:
return FuzzySuggestions.rank(requested_class, ClassDB.get_class_list())
static func _global_script_class(requested_class: String) -> Dictionary:
for raw_info in ProjectSettings.get_global_class_list():
var info: Dictionary = raw_info
if info.get("class", "") == requested_class:
return info
return {}
static func _script_class_error(requested_class: String, script_class: Dictionary) -> Dictionary:
var path := str(script_class.get("path", ""))
var base := str(script_class.get("base", ""))
var message := (
"%s is a project script class, not a ClassDB class. "
+ "Use script_manage(op=\"find_symbols\", params={\"path\": \"%s\"}) for script symbols."
) % [requested_class, path]
var result := ErrorCodes.make(ErrorCodes.WRONG_TYPE, message)
result["error"]["data"] = {
"script_class": true,
"class_name": requested_class,
"base_class": base,
"path": path,
}
return result
static func _invalid_sections_error(invalid_sections: Array[String]) -> Dictionary:
var suggestions := {}
for section in invalid_sections:
suggestions[section] = FuzzySuggestions.rank(
section,
ClassIntrospection.SUGGESTABLE_SECTION_TOKENS,
3,
0.3
)
var message := "Unknown class-info section(s): %s. Valid sections: %s (or \"all\" for all documentation sections; \"inheritors\" must be requested by name)" % [
", ".join(invalid_sections),
", ".join(ClassIntrospection.KNOWN_SECTIONS),
]
var result := ErrorCodes.make(ErrorCodes.INVALID_PARAMS, message)
result["error"]["data"] = {"suggestions": suggestions}
return result
@@ -0,0 +1 @@
uid://v3rkd7ueunii
+361
View File
@@ -0,0 +1,361 @@
@tool
extends RefCounted
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
## Handles AudioStreamPlayer / 2D / 3D authoring — node creation, stream
## assignment, playback-property edits, and real editor preview playback.
##
## Stream assignment loads a Godot-imported AudioStream resource from
## res:// (the editor's import step converts .ogg / .wav / .mp3 into a
## streamable AudioStream subclass before we ever see it).
##
## play() / stop() call the live node method directly — no undo, no
## persistence; they match what the inspector's play button does.
const _VALID_TYPES := {
"1d": "AudioStreamPlayer",
"2d": "AudioStreamPlayer2D",
"3d": "AudioStreamPlayer3D",
}
## Whitelist of playback properties settable via audio_player_set_playback.
## Each value is the expected Variant type of the param dict value.
const _PLAYBACK_KEYS := {
"volume_db": TYPE_FLOAT,
"pitch_scale": TYPE_FLOAT,
"autoplay": TYPE_BOOL,
"bus": TYPE_STRING,
}
var _undo_redo: EditorUndoRedoManager
func _init(undo_redo: EditorUndoRedoManager) -> void:
_undo_redo = undo_redo
# ============================================================================
# audio_player_create
# ============================================================================
func create_player(params: Dictionary) -> Dictionary:
var parent_path: String = params.get("parent_path", "")
var node_name: String = params.get("name", "AudioStreamPlayer")
var type_str: String = params.get("type", "1d")
if not _VALID_TYPES.has(type_str):
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE,
"Invalid audio player type '%s'. Valid: %s" % [type_str, ", ".join(_VALID_TYPES.keys())]
)
var _scene_check := McpNodeValidator.require_scene_or_error()
if _scene_check.has("error"):
return _scene_check
var scene_root: Node = _scene_check.scene_root
var parent: Node = scene_root
if not parent_path.is_empty():
parent = McpScenePath.resolve(parent_path, scene_root)
if parent == null:
return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, McpScenePath.format_parent_error(parent_path, scene_root))
var node := _instantiate_player(type_str)
if node == null:
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate audio player")
if not node_name.is_empty():
node.name = node_name
_undo_redo.create_action("MCP: Create %s '%s'" % [_VALID_TYPES[type_str], node.name])
_undo_redo.add_do_method(parent, "add_child", node, true)
_undo_redo.add_do_method(node, "set_owner", scene_root)
_undo_redo.add_do_reference(node)
_undo_redo.add_undo_method(parent, "remove_child", node)
_undo_redo.commit_action()
return {
"data": {
"path": McpScenePath.from_node(node, scene_root),
"parent_path": McpScenePath.from_node(parent, scene_root),
"name": String(node.name),
"type": type_str,
"class": _VALID_TYPES[type_str],
"undoable": true,
}
}
# ============================================================================
# audio_player_set_stream
# ============================================================================
func set_stream(params: Dictionary) -> Dictionary:
var player_path: String = params.get("player_path", "")
var stream_path: String = params.get("stream_path", "")
if player_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path")
if stream_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: stream_path")
var stream_path_err = McpPathValidator.loadable_error(stream_path, "stream_path")
if stream_path_err != null:
return stream_path_err
var resolved := _resolve_player(player_path)
if resolved.has("error"):
return resolved
var player: Node = resolved.player
if not ResourceLoader.exists(stream_path):
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "AudioStream not found: %s" % stream_path)
var loaded := ResourceLoader.load(stream_path)
if loaded == null:
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to load AudioStream: %s" % stream_path)
if not (loaded is AudioStream):
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"Resource at %s is not an AudioStream (got %s)" % [stream_path, loaded.get_class()]
)
var old_stream: AudioStream = player.stream
_undo_redo.create_action("MCP: Set audio stream on %s" % player.name)
_undo_redo.add_do_property(player, "stream", loaded)
_undo_redo.add_undo_property(player, "stream", old_stream)
_undo_redo.commit_action()
return {
"data": {
"player_path": player_path,
"stream_path": stream_path,
"stream_class": loaded.get_class(),
"duration_seconds": float(loaded.get_length()),
"undoable": true,
}
}
# ============================================================================
# audio_player_set_playback
# ============================================================================
func set_playback(params: Dictionary) -> Dictionary:
var player_path: String = params.get("player_path", "")
if player_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path")
var resolved := _resolve_player(player_path)
if resolved.has("error"):
return resolved
var player: Node = resolved.player
var updates: Dictionary = {}
for key in _PLAYBACK_KEYS:
if params.has(key):
var expected_type: int = _PLAYBACK_KEYS[key]
var value = params.get(key)
var coerced = _coerce_playback_value(value, expected_type)
if coerced == null:
return ErrorCodes.make(
ErrorCodes.INVALID_PARAMS,
"Invalid value for %s: expected %s, got %s" % [
key, type_string(expected_type), type_string(typeof(value))
]
)
updates[key] = coerced
if updates.is_empty():
return ErrorCodes.make(
ErrorCodes.MISSING_REQUIRED_PARAM,
"At least one of %s is required" % ", ".join(_PLAYBACK_KEYS.keys())
)
var old_values: Dictionary = {}
for key in updates:
old_values[key] = player.get(key)
_undo_redo.create_action("MCP: Update playback on %s" % player.name)
for key in updates:
_undo_redo.add_do_property(player, key, updates[key])
_undo_redo.add_undo_property(player, key, old_values[key])
_undo_redo.commit_action()
return {
"data": {
"player_path": player_path,
"applied": updates.keys(),
"values": updates,
"undoable": true,
}
}
# ============================================================================
# audio_play (runtime preview — not saved with scene)
# ============================================================================
func play(params: Dictionary) -> Dictionary:
var player_path: String = params.get("player_path", "")
var from_position: float = float(params.get("from_position", 0.0))
if player_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path")
var resolved := _resolve_player(player_path)
if resolved.has("error"):
return resolved
var player: Node = resolved.player
if player.stream == null:
return ErrorCodes.make(
ErrorCodes.MISSING_REQUIRED_PARAM,
"Player has no stream assigned — call audio_player_set_stream first"
)
player.play(from_position)
return {
"data": {
"player_path": player_path,
"from_position": from_position,
"playing": bool(player.playing),
"undoable": false,
"reason": "Runtime playback state — not saved with scene",
}
}
# ============================================================================
# audio_stop (runtime preview — not saved with scene)
# ============================================================================
func stop(params: Dictionary) -> Dictionary:
var player_path: String = params.get("player_path", "")
if player_path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path")
var resolved := _resolve_player(player_path)
if resolved.has("error"):
return resolved
var player: Node = resolved.player
player.stop()
return {
"data": {
"player_path": player_path,
"playing": bool(player.playing),
"undoable": false,
"reason": "Runtime playback state — not saved with scene",
}
}
# ============================================================================
# audio_list (read — scan project for AudioStream resources)
# ============================================================================
func list_streams(params: Dictionary) -> Dictionary:
var root: String = params.get("root", "res://")
var include_duration: bool = bool(params.get("include_duration", true))
var root_err = McpPathValidator.path_error(root, "root")
if root_err != null:
return root_err
var efs := EditorInterface.get_resource_filesystem()
if efs == null:
return ErrorCodes.make_not_ready(
ErrorCodes.SUB_EDITOR_UNAVAILABLE,
"EditorFileSystem not available", false)
var results: Array[Dictionary] = []
var start_dir := efs.get_filesystem_path(root)
if start_dir == null:
start_dir = efs.get_filesystem()
_scan_audio(start_dir, root, include_duration, results)
return {
"data": {
"root": root,
"streams": results,
"count": results.size(),
}
}
func _scan_audio(dir: EditorFileSystemDirectory, root: String, include_duration: bool, out: Array[Dictionary]) -> void:
if dir == null:
return
for i in dir.get_file_count():
var file_path := dir.get_file_path(i)
if not file_path.begins_with(root):
continue
var file_type := dir.get_file_type(i)
var is_audio := file_type == "AudioStream" or ClassDB.is_parent_class(file_type, "AudioStream")
if not is_audio:
continue
var entry: Dictionary = {
"path": file_path,
"class": file_type,
}
if include_duration:
var res := ResourceLoader.load(file_path)
if res is AudioStream:
entry["duration_seconds"] = float((res as AudioStream).get_length())
else:
entry["duration_seconds"] = 0.0
out.append(entry)
for i in dir.get_subdir_count():
_scan_audio(dir.get_subdir(i), root, include_duration, out)
# ============================================================================
# Helpers
# ============================================================================
static func _instantiate_player(type_str: String) -> Node:
match type_str:
"1d":
return AudioStreamPlayer.new()
"2d":
return AudioStreamPlayer2D.new()
"3d":
return AudioStreamPlayer3D.new()
return null
func _resolve_player(player_path: String) -> Dictionary:
var resolved := McpNodeValidator.resolve_or_error(player_path, "player_path")
if resolved.has("error"):
return resolved
var node: Node = resolved.node
var is_player := node is AudioStreamPlayer \
or node is AudioStreamPlayer2D \
or node is AudioStreamPlayer3D
if not is_player:
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"Node at %s is not an AudioStreamPlayer/2D/3D (got %s)" % [player_path, node.get_class()]
)
return {"player": node}
## Coerce a playback param value to the expected type. int→float is allowed
## so JSON integers pass through; everything else requires the exact type.
## Returns the coerced value, or null on type mismatch.
static func _coerce_playback_value(value: Variant, expected_type: int) -> Variant:
match expected_type:
TYPE_FLOAT:
if value is float or value is int:
return float(value)
TYPE_BOOL:
if value is bool:
return value
TYPE_STRING:
if value is String:
return value
return null
@@ -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

Some files were not shown because too many files have changed in this diff Show More