first commit
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
@@ -0,0 +1,2 @@
|
||||
# Normalize EOL for all files that Git considers text files.
|
||||
* text=auto eol=lf
|
||||
@@ -0,0 +1,3 @@
|
||||
# Godot 4+ specific ignores
|
||||
.godot/
|
||||
/android/
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Godot AI contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,53 @@
|
||||
# Godot AI
|
||||
|
||||
Connect AI assistants to a live Godot editor via the [Model Context Protocol](https://modelcontextprotocol.io/introduction) (MCP).
|
||||
|
||||
Godot AI bridges Claude Code, Codex, Antigravity, and other MCP clients with your editor — inspect scenes, create nodes, modify properties, run tests, search project files, and more, all from a prompt.
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. Copy `addons/godot_ai/` into your project's `addons/` folder
|
||||
2. Enable the plugin: **Project > Project Settings > Plugins > Godot AI**
|
||||
3. Pick your MCP client in the **Godot AI** dock and press **Configure**
|
||||
|
||||
The plugin auto-starts the MCP server and connects over WebSocket. No manual configuration required.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Godot 4.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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://cyowqr1x12ilg
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://dhoe3ypkhm12v
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,341 @@
|
||||
@tool
|
||||
class_name McpJsonStrategy
|
||||
extends RefCounted
|
||||
|
||||
## Read–merge–write strategy for JSON-backed MCP clients.
|
||||
## All knobs come from the McpClient descriptor as plain data — no Callables.
|
||||
## See `_base.gd` for why descriptors are data-only.
|
||||
|
||||
|
||||
static func configure(
|
||||
client: McpClient,
|
||||
server_name: String,
|
||||
server_url: String,
|
||||
launch: Dictionary = {},
|
||||
) -> 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://bxougoq8xwg1
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://d36nywn2nkgts
|
||||
@@ -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())
|
||||
@@ -0,0 +1 @@
|
||||
uid://hdlwcfdr8mdk
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://bvpbssfanukef
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -0,0 +1 @@
|
||||
uid://ckchsj5s3q1b0
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://ewmadhrvs5d7
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://dc1x77i1cmb6w
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://d2whd6a5fofhg
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://dqdmd2jw5qen7
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://s8n0vfirf2pj
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://qwb5udkf423q
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://denjdf50qrf66
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://cwpu48772vfj1
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://b6pqiok2mlsmg
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://d152l0u0r6fsc
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://fmp0nlzcm3ukl
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
uid://bmnk8rsotiks2
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
uid://bd1k63iye1bsl
|
||||
@@ -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.0–1.0). Returns an empty
|
||||
## array if no candidates clear the threshold. Used by batch_execute to surface
|
||||
## "did you mean" suggestions when an unknown command is passed.
|
||||
func suggest_similar(cmd_name: String, limit: int = 3, threshold: float = 0.5) -> Array[String]:
|
||||
return FuzzySuggestions.rank(cmd_name, _registered_command_names(), limit, threshold, 0.0, 0.0)
|
||||
|
||||
|
||||
## Union of eagerly-registered and lazily-registered command names.
|
||||
## Materialized lazy commands live in both dicts, so dedupe via keys.
|
||||
func _registered_command_names() -> Array:
|
||||
var names: Dictionary = {}
|
||||
for command in _handlers:
|
||||
names[command] = true
|
||||
for command in _lazy_commands:
|
||||
names[command] = true
|
||||
return names.keys()
|
||||
|
||||
|
||||
## Enqueue a raw command dict received from the WebSocket.
|
||||
func enqueue(cmd: Dictionary) -> void:
|
||||
_command_queue.append(cmd)
|
||||
|
||||
|
||||
func pending_deferred_count() -> int:
|
||||
return _pending_deferred.size()
|
||||
|
||||
|
||||
func clear_deferred_responses() -> void:
|
||||
_pending_deferred.clear()
|
||||
|
||||
|
||||
func has_pending_deferred_response(request_id: String) -> bool:
|
||||
return request_id.is_empty() or _pending_deferred.has(request_id)
|
||||
|
||||
|
||||
func complete_deferred_response(request_id: String) -> bool:
|
||||
if request_id.is_empty():
|
||||
return true
|
||||
if not _pending_deferred.has(request_id):
|
||||
return false
|
||||
_pending_deferred.erase(request_id)
|
||||
return true
|
||||
|
||||
|
||||
## Handlers whose response flows out-of-band (e.g. debugger-channel capture)
|
||||
## return this marker so tick() skips auto-sending a response. The handler is
|
||||
## responsible for pushing the final response via McpConnection._send_json when
|
||||
## the async operation completes. The dispatcher tracks the request_id and emits
|
||||
## DEFERRED_TIMEOUT if the out-of-band response never arrives. The request_id is
|
||||
## threaded through params under the "_request_id" key so the handler can
|
||||
## correlate the response.
|
||||
const DEFERRED_RESPONSE := {"_deferred": true}
|
||||
|
||||
|
||||
## Process queued commands within a frame budget (milliseconds).
|
||||
## Returns an array of response dictionaries to send back.
|
||||
func tick(budget_ms: float = 4.0) -> Array[Dictionary]:
|
||||
var responses: Array[Dictionary] = _collect_deferred_timeouts()
|
||||
var start := Time.get_ticks_msec()
|
||||
var idx := 0
|
||||
|
||||
while idx < _command_queue.size() and (Time.get_ticks_msec() - start) < budget_ms:
|
||||
var cmd: Dictionary = _command_queue[idx]
|
||||
var response := _dispatch(cmd)
|
||||
if not response.get("_deferred", false):
|
||||
responses.append(response)
|
||||
idx += 1
|
||||
|
||||
if idx > 0:
|
||||
_command_queue = _command_queue.slice(idx)
|
||||
|
||||
return responses
|
||||
|
||||
|
||||
func _dispatch(cmd: Dictionary) -> Dictionary:
|
||||
var request_id: String = cmd.get("request_id", "")
|
||||
var command: String = cmd.get("command", "")
|
||||
var raw_params: Dictionary = cmd.get("params", {})
|
||||
## Duplicate so the internal _request_id key we thread through doesn't
|
||||
## mutate the queued command's params (which is the same dict we're
|
||||
## about to JSON-log below, and which later readers like batch_execute
|
||||
## shouldn't see dispatcher-internal metadata from).
|
||||
var params: Dictionary = raw_params.duplicate()
|
||||
params["_request_id"] = request_id
|
||||
|
||||
if mcp_logging:
|
||||
_log_buffer.log("[recv] %s(%s)" % [command, JSON.stringify(raw_params)])
|
||||
|
||||
var result: Dictionary
|
||||
|
||||
if has_command(command):
|
||||
result = _call_handler(command, params)
|
||||
else:
|
||||
result = ErrorCodes.make(ErrorCodes.UNKNOWN_COMMAND, "Unknown command: %s" % command)
|
||||
|
||||
if result.get("_deferred", false):
|
||||
## A handler may attach `_deferred_timeout_ms` to its deferred sentinel
|
||||
## to claim a per-request budget larger than its command's shared entry
|
||||
## (e.g. game_command's `input_sequence`, which steps frames well past
|
||||
## the 15s that suits one-shot game ops). 0/absent falls back to the
|
||||
## per-command table.
|
||||
_register_deferred(request_id, command, int(result.get("_deferred_timeout_ms", 0)))
|
||||
if mcp_logging:
|
||||
_log_buffer.log("[defer] %s (request %s)" % [command, request_id])
|
||||
return result
|
||||
|
||||
result["request_id"] = request_id
|
||||
if not result.has("status"):
|
||||
result["status"] = "ok"
|
||||
## Stamp live editor readiness onto every command-response envelope so
|
||||
## the server's `Session.readiness` cache self-heals on the very next
|
||||
## tool call. Without this, a single dropped `readiness_changed` event
|
||||
## (or a one-frame race around `pause_processing`) leaves the cache
|
||||
## stuck at "playing" / "importing" long after the editor has settled,
|
||||
## and write tools fail with EDITOR_NOT_READY against a writable editor.
|
||||
## See connection.gd::send_deferred_response for the deferred-response
|
||||
## counterpart, which stamps the same field.
|
||||
result["readiness"] = McpConnection.get_readiness()
|
||||
_stamp_error_watermark(result)
|
||||
|
||||
if mcp_logging:
|
||||
var status: String = result.get("status", "ok")
|
||||
if status == "ok":
|
||||
_log_buffer.log("[send] %s -> ok" % command)
|
||||
else:
|
||||
var err_msg: String = result.get("error", {}).get("message", "unknown")
|
||||
_log_buffer.log("[send] %s -> error: %s" % [command, err_msg])
|
||||
|
||||
return result
|
||||
|
||||
|
||||
## Truncate JSON-stringified args at this many chars when stuffing them into
|
||||
## a malformed-result error message — large dicts shouldn't bloat the
|
||||
## response, but a few hundred chars usually pinpoints which param was the
|
||||
## wrong shape.
|
||||
const _MALFORMED_ARGS_MAX := 400
|
||||
|
||||
|
||||
func _call_handler(command: String, params: Dictionary) -> Dictionary:
|
||||
if not _handlers.has(command):
|
||||
var materialize_error := _materialize_lazy_command(command)
|
||||
if not materialize_error.is_empty():
|
||||
return materialize_error
|
||||
## #712: a handler that crashes between pause_processing = true and its
|
||||
## matching false leaves the pause depth unbalanced — GDScript swallows
|
||||
## the error, the dispatcher reports "malformed result", and the
|
||||
## transport stays paused FOREVER (no watchdog, no disconnect reset).
|
||||
## Restore balance at this boundary: the depth a handler leaves behind
|
||||
## must equal the depth it started with.
|
||||
var pause_depth_before: int = pause_target.pause_depth() if pause_target != null else 0
|
||||
var result: Dictionary = _handlers[command].call(params)
|
||||
if pause_target != null and pause_target.pause_depth() > pause_depth_before:
|
||||
var leaked: int = pause_target.pause_depth() - pause_depth_before
|
||||
while pause_target.pause_depth() > pause_depth_before:
|
||||
pause_target.resume()
|
||||
if mcp_logging and _log_buffer != null:
|
||||
_log_buffer.log(
|
||||
"[error] %s leaked %d pause_processing level(s) — restored (handler crash?)"
|
||||
% [command, leaked]
|
||||
)
|
||||
## Handlers must return {"data": ...} on success or {"error": ...} on failure.
|
||||
## Anything else (null, empty, missing keys) means the handler crashed
|
||||
## mid-call — GDScript swallows the error and returns an empty dict.
|
||||
if result == null or not (result.has("data") or result.has("error") or result.has("_deferred")):
|
||||
var safe_params := params.duplicate()
|
||||
safe_params.erase("_request_id")
|
||||
var args_json := JSON.stringify(safe_params)
|
||||
if args_json.length() > _MALFORMED_ARGS_MAX:
|
||||
args_json = args_json.substr(0, _MALFORMED_ARGS_MAX) + "..."
|
||||
var backtrace := _capture_compact_backtrace()
|
||||
var msg := (
|
||||
"Handler '%s' returned malformed result — likely a runtime error in the handler "
|
||||
+ "(e.g. param type mismatch). Args received: %s"
|
||||
) % [command, args_json]
|
||||
if not backtrace.is_empty():
|
||||
msg += "\nBacktrace:\n%s" % backtrace
|
||||
if mcp_logging and _log_buffer != null:
|
||||
var compact_backtrace := backtrace.replace("\n", " | ")
|
||||
_log_buffer.log(
|
||||
"[error] %s -> malformed result; args=%s; backtrace=%s"
|
||||
% [command, args_json, compact_backtrace]
|
||||
)
|
||||
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, msg)
|
||||
return result
|
||||
|
||||
|
||||
## Resolve a lazily-registered command into a live Callable in `_handlers`.
|
||||
## Loads + constructs the owning handler on first use (cached per handler
|
||||
## key, so one load() covers every command the handler serves). Returns an
|
||||
## empty dict on success or a protocol error dict on failure — a missing
|
||||
## script or method is a plugin packaging bug and must surface loudly, not
|
||||
## as a silent no-op.
|
||||
func _materialize_lazy_command(command: String) -> Dictionary:
|
||||
var command_spec: Dictionary = _lazy_commands.get(command, {})
|
||||
if command_spec.is_empty():
|
||||
return ErrorCodes.make(ErrorCodes.UNKNOWN_COMMAND, "Unknown command: %s" % command)
|
||||
var handler_key: String = command_spec["handler"]
|
||||
var instance = _lazy_handler_cache.get(handler_key)
|
||||
if instance == null:
|
||||
var handler_spec: Dictionary = _lazy_handler_specs.get(handler_key, {})
|
||||
if handler_spec.is_empty():
|
||||
return ErrorCodes.make(
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
"No lazy handler '%s' declared for command '%s'" % [handler_key, command]
|
||||
)
|
||||
## Existence-check first so a missing script surfaces as one clean
|
||||
## protocol error instead of also spraying engine load errors.
|
||||
if not ResourceLoader.exists(handler_spec["path"]):
|
||||
return ErrorCodes.make(
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
"Missing handler script '%s' for command '%s'" % [handler_spec["path"], command]
|
||||
)
|
||||
var script := load(handler_spec["path"]) as GDScript
|
||||
if script == null:
|
||||
return ErrorCodes.make(
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
"Failed to load handler script '%s' for command '%s'" % [handler_spec["path"], command]
|
||||
)
|
||||
instance = script.callv("new", handler_spec["args"])
|
||||
if instance == null:
|
||||
return ErrorCodes.make(
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
"Failed to construct handler '%s' for command '%s'" % [handler_key, command]
|
||||
)
|
||||
_lazy_handler_cache[handler_key] = instance
|
||||
var method: StringName = command_spec["method"]
|
||||
if not instance.has_method(method):
|
||||
return ErrorCodes.make(
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
"Handler '%s' has no method '%s' for command '%s'" % [handler_key, method, command]
|
||||
)
|
||||
_handlers[command] = Callable(instance, method)
|
||||
return {}
|
||||
|
||||
|
||||
func _register_deferred(request_id: String, command: String, timeout_override_ms: int = 0) -> void:
|
||||
if request_id.is_empty():
|
||||
return
|
||||
## A positive per-request override wins over the per-command table so a
|
||||
## single deferred call can claim more headroom without globally widening
|
||||
## the command's budget (see _dispatch: input_sequence needs ~30s, but the
|
||||
## other game_command ops must keep their tight 15s).
|
||||
var timeout_ms: int = (
|
||||
timeout_override_ms if timeout_override_ms > 0
|
||||
else _deferred_timeout_ms_for_command(command)
|
||||
)
|
||||
_pending_deferred[request_id] = {
|
||||
"command": command,
|
||||
"started_ms": Time.get_ticks_msec(),
|
||||
"timeout_ms": timeout_ms,
|
||||
}
|
||||
|
||||
|
||||
func _deferred_timeout_ms_for_command(command: String) -> int:
|
||||
if deferred_timeout_overrides_ms.has(command):
|
||||
return int(deferred_timeout_overrides_ms[command])
|
||||
return int(DEFERRED_TIMEOUT_MS_BY_COMMAND.get(command, DEFAULT_DEFERRED_TIMEOUT_MS))
|
||||
|
||||
|
||||
func _collect_deferred_timeouts() -> Array[Dictionary]:
|
||||
var responses: Array[Dictionary] = []
|
||||
if _pending_deferred.is_empty():
|
||||
return responses
|
||||
var now := Time.get_ticks_msec()
|
||||
for request_id in _pending_deferred.keys():
|
||||
var entry: Dictionary = _pending_deferred[request_id]
|
||||
var timeout_ms: int = entry.get("timeout_ms", DEFAULT_DEFERRED_TIMEOUT_MS)
|
||||
var elapsed_ms := now - int(entry.get("started_ms", now))
|
||||
if elapsed_ms < timeout_ms:
|
||||
continue
|
||||
_pending_deferred.erase(request_id)
|
||||
var command: String = entry.get("command", "")
|
||||
var response := ErrorCodes.make(
|
||||
ErrorCodes.DEFERRED_TIMEOUT,
|
||||
"Deferred response for '%s' timed out after %dms" % [command, timeout_ms]
|
||||
)
|
||||
response["request_id"] = request_id
|
||||
response["error"]["data"] = {
|
||||
"command": command,
|
||||
"elapsed_ms": elapsed_ms,
|
||||
"timeout_ms": timeout_ms,
|
||||
}
|
||||
## Same envelope-level readiness stamp as `_dispatch` — keep the
|
||||
## self-heal channel symmetric across every reply shape the
|
||||
## dispatcher emits so the server cache can't drift just because
|
||||
## the editor happened to time out a deferred command.
|
||||
response["readiness"] = McpConnection.get_readiness()
|
||||
_stamp_error_watermark(response)
|
||||
responses.append(response)
|
||||
if mcp_logging and _log_buffer != null:
|
||||
_log_buffer.log("[defer] %s (request %s) -> timeout" % [command, request_id])
|
||||
return responses
|
||||
|
||||
|
||||
func _stamp_error_watermark(response: Dictionary) -> void:
|
||||
McpSurfacedErrorTracker.stamp_watermark(response, _surfaced_error_tracker)
|
||||
|
||||
|
||||
static func _capture_compact_backtrace(max_frames: int = 8) -> String:
|
||||
var traces: Array = Engine.capture_script_backtraces(false)
|
||||
for bt in traces:
|
||||
if bt != null and not bt.is_empty():
|
||||
return _trim_backtrace_string(bt.format(0, 2), max_frames)
|
||||
return _format_stack_frames(get_stack(), max_frames)
|
||||
|
||||
|
||||
static func _trim_backtrace_string(text: String, max_frames: int) -> String:
|
||||
var lines := text.strip_edges().split("\n")
|
||||
var kept: Array[String] = []
|
||||
for i in range(min(lines.size(), max_frames)):
|
||||
kept.append(lines[i].strip_edges())
|
||||
return "\n".join(kept)
|
||||
|
||||
|
||||
static func _format_stack_frames(frames: Array, max_frames: int) -> String:
|
||||
var lines: Array[String] = []
|
||||
for i in range(min(frames.size(), max_frames)):
|
||||
var frame: Dictionary = frames[i]
|
||||
lines.append(
|
||||
"%s:%s in %s"
|
||||
% [
|
||||
frame.get("source", "?"),
|
||||
frame.get("line", 0),
|
||||
frame.get("function", "?"),
|
||||
]
|
||||
)
|
||||
return "\n".join(lines)
|
||||
@@ -0,0 +1 @@
|
||||
uid://ctldk7ivsoo3i
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user